diff --git a/.claude/launch.json b/.claude/launch.json index df3231b..70d7ae1 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -1,6 +1,6 @@ { "version": "0.0.1", "configurations": [ - { "name": "norma", "runtimeExecutable": "python3", "runtimeArgs": ["server.py", "--mode", "local", "--port", "8765", "--no-open"], "port": 8765 } + { "name": "norma", "runtimeExecutable": "python3", "runtimeArgs": ["backend/server.py", "--mode", "local", "--port", "8765", "--no-open"], "port": 8765 } ] } diff --git a/.dockerignore b/.dockerignore index 3796c1d..98f7754 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,15 +1,20 @@ -# Keep the image to what server.py actually needs to run and serve. -# (Docker's build context ignores .gitignore, so this has to be separate.) +# Keep the build context to what the image needs. .git -.gitignore -.Rproj.user -.Rhistory -*.Rproj -__pycache__ -*.pyc -.DS_Store - -# deployment/build files: useful on the host, not inside the running container -Dockerfile -docker-compose.yml -deploy/ +.github +.serena +.claude +docs +deploy +tests +backend/tests +backend/.venv +backend/.ruff_cache +backend/.mypy_cache +backend/.pytest_cache +frontend/node_modules +frontend/dist +frontend/test-results +frontend/playwright-report +frontend/e2e +**/__pycache__ +*.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1119d14 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: [master, NORMA_v3, update_architecture] + pull_request: + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: { working-directory: backend } + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv sync --frozen + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run mypy norma + - run: uv run pytest + + frontend: + runs-on: ubuntu-latest + defaults: + run: { working-directory: frontend } + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: 22, cache: npm, cache-dependency-path: frontend/package-lock.json } + - run: npm ci + - run: npm run lint + - run: npx prettier --check src e2e + - run: npx tsc --noEmit + - run: npm test -- --run + - run: npm run build + + e2e: + runs-on: ubuntu-latest + needs: [backend, frontend] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: 22, cache: npm, cache-dependency-path: frontend/package-lock.json } + - run: cd frontend && npm ci && npx playwright install --with-deps chromium + # playwright.config.ts builds the page and starts backend/server.py itself + - run: cd frontend && npm run test:e2e diff --git a/.gitignore b/.gitignore index 4a1f431..401f651 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,16 @@ vignettes/*.pdf user_temp/*.html user_temp/*.rda + +# Python +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.venv/ + +# Node +node_modules/ +frontend/dist/ +frontend/test-results/ +frontend/playwright-report/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d2e1dd3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.0 + hooks: + - id: ruff-check + args: [--fix] + files: ^backend/ + - id: ruff-format + files: ^backend/ + + - repo: local + hooks: + - id: eslint + name: eslint + language: system + entry: bash -c 'cd frontend && npx eslint src e2e' + files: ^frontend/(src|e2e)/.*\.ts$ + pass_filenames: false + - id: prettier + name: prettier + language: system + entry: bash -c 'cd frontend && npx prettier --check src e2e' + files: ^frontend/(src|e2e)/ + pass_filenames: false + - id: tsc + name: tsc + language: system + entry: bash -c 'cd frontend && npx tsc --noEmit' + files: ^frontend/.*\.ts$ + pass_filenames: false diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..2e510af --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..1d3b834 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,169 @@ +# the name by which the project can be referenced within Serena/when chatting with the LLM. +project_name: "NORMA" + +# list of language servers to start when using the LSP backend; choose from: +# ada al angular ansible bash +# bsl clojure cpp cpp_ccls crystal +# csharp csharp_omnisharp cue dart deno +# elixir elm erlang fortran fsharp +# gdscript gleam go groovy haskell +# haxe hlsl html java json +# julia kotlin latex lean4 lua +# luau markdown matlab msl nextflow +# nix ocaml pascal perl php +# php_phpactor php_phpantom powershell python python_basedpyright +# python_jedi python_pyrefly python_ty qml r +# rego ruby ruby_solargraph rust scala +# scss solidity svelte swift systemverilog +# terraform toml typescript typescript_vts vue +# wolfram yaml zig +# (This list may be outdated; generated with scripts/print_language_list.py; +# For the current list, see values of the LanguageServerId enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) +# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some language servers require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple language servers, the first language server that supports a given file will be used for that file. +# The first language server is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +language_servers: +- typescript + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# the command runs in the project root directory and is only executed if the project is trusted +# (see trusted_project_path_patterns in the global configuration). +# serena waits for the command to exit: a non-zero exit code is logged as an error but does not +# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety +# backstop for non-terminating commands; on expiry the process is killed and activation continues. +# example: activation_command: "npx nx run-many -t build" +activation_command: + +# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). +# must be a positive number. +activation_command_timeout: 180.0 + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# The settings are considered only if the project is trusted (see global configuration to define trusted projects). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings +ls_specific_settings: {} + +# list of workspace folder paths (LSP backend only). +# These folders will be used to build up Serena's symbol index. +# Paths must be within the project root and should thus be relative to the project root. +# Furthermore, the paths should not be filtered by ignore settings. +# Default setting: The entire project root folder (".") is considered. +# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. +# ls_workspace_folders: +# - "./subproject1" +# - "./subproject2" +ls_workspace_folders: +- "." + +# list of additional workspace folder paths for cross-package reference support. +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries, but these folders are not indexed by Serena, +# i.e. the respective symbols will not be found using Serena's symbol search tools. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +ls_additional_workspace_folders: [] + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases. +# Example: +# ignored_paths: +# - "examples/**" +# - ".worktrees/**" +# - "**/bin/**" +# - "**/obj/**" +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +fixed_tools: [] + +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. +# This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ad0f1c3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,107 @@ +# AGENTS.md + +Guidance for coding agents (Claude Code, Codex, …) working in this repository. + +NORMA 3 (The Network Makeup Artist) visualises and analyses networks together with their annotated +groups. It is a **browser application** (Vite / TypeScript / Cytoscape.js, in `frontend/`) served by a +**dependency-free Python server** (`backend/`, standard library only) that adds the REST API and the +STRING / Arena3D / database relays. Everything computational (layouts, clustering, statistics, +exports) runs in the browser; the server never sees network data except for the token API. + +The app was migrated from R/Shiny (branch `master`) to this stack (branches `NORMA_v3`, +`update_architecture`). The architecture decisions live in `docs/superpowers/specs/`. + +## Rules for Agents + +- **Never push to remote.** Commit only when explicitly asked, one commit per feature. +- **Always use the `token-saviour` skill** — and the tools/skills it routes to — wherever it makes sense. +- **When a plan is active** (`docs/superpowers/plans/*.md`): one feature per commit, tick the checkboxes + as you go, and verify each feature at runtime before moving to the next. +- **Before every commit**: run lint, typecheck and the relevant test suite (commands below). Verify UI + changes at runtime with Playwright. +- **Keep the server dependency-free.** `backend/` must run with a plain `python3`; dev tools (pytest, + ruff, mypy) go in the `dev` group only. +- **Keep the single-file build working.** `frontend/dist/norma.html` must open from disk without a server. +- **Keep responses concise** — summarise rather than dumping full files. + +## Running the App + +**Backend** (no runtime dependencies; `uv` only for dev tools): +```bash +python3 backend/server.py # http://localhost:8000, serves frontend/dist +python3 backend/server.py --help # every option; also norma.config.json / NORMA_* env +cd backend && uv sync # dev tools into .venv +``` + +**Frontend:** +```bash +cd frontend +npm install +npm run dev # http://localhost:5173 — /api, /string-api, … proxied to :8000 +npm run build # frontend/dist/ (norma.html is self-contained) +``` + +**Docker:** `docker compose up -d` (two-stage image: node build → python:slim). + +**Tests:** +```bash +cd backend && uv run pytest # server unit/integration tests (fake upstreams, no network) +cd frontend && npm test # Vitest unit tests +cd frontend && npm run test:e2e # Playwright (starts backend/server.py itself) +``` + +**Lint / format / typecheck:** +```bash +cd backend && uv run ruff check . && uv run ruff format . && uv run mypy norma +cd frontend && npm run lint && npm run format && npx tsc --noEmit +``` + +`pre-commit install` wires the same checks into git. + +## Architecture Overview + +### Backend (`backend/norma/`) +`http.server`-based, stateless apart from the in-memory token store. +- `config.py` — `DEFAULTS`, `ENV_MAP`, `load_config` (defaults < JSON file < `NORMA_*` env < CLI), + `client_config` (the subset sent to the page as `/norma-config.js`). +- `api.py` — token store for `POST /api/external` / `GET /api/session/TOKEN` (TTL, size and count caps). +- `relays.py` — read-only relays to STRING, Arena3D and the database importers; strict upstream + allowlists, body limits, rate spacing. +- `handler.py` — `NormaHandler` routing, security headers, access log; `ThreadingServer`, `RedirectHandler`. +- `static.py` — static root (`frontend/dist`) and the blocklist for deployment files. +- `main.py` — argparse, TLS, browser opening, serve loop. `backend/server.py` is the entry point. + +### Frontend (`frontend/src/`) +The former 15K-line ` + + + + +
+ +
+ +
3.0The Network Makeup Artist
+
+ +
+ +
+ +
+ + + + + + +
+ + + +
+
+ +
+ + +
+ +
+
+ + + + + +
+ +
+ +
+
+
+ 0 nodes + 0 edges + 0 channels + 0 groups + +
+
+ + + + +
+

Delete file

+ +
+
+ + +
+
+
+ + +
+

Export image

+

Saves the current view as it looks, including group shading, shapes and labels.

+
+ Format + + + + + +
+
+ + + + + +

+ +
+ + +
+ +
.png
+
+ + + +
+
+ + +
+
+
+ + + +
+ +
+ + + + + +
Drag nodes · Shift-drag or Shift-click to select several · scroll to zoom · double-click to recenter
+
+ + + + + 100% +
+ + +
+ +

+
+ +
+
+ +
+
+
+ +
+
Source
+
Target
+
Direction
+
Edge ID
+
+
+
+ +
+
+
+
+ +
+

The Network Makeup Artist

+

See how annotated groups sit in a network: bubble sets, hulls and fog clouds, pie nodes and shapes, group-aware layouts, numeric values, a 3D view, communities, enrichment and network comparison.

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Bubble-set groups +outlines that hug only the members + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +−2 +0 +2 +log2FC +Numeric values +color scales, sizes, legends, steps + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Ribosome + +Proteasome + +Spliceosome + +Glycolysis + +−log10 FDR +Communities and enrichment +Leiden, MCL, Walktrap; term tests + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3D network +rotate, tilt and zoom freely + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +A + +B + +C +shared +edges +Compare networks +UpSet plots, Venn diagrams, statistics +
+
    +
  1. Load a networkupload files, fetch one from a database, or open an example
  2. +
  3. Add groups and valuesannotations, expression colors or numbers, communities
  4. +
  5. Arrange, analyse, sharelayouts, statistics, images, Arena3D, Cytoscape, Gephi
  6. +
+
+ + + + +
+ +

NORMA 3.0 runs in your browser: your files stay on this computer, except what you send to online databases or Arena3D yourself. Networks of up to 10,000 nodes.

+
+
+
+ +
+
+
+

Network Profiler

+

Topology statistics for one or more networks, computed the way igraph computes them. Each network is treated as a simple undirected graph: self-loops and parallel edges are set aside first.

+
+
+
+
Networks to profile
+
+
+
+ + + +
+
+
+
+
+

Group analysis (current view)

+

Statistics of the ticked groups of the current view, the network of groups, and an enrichment test, all on the shown part of the view (ticked groups and channels).

+
+ + + +
+
+
+

Enrichment

+

Tests whether groups of an annotation (terms) are over-represented, with a one-sided hypergeometric test and Benjamini–Hochberg FDR per tested set.

+
+ + + +
+ + + + + + + + +
+
+
+ + +
+
+
+
+
+

Layout benchmark

+

Runs every layout on the current view (its ticked groups and channels) without changing it, and scores how well each separates the groups: the silhouette width of the node positions (higher is better), the share of nodes inside another group's outline, and how much the outlines overlap (lower is better). Group layouts use the settings of the Layout section.

+
+ + + +
+
+
+
+
+

Runtime table

+

Times the main steps on random networks of growing size (generated from fixed seeds; the table lists their edges), in this browser: reading the network file, building the network, the weighted layout, edge bundling, the profile statistics, Louvain and the group separation score. The view is not changed.

+
+ + + +
+
+
+
+
+
+ +
+
+
+

Network Comparison

+

Compare two to ten networks from your open views or from the network files: which nodes and edges they share, how similar they are, whether hubs stay hubs, and how their topology differs.

+
+
+
+
Networks to compare
+
+
+
+ + + + +
+ +
+
+
+
+
+ +
+
+

NORMA API

+

Other applications can open NORMA with their own networks, groups and values, so that a pathway database, an analysis pipeline, a notebook or a web page can show its results in NORMA with one call. There are three ways in, all taking the same payload:

+
+
RESTPOST the payload to /api/external on a server running server.py; open the link it returns. Works from any language.
+
LinksA link to norma.html that points to the data (?data=, ?network=) or carries it (#json=). Needs no server.
+
postMessageA web page opens NORMA in a window or an iframe and sends it the payload directly. Needs no server.
+
+ +

This copy of NORMA answers at …

+
+ Python template script +

A ready-to-run client that checks a NORMA server, sends a network with groups, reads the stored payload back and builds a link that needs no server; it parses every answer and uses only Python's standard library. Adapt its build_payload() to your data.

+

python3 norma_api_client.py --server http://localhost:8000/ --open

+
+ +

The payload

+

A JSON object. Give the network in one of three forms, and add groups, values and settings as needed:

+ + + + + + + + + + + + + + + + +
FieldTypeMeaning
nametextThe view's name (and the prefix of its files in Files).
edgeslistThe network as {"source", "target"} objects, each optionally with type (the channel), weight (a number) and directed (true/false).
nodeslistOptional, with edges: {"id", …} objects whose other fields become node attributes (shown in node details and searchable).
filesobjectThe network as NORMA files instead: network (text, or a list for several networks), annotation (text or list) and expression (text), in the tab-separated formats. A list item can be {"name", "text"}.
networkobjectOr a complete NORMA view (nodes, edges, groupOrder, …) as saved by Save view file; see JSON format.
groupsobject or list{"Group": ["node", …]}, or [{"name", "members", "color", "description"}]. Nodes may be in several groups.
annotationslistMore groupings, each {"name", "groups"}; the view's Grouping list switches between them.
expressionobject{"node": "#e11d48"} (colors), {"node": 1.7} (one numeric value) or {"node": {"t0": 0.2, "t1": 1.4}} (several columns, e.g. time points).
directedtrue/falseRead files.network as directed.
settingsobjectDisplay settings by their names in a settings file (for example edgeDirection, edgeCurveStyle, showGroupHulls, hullStyle, nodeFillSelect, legendShow), plus theme and layout (fr, kk, stress, cose, circle, …).
tabtext"3d" opens the 3D page instead of the 2D page.
format / views—A whole session file ("format": "norma3-session") can be sent as the payload too.
+

With edges or files, NORMA adds the network, groupings and values to Files like uploaded files and opens them in a new view, so everything in NORMA works on them. Networks larger than 10,000 nodes are cut to their first 10,000. The example in Try it shows all common fields.

+ +

REST

+

When NORMA runs with server.py (see Running NORMA locally or on a server), the server offers:

+ + + + + + + + +
Web services (all answer in JSON)
RequestAnswer
POST /api/external with the payload as JSON (Content-Type: application/json){"token", "url", "expiresInHours"}. Open url in a browser: NORMA starts with the payload loaded.
GET /api/session/TOKENThe stored payload (NORMA fetches it when opened with ?session=TOKEN); 404 once it has expired.
GET /api/health{"status": "ok", "api": "1.0"}, to check that a server offers the API.
+

The token is made by the server; you never build the link yourself. Payloads are kept in the server's memory for 24 hours (set NORMA_API_TTL_HOURS), up to 50 MB each; the API accepts calls from any web page (CORS).

+

curl

+
curl -X POST http://localhost:8000/api/external \
+  -H "Content-Type: application/json" \
+  -d '{"name": "My network",
+       "edges": [{"source": "A", "target": "B"}, {"source": "B", "target": "C"}],
+       "groups": {"Module 1": ["A", "B"], "Module 2": ["C"]}}'
+
+# answer:
+# {"token": "k3J9…", "url": "http://localhost:8000/norma.html?session=k3J9…",
+#  "expiresInHours": 24}
+

Python

+
import requests, webbrowser
+
+payload = {
+    "name": "My network",
+    "edges": [{"source": s, "target": t} for s, t in [("A", "B"), ("B", "C"), ("C", "A")]],
+    "groups": {"Module 1": ["A", "B"], "Module 2": ["C"]},
+    "expression": {"A": 1.5, "B": -0.4, "C": 2.1},
+    "settings": {"layout": "fr", "showGroupHulls": True},
+}
+r = requests.post("http://localhost:8000/api/external", json=payload, timeout=30)
+r.raise_for_status()
+webbrowser.open(r.json()["url"])      # opens NORMA with the network
+

R

+
library(httr)
+payload <- list(
+  name   = "My network",
+  edges  = data.frame(source = c("A", "B"), target = c("B", "C")),
+  groups = list(`Module 1` = c("A", "B"), `Module 2` = list("C"))
+)
+r <- POST("http://localhost:8000/api/external", body = payload, encode = "json")
+browseURL(content(r)$url)
+

JavaScript

+
const r = await fetch("http://localhost:8000/api/external", {
+  method: "POST",
+  headers: { "Content-Type": "application/json" },
+  body: JSON.stringify({ name: "My network", edges, groups }),
+});
+const { url } = await r.json();
+window.open(url, "_blank");
+ + +

Links need no server: any copy of norma.html, on a web server or opened from disk, reads them when it starts.

+
http://localhost:8000/norma.html?data=https://example.org/my-payload.json
+http://localhost:8000/norma.html?network=https://example.org/net.txt&annotation=https://example.org/groups.txt&expression=https://example.org/values.txt&layout=fr
+http://localhost:8000/norma.html#json=eyJuYW1lIjoiTXkgbmV0d29yayIsImVkZ2VzIjpb…
+
    +
  • ?data=URL fetches a payload file. ?network=, ?annotation= and ?expression= fetch NORMA files; add &name=, &layout=, &theme= or &tab=3d if you like. The site holding the files must allow cross-site downloads (CORS), as GitHub raw files, Zenodo and most data portals do.
  • +
  • #json= carries the payload itself, as base64url-encoded JSON. It suits small networks (links longer than a few thousand characters may be cut by chat and mail programs); the part after # is never sent to any server. Make a link below builds one.
  • +
+

A button on your own site:

+
<a href="http://localhost:8000/norma.html?data=https://example.org/pathway-42.json"
+   target="_blank">Open in NORMA</a>
+ +

postMessage

+

A web page can open NORMA and hand it data directly, with no server and no size limit other than the browser's. NORMA sends {"type": "norma:ready"} to the page that opened or embedded it when it has started (and answers {"type": "norma:ping"} the same way). Send {"type": "norma:load", "payload": …, "requestId": …}; NORMA answers {"type": "norma:loaded", "ok": true, "summary": …} or {"ok": false, "error": …} with the same requestId. Each load opens a new view, so a page can send several networks.

+

In a new window

+
// open NORMA (any copy: a file, a web server or server.py)
+const norma = window.open("http://localhost:8000/norma.html", "_blank");
+
+window.addEventListener("message", (e) => {
+  if (e.source !== norma) return;
+  if (e.data.type === "norma:ready") {           // NORMA has started
+    norma.postMessage({
+      type: "norma:load",
+      requestId: 1,
+      payload: { name: "My network", edges, groups, expression },
+    }, "*");
+  }
+  if (e.data.type === "norma:loaded") {          // the answer
+    console.log(e.data.ok ? e.data.summary : e.data.error);
+  }
+});
+

In an iframe

+
<iframe id="norma" src="norma.html" style="width:100%;height:700px;border:0"></iframe>
+<script>
+  const frame = document.getElementById("norma");
+  window.addEventListener("message", (e) => {
+    if (e.data.type === "norma:ready")
+      frame.contentWindow.postMessage({ type: "norma:load", payload }, "*");
+  });
+</script>
+ +

Try it

+

Edit the payload and choose what to do with it.

+ +
+ + + + +
+
+ + +
+
+ +

Limits and privacy

+
    +
  • Payloads up to 50 MB; networks up to 10,000 nodes are shown in full.
  • +
  • Data sent with postMessage or #json links stays in the browser. With REST, the payload is kept only in the memory of the server you sent it to, for the time set there, and anyone with the link can open it until then; don't use it for confidential data on a shared server.
  • +
  • NORMA only displays what it receives: payloads cannot run code, and NORMA sends nothing back except the norma:loaded summary to the page that asked.
  • +
+
+
+ +
+
+ +
+
+ How to cite NORMA +

If NORMA helps your work, please cite:

+
    +
  • Karatzas E, Koutrouli M, Baltoumas FA, Papanikolopoulou K, Bouyioukos C, Pavlopoulos GA. The network makeup artist (NORMA-2.0): distinguishing annotated groups in a network using innovative layout strategies. Bioinformatics Advances. 2022;2(1):vbac036. doi:10.1093/bioadv/vbac036
  • +
  • Koutrouli M, Karatzas E, Papanikolopoulou K, Pavlopoulos GA. NORMA: The Network Makeup Artist, a web tool for network annotation visualization. Genomics, Proteomics & Bioinformatics. 2022;20(3):578–586. doi:10.1016/j.gpb.2021.02.005
  • +
+

Please also cite the databases and methods you use; see Resources and methods.

+
+

This is NORMA's technical help: how to load data, use each control and page, save your work and run NORMA on a server. For an overview of NORMA, its data sources, what's new and how to cite it, see About.

+ +
Getting started
+

Getting started

+

NORMA 3.0 shows networks whose nodes belong to groups (annotations) and whose node pairs can be linked by several kinds of edge (channels); unlike earlier versions of NORMA, it handles these multi-edge networks. The application is a single file, norma.html, that opens in a browser. It starts on the Welcome tab, which shows what NORMA does and offers quick ways to open an example, fetch a network from a database or upload files; the tab stays there, so you can always go back to it. Opening a network moves to 2D Network. It reads the same tab-delimited files as NORMA, plus a richer JSON format.

+

The screen has eight pages. Welcome is the start page, 2D Network is the interactive view, 3D Network shows the same view in three dimensions, Network Profiler computes topology statistics and group analyses, Network Comparison sets up to ten networks side by side, API explains how other programs can open NORMA with their data, Help is this page, and About describes NORMA, its data sources and how to cite it. The NORMA logo at the top left always leads back to Welcome. The ? next to a sidebar heading opens the matching section here. Finding your way around explains the rest of the screen.

+
    +
  1. Upload your files or open an example in the sidebar's Upload Data tab, or fetch a network from a database (STRING, Reactome, OmniPath, NDEx, IntAct or GO-CAM) in the Database importers tab.
  2. +
  3. Tick networks and choose an annotation and an expression file, then choose Open in new view.
  4. +
  5. Style that view in the Display tab: layout, groups, colors, sizes, labels and edges.
  6. +
  7. Repeat with other combinations. Each becomes its own view; switch between them with the View list in the top bar.
  8. +
  9. Save a picture with Export image (camera button, top bar), or the data in other formats with the Export tab.
  10. +
+ +

Finding your way around

+
    +
  • Top bar (purple): the View list, which picks the visualization you are working on, with Duplicate, Rename and Delete…, and the Grouping list (amber), which switches the current view to any annotation in Files while keeping node positions. For views opened from a built-in demo or a JSON file, the groups come with the data and the list only shows how many there are. On the right are Export image (the camera button), undo and redo and the theme.
  • +
  • Page tabs: Welcome (the start page, always available), 2D Network, 3D Network, Network Profiler, Network Comparison and Help. In the row below them, three colored labels say what the current view shows: the Network (blue), the Grouping with its number of groups (amber) and where node Colors come from (pink). Hover a label for its full text. At the right end of that row are the numbers of nodes, edges, channels and groups, with a directed or mixed direction tag when edges have a direction; when some groups are unticked, the group count reads, for example, 8/9. The labels and counts stay visible on every page, so the Network Profiler and Network Comparison pages always tell you which view "Current view" means.
  • +
  • Sidebar tabs: Upload Data (blue) holds examples, your files and opening saved work; Database importers (red) fetches networks and groupings from STRING, Reactome, OmniPath, NDEx, IntAct and the Gene Ontology; Export (purple) saves pictures, files for NORMA and other tools, Arena3D networks and sessions; Display (green) holds everything about how the current view looks. The Examples list only chooses what Open example opens next; it doesn't show what is on screen. The View list and the colored labels do.
  • +
  • Opening and closing sections: click a section's title (or focus it and press Enter or Space) to fold it away or open it again; the ? in a title opens its help instead. All sections start folded, so each tab shows a short list of titles; open the ones you need. Inside a section, small colored headings with a line (for example Direction and arrows or Session file (.json)) separate groups of related options.
  • +
  • Section order and colors: every sidebar section has a color of its own, and neighbouring sections always have clearly different ones. Upload Data: Examples (green), Files (blue) and Open saved work (purple). Database importers: STRING (red), Reactome (blue), OmniPath (teal), NDEx (orange), IntAct (green) and Gene Ontology (purple); click an importer's title to open or close it. Export: NORMA files (blue), Image (pink), Other tools (orange), Save your work (slate) and Arena3D (teal). The Display tab starts with the groups and how they are drawn and placed, then the look of nodes and edges, then extras: Node groups (amber), Group highlighting (magenta), Layout (green; 3D layout, teal, on the 3D page), Colors (pink), Nodes (sky blue), Labels (orange), Edges (slate), Edge channels (lime), Legend (crimson), Attributes (cyan) and Performance (brown). The labels below the page tabs use the matching colors: Network blue like Files, Grouping amber like Node groups, and Colors pink like Colors.
  • +
+ +

Views

+

A view is one visualization: a set of networks with an annotation and an expression file, or an example or JSON file, together with how it looks. Each view keeps its own display settings, node positions, zoom, ticked groups and channels, group colors and shapes, channel colors, edge style including bundling, and its 3D positions and camera, so you can prepare several pictures from the same uploads and move between them without losing work. The theme and the search options apply to every view.

+
    +
  • Duplicate copies the current view, for example to compare two annotations or two layouts of the same network.
  • +
  • Rename names the current view; press Enter to keep the name or Escape to cancel.
  • +
  • Grouping (top bar) regroups the current view by another annotation, or by none, and keeps node positions; the same as ticking an annotation in Files and choosing Show in this view.
  • +
+

In the Upload Data tab, Show in this view replaces what the current view shows with the ticked files and keeps its display settings; if only the annotation or expression file changed, node positions are kept. Open in new view shows the ticked files in a new view and leaves the current one as it was. Open example always opens a new view, reusing an empty untitled one if there is one.

+ +

Undo and redo

+

The arrows in the top bar undo and redo changes to the current view: moved nodes, layouts, spread, display settings, ticked groups and channels, group and channel colors, and switching or clearing what the view shows. Keyboard: Ctrl+Z (⌘Z on a Mac) undoes, Ctrl+Shift+Z or Ctrl+Y redoes. A quick series of changes, such as dragging a slider or a layout's animation, counts as one step. Each view has its own history of up to 60 steps, kept while the page is open. Zooming, panning, searching, selecting and the theme are not part of the history.

+ +
Data
+

Examples

+

The first block of the list holds NORMA's example datasets. The TP53 documentation example also brings a small file of simulated log2 fold changes and adjusted p-values, for trying the numeric color scale; tick it under Expressions. Opening one adds its network, annotations and expression file to Files, selects them and shows the result in a new view. Their other annotations stay in the list so you can switch between them.

+ + + + + + + + + + + + + + +
ExampleFilesSource
STRING: TP53 interactorsNetwork (weighted), groups, expressionSTRING
STRING: BCAR3 interactorsNetwork; GO biological process, GO molecular function, KEGGSTRING
Drosophila Tau networkNetwork; KEGG, Louvain communities; expressionPMID 31488613, doi:10.1523/JNEUROSCI.0391-19.2019
Human gene co-expressionNetwork; GO biological process, molecular function, cellular component; KEGG; MCODE cluster colorsPMID 19081792, doi:10.1371/journal.pone.0003911, bioinfow.dep.usal.es/coexpression
COVID-19Network; InterPro, SMART, GO (three), KEGGIntAct
Gallus gallusNetwork; KEGGBioGRID
Signalling cascade (showcase)Directed multi-edge network with five channels; four cascade levels; simulated log2FC and adjusted p-values. Opens with arrows, curved parallel edges, the numeric color scale and the legend.Simulated for NORMA 3.0
Healthy vs disease (showcase)Two networks over the same 64 nodes (the disease network loses about a fifth of the edges and gains links in one module); four modules; simulated log2FC. Opens with both networks overlaid as two channels; compare them on the Network Comparison page.Simulated for NORMA 3.0
Arena3D example (showcase)The seven-layer example network from Arena3D's documentation; its layers become groups. Try Open in Arena3D to send it back.Arena3D
TP53, NORMA documentationThe small example from NORMA's help pagesNORMA
+

The second block holds built-in demos in JSON form: a curated multi-edge operon, two synthetic networks of four overlapping modules with attributes on nodes, edges and groups, and random networks from 20 to 5,000 nodes. The 40-node random directed multi-edge network opens with arrows, curved parallel edges and a hierarchical layout: its four groups form a signalling cascade from receptors to target genes, pairs carry one to three channels, and about a quarter of them also have a feedback edge in the opposite direction. The 20-node random network opens with edge labels: each edge carries an interaction attribute (activates, inhibits, binds, …) shown at its middle. All demos are generated from fixed seeds and can be downloaded under Example files. Clear view empties the current view but keeps your files.

+ +

Sample data and sample output

+

Every example opens with one click, from Open an example on the Welcome page or the Examples list in the Upload Data tab. Its files (the sample data) can be downloaded under Example files, to see the formats or to try uploading them. The links below open a sample result directly; they behave exactly like results from your own data:

+ + + + + + + + + + +
Sample outputShows
Drosophila Tau networkA published network with KEGG pathways, Louvain communities and expression colors; try the Network Profiler and Group analysis on it.
STRING: TP53 interactorsA STRING network with evidence channels, groups and expression.
Signalling cascadeDirected, multi-channel edges with numeric values, a color scale and a legend.
Healthy vs diseaseTwo networks to compare on the Network Comparison page.
Arena3D exampleSeven layers, ready to open in Arena3D.
Four overlapping modules in 3DPie-chart nodes and group layouts on the 3D page.
+

Each link can also be bookmarked or shared: norma.html?example=NAME (add &tab=3d for the 3D page).

+ +

Example files

+

Sample data: every file behind the examples, so you can see the file formats and try NORMA's upload with them. Click a name to download the file; the same examples can be opened directly from the Examples list or with the sample-output links in Sample data and sample output.

+
+

Built-in demos

+

The demos are generated from fixed random seeds, so each download matches what Open example shows. Network and Groups are NORMA files: the network has a Weight column when any edge is weighted (edges without one are written with weight 1) and a Type column when there are several channels. JSON keeps everything, including attributes, group descriptions and, for the labelled demo, the settings that turn edge labels on.

+
+ +

Files and formats

+

Upload one or many files at once with Upload files…, or drop them on the upload box. Detect file type recognizes networks by their header and expression files by their colors; anything else is read as an annotation. Pick a type yourself if detection guesses wrong. Networks with more than 10,000 nodes are cut to their first 10,000 nodes, with a note in the list. Arena3D network files (columns SourceNode, SourceLayer, TargetNode, TargetLayer, Weight, Channel) are recognized too: each becomes a network and an annotation with one group per layer. The list below it chooses whether network rows without a Direction value are read as undirected or directed. The name box names a single upload; with several files, each keeps its file name. Every file appears in one of three lists (Networks, Annotations, Expressions), in A–Z order, and stays there until you delete it (see Deleting files). Files live only in this browser tab.

+

Deleting files

+

There are four ways to remove things completely:

+
    +
  • Delete… in the top bar (next to Rename) lists the current view and every file it shows (its network files, grouping and expression file), each with a tick box; untick what you want to keep. Removing the view closes it (the previous view is shown; removing the only view leaves an empty one). For a view opened from an example or a JSON file, only the view itself can be removed, since its data belongs to the view.
  • +
  • The ✕ next to the Grouping menu removes the grouping file the view uses.
  • +
  • The ✕ next to a network, annotation (grouping) or expression file in Upload Data → Files removes that file.
  • +
  • Delete all files… under the Files lists removes every file at once.
  • +
+

Each asks Are you sure you want to completely remove …? and names the views that will change; nothing happens until you choose Yes, remove. Deleting a file:

+
    +
  • removes the file from Files, from the Grouping menu and from every other file list and menu, together with everything NORMA read from it (for STRING and database imports, also the extra node details that came with the network);
  • +
  • updates every view that shows it, including views you are not looking at: a view keeps its remaining networks, node positions, group colors and ticked groups and channels, and loses the deleted network's nodes and edges, or the deleted groups or colors; a view left with no network becomes empty (its display settings stay);
  • +
  • clears the undo history of those views, so the deleted data can't come back, and removes Network Profiler and Network Comparison results that were computed from it;
  • +
  • leaves out the deleted data from sessions saved afterwards;
  • +
  • renames views that were named after their files automatically (views you renamed keep their names).
  • +
+

Groupings that were fetched for a deleted network (for example from STRING) are not deleted with it; delete them separately if you don't need them. Deleting never touches the files on your disk, and cannot be undone inside NORMA: to use a file again, upload it again. Views opened from examples or JSON files hold their own data; empty them with Clear view.

+

Network file

+

Tab-separated, with a header row Source, Target and optionally Weight. Connections are undirected: A–B and B–A are the same connection, so repeats and self-loops are removed and reported.

+
Source	Target	Weight
+CDKN1A	TP53	5
+TP53	MDM2	1
+

An optional Type column (also accepted as Channel, Layer or Interaction) keeps several edges between the same pair, one per type, and makes each type a channel. Only a repeat of the same pair and type is removed. This column is new in NORMA 3.0: earlier versions of NORMA read only Source, Target and Weight and kept a single connection per pair.

+
Source	Target	Weight	Type
+TP53	MDM2	0.9	experiments
+TP53	MDM2	0.7	textmining
+

An optional Direction column (also accepted as Directed, Dir or Arrow) marks each row as directed (also yes, true, 1 or ->) or undirected (also no, false, 0 or -). A directed row points from Source to Target, so A→B and B→A are two connections; for undirected rows they are one. Rows with an empty or unknown value follow the direction chosen when uploading. This column is new in NORMA 3.0; see Directed and undirected networks.

+
Source	Target	Type	Direction
+EGFR	GRB2	binding	undirected
+MAPK1	ELK1	phosphorylation	directed
+ELK1	FOS	expression	directed
+

Annotation file

+

No header. Each line is a group name, a tab, then the group's nodes separated by commas without spaces. Group names may contain spaces and commas; node names may not contain commas. A node can be in any number of groups.

+
Group-2	BCL2L1,MDM4,MDM2,CHEK2
+Group-5	TP53,EP300
+

Expression file

+

No header. Each line is a node name, a tab, then a color: a name such as red or a hex code such as #ff0000. Nodes without a color are gray.

+
CDKN1A	blue
+MDM4	#00ff00
+

Numeric values can be used instead of colors (new in NORMA 3.0): each line is a node name, a tab, then one or more numbers, such as a log2 fold change and an adjusted p-value. An optional first line names the columns; without it they are called value, value 2 and so on. NA, empty cells and similar entries count as missing. Decimal commas are accepted. Detect file type recognizes such files, and the list shows how many value columns they have.

+
Gene	log2FC	padj
+CDKN1A	2.4	0.000001
+BCL2L1	-1.8	0.0003
+TP53	NA	NA
+

Choosing what to show

+

Tick one or more networks, choose one annotation and one expression file (or None), then choose Show in this view or Open in new view. A note under the buttons tells you when the selection differs from what the view shows. If only the annotation or expression changed, Show in this view updates in place and keeps node positions, so you can flip between GO, KEGG and community groupings of the same layout, or open each grouping in its own view to keep them side by side.

+

With an expression file selected, nodes are filled with its colors and groups are shaded behind them, as in NORMA. Without one, nodes are filled with their groups' colors, split into a pie for nodes in several groups.

+

Annotation names not in the network

+

Node names in an annotation must also appear in the network. The check happens twice:

+
    +
  • When you upload an annotation, its names are compared with the networks uploaded together with it, or, if there are none, with the ticked networks, or else with every network in the list. Names found in none of them are discarded right away, groups left empty are removed, and a warning lists what was dropped. Download corrected annotation saves the cleaned file, which is what NORMA's companion R script produces. If no name matches at all, the file is not kept. If no network has been uploaded yet, the check waits until the annotation is shown.
  • +
  • When you show files, annotation and expression entries for nodes that aren't in the selected networks are left out and listed in the status notes, again with a corrected annotation to download.
  • +
+ +

Several networks and multi-edges

+

New in NORMA 3.0. Previous versions of NORMA did not support multi-edge graphs: every pair of nodes could have only one connection, and repeated connections were removed when a network was loaded. NORMA 3.0 keeps all of them, as parallel edges in separate channels.

+

Parallel edges come from three sources: a Type column in a network file, several network files shown together, or JSON files with more than one edge per pair.

+

Ticking more than one network overlays them. Nodes with the same name are merged, and each network becomes its own edge channel, so a connection found in two networks is drawn as two parallel edges in two colors. Edges and nodes then carry a network / networks attribute naming their source, which you can search for.

+

Ways to work with parallel edges:

+
    +
  • Edge channels turns each channel on or off and recolors it.
  • +
  • Edges → Parallel edges: merge per node pair draws one edge per pair, thicker the more channels it carries. It's gray when channels disagree and in the channel's color when there's only one. Clicking a merged edge lists all its channels.
  • +
  • Edges → Shape: curved fans parallel edges apart so each stays visible.
  • +
  • Layout → Use checked channels only positions nodes using only the channels you've ticked, so you can lay the graph out by one network and compare the others on top.
  • +
  • Edges → Shape: bundled draws edges running in similar directions together, and Labels → Edge labels can name each edge's channel.
  • +
  • Network Profiler reports, for the current view, how many node pairs are shared between channels and how similar each pair of channels is; Network Comparison sets whole networks side by side.
  • +
  • Export → NORMA files → Keep channels as a Type column saves parallel edges in a NORMA network file instead of merging them.
  • +
+ +

Directed and undirected networks

+

New in NORMA 3.0. Earlier versions of NORMA treated every connection as undirected. Edges can now point from a source to a target, and a network can mix directed and undirected edges.

+

Bringing direction in

+
    +
  • When uploading, the list under the file type sets how network rows are read: undirected (A–B), the default, or directed (Source → Target). Choose directed before uploading if A→B and B→A are different interactions: an undirected upload treats them as the same connection and keeps only one.
  • +
  • A Direction column in the network file overrides that choice row by row (see Network file), so one file can mix both kinds.
  • +
  • In JSON, "directed": true at the top makes every edge directed, and "directed": true or false on an edge sets that edge (see JSON format).
  • +
  • The built-in Random directed multi-edge network shows a signalling cascade with up to three channels per pair and some feedback edges.
  • +
+

Handling a network as directed or undirected

+

Display → Edges → Direction decides, for the current view:

+
    +
  • Treat all edges as undirected (default): direction is ignored everywhere, as in earlier versions of NORMA. When the data does mark edges as directed, the top bar shows a direction ignored tag; click it to switch to As in the data.
  • +
  • As in the data: edges marked as directed get arrows and are treated as directed; the rest are undirected.
  • +
  • Treat all edges as directed: every edge points from its source to its target, whatever the file says.
  • +
+

With arrows shown, Arrow chooses a triangle, vee, curved triangle or chevron, and Arrow size scales them. The top bar shows a directed or mixed direction tag next to the counts.

+

What direction changes

+
    +
  • Parallel edges: A→B and B→A are separate edges. With Shape: curved they are drawn apart; merge per node pair merges only edges running the same way.
  • +
  • Node size and details: In-degree and Out-degree count incoming and outgoing edges (an undirected edge counts as both). Betweenness and closeness follow edge directions when the shown part has directed edges; clustering ignores direction. A node's details show in- and out-degree and mark each neighbor with → (outgoing) or ← (incoming); an edge's details show its direction.
  • +
  • Layout: Hierarchical places sources above their targets. Force-directed layouts and the group strategies ignore direction.
  • +
  • Network Profiler and Network Comparison: with Use edge direction ticked, they add directed statistics and match edges by direction (see below).
  • +
  • Exports: NORMA network files get a Direction column when any edge is directed, and JSON keeps each edge's direction. Earlier versions of NORMA read neither.
  • +
+

Search, group shading, shapes, bundling and edge labels work the same for directed and undirected edges.

+ +

Database importers

+

The Database importers tab fetches networks and groupings from public databases; no account is needed. Every import is added to Files (a network plus one annotation per grouping) and opens in a new view, where the Grouping list switches between its groupings. Nodes keep useful identifiers as attributes, in particular uniprot, which the Gene Ontology importer uses. Networks with directed edges open with arrows. Very large results are cut to 10,000 nodes, and every importer has a Cancel button. When NORMA runs with server.py, all requests go through the server (see Running NORMA locally or on a server); otherwise the browser calls the services directly, which some browsers block. STRING is described in Importing from STRING.

+

Reactome

+

Curated pathways from Reactome. Type a pathway name (choose the species) or a stable identifier such as R-HSA-69278, choose Search pathways, pick one, and choose Fetch pathway network. NORMA reads the pathway's reactions (up to Most reactions) and their participating molecules: nodes are the proteins (and, if ticked, small molecules), and two nodes are linked when they take part in the same reaction, weighted by how many reactions they share. Reactions with more than the chosen number of molecules are left out, since they would link everything to everything. Groupings: the pathway's sub-pathways (the proteins of the reactions each contains) and, optionally, the 40 largest reactions.

+

OmniPath

+

Signalling, transcriptional and ligand–receptor interactions from OmniPath for human, mouse or rat. Type gene symbols or UniProt accessions and tick the datasets (OmniPath core, pathway, kinase–substrate and ligand–receptor extras, CollecTRI and DoRothEA). NORMA fetches their interactions with all partners (keeping the Most partners best supported by curation effort) or only those among the given proteins. Edges keep OmniPath's direction; Channels split them by effect (stimulation, inhibition, both, unsigned) or by interaction type, and their weight is the curation effort. Groupings: protein complexes with at least two members in the network, intercellular roles (ligand, receptor, …) and, optionally, an annotation resource such as SignaLink pathways or NetPath.

+

NDEx

+

Public networks from the Network Data Exchange. Search with words or paste a network UUID, pick a network (the list shows its size and owner) and choose Fetch network. Node names, edge interactions and numeric weights are read from the network, its saved layout is kept, and node attributes become node attributes in NORMA. Groups depend on the network ("limited"): every node attribute with 2 to 60 distinct values covering at least 30% of the nodes (such as a type or module) becomes a grouping, up to six.

+

IntAct

+

Experimentally detected molecular interactions from IntAct, through its PSICQUIC service. Type gene names or UniProt accessions and choose the organism (both partners must belong to it). Interaction records below the Lowest MI-score are left out; the others are merged per pair and interaction type (physical association, direct interaction, …), which become channels, with the best MI-score as the weight. Most partners keeps the highest-scoring partners, and Most records limits how much is read. IntAct has no groupings of its own, so NORMA adds one that separates the query proteins from their partners; add GO terms or communities for more.

+

Gene Ontology

+
    +
  • GO-CAM network (limited): GO-CAM models from the Gene Ontology link the activities of gene products causally. Choose the organism, List models, filter and pick one (or type a model ID), and Fetch GO-CAM network. Nodes are the gene products that enable the activities; directed edges follow the causal relations (directly positively regulates, provides input for, …), which become channels. Groupings: the biological process, cellular component and molecular function of each activity.
  • +
  • GO groupings add GO terms as groups to the network already shown, from QuickGO annotations of the nodes' UniProt accessions (the node name or its uniprot attribute). Tick the aspects; Leave out electronic annotations skips IEA evidence, NOT annotations are always skipped, and each aspect keeps up to Groups per aspect terms with at least Smallest group members (terms that contain every node are left out). The groupings are added to Files and shown in the current view.
  • +
+ +

Importing from STRING

+

New in NORMA 3.0. STRING, in the Database importers tab, fetches a protein interaction network, and functional groupings for it, directly from the STRING database. It needs an internet connection.

+

The network

+
    +
  • Proteins or genes: one or more names, such as TP53, p53 or a UniProt accession, separated by spaces, commas or new lines. STRING matches each name to its best protein and the status notes say how each was matched and which names weren't found.
  • +
  • Organism: pick one from the list, or choose Other organism and type its NCBI taxon ID (for example 7955 for zebrafish). Any organism in STRING can be used.
  • +
  • Network: functional associations use all of STRING's evidence; physical interactions only keeps proteins that bind each other or form a complex.
  • +
  • Interactors to add (0–500) extends the network with the proteins most confidently connected to yours, best first. With 0 and several names, only the connections among them are fetched; with a single name STRING always adds its 10 best interactors.
  • +
  • Minimum confidence: highest (0.900), high (0.700), medium (0.400), low (0.150) or a custom value; weaker connections are left out.
  • +
  • Evidence channels: gene neighborhood, gene fusion, co-occurrence, co-expression, experiments, curated databases and text mining. Untick channels to leave that evidence out: each connection's confidence is then recomputed from the ticked channels only, the way STRING combines them, and connections that fall below the minimum are dropped (the notes say how many).
  • +
  • Edges: one edge per channel makes a multi-edge network in which each channel has STRING's color and its own score as the weight, so you can tick channels on and off, merge or bundle them later. One edge per pair keeps a single edge with the combined score.
  • +
+

Each protein carries its STRING identifier, STRING's description of it, and whether it was one of your query proteins, as attributes: they appear in the node's details and can be searched.

+

Groupings from functional terms

+
    +
  • Terms: enriched in the network (recommended) runs STRING's enrichment analysis on the network's proteins and keeps terms with an FDR at or below the chosen value; all annotations of its proteins takes every term any of them has. STRING doesn't share full KEGG annotations for licensing reasons, so KEGG pathways come only with enriched terms.
  • +
  • Term collections: GO biological process, molecular function and cellular component, KEGG, Reactome and WikiPathways pathways, UniProt keywords, Pfam, InterPro and SMART domains, diseases, tissues, subcellular localization, phenotype ontologies (human, mammalian, fly, worm, zebrafish, fission yeast), STRING's local network clusters and reference publications. Which ones have terms depends on the organism and the STRING version; the notes list what STRING returned. Any other collection STRING offers includes collections not in the list.
  • +
  • Groups per collection keeps the most significant (enrichment) or largest (all annotations) terms, and Smallest group leaves out terms with fewer proteins in the network.
  • +
+

Each collection becomes an annotation in Files, named after the network and the collection, and the view opens grouped by the first available of KEGG, Reactome, GO biological process and so on. Switch collections with the Grouping list in the top bar; node positions stay. Every group carries the term's identifier and collection and, for enrichment, its FDR, p-value and number of proteins in the genome, shown in the group's details (its i button). Fetch groupings for the current view adds more collections, or the same ones with other settings, to a STRING network that is already shown.

+

STRING server

+

By default NORMA asks https://string-db.org, which always answers with the latest STRING version; the notes say which version was used. To get the same answers every time, enter a version address such as https://version-12-0.string-db.org. NORMA identifies itself to STRING and waits a second between requests, as STRING asks, so a full import takes a few seconds. Cancel stops a request that takes too long. Untick Fetch protein descriptions to save one request.

+

Networks and groupings fetched from STRING can be saved as NORMA files or JSON like any other; please cite STRING (Szklarczyk et al., Nucleic Acids Research 2023;51(D1):D638–D646, doi:10.1093/nar/gkac1000) when you use them.

+ +

JSON format

+

The only required fields are nodes and edges:

+
{
+  "nodes": [
+    { "id": "A", "groups": ["g1", "g2"], "size": 40, "color": "#e07a7a", "tissue": "liver" },
+    { "id": "B", "group": "g1" }
+  ],
+  "edges": [
+    { "source": "A", "target": "B", "type": "experiments", "weight": 0.8, "pmid": 123 },
+    { "source": "B", "target": "A", "type": "regulation", "directed": true }
+  ],
+  "directed": false,
+  "groupAttrs": { "g1": { "label": "Group one", "description": "…", "curated": true } },
+  "nodeColors": { "g1": "#e8a15f" },
+  "edgeColors": { "experiments": "#b464c9" },
+  "groupShapes": { "g1": "diamond" },
+  "config": { "showEdgeLabels": true, "edgeLabelContent": "attr:pmid" }
+}
+
    +
  • Nodes: id, group or groups, size and color have fixed meanings; any other field is kept as an attribute.
  • +
  • Edges: id, source, target, type (the channel) and weight have fixed meanings; any other field is kept as an attribute. Any number of edges may join the same pair, for example one per channel. directed (true or false) sets an edge's direction.
  • +
  • Direction: a top-level "directed": true makes every edge without its own directed field directed.
  • +
  • Numeric values: "values": { "log2FC": 1.2, "padj": 0.003 } on a node gives it numeric values for the color scale and node size.
  • +
  • Groups: in groupAttrs, label, description and color have fixed meanings; anything else is a group attribute. Attributes can also be nested under an attrs object.
  • +
  • Colors: nodeColors and edgeColors fix group and channel colors.
  • +
  • Shapes: groupShapes gives a group a node shape. Names are Cytoscape's: rectangle, round-rectangle, diamond, triangle, vee, pentagon, hexagon, octagon, star, rhomboid, tag, concave-hexagon, barrel; groups not listed are circles.
  • +
  • Group order: an optional groupOrder list sets the order of groups in the legend and the order palette colors are handed out.
  • +
  • Settings: an optional config object holds display settings in the same shape as a settings file. For example, "edgeLabelContent": "attr:pmid" labels edges with their pmid attribute.
  • +
+ +
Viewing and styling
+

Exploring the network

+
    +
  • Selecting several nodes: Shift-click (or Ctrl/⌘-click) adds or removes a node, Shift-drag on the background draws a selection box, Ctrl+A selects every visible node and Escape clears the selection. Selected nodes get a thick yellow border and halo, which disappears as soon as they are deselected; a bar at the top right shows how many are selected with Zoom to selection and Clear, and dragging any selected node moves them all.
  • +
  • Drag nodes to move them, scroll to zoom, and drag the background to pan. Zoom has no practical limit in either direction, and the view always keeps the network in frame: a network smaller than the canvas can't be pushed off it, and a larger one can't be panned past its edges. The +, – and Fit buttons at the bottom left zoom around the center; the number under them is the zoom level. Double-click the background to fit everything, or a node to center it.
  • +
  • Click a node for its groups, degree (with in- and out-degree for directed edges), betweenness, closeness, clustering coefficient (computed on the ticked groups and channels), attributes and neighbors; with directed edges, → and ← mark outgoing and incoming connections. Group names in that panel open the group's details.
  • +
  • Neighbourhoods and paths (new in NORMA 3.0): the node details offer Open neighbourhood as a view with 1, 2 or 3 steps. With nodes selected, Neighbourhood in the selection bar opens them with their direct neighbours; with exactly two selected, Shortest paths opens every shortest path from the node selected first to the second, keeping only the edges on those paths, and says how many paths there are. Both follow edge directions where edges are directed, use only the ticked groups and channels, and open as new views that keep the nodes' positions, groups, colors and settings; the starting nodes are selected and carry a role attribute (center, source or target).
  • +
  • Click an edge for its channel, endpoints, direction, weight and attributes.
  • +
  • Click the i next to a group for its description, attributes, member and edge counts, density, and the groups it shares members with.
  • +
  • Click empty space to clear the highlight.
  • +
  • Find nodes, at the top left of the network (the – button at its right minimizes the panel to a small Find nodes button, which still shows the number of matches; click it to open the panel again), highlights matching nodes and shows how many matched. Choose how to match: Contains, Exact name, Starts with, Ends with (suffix) or Regular expression. A regular expression can be typed as is, such as ^Rp[LS]\d+$, or between slashes with flags, such as /kinase$/i. Matching ignores case unless Match case is ticked, and also checks attribute values while Search attributes is ticked. Press Enter to zoom to the matches and Escape to clear.
  • +
  • The numbers at the top right count nodes, edges, edge channels and groups in the view; with groups unticked, the group count shows ticked/all, for example 8/9.
  • +
+ +

3D Network

+

New in NORMA 3.0. The 3D Network page shows the current view in three dimensions. It is the same view as 2D Network: groups, colors, pie slices, node shapes, borders, labels, edge labels, arrows, curved parallel edges, group shading, ticked groups and channels, search results and selected nodes all carry over, and every setting in the Display tab except the 2D Layout section changes both pictures. Node positions and the camera are separate for 3D, and each view keeps its own.

+

Moving around

+
    +
  • Drag the background to rotate, right-drag (or Alt-drag) to pan, and scroll to zoom. Turning and tilting have no limit: drag on, or keep pressing a turn or tilt button, and the network rolls over as often as you like, so it can be seen from any side, including upside down.
  • +
  • Drag a node to move it within the screen plane; with several nodes selected, dragging one moves them all.
  • +
  • Click a node or an edge for its details, exactly as in 2D; click the background to clear. Shift-click selects nodes and Shift-drag draws a selection box. Double-click a node to turn around it, or the background to fit everything.
  • +
  • The buttons at the bottom left zoom (+, –, Fit), turn (↺, ↻), tilt (↑, ↓), reset the view (⟲) and switch automatic rotation on and off (Auto). With the 3D picture focused, the arrow keys turn and tilt, + and – zoom, and F fits.
  • +
  • Find nodes, Zoom to selection and Export image work here too and act on the 3D picture.
  • +
+

3D layout

+

On this page the Display tab shows a 3D layout section in place of the 2D one. As in 2D, layouts arrange only the nodes of ticked groups and, with Use checked channels only, only the ticked channels count.

+
    +
  • Force-directed 3D, weighted: the Fruchterman–Reingold idea in three dimensions; heavier edges pull harder. Each connected piece is laid out on its own and smaller pieces are placed around the largest. It runs in the background and is used the first time a view is shown in 3D.
  • +
  • Sphere, Cube grid and Random place nodes on a sphere, in a cube or at random, best-connected nodes first.
  • +
  • Groups as 3D clusters: each ticked group becomes a ball of its nodes, with hubs in the middle. Groups that share many edges are placed close together, and the balls never overlap. Nodes in several groups sit between their groups.
  • +
  • Groups on stacked layers: the 2D layout lies flat and each group gets its own layer, one above the other, so the links between groups run between the layers.
  • +
  • Same as 2D (flat), Height by hierarchy level and Height by degree keep the 2D layout and add height: none, the level from the top of a hierarchy (following edge directions when edges are directed), or the number of connections, so hubs rise above the rest. Height step sets the distance between levels.
  • +
+

Spread pushes nodes apart or pulls them together, as in 2D. Double-click the slider to go back to 1×.

+

Look

+
    +
  • View from turns the camera to the front, top, side or a tilted angle and fits the network.
  • +
  • Perspective: normal, strong (wide angle, more depth) or none (parallel projection, where sizes don't change with distance).
  • +
  • Nodes: shaded spheres or flat, as in 2D. Group shapes and pies are always drawn flat, facing you.
  • +
  • Depth fading blends distant nodes and edges into the background, which helps tell near from far.
  • +
  • Floor grid and axes draws a grid under the network and short x, y and z axes at its center.
  • +
  • Rotate automatically turns the network slowly; Rotation speed sets how fast.
  • +
+

Group shading follows the nodes as seen from the camera. Edge bundling applies to the 2D picture only; in 3D, edges are straight, and parallel edges curve apart when Shape is curved or bundled. On large networks the picture is simplified while you move it (no labels, pies or arrows) and drawn in full as soon as you stop; at most 500 node labels and 300 edge labels are drawn at a time, the nearest first.

+ +

Layout

+

This section is about the 2D Network page; the 3D page has its own layouts (see 3D Network). Every layout arranges only the shown part of the view: nodes in ticked groups, connected by edges of ticked channels while Use checked channels only is on. Nodes of unticked groups stay where they are, so ticking them again brings them back in place.

+

The switch at the top of Layout chooses how nodes are arranged. Only one side is active at a time; the other is shown greyed out and marked frozen, and Run layout runs only the active side. The choice is saved with the view.

+

By connections

+

Positions come from the edges alone; groups play no part. Force-directed (cose) places connected nodes near each other. Force-directed, weighted is the Fruchterman–Reingold layout NORMA uses: heavier edges pull their nodes closer, and the result is the same every time for the same input. Kamada–Kawai (new in NORMA 3.0) treats every pair of nodes as a spring whose length is the number of steps between them and relaxes the springs one node at a time; stress majorization starts from a pivot-MDS layout and then minimises the same kind of stress (the mismatch between on-screen and network distances) for all nodes together, which is usually faster and keeps distances most faithfully. Both ignore edge weights, lay out each connected part separately, and handle parts of up to 2,500 nodes. They are also available as group strategies and inside groups. Concentric puts high-degree nodes in the middle; hierarchical arranges nodes in levels from a root, following edge directions when the view has directed edges. Networks above 700 nodes load with a grid layout, since a force-directed layout can freeze the page for a while at that size; you can still run it yourself.

+

By groups

+

Group arrangement decides where each group goes, and Layout inside each group arranges the group's own nodes (circle, force-directed, concentric, hierarchical, grid or random). A node in several groups sits at the average of its positions, between those groups. Only groups ticked under Node groups count.

+
    +
  • Groups as separate blocks treats each group, plus one block for nodes in no group, as a disc sized to fit its nodes, and places the discs so they never overlap. Force-directed puts groups that share many edges next to each other and packs the rest around them; Circle lines them up on a ring; Grid packs them in rows; Hierarchical stacks them in levels starting from the best-connected group; Concentric puts the largest group in the middle with the others in rings by size. Space between groups sets the gap.
  • +
  • NORMA-2.0 strategies (Karatzas et al., 2022, Bioinformatics Advances 2:vbac036, Figure 1) use the same weights and steps as NORMA's own code, with the algorithm you pick under Algorithm the strategy runs. A small diagram shows how the selected strategy works; links a strategy adds exist only while positions are computed. Weights change the result only with the two force-directed algorithms. +
      +
    • Strategy 1, virtual node per group: each group gets a hidden hub linked to all its members with weight 50. Real edges get weight 5, and every pair of members gets a light link (0.1). The algorithm runs on this larger network and the hubs are then removed.
    • +
    • Strategy 2, group gravity: every pair of nodes in a group gets a link at the network's highest weight. Real edges inside a group are multiplied by Force strength, and edges between groups, or to ungrouped nodes, are divided by it.
    • +
    • Strategy 3, super nodes per group: each group becomes a single node and ungrouped nodes stay as they are. The algorithm lays out this collapsed network and Force strength pushes the result outward.
    • +
    + Strategies 1 and 2 place every node themselves. With them, Layout inside each group can also be Keep the strategy's arrangement; any other choice rearranges each group around where the strategy put it and keeps groups from overlapping.
  • +
+

Group size scales each group's local layout. Force strength runs from 1 to 20 (NORMA's default is 10). Use checked channels only applies to both sides. A group with more than 400 nodes gets 60 random partner links per node instead of links to every other member, to keep the layout fast. While By groups is active, it is also used when a network loads and when you switch annotations.

+

Fit to screen zooms to show the whole network. Spread changes how strongly nodes repel each other compared with the pull of their links, from a quarter to four times the strength of the current arrangement. To the right, dense clusters open up and loosely linked parts move apart; to the left, linked nodes are drawn together while nodes are kept from overlapping. It starts from the current positions and keeps the overall arrangement, and going back to 1× restores them. Double-click the slider to go back to 1×. Moving nodes by hand starts again from their new positions, and running a layout resets it.

+ +

Node groups

+

Groups are listed A–Z (numbers in numeric order, so Group-2 comes before Group-10). The Order menu under the filter also sorts them by their number of nodes (the count shown on each row), Most nodes first or Fewest nodes first (groups of equal size stay A–Z), or keeps the File order of the annotation file. Nodes in no group always come last. The legend follows the same order, each view remembers the choice, and group colors don't change. Node names in the details panels, and the files in Files and in every file menu (Grouping, Network Profiler, Network Comparison, enrichment) are always listed A–Z.

+

Lists the groups of the current annotation with their node counts. Untick a group to hide nodes that are only in inactive groups; a node stays visible while any of its groups is active, and its pie shows only its active groups. Click a swatch to recolor a group. Nodes that no group lists appear under Not in any group.

+

Type in the filter box to narrow long lists such as GO terms. While a filter is set, the two buttons activate or deactivate only the matching groups, which makes it quick to show, for example, only terms containing "signaling". Pies show up to 16 slices; a node in more active groups gets a gray slice for the rest.

+

Node shapes

+

The small shape button on each group row opens a choice of shapes: circle, square, rounded square, diamond, triangle, vee, pentagon, hexagon, octagon, star, rhomboid, tag, concave hexagon and barrel. Use the arrow keys to move through them and Escape to close. The same shapes are offered in a group's details panel (its i button). Assign shapes gives each listed group a different shape at once, and Reset shapes makes them circular again; with a filter set, both apply only to the matching groups.

+

A node takes its group's shape only when exactly one of its groups is ticked. Nodes shown with two or more active groups stay circular, so their pie slices stay readable. Unticking groups therefore changes shapes: a node in a square group and a diamond group is a two-color circle, becomes a square when the diamond group is unticked, and a diamond when the square one is. Shapes combine with either fill: with expression colors, the shape shows the group and the color shows expression. Each view keeps its own shapes, and they are saved in JSON.

+ +

Group highlighting

+

Shades the area behind each active group in its color, as NORMA does. Opacity controls how strong the shading is. Three styles are available:

+
    +
  • Convex hull draws a sharp outline around all of the group's nodes. Groups of one or two nodes get a circle or capsule.
  • +
  • Bubble sets draws contours that hug the group, in the style of Collins, Penn and Carpendale's Bubble Sets (IEEE TVCG 2009): the outline follows the group's nodes closely, links them with narrow bands so each group stays in one piece where possible, and bends around nodes that are not in the group, leaving holes where outsiders sit inside. Interleaved groups therefore stay readable where convex hulls would cover each other. Contours are recomputed when nodes move, groups are ticked or the zoom changes a lot; on networks with more than 1,500 shown nodes they are updated once the movement stops.
  • +
  • Fog cloud draws a soft, blurred area.
  • +
+

All three styles appear in image exports (SVG included) and on the 3D Network page, where bubble sets are drawn from the camera's point of view and simplified while the picture moves.

+ +

Colors

+
    +
  • Node fill chooses between group colors, expression colors (from an expression file or each node's color field) and numeric values on a color scale, when the view has numeric values.
  • +
  • Numeric values (shown when the view has them): +
      +
    • Column picks which value to show, for example log2FC or padj.
    • +
    • Transform: use the values as they are, their −log10 (so small p-values become large), or their absolute values.
    • +
    • Scale: diverging scales (blue–white–red, purple–white–orange, purple–white–green, brown–white–teal) show values below and above a Center (0 by default), which suits fold changes; sequential scales (viridis, magma, blues, reds) run from low to high, which suits p-values and intensities. Most of these scales stay readable under common forms of color vision deficiency.
    • +
    • Range: symmetric around the center (diverging scales), from the lowest to the highest shown value, or custom; values beyond the range get the end colors.
    • +
    • No value sets the color of nodes without a value. A line under the controls says how many shown nodes have a value and their range.
    • +
    + Steps (time points or conditions) appear when there are several value columns: ◀ and ▶ move one step, Play steps through them repeatedly at the chosen speed, and the slider jumps to any step, while node positions stay put. The current step shows at the top of the network. Same color scale for all steps (on by default) fixes the scale over all columns so colors can be compared between steps. Add the numeric files in Files as steps adds every column of the other numeric files as further steps, named file: column; nodes a file doesn't list get no value for its steps. + A numeric file selected in Files switches the fill to numeric values automatically.
  • +
  • Group palette recolors all groups. The Okabe–Ito, IBM and Viridis palettes stay distinguishable under common forms of color vision deficiency. Past a palette's own colors, further colors are spread evenly around the color wheel, which keeps around 100 groups distinguishable.
  • +
  • Channel palette does the same for edge channels. Classic uses STRING's evidence colors.
  • +
+

Themes (top bar) change the interface and canvas background, not the data colors.

+ +

Node size

+

Nodes can keep the size given in the data or be sized by degree, in-degree, out-degree, betweenness, closeness, clustering coefficient or a numeric value (the column and transform chosen under Colors; on a diverging scale the distance from the center, so strongly raised and strongly lowered nodes are both large). With directed edges, betweenness and closeness follow edge directions. Sizes run from Min px for the lowest value to Max px for the highest. Metrics are computed on the nodes of ticked groups; Use checked channels only (on by default) also limits them to the ticked channels. Sizes update as you tick and untick. Node scale multiplies every size, from a tenth to five times.

+ +

Labels

+
    +
  • Node labels show node names, centered on the node by default; the position list moves them above, below or to a side. Node label size sets their size, and Grow labels with node size scales each label with its node, so bigger nodes get bigger names.
  • +
  • Edge labels are drawn at the middle of each edge, whether straight, curved or bundled. They can show the channel, the weight, both, or any edge attribute in the network. Merged edges show how many channels they combine and their highest weight. Orientation runs them along the edge or keeps them horizontal, and Edge label size sets their size. With edge labels on, straight edges are drawn with a slightly slower method that supports labels.
  • +
  • Label colors: node and edge labels can follow the theme, use black, dark gray, white, blue, red or green, match their node's color or their edge's channel color, or use any color chosen with Custom…. White labels get a dark outline so they stay readable on a light background.
  • +
  • Hide labels when zoomed out drops labels that would appear smaller than the chosen size on screen, which keeps large networks readable and fast.
  • +
+ +

Edges

+
    +
  • Direction: handle edges as in the data, all as directed, or all as undirected; with arrows shown, choose their shape and size (see Directed and undirected networks).
  • +
  • Parallel edges: draw every channel separately, or merge each node pair into one edge (see multi-edges); directed edges merge only with edges running the same way.
  • +
  • Shape: straight lines are fastest. Curved edges fan parallel edges apart, and Fan-out sets how far.
  • +
  • Bundled draws edges that run in similar directions together, like cables in a tray, which untangles dense networks and shows the main routes between groups. It uses force-directed edge bundling (Holten and van Wijk, 2009): edges whose direction, length, position and overlap are similar pull on each other, while springs keep each edge smooth. Bundling strength sets how readily edges join a bundle and how tightly they are drawn together. Bundles are computed in the background, so the page stays usable: about 5 seconds for 3,000 edges and half a minute for 8,000. They are recomputed after a layout, after dragging nodes, after using Spread, and when groups or channels are ticked or unticked. Parallel edges between the same pair follow the same bundle. Only visible edges are bundled, up to 10,000 at a time; merging parallel edges or unticking channels brings larger networks under that limit.
  • +
  • Thickness: a fixed width, or mapped from each edge's weight between Min and Max px. Edges without a weight get the middle width.
  • +
  • Edge opacity: how solid edges are drawn; lower it to let dense networks show their nodes.
  • +
+ +

Edge channels

+

Lists each kind of edge with its count. Untick a channel to hide its edges; click its swatch to recolor it. For files loaded from Files, each network (or each value of its Type column) is a channel.

+ +

Legend

+

Display → Legend → Show a legend on the network places a legend at the bottom right of the 2D and 3D network (it steps aside while a node's or edge's details are open). It can include the color scale of numeric values with its ticks and the "no value" color, the groups with their colors and, with Group shapes, their node shapes, and the edge channels when there are two or more. An optional title goes on top. Groups and channels follow what is ticked, and the legend updates as colors, shapes or settings change.

+

Views made by Show as network on the comparison page come with the legend switched on and an extra Networks part that says which letter stands for which network.

+

In Export image, Add the legend puts the same legend to the right of the picture, at the picture's scale, in every format; in SVG files it is a separate, editable group. The legend is included whether or not it is shown on screen.

+ +

Attributes

+

Summarizes the extra data carried by nodes, edges and groups: each field, its kind (text, number, yes/no, list), its range or values, and how many items have it. Values appear when you click a node, an edge or a group's i, and search looks through node attribute values.

+ +
Analysis
+

Network Profiler

+

Besides the statistics below, the page holds Group analysis, the Layout benchmark and the Runtime table.

+

Tick any mix of the current view and networks from Files, then choose Compute statistics. Results appear side by side so networks can be compared, and Download table saves them as tab-separated text. Each network is profiled as a simple undirected graph. The current view is profiled as shown: only nodes of ticked groups and edges of ticked channels count, and parallel edges from different channels count as one connection.

+

With Use edge direction ticked (it is off by default), networks with directed edges get a second table, Direction: directed edges, reciprocity (the share of directed edges whose reverse also exists), maximum in- and out-degree, source and sink nodes, strongly connected components, the share of node pairs that can reach each other, and the average directed path length and diameter. Undirected edges can be walked both ways. The main table always treats edges as undirected, as igraph does for these statistics.

+ + + + +
StatisticMeaning
+

Below the table:

+
    +
  • Degree distribution: how many nodes have each degree. Tick log–log to check for a power law.
  • +
  • Most central nodes: the top ten by the measure you choose. Click a name to find it in the 2D Network page when it's in the current view.
  • +
  • Communities: choose an algorithm and choose Find communities; Louvain is shown by default. Add as annotation saves the communities (without single nodes) to Files as a new annotation you can view like any other; NORMA's Tau example ships such a file. All algorithms work on the simple undirected network, use a fixed random seed so results repeat, and are scored by the same modularity. +
      +
    • Louvain (Blondel et al., 2008): greedy modularity optimisation. Resolution above 1 gives more, smaller communities.
    • +
    • Leiden (Traag et al., 2019): Louvain with a refinement step that guarantees every community is connected; same resolution setting.
    • +
    • Label propagation (Raghavan et al., 2007): each node repeatedly takes the most common label among its neighbours; very fast. Communities that end up in pieces are split into connected parts.
    • +
    • Walktrap (Pons and Latapy, 2006): merges communities that short random walks of the chosen walk length cannot tell apart, and keeps the partition with the highest modularity. Limited to 2,000 nodes.
    • +
    • Markov clustering, MCL (van Dongen, 2000): simulates flow on the network; higher inflation gives more, smaller clusters. Clusters that end up in pieces are split into connected parts.
    • +
  • +
  • Edge channels (current view only, with two or more channels): how many node pairs carry one, two or more channels, and how much each pair of channels overlaps (Jaccard index of their node pairs).
  • +
+

Path-based statistics (distances, betweenness, closeness) visit every node from every node, so networks above 8,000 nodes skip them.

+ +

Group analysis

+

The Group analysis part of the Network Profiler page works on the current view: its ticked groups and channels.

+
    +
  • Group statistics: for each group, its nodes, the edges inside it and leaving it, its density inside (edges inside divided by all possible pairs), the mean degree inside, its conductance (edges leaving divided by the smaller of the group's and the rest's total degree; lower means a better-separated group) and its share of the modularity. Above the table, the modularity of the grouping says how well the groups as a whole follow the network's structure (above about 0.3 is clear structure). For this number, a node in several groups counts for its first group and a node in none counts as a group of its own. Download statistics saves the table.
  • +
  • Open group network opens a new view with one node per group, sized by the group's size, and an edge between two groups weighted (and labelled) by how many of their members are connected; each node also carries its group's edges inside as an attribute. Parallel channels count once. This is the network NORMA-2.0's strategy 3 lays out internally.
  • +
  • Enrichment tests whether the groups of an annotation (the terms, for example KEGG pathways) are over-represented, with a one-sided hypergeometric test: +
      +
    • Test: the nodes selected in the view (for example a cluster you selected by hand, or the nodes a Select button picked), or each group of an annotation (for example Louvain communities or the view's own groups).
    • +
    • Terms from: any annotation in Files, or the current view's groups.
    • +
    • Background: the shown nodes of the current view (the usual choice), or those plus every node of the term annotation.
    • +
    • Only terms and tested sets with at least Smallest overlap nodes count. p-values are adjusted with the Benjamini–Hochberg procedure within each tested set, and terms with an FDR above the limit are left out.
    • +
    + The table lists each enriched term with its overlap, its size in the background, the fold enrichment, the p-value and the FDR; Select selects the overlapping nodes in the view, and Download results saves every row with the node names.
  • +
+ +

Group separation and layout benchmark

+

The Group separation box in the 2D Layout section measures how well the current layout separates the ticked groups. It updates after every layout, after dragging nodes or using Spread, and when groups are ticked or unticked.

+
    +
  • Silhouette: the mean silhouette width (Rousseeuw, 1987) of the nodes that are in exactly one group, from their positions. For each node, it compares the mean distance to its own group with the mean distance to the nearest other group. It runs from −1 (groups intermixed) through 0 (no separation) to 1 (compact, well-separated groups). On large networks it is computed on a fixed sample of 1,500 nodes.
  • +
  • Inside other outlines: the share of shown nodes that lie inside the convex outline of a group they don't belong to. These are the nodes a hull drawing would misleadingly shade.
  • +
  • Outline overlap: the share of the outlined area that is covered by two or more group outlines. Groups of fewer than three nodes have no outline.
  • +
+

The Layout benchmark at the end of the Network Profiler page runs every layout on the current view without changing it: the seven layouts by connections (random as a baseline), the six group arrangements and the three NORMA-2.0 strategies, with the Layout section's settings for group layouts. For each it reports the run time and the three scores, as the mean (and standard deviation) over 1, 3 or 5 runs; the best value in each column is highlighted. Use applies a layout to the view, and Download table saves the results as tab-separated text, ready for a supplementary table.

+ +

Network Comparison

+

The Network Comparison page sets two to ten networks side by side. Tick them in the list: Open views compare what each view shows: its files, and only its ticked groups and channels, and Network files compare uploaded or example networks directly. Choose Compare; each network gets a letter (A to J) and a color used throughout the results. Nodes are matched by name and edges by the pair of nodes they join, ignoring channels. With Use edge direction ticked (off by default), direction counts: A→B, B→A and an undirected A–B are three different edges, and directed statistics are added to the topology table. Untick it to match edges regardless of direction.

+
    +
  • Networks compared: the size of each network and how many nodes and edges all of them share.
  • +
  • Node overlap and Edge overlap: for two or three networks, a Venn diagram with the count in every region (circle sizes are schematic). For any number, an UpSet plot: each column is a group of nodes or edges found in exactly the networks marked by dots below it, sorted largest first; the bars at the left give each network's total. Hover a bar for its count. With many networks only the 40 largest combinations are drawn; the rest are listed in the downloaded table.
  • +
  • Jaccard similarity: for each pair, shared items divided by items in either network, from 0 (nothing in common) to 1 (identical). Darker cells are more similar; hover for the counts.
  • +
  • Degree agreement on shared nodes: the Spearman correlation of a node's degree in two networks, over the nodes they share. Close to 1 means hubs in one are hubs in the other; negative values (red) mean roles swap. A dash means fewer than three shared nodes.
  • +
  • Degree distributions: for each network, the share of nodes with at least a given degree, on log–log axes.
  • +
  • Topology side by side: every Network Profiler statistic for each network, with the highest value in each row in bold.
  • +
+

Download table saves the node and edge membership (1 or 0 per network) and the topology table as tab-separated text.

+

Show as network (new in NORMA 3.0) opens the compared networks as one view, with a legend that explains its colors: which letter stands for which network, which node colors mean "in which networks", and which edge colors mean "A only", "B only" or "Shared". Every edge becomes a channel that says which networks contain it: A only and B only in the networks' colors and Shared in gray (for three or more networks, combinations such as A + C and In all). Nodes are grouped the same way. Tick channels in the Display tab to see, for example, only what the disease network gained, and use edge direction as set for the comparison.

+ +
Saving and sharing
+

Interpreting the results

+

NORMA's results are pictures and numbers about how groups sit in a network. How to read them:

+

The network picture

+
    +
  • Groups: a shaded area (hull, bubble set or fog cloud) shows which nodes belong to a group; a pie-chart node belongs to several groups (one slice each), and a node shape stands for one group. Overlapping shading means shared members or nodes placed close together, not necessarily shared biology.
  • +
  • Positions: in layouts by connections, nodes that are close are well connected, so groups that come out compact and separate really are separated in the network. Layouts by groups place groups apart on purpose, which makes them easy to see but says nothing about how separated they are; use the group separation score on a connection layout to judge that.
  • +
  • Colors, sizes and edges: node colors show group membership, expression colors or numeric values (read them against the legend; with Same color scale for all steps, colors can be compared between time points). Node size follows the chosen measure (for example degree: bigger nodes have more connections). Edge colors are channels (evidence types, interaction types or networks), thickness can follow weight, and arrows show direction.
  • +
  • Edge weights from databases: STRING's confidence score (0–1), the number of shared reactions (Reactome), curation effort (OmniPath) and the IntAct MI-score (0–1). Higher means better supported.
  • +
+

Network Profiler

+
    +
  • Size and density: density is the share of possible connections present; biological networks are usually sparse (well below 0.1).
  • +
  • Connectivity: the number of connected components and the size of the largest; the average path length and diameter say how far apart nodes are (small values mean a "small-world" network).
  • +
  • Clustering coefficient (0–1): how often a node's neighbours are connected to each other; high values mean tightly knit neighbourhoods.
  • +
  • Degree distribution: a straight line on the log–log plot suggests a few highly connected hubs and many poorly connected nodes.
  • +
  • Centrality: nodes with high betweenness lie on many shortest paths (bridges between modules); high closeness means close to all others; high degree means many partners. The top-ten lists point to candidate key nodes.
  • +
  • Communities and modularity: each community is a set of nodes more connected among themselves than to the rest. Modularity above about 0.3 indicates clear community structure; compare the communities with your annotation using Group analysis or enrichment.
  • +
+

Group analysis and enrichment

+
    +
  • Density inside higher than the network's density, and conductance close to 0, mean a group is a real module of the network; conductance near 1 means its members mostly connect outside the group. The modularity of the grouping summarises this for all groups (above about 0.3: the grouping follows the network's structure).
  • +
  • Enrichment: fold enrichment above 1 means a term is more common in the tested set than in the background; the FDR (Benjamini–Hochberg adjusted p-value) says how likely that is by chance, and terms with FDR ≤ 0.05 are usually reported. Small overlaps (2–3 nodes) and the choice of background strongly affect the result; the background should be the nodes you could have picked.
  • +
+

Group separation and layout benchmark

+
    +
  • Silhouette (−1 to 1): above about 0.5, groups are well separated in the picture; 0.25–0.5 moderately; below 0.25 they overlap; negative values mean nodes sit closer to other groups than to their own.
  • +
  • Outline intrusion and overlap (0–100%): the share of nodes inside another group's outline and of shaded area covered twice; lower is clearer. The benchmark compares layouts on these scores, averaged over repeated runs.
  • +
+

Network Comparison

+
    +
  • Venn and UpSet plots count the nodes and edges found in exactly each combination of networks; the tallest UpSet bars show the largest shared or unique parts.
  • +
  • Jaccard similarity (0–1): shared items divided by items in either network; 1 means identical. Degree agreement (Spearman, −1 to 1) says whether shared nodes are similarly connected in both networks.
  • +
  • Show as network colors each edge by the networks that contain it (A only, B only, shared), so gained and lost connections can be seen directly.
  • +
+ +

Export and saved work

+

The Export tab saves and sends: NORMA files, Image, Other tools, Save your work and, at the bottom, Arena3D. Saved work is opened again under Upload Data → Open saved work. Three kinds of NORMA JSON file keep work for later, and each is saved and opened with its own button:

+ + + + + + + +
FileHoldsSave / open
View file (.json)One view: its network, groups, colors, attributes and node positions (see JSON format)Save view file / Open view file… or Paste JSON…
Session file (.json)Every open view and every file in FilesSave session file / Open session file…
Settings file (.json)Display settings only, to apply the same look to other viewsSave settings file / Open settings file…
+

The other JSON files NORMA writes are for other programs: Arena3D (.json) for Arena3D and Cytoscape (.cyjs) for Cytoscape.

+
    +
  • NORMA files save the current view as a network, annotation or expression file that NORMA can read. +
      +
    • Parallel edges are merged into one connection and keep their highest weight, unless you tick Keep channels as a Type column. When any edge is directed, a Direction column is added and A→B and B→A stay separate rows. Files with Type or Direction columns open in NORMA 3.0 as multi-edge or directed networks; earlier versions of NORMA expect only Source, Target and Weight and may not read them, so leave the box unticked for files meant for those versions.
    • +
    • Nodes without connections are left out, since NORMA can't list them.
    • +
    • Commas in node names become semicolons.
    • +
    • With no expression colors loaded, each node gets its first group's color.
    • +
    +
  • +
  • Only ticked groups and channels (on by default) makes every export below save just the shown part of the view; untick it to save the whole network.
  • +
  • Export image… (also the camera button in the top bar) saves the current view as a picture: +
      +
    • Formats: PNG (supports a transparent background), JPEG (smaller files), WebP (small, sharp web images), SVG (vector: stays sharp at any size and can be edited in Illustrator, Inkscape or PowerPoint, with nodes, edges, labels and group shading in separate layers) and PDF (one page with a high-resolution image, sized so one screen pixel is one point).
    • +
    • Area: the whole network (the ticked groups and channels, with a margin for group shading) or exactly what is on screen.
    • +
    • Resolution: 1× to 8× the on-screen size. The screen counts as 96 dpi, so each step adds 96 dpi at the size the network has on screen: 1× is 96 dpi, 2× 192 dpi, 3× 288 dpi (about the 300 dpi journals ask for), 4× 384 dpi, 6× 576 dpi and 8× 768 dpi. Below the options, the dialog shows the image's exact size in pixels, the dpi and print size (inches and centimeters) at the on-screen size, and the print size at 300 dpi. PNG and JPEG files store this dpi, so layout and print programs open them at the right size. For a figure of a given width, divide the pixel width by the dpi you need: a 3,000-pixel-wide image prints 10 inches (25.4 cm) wide at 300 dpi. SVG is vector graphics, sharp at any size, so its "resolution" only sets the picture's size in the file; PDF pages have the on-screen size. Very large images are reduced to the biggest size the browser can draw, and the dialog says so.
    • +
    • Background: the theme color, white, transparent (PNG, WebP and SVG) or any color.
    • +
    • Add the legend puts the legend set under Display → Legend to the right of the picture.
    • +
    • Include group shading adds the shaded group areas, and Leave out selection and search highlighting (on by default) draws the network without yellow selection borders or dimmed nodes; the selection is restored afterwards.
    • +
    + The picture shows the view as it looks: layout, colors, shapes, pies, labels, edge labels and bundled edges. On the 3D Network page the dialog saves the 3D picture from the current angle, in any of the same formats (SVG included); Whole network fits the network into the picture without changing the angle.
  • +
  • Other tools: GraphML (yEd, Cytoscape, igraph, NetworkX), GEXF 1.3 with node colors, sizes and positions (Gephi), SIF (Cytoscape's simple interaction format: source, channel, target) and Cytoscape (.cyjs) (with positions, for Cytoscape desktop and Cytoscape.js). Nodes carry their groups (separated by semicolons), color, size, numeric values and attributes; edges their channel, color, weight, direction and attributes. Like the other exports, they include only ticked groups and channels unless that option is off.
  • +
  • Session: Save session file saves every open view (network, positions, settings, 3D camera) and every file in Files, including groupings fetched from STRING, in one JSON file; Open session file… brings it all back, replacing the current views and files.
  • +
  • Arena3D: open the view in Arena3D, or save it in Arena3D's formats, with each group as a layer (see Arena3D).
  • +
  • Save view file saves everything in the current view, including attributes, colors and group descriptions. Open view file… and Paste JSON… load that format into a new view.
  • +
  • Save settings file stores the current view's display options (palettes, layout, sizing, labels, shading, edges) and the theme in a small file that Open settings file… applies to the current view.
  • +
+ +

Arena3D

+

Arena3D shows multilayer networks in 3D. Under Export → Arena3D, NORMA turns the current view into an Arena3D network in which each ticked group is a layer:

+
    +
  • Layers appear in the order of the group list, side by side, each floor in its group's color; nodes in no group get a layer of their own.
  • +
  • Inside a layer, nodes keep their 2D positions from NORMA, scaled to fit the layer, and their size.
  • +
  • Edges between members of the same group stay in that group's layer; edges between groups run between the layers. A node in several groups appears in each of its layers, and with Link the copies of a node that is in several layers (on by default) its copies are joined by white edges.
  • +
  • Edge colors are the channel colors; with weights, stronger edges are more opaque. Several channels are kept as Arena3D channels, and directed edges switch on Arena3D's direction display.
  • +
  • Node colors: as shown in NORMA (group, expression or numeric colors; pie nodes take the color of the layer they are drawn in), or the color of each node's layer.
  • +
  • Only the ticked groups and channels are exported, like everywhere else in NORMA. Arena3D takes up to 20 layers: with more ticked groups (counting the layer for nodes in no group), only the first 20 in the group list are exported, and the status note says how many groups and nodes were left out. Untick groups you don't need to choose which 20 go.
  • +
+

Comparing networks in Arena3D: after Compare, the Network Comparison page offers the same three ways out for the comparison, with one layer per compared network (A, B, … in the networks' colors). Each layer holds that network's nodes and edges; nodes and edges found only in that network take its color, and common ones are gray. All networks are laid out together, so a node sits at the same place in every layer. Between layers chooses what links the layers: Common edges (an edge found in networks A and B also runs from its source in layer A to its target in layer B, in white), Common nodes (the copies of a node found in several networks are joined, in yellow), or both. Up to 20 layers fit, so all ten comparable networks can go.

+

Three ways out (for the current view, under Export → Arena3D):

+
    +
  • Open in Arena3D sends the network to Arena3D (/api/external) and opens the returned link in a new tab. Some browsers block direct calls to other sites; running NORMA with server.py avoids that, because the server passes the request on (see Running NORMA locally or on a server). The address can be changed under Arena3D options.
  • +
  • Arena3D (.json) saves the same network in Arena3D's JSON format, to load in Arena3D yourself.
  • +
  • Table (.txt) saves Arena3D's tab-separated upload format: SourceNode, SourceLayer, TargetNode, TargetLayer, Weight, Channel. NORMA can read such files back (see Files and formats).
  • +
+ +
Running, API and troubleshooting
+

API for other applications

+

Other programs can open NORMA with their own networks, groups and values, through the API tab's three routes (full reference, code for curl, Python, R and JavaScript, and a tester are on that tab):

+
    +
  • REST (needs server.py): POST /api/external with a JSON payload returns {"token", "url"}; opening url (norma.html?session=TOKEN) shows the data. Payloads are kept in the server's memory for 24 hours (NORMA_API_TTL_HOURS). GET /api/health tells whether a server offers the API.
  • +
  • Links (no server needed): norma.html?data=URL loads a payload file, ?network=URL&annotation=URL&expression=URL loads NORMA files (the other site must allow cross-site downloads), and #json=… carries a small payload inside the link.
  • +
  • postMessage (no server needed): a page that opens NORMA in a window or iframe receives norma:ready, sends {"type": "norma:load", "payload": …} and gets norma:loaded back with a summary or an error.
  • +
+

The payload is a JSON object: name; the network as edges (source, target, optional type, weight, directed), as NORMA files or as a complete network view; optional nodes with attributes, groups (and more groupings in annotations), expression (colors or numbers per node), settings (display settings, theme, layout) and tab. A whole session file is accepted too. Each call opens a new view; the network, groupings and values also appear in Files. If a link's data cannot be read, NORMA opens the API tab and says why.

+ +

Running NORMA locally or on a server

+

NORMA is one web page (norma.html) plus a small server program (server.py, Python 3.8 or newer, no extra packages). The same package runs NORMA on your own computer or as a public web server; everything that differs is a setting.

+

On your own computer

+
    +
  • Double-click run_local.sh (macOS, Linux) or run_local.bat (Windows), or run python3 server.py: NORMA opens in your browser at http://localhost:8000, reachable only from this computer.
  • +
  • Without Python: open norma.html directly. Everything works except the REST API and the relays; database importers then call the services directly, which some browsers block.
  • +
+

As a public web server

+
    +
  • Run python3 server.py --mode hosted --config norma.config.json (start from norma.config.hosted.json), behind a web server that provides HTTPS on port 443 (deploy/nginx.conf), as a system service (deploy/norma.service) or in a container (Dockerfile, docker-compose.yml). The server can also serve HTTPS itself (--tls-cert, --tls-key, optional --http-redirect-port 80).
  • +
  • Hosted mode listens on all addresses, trusts the proxy's X-Forwarded-* headers, logs requests without IP addresses, sends security headers (HSTS over HTTPS) and never opens a browser.
  • +
  • Any static web server (nginx, Apache, GitHub Pages) can also serve the folder; settings then come from norma-config.js, and the REST API and relays are not available.
  • +
+

Settings

+

Settings come from, in increasing priority: built-in defaults, the settings file (norma.config.json, or --config / NORMA_CONFIG), environment variables and command-line options; python3 server.py --print-config shows the result, and --help lists every option. The server hands the page its part of the settings as /norma-config.js.

+ + + + + + + + + + + + + + +
SettingOption / variableMeaning
mode--mode, NORMA_MODElocal (default) or hosted; sets sensible defaults for the rest
server.host, server.port--host, --port, NORMA_HOST, NORMA_PORTWhere the server listens (local 127.0.0.1, hosted 0.0.0.0; port 8000)
server.publicUrl--public-url, NORMA_PUBLIC_URLThe address users see (e.g. https://norma.example.org/); used in API links
server.tlsCert, tlsKey, httpRedirectPort, hsts--tls-cert, --tls-key, --http-redirect-portServe HTTPS directly, redirect HTTP to it, send Strict-Transport-Security
server.trustProxyNORMA_TRUST_PROXYRead the reverse proxy's forwarded headers (hosted: on)
server.accessLog--access-log, NORMA_ACCESS_LOGoff (local), anonymous (hosted: no IP addresses) or full
api.enabled, ttlHours, maxMB, maxSessions--no-api, NORMA_API, NORMA_API_TTL_HOURS, NORMA_API_MAX_MBThe REST API and how long, how large and how many payloads it keeps
relays.string, arena3d, databases--no-relays, NORMA_RELAYSTurn the relays on or off; without them the page calls the services directly
site.*NORMA_CONTACT_EMAIL, NORMA_INSTITUTION, NORMA_NOTICEName, institution, contact e-mail and page, source code, licence name and link, privacy policy and imprint links, maintenance statement, tested browsers, and a notice shown at the top of the page (e.g. planned maintenance)
app.maxNodes, theme, startTab, cdnFallbackNORMA_MAX_NODESLargest network shown (default 10,000), starting theme and page, and whether Cytoscape.js may be downloaded if the bundled copy is missing
+

What the server offers

+
    +
  • API: NORMA's REST API (/api/external, /api/session/TOKEN, /api/health); see API for other applications.
  • +
  • STRING relay: /string-api/ passes read-only STRING calls to string-db.org addresses, spaced a second apart. With Database importers → STRING → STRING server → Connect set to Automatically, NORMA uses the relay when it is available and otherwise calls STRING directly.
  • +
  • Database relay: /db-api/fetch passes the importers' calls to Reactome, OmniPath, NDEx, IntAct, QuickGO and the GO API, and only to those services' API addresses.
  • +
  • Arena3D relay: /arena3d-api/external passes Open in Arena3D to arena3d.org.
  • +
  • Package contents: norma.html, index.html (forwards static web servers' root address to norma.html), norma-config.js, server.py, norma.config.json and norma.config.hosted.json, norma_api_client.py (Python template), run_local.sh / run_local.bat, Dockerfile, docker-compose.yml, deploy/ (nginx, systemd), vendor/ (Cytoscape.js), assets/, README.md and NAR_CHECKLIST.md.
  • +
  • STRING logo: to show STRING's official logo in the importer, save it as assets/string-logo.png.
  • +
+ +

Performance

+
    +
  • Display → Performance → Draw with WebGL uses Cytoscape.js's WebGL renderer, which draws large networks much faster. It is experimental: the choice is made when the page starts, so NORMA asks to reload the page (save the session first to keep your views). Adding ?webgl=1 or ?webgl=0 to the address sets it for one visit. Exported images, group shading, the 3D page and everything else work the same.
  • +
  • The Runtime table at the end of the Network Profiler page times the main steps on random networks of 100 to 10,000 nodes in your browser: reading the network file, building the network, the weighted layout, edge bundling, the profile statistics, Louvain and the group separation score. Drawing time depends on the screen and is not included. Download table saves the times with the browser and processor count, for a supplementary table.
  • +
  • Network size: NORMA shows networks of up to 10,000 nodes. A network file, JSON file, session or combination of networks with more nodes is cut to its first 10,000 nodes (in the order they first appear) and the edges among them, and a message says how much was left out. To study part of a bigger network, filter it first (for example to a pathway, a STRING neighbourhood or a community).
  • +
  • Networks of a few thousand nodes are comfortable; beyond that, turn on WebGL drawing, turn off labels, curved edges and edge bundling, and use the grid or weighted force-directed layout.
  • +
+ +

Keyboard shortcuts

+ + + + + + + + + + + + + + + + + +
KeysAction
Ctrl+Z (⌘Z)Undo the last change to the view
Ctrl+Shift+Z or Ctrl+YRedo
Ctrl+A (⌘A)Select every shown node (2D and 3D Network pages)
EscapeClear the selection; in the search box, clear the search; in a dialog or shape chooser, close it
Enter in the search boxZoom to the matching nodes
Shift-drag on the backgroundDraw a selection box
Shift-click or Ctrl/⌘-clickAdd a node to the selection or remove it
Double-clickOn the background: fit the network; on a node: center it
Left/Right arrows on a tabMove between pages or between the Data and Display tabs
Arrow keys in the shape chooserMove between shapes; Enter picks one
Arrow keys on the 3D networkTurn and tilt the view (with Shift: in bigger steps)
+ / – on the 3D networkZoom in and out
F or 0 on the 3D networkFit the network to the screen
+ +

Troubleshooting

+
    +
  • Only part of my network appears: NORMA shows up to 10,000 nodes and keeps the first 10,000 of a larger network (see Performance).
  • +
  • A network file is rejected: its first line must be the header Source, Target (optionally Weight and Type), separated by tabs.
  • +
  • Groups are missing: node names in the annotation must match the network exactly, including capitalization. Names that don't match are discarded when the annotation is uploaded and when it is shown; the status notes list them, and Download corrected annotation saves the cleaned file.
  • +
  • An annotation was not kept: none of its names matched the networks it was checked against. Upload it together with its network, or tick the right network before uploading it.
  • +
  • A node doesn't take its group's shape: it is shown with more than one active group, so it stays a circle. Untick the other groups to see the shape.
  • +
  • Arena3D doesn't open: if the browser blocked the new tab, use the Open Arena3D link in the status note. If Arena3D can't be reached, run NORMA with server.py, or save the Arena3D (.json) and load it in Arena3D.
  • +
  • STRING can't be reached: check the internet connection, and run NORMA with server.py (see Running NORMA locally or on a server) so that requests go through its relay. If the server is running but requests fail, set Connect to Through this server to see its error. If a request takes too long, lower Interactors to add or try again later.
  • +
  • STRING found none of the names: check the spelling and the organism; a gene name from one species often isn't found in another.
  • +
  • A STRING grouping is missing: that collection had no terms passing the FDR and group-size limits, or isn't available for the organism. The notes list what STRING returned; raise the FDR limit or lower Smallest group.
  • +
  • A file was read as the wrong type: remove it, pick its type in the upload box and upload it again.
  • +
  • The view is slow: try straight edges instead of bundled or curved ones, turn off group shading or use the hull style instead of fog, merge parallel edges, or deactivate groups you don't need.
  • +
  • Colors look alike: switch to a colorblind-safe palette, or click a swatch to set a color yourself.
  • +
  • No arrows are shown: the edges aren't marked as directed, or Edges → Direction is set to undirected. Choose Treat all edges as directed, or upload the file again as directed or with a Direction column.
  • +
  • B→A disappeared after uploading: the file was read as undirected, where A→B and B→A are the same connection. Delete it (its ✕ in Files) and upload it again with Networks: directed.
  • +
  • My parallel edges became one edge: the network file has no Type column, or Edges → Parallel edges is set to merge. Add a Type column, show the networks together as separate files, or switch back to drawing each channel.
  • +
  • A function ignores some nodes or edges: layouts, sizes, statistics, Network Comparison and exports work on the ticked groups and channels only. Tick them again, or untick Only ticked groups and channels before exporting.
  • +
  • An exported image is smaller than chosen: the browser can't draw images beyond about 16,000 pixels per side; the dialog says when the size was reduced. Choose a lower resolution or export only what is on screen, or use SVG, which has no size limit.
  • +
  • The 3D picture is slow: turn off group shading or shaded spheres, lower Depth fading, hide labels, or untick groups and channels you don't need. Large networks are drawn simplified while they move.
  • +
  • The 3D network looks flat: the layout is Same as 2D (flat), or the camera is facing it from the front. Run another 3D layout or choose View from → Tilted.
  • +
  • A few edges are missing: edges between two nodes that sit on top of each other can't be drawn. Run a layout again or use Spread.
  • +
+ +
+
+
+ +
+
+
+ +
+

NORMA 3.0 The Network Makeup Artist

+

A browser application for visualising and analysing biological networks together with their functional annotations: how genes, proteins or other entities that share a pathway, complex, GO term or community sit in a network, and how several networks compare.

+
+
+ +
+

How to cite

+

If NORMA helps your work, please cite:

+
    +
  • Karatzas E, Koutrouli M, Baltoumas FA, Papanikolopoulou K, Bouyioukos C, Pavlopoulos GA. The network makeup artist (NORMA-2.0): distinguishing annotated groups in a network using innovative layout strategies. Bioinformatics Advances. 2022;2(1):vbac036. PMID: 36699373, doi:10.1093/bioadv/vbac036 (NORMA-2.0, group-aware layout strategies)
  • +
  • Koutrouli M, Karatzas E, Papanikolopoulou K, Pavlopoulos GA. NORMA: The Network Makeup Artist, a web tool for network annotation visualization. Genomics, Proteomics & Bioinformatics. 2022;20(3):578–586. doi:10.1016/j.gpb.2021.02.005 (the original NORMA application)
  • +
+

Please also cite the databases and methods you use; see Resources and methods.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Bubble-set groups +outlines that hug only the members + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +−2 +0 +2 +log2FC +Numeric values +color scales, sizes, legends, steps + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Ribosome + +Proteasome + +Spliceosome + +Glycolysis + +−log10 FDR +Communities and enrichment +Leiden, MCL, Walktrap; term tests + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3D network +rotate, tilt and zoom freely + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +A + +B + +C +shared +edges +Compare networks +UpSet plots, Venn diagrams, statistics +
+ +

What NORMA does

+

Networks of interacting molecules are easier to understand when you can see which nodes belong together. NORMA takes a network and one or more annotations (groupings of nodes, such as pathways, complexes, GO terms, tissues or communities) and shows them together: groups are shaded with hulls, bubble sets or fog clouds, nodes in several groups become pie charts, and layouts can arrange the network by its connections or by its groups, so that groups separate cleanly.

+

On top of the picture, NORMA adds node colors from expression data or numeric values, node sizes from topology, several edge channels between the same nodes (evidence types, interaction types or whole networks), directed edges, a 3D view, network statistics, community detection, group enrichment and a side-by-side comparison of up to ten networks. Networks can be uploaded as simple tab-separated files, fetched from six public databases, and exported as images, as files for Cytoscape, Gephi and yEd, or straight into Arena3D.

+

Everything runs in your browser, from a single file (norma.html); a small optional server (server.py) relays database requests. NORMA 3.0 continues NORMA and NORMA-2.0, which introduced group-aware layout strategies.

+ +

Highlights

+
+
Groups made visibleConvex hulls, bubble sets and fog clouds; pie-chart nodes; one shape per group; group colors and descriptions.
+
Group-aware layoutsThe NORMA-2.0 strategies, groups as blocks, clusters or layers, plus force-directed, Kamada–Kawai and stress majorization; a separation score and benchmark.
+
Multi-edge and directedSeveral channels per node pair, merged, fanned, bundled or labelled; arrows and direction-aware statistics.
+
Values and legendsExpression colors, numeric color scales, time-series steps, node sizes and exportable legends.
+
AnalysisNetwork profiles, five community detection methods, group statistics, enrichment, neighbourhoods and shortest paths.
+
ComparisonUp to ten networks with Venn and UpSet plots, similarity and degree statistics, drawn as one network or sent to Arena3D.
+
Database importersSTRING, Reactome, OmniPath, NDEx, IntAct and the Gene Ontology, with their groupings.
+
3D and exportA 3D page, high-resolution PNG, SVG and PDF, NORMA files, GraphML, GEXF, SIF, Cytoscape, Arena3D and sessions.
+
+ +

Data sources

+

NORMA reads your own files and the resources below. Nothing is sent to a resource until you use its importer; see Database importers in Help for how each one works.

+ + + + + + + + + + + +
ResourceNetworkGroupingsWhat NORMA fetches
STRINGYesYesProtein association networks with evidence channels; enriched GO, KEGG, Reactome, UniProt, domain, disease and tissue terms
ReactomeYesYesThe molecules of a pathway's reactions, linked when they share a reaction; sub-pathways and reactions as groups
OmniPathYesYesSigned, directed signalling, TF–target and ligand–receptor interactions; complexes, intercellular roles, pathway annotations
NDExYesLimitedPublic networks with their layout; groups from node attributes
IntActYesNoExperimentally detected interactions with MI-scores and interaction types
Gene OntologyLimited (GO-CAM)YesCausal GO-CAM models as networks; GO terms (QuickGO) as groups for any network
Arena3DExportViews and comparisons as multilayer networks, one layer per group or network
+

None of these services needs an account for public data. Please follow each resource's terms of use and cite it when you use its data (see Resources and methods).

+ +

What's new in NORMA 3.0

+
+

Everything works on the groups and channels you tick

+

This is the main difference from earlier versions of NORMA. Untick groups under Node groups or channels under Edge channels, and every function works on the part that stays ticked: nodes in at least one ticked group, and edges of ticked channels between those nodes. Nothing has to be re-uploaded or filtered by hand.

+
    +
  • Layouts, both by connections and by groups (including the NORMA-2.0 strategies), arrange only the shown nodes using only the ticked channels; hidden nodes keep their places.
  • +
  • Node sizes by degree, betweenness, closeness or clustering, and the statistics in a node's details panel, are computed on the shown part.
  • +
  • Group shading, node shapes and pie slices follow the ticked groups: a node in two groups turns into a single-group shape when the other group is unticked.
  • +
  • Edge bundling, edge merging and edge labels use only the ticked channels.
  • +
  • Search finds only shown nodes, and Spread and Fit to screen work on what you see.
  • +
  • The Network Profiler and Network Comparison pages analyse the shown part of each view.
  • +
  • Exports (NORMA files and JSON) save the shown part, unless you untick Only ticked groups and channels.
  • +
+

So you can, for example, tick two KEGG pathways and one evidence channel, lay them out, size nodes by betweenness within that sub-network, profile it, compare it with another view, and export exactly that.

+
+
+

Multi-edge networks are supported

+

Previous versions of NORMA did not support multi-edge graphs. A network could hold only one connection per pair of nodes: repeated connections were removed automatically when a file was loaded, so a pair supported by, say, both experiments and text mining ended up as one plain edge.

+

NORMA 3.0 keeps every connection. The same pair of nodes can be linked by several edges, each belonging to a channel (an interaction type, an evidence source or a whole network):

+
    +
  • An optional Type column in a network file keeps one edge per pair and type; only exact repeats of the same pair and type are removed (see Network file).
  • +
  • Ticking several network files overlays them, with each network as its own channel (see Several networks and multi-edges).
  • +
  • JSON files and the built-in demos can carry any number of edges per pair, each with its own type, weight, direction and attributes.
  • +
  • Channels can be colored, ticked and unticked one by one; parallel edges can be fanned apart, merged per pair, bundled or labelled; and layouts, node sizes, the Network Profiler and Network Comparison can be limited to the ticked channels.
  • +
+
+ +
+

Directed networks

+

Edges can point from a source to a target, and each view can handle its network as directed or undirected. Arrows show the direction; a Direction column in network files, a directed/undirected choice when uploading, and a directed field in JSON bring direction in; in- and out-degree, direction-following betweenness and closeness, reciprocity, strongly connected components and directed path lengths analyse it. See Directed and undirected networks.

+
+ +
+

A 3D Network page

+

The new 3D Network tab shows the current view in three dimensions: the same groups, colors, pies, shapes, labels, arrows and group shading, with its own 3D layouts (weighted force-directed, groups as 3D clusters, groups on stacked layers, height by hierarchy or degree), rotate, pan and zoom controls, automatic rotation and 3D image export. See 3D Network.

+
+
+

Import networks and groupings from STRING

+

STRING in the Database importers tab fetches the interaction network of one or more proteins for any organism, with control over the number of interactors, confidence, network type and evidence channels, and turns enriched GO terms, KEGG and Reactome pathways, UniProt keywords, protein domains, diseases, tissues and more into groupings you can switch between. See Importing from STRING.

+
+ +

Also new

+
    +
  • Deleting files and views: views, networks, groupings and expression files can be removed completely with Delete… next to Rename, the ✕ next to the Grouping menu, or the ✕ in Files.
  • +
  • API: other applications can open NORMA with their networks and groups by REST, links or postMessage; see the API tab.
  • +
  • Welcome tab and tidier menus: a permanent Welcome tab; every sidebar section folds open and closed from its title; separators group related options; the three NORMA JSON files are named view, session and settings files.
  • +
  • Database importers: besides STRING, Reactome, OmniPath, NDEx, IntAct and the Gene Ontology (GO-CAM networks and GO term groupings), in a tab of their own; exports moved to an Export tab.
  • +
  • Welcome page: NORMA starts on a page that shows what it does, with quick ways to open an example, fetch from STRING or upload files.
  • +
  • Classic layouts: Kamada–Kawai and stress majorization, for connections, as group strategies and inside groups.
  • +
  • Group analysis: group statistics and the modularity of a grouping, a network of groups, and enrichment of annotation terms in selected nodes or in each group of another annotation.
  • +
  • Comparison as a network: Show as network merges compared networks into one view whose edges say which networks contain them.
  • +
  • Local exploration: a node's neighbourhood or the shortest paths between two nodes, opened as a new view.
  • +
  • Time series and conditions: step through value columns or several numeric files, by hand or as an animation, with the layout fixed.
  • +
  • More export formats and sessions: GraphML, GEXF, SIF and Cytoscape (.cyjs) for Cytoscape, Gephi and yEd, and whole sessions (all views and files) in one file.
  • +
  • Performance: optional WebGL drawing for large networks, and a runtime table of the main steps.
  • +
  • Export to Arena3D: open the current view in Arena3D with one click, each group as its own layer, or save it as an Arena3D file (.json) or table (.txt); Arena3D tables can also be uploaded.
  • +
  • Showcase examples: a directed signalling cascade with simulated fold changes, a simulated healthy-vs-disease pair for Network Comparison, and Arena3D's seven-layer example (see Examples).
  • +
  • Numeric expression values: log2 fold changes, p-values or any numbers in expression files, shown on diverging or sequential color scales and as node size, with an optional legend that can also go into exported images.
  • +
  • More community detection: Leiden, label propagation, Walktrap and Markov clustering next to Louvain in the Network Profiler, each able to become a grouping.
  • +
  • A number for group separation: silhouette width, nodes inside other groups' outlines and outline overlap after every layout, and a layout benchmark that compares all layouts, including the NORMA-2.0 strategies, on the current view.
  • +
  • STRING import: fetch a protein's interaction network from the STRING database for any organism, with control over the number of interactors, confidence and evidence channels, and turn its enriched GO terms, KEGG and Reactome pathways, keywords, domains, diseases, tissues and more into groupings.
  • +
  • A 3D network: the 3D Network page shows the current view in three dimensions, with the same groups, colors, shapes, labels, arrows and shading, its own 3D layouts, and rotate, pan and zoom controls.
  • +
  • Working with several visualizations: views, each with its own settings; a Grouping list in the top bar to regroup a view while keeping positions; undo and redo for every change to a view.
  • +
  • A clearer screen: the View list in the top bar, colored labels saying which network, grouping and colors are shown, node, edge, channel and group counts, and color-coded sidebar sections split into Upload Data, Database importers, Display and Export (see Finding your way around).
  • +
  • Layout: arrangement by connections or by groups, NORMA-2.0's three group strategies, groups as non-overlapping blocks, a weighted Fruchterman–Reingold layout, and a Spread slider.
  • +
  • Groups: Bubble Sets–style contours that hug each group, node shapes per group alongside group colors, a filter for long group lists, pies of up to 16 slices, and group details with shared members.
  • +
  • Labels: adjustable sizes and colors for node and edge labels, edge labels showing channels, weights or any edge attribute, and hiding labels when zoomed out.
  • +
  • Edges: edge bundling, merging of parallel edges, curved fan-out, weight-based thickness and edge opacity.
  • +
  • Exploring: search by name, prefix, suffix or regular expression in a panel that can be minimized; selecting several nodes (shown in yellow); zoom without limits while the network stays in frame.
  • +
  • Data: attributes on nodes, edges and groups (Attributes, JSON format); annotation checks on upload with a corrected annotation to download (Files and formats).
  • +
  • Direction: networks are handled as undirected by default, as in earlier versions of NORMA; directed data can be shown with arrows in one click (see Directed and undirected networks).
  • +
  • Analysis: a Network Profiler with a short description of each statistic, Louvain communities that can be added as an annotation, and Network Comparison of up to ten networks with Venn diagrams, UpSet plots, similarity matrices, degree agreement and topology side by side.
  • +
  • Output: image export in high resolution as PNG, JPEG, WebP, vector SVG or PDF; NORMA files and JSON limited to the ticked groups and channels if you like.
  • +
  • Examples: NORMA's example datasets and built-in demos, with every file downloadable.
  • +
  • A white theme by default, and keyboard shortcuts.
  • +
+ + +

Resources and methods

+

NORMA builds on the following software, data resources and published methods. When you use their data or results, please cite them as well.

+

Software

+
    +
  • Cytoscape.js draws the 2D network; statistic definitions follow igraph; color scales follow ColorBrewer and viridis.
  • +
+

Data resources

+
    +
  • STRING: Szklarczyk et al., Nucleic Acids Research 2023;51(D1):D638–D646.
  • +
  • Reactome: Milacic et al., Nucleic Acids Research 2024;52(D1):D672–D678.
  • +
  • OmniPath: Türei et al., Molecular Systems Biology 2021;17:e9923.
  • +
  • NDEx: Pratt et al., Cell Systems 2015;1(4):302–305.
  • +
  • IntAct: del Toro et al., Nucleic Acids Research 2022;50(D1):D648–D653.
  • +
  • Gene Ontology: Gene Ontology Consortium, Genetics 2023;224(1):iyad031.
  • +
  • Arena3D: Karatzas et al., Nucleic Acids Research 2021;49(W1):W36–W45, doi:10.1093/nar/gkab278.
  • +
+

Methods

+
    +
  • Community detection: Louvain (Blondel et al., J Stat Mech 2008:P10008), Leiden (Traag et al., Sci Rep 2019;9:5233), label propagation (Raghavan et al., Phys Rev E 2007;76:036106), Walktrap (Pons and Latapy, J Graph Algorithms Appl 2006;10(2):191–218) and MCL (van Dongen, PhD thesis, University of Utrecht, 2000).
  • +
  • Layouts: Kamada and Kawai (Inf Process Lett 1989;31(1):7–15); stress majorization (Gansner, Koren and North, Graph Drawing 2004, LNCS 3383:239–250) with a pivot-MDS start (Brandes and Pich, Graph Drawing 2006, LNCS 4372:42–53).
  • +
  • Edge bundling: Holten and van Wijk's force-directed method (Computer Graphics Forum 2009;28(3):983–990, doi:10.1111/j.1467-8659.2009.01450.x).
  • +
  • Statistics: enrichment p-values adjusted as in Benjamini and Hochberg (J R Stat Soc B 1995;57(1):289–300); group separation by the silhouette width of Rousseeuw (J Comput Appl Math 1987;20:53–65).
  • +
+ +

Team, access and maintenance

+
    +
  • Team: NORMA is developed by the Pavlopoulos Lab (GitHub). Source code: github.com/PavlopoulosLab/NORMA.
  • +
  • Access: NORMA is free and open to all users. It needs no login, registration or e-mail address, and no installation: it runs in the web browser.
  • +
  • Licence: NORMA is released under the licence shown on the Welcome page (MIT by default), which allows free use, including non-commercial and commercial use. The data resources have their own terms of use.
  • +
+
    + +

    Privacy, data and cookies

    +
      +
    • Your data stays private. Files you upload, the networks you build, views and sessions are processed in your own browser and are not uploaded anywhere. Nobody else can see them. Deleting a file removes it and everything read from it; closing the browser tab removes everything.
    • +
    • What leaves your browser: only what you send yourself: the names you search for with a database importer (to that database), a network you open in Arena3D (to Arena3D), and a payload you send to the NORMA REST API (to that server). On a NORMA server, these requests pass through the server, which does not store them; API payloads are kept in the server's memory only for the time shown in the API tab, and only someone with the link can open them.
    • +
    • No cookies, no tracking. NORMA sets no cookies and uses no analytics, advertising or third-party tracking. The only thing it stores is the WebGL drawing preference, in your browser's local storage, when you change it. Public NORMA servers log requests without IP addresses by default.
    • +
    • Third-party content: NORMA loads no fonts, scripts or images from other sites (a copy of Cytoscape.js is bundled).
    • +
    +
    +
    + + +
    + + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..af73f76 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3072 @@ +{ + "name": "norma", + "version": "3.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "norma", + "version": "3.0.0", + "dependencies": { + "cytoscape": "^3.34.3" + }, + "devDependencies": { + "@playwright/test": "^1.61.1", + "@types/cytoscape": "^3.31.0", + "@types/node": "^26.2.0", + "eslint": "^10.6.0", + "jsdom": "^30.0.1", + "prettier": "^3.9.4", + "typescript": "^6.0.3", + "typescript-eslint": "^8.62.1", + "vite": "^8.1.3", + "vite-plugin-singlefile": "^2.3.3", + "vitest": "^5.0.1" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/cytoscape": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.31.0.tgz", + "integrity": "sha512-EXHOHxqQjGxLDEh5cP4te6J0bi7LbCzmZkzsR6f703igUac8UGMdEohMyU3GHAayCTZrLQOMnaE/lqB2Ekh8Ww==", + "deprecated": "This is a stub types definition. cytoscape provides its own type definitions, so you do not need this installed.", + "dev": true, + "license": "MIT", + "dependencies": { + "cytoscape": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.1.tgz", + "integrity": "sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cytoscape": { + "version": "3.34.3", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz", + "integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", + "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.7.tgz", + "integrity": "sha512-T3mF5P7HFzZVLDQuCUNtJj6WK1pcpLMweMNUVwGb01bLNkH9AkKxtZvmInw8K6bnn2O3d6/5RHl6zSHiBAD0Og==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.13.tgz", + "integrity": "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.13" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.13.tgz", + "integrity": "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", + "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-singlefile": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz", + "integrity": "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">18.0.0" + }, + "peerDependencies": { + "rollup": "^4.59.0", + "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz", + "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..25b7615 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "norma", + "private": true, + "version": "3.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "lint": "eslint src e2e", + "format": "prettier --write src e2e", + "test": "vitest", + "test:e2e": "playwright test" + }, + "dependencies": { + "cytoscape": "^3.34.3" + }, + "devDependencies": { + "@playwright/test": "^1.61.1", + "@types/cytoscape": "^3.31.0", + "@types/node": "^26.2.0", + "eslint": "^10.6.0", + "jsdom": "^30.0.1", + "prettier": "^3.9.4", + "typescript": "^6.0.3", + "typescript-eslint": "^8.62.1", + "vite": "^8.1.3", + "vite-plugin-singlefile": "^2.3.3", + "vitest": "^5.0.1" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..f52e8cb --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from '@playwright/test' + +// E2E runs against server.py serving the page (relays off, API on). +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + retries: process.env.CI ? 2 : 0, + timeout: 60_000, + reporter: [['list']], + use: { + baseURL: 'http://127.0.0.1:8765', + trace: 'on-first-retry', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: + 'npm run build && python3 ../backend/server.py --port 8765 --no-open --no-relays --access-log off', + url: 'http://127.0.0.1:8765/api/health', + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, +}) diff --git a/assets/apple-touch-icon.png b/frontend/public/assets/apple-touch-icon.png similarity index 100% rename from assets/apple-touch-icon.png rename to frontend/public/assets/apple-touch-icon.png diff --git a/assets/banner.svg b/frontend/public/assets/banner.svg similarity index 100% rename from assets/banner.svg rename to frontend/public/assets/banner.svg diff --git a/assets/favicon-32.png b/frontend/public/assets/favicon-32.png similarity index 100% rename from assets/favicon-32.png rename to frontend/public/assets/favicon-32.png diff --git a/assets/favicon.ico b/frontend/public/assets/favicon.ico similarity index 100% rename from assets/favicon.ico rename to frontend/public/assets/favicon.ico diff --git a/assets/favicon.svg b/frontend/public/assets/favicon.svg similarity index 100% rename from assets/favicon.svg rename to frontend/public/assets/favicon.svg diff --git a/assets/logo.svg b/frontend/public/assets/logo.svg similarity index 100% rename from assets/logo.svg rename to frontend/public/assets/logo.svg diff --git a/index.html b/frontend/public/index.html similarity index 100% rename from index.html rename to frontend/public/index.html diff --git a/norma-config.js b/frontend/public/norma-config.js similarity index 100% rename from norma-config.js rename to frontend/public/norma-config.js diff --git a/frontend/src/api/tester.ts b/frontend/src/api/tester.ts new file mode 100644 index 0000000..64c62c1 --- /dev/null +++ b/frontend/src/api/tester.ts @@ -0,0 +1,639 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import apiClientTemplate from '../../../examples/norma_api_client.py?raw' +import { + MAX_NETWORK_NODES, + escapeHtml, + getUsedGroups, + refreshNodeVisual, + sanitizeColor, +} from '../network_state' +import { NORMA_CFG } from '../config' +import { S } from '../state' +import { + activeView, + captureActiveView, + closeActiveView, + nameForSelection, + profilerState, + renderProfilerNetworkList, + renderProfilerResults, + renderViewBar, + restoreView, + uniqueViewName, + views, +} from '../profiler' +import { applyGroupVisibility, buildGroupLegend, buildLegend, getUsedTypes } from '../hulls' +import { applyTypeVisibility } from '../parallel_edges' +import { b64urlEncode, normaApiLoad } from './wiring' +import { compareState, renderCompareList } from '../label_colors' +import { currentTab } from '../wiring' +import { cy } from '../cy' +import { dataVersion, setHistoryBaseline, updateUndoButtons } from '../demo_downloads' +import { + downloadText, + libEntry, + libSelection, + normaLibrary, + plural, + renderLibraryLists, + setStatus, +} from '../layouts/controls' +import { refreshEnrichmentChoices, toast } from '../enrichment' +import { refreshLibraryView } from '../library' +import { renderGroupingSelect, updateContextInfo } from '../recording' +import { updateEmptyState } from '../welcome' + +/* ---------- the API tab's tester ---------- */ +const API_EXAMPLE = { + name: 'API example', + edges: [ + { source: 'TP53', target: 'MDM2', type: 'binding', weight: 0.9 }, + { source: 'MDM2', target: 'TP53', type: 'ubiquitination', directed: true }, + { source: 'TP53', target: 'CDKN1A', type: 'expression', directed: true }, + { source: 'CDKN1A', target: 'CDK2', type: 'inhibition', directed: true }, + { source: 'CDK2', target: 'CCNE1', type: 'binding' }, + { source: 'ATM', target: 'TP53', type: 'phosphorylation', directed: true }, + { source: 'ATM', target: 'CHEK2', type: 'phosphorylation', directed: true }, + { source: 'CHEK2', target: 'TP53', type: 'phosphorylation', directed: true }, + ], + groups: [ + { name: 'DNA damage sensing', members: ['ATM', 'CHEK2'], color: '#8b5cf6' }, + { + name: 'p53 core', + members: ['TP53', 'MDM2'], + color: '#f59e0b', + description: 'p53 and its E3 ligase', + }, + { name: 'Cell cycle', members: ['CDKN1A', 'CDK2', 'CCNE1', 'TP53'] }, + ], + expression: { TP53: 1.8, MDM2: 1.2, CDKN1A: 2.6, CDK2: -1.1, CCNE1: -0.7, ATM: 0.3, CHEK2: 0.4 }, + settings: { + edgeDirection: 'data', + edgeCurveStyle: 'bezier', + showGroupHulls: true, + hullStyle: 'bubble', + legendShow: true, + layout: 'fr', + }, +} + +function apiPayloadFromBox() { + const text = document.getElementById('apiPayload').value + try { + return JSON.parse(text) + } catch (e) { + throw new Error(`The payload is not valid JSON: ${e.message}`) + } +} + +function apiBase() { + return location.href.replace(/[?#].*$/, '') +} + +/* ============================================================ + SITE INFORMATION (licence, access, privacy, contact) + Shown on the Welcome page, in About and as an optional notice, from + the settings (see Help: Running NORMA locally or on a server). + ============================================================ */ +function siteLink(url, text) { + return url + ? `${escapeHtml(text)}` + : escapeHtml(text) +} + +export function renderSiteInfo() { + const S = NORMA_CFG.site + const hosted = NORMA_CFG.mode === 'hosted' + const contact = S.contactEmail + ? `${escapeHtml(S.contactEmail)}` + : siteLink(S.contactUrl, S.contactName || 'contact') + const foot = document.getElementById('siteFooter') + if (foot) { + foot.innerHTML = [ + `Free and open to all users. No login, registration or e-mail address is needed.`, + `No cookies and no tracking; your data stays in your browser (privacy).`, + `Licence: ${siteLink(S.licenceUrl, S.licenceName || 'MIT License')}`, + `${siteLink(S.sourceUrl, 'Source code')}`, + `Contact: ${contact}`, + `Programmatic access: Python template`, + S.privacyUrl ? `${siteLink(S.privacyUrl, 'Privacy policy')}` : '', + S.imprintUrl ? `${siteLink(S.imprintUrl, 'Imprint')}` : '', + ] + .filter(Boolean) + .join('·') + } + const box = document.getElementById('aboutSiteInfo') + if (box) { + const browsers = + Array.isArray(S.testedBrowsers) && S.testedBrowsers.length ? S.testedBrowsers.join(', ') : '' + box.innerHTML = ` +
  • Running here: ${hosted ? `a public NORMA server${NORMA_CFG.publicUrl ? ` at ${siteLink(NORMA_CFG.publicUrl, NORMA_CFG.publicUrl)}` : ''}` : NORMA_CFG.served ? 'a NORMA copy on this computer or network' : 'a NORMA file opened on this computer'}${S.institution ? `, provided by ${escapeHtml(S.institution)}` : ''}. NORMA ${escapeHtml(NORMA_CFG.version || '3.0')}.
  • +
  • Contact: ${contact}${S.contactEmail && S.contactUrl ? ` · ${siteLink(S.contactUrl, 'issue tracker')}` : ''}.
  • +
  • Maintenance: ${S.maintainedUntil ? `this server is maintained at least until ${escapeHtml(S.maintainedUntil)}.` : 'NORMA is maintained by the Pavlopoulos Lab; the source code stays available in its public repository.'}
  • +
  • Browsers: NORMA is made for current versions of Chrome, Edge, Firefox and Safari on Windows, macOS and Linux, and needs no plug-ins or installation.${browsers ? ` Tested on this server with ${escapeHtml(browsers)}.` : ''}
  • ` + } + if (S.notice) { + const bar = document.getElementById('siteNotice') + bar.querySelector('span').textContent = S.notice + bar.hidden = false + } + document.querySelectorAll('[data-download-template]').forEach((a) => + a.addEventListener('click', (e) => { + e.preventDefault() + downloadApiTemplate() + }) + ) + const wf = document.querySelector('.welcome-foot') + if (wf) + wf.textContent = `NORMA ${NORMA_CFG.version || '3.0'} runs in your browser: your files stay on this computer, except what you send to online databases or Arena3D yourself. Networks of up to ${MAX_NETWORK_NODES.toLocaleString('en-US')} nodes.` + // REST API availability + const restBtn = document.getElementById('btnApiServer') + const setRest = (on) => { + restBtn.disabled = !on + restBtn.title = on + ? '' + : 'The REST API needs NORMA to run with server.py (with the API turned on).' + } + if (NORMA_CFG.features.restApi === false) setRest(false) + else if (!NORMA_CFG.served) setRest(false) + else if (NORMA_CFG.features.restApi !== true) { + fetch('api/health') + .then((r) => (r.ok ? r.json() : null)) + .then((j) => setRest(!!(j && j.status === 'ok'))) + .catch(() => setRest(false)) + } +} + +function downloadApiTemplate() { + const el = { textContent: apiClientTemplate } + let text = el ? el.textContent : '' + const base = + NORMA_CFG.publicUrl || + (NORMA_CFG.served + ? location.href.replace(/[?#].*$/, '').replace(/norma\.html$|index\.html$/, '') + : '') + if (base) + text = text.replace('DEFAULT_SERVER = "http://localhost:8000/"', `DEFAULT_SERVER = "${base}"`) + downloadText('norma_api_client.py', text) +} + +/* ============================================================ + DELETING FILES + A network, annotation or expression file can be deleted completely: + it leaves Files, every view that shows it is updated (views left with + no network become empty), undo can't bring it back, and results of + the Network Profiler and Network Comparison that used it are removed. + ============================================================ */ +const KIND_WORD = { network: 'network', annotation: 'annotation', colors: 'expression file' } + +const SEL_FIELD = { annotation: 'annotation', colors: 'colors' } + +function libViewUses(lv, kind, id) { + if (!lv) return false + if (kind === 'network') + return String(lv.nets || '') + .split('|') + .includes(id) + return lv[SEL_FIELD[kind]] === id +} + +// views that show a file (the current one as it is now, others as saved) +function viewsShowing(kind, id) { + return views.filter((v) => + libViewUses(v.id === S.activeViewId ? S.currentLibView : v.state && v.state.libView, kind, id) + ) +} + +export let nextLoadPositions = null + +const KIND_TITLE = { network: 'network file', annotation: 'grouping', colors: 'expression file' } + +// items: [{ kind, id }] files; opts.view: also offer to remove this view +export function askDelete(items, opts = {}) { + const files = items + .map(({ kind, id }) => ({ kind, id, entry: libEntry(kind, id) })) + .filter((x) => x.entry) + const choices = [ + ...(opts.view ? [{ type: 'view', view: opts.view }] : []), + ...files.map((f) => ({ type: 'file', ...f })), + ] + if (!choices.length) return + const dlg = document.getElementById('deleteDialog') + const box = document.getElementById('deleteChoices') + const single = choices.length === 1 + const label = (c) => + c.type === 'view' ? `the view "${c.view.name}"` : `the ${KIND_TITLE[c.kind]} "${c.entry.name}"` + document.getElementById('deleteTitle').textContent = single + ? `Are you sure you want to completely remove ${label(choices[0])}?` + : opts.all + ? `Are you sure you want to completely remove all ${plural(files.length, 'file')}?` + : 'Are you sure you want to completely remove these?' + box.innerHTML = '' + box.hidden = single || opts.all + if (!box.hidden) { + choices.forEach((c, i) => { + const row = document.createElement('label') + const cb = document.createElement('input') + cb.type = 'checkbox' + cb.checked = true + cb.dataset.index = i + const others = + c.type === 'file' + ? viewsShowing(c.kind, c.id).filter((v) => !opts.view || v.id !== opts.view.id) + : [] + const note = + c.type === 'view' + ? 'closes the view; its display settings and positions are lost' + : others.length + ? `also shown in ${others.map((v) => `"${v.name}"`).join(', ')}, which will be updated` + : 'removed from Files and every menu' + const text = document.createElement('span') + text.innerHTML = `${escapeHtml( + label(c) + .replace(/^the /, '') + .replace(/^./, (ch) => ch.toUpperCase()) + )}${escapeHtml(note)}` + row.append(cb, text) + box.appendChild(row) + cb.addEventListener('change', update) + }) + } + const confirm = document.getElementById('btnDeleteConfirm') + function picked() { + if (box.hidden) return choices + return [...box.querySelectorAll('input')] + .filter((x) => x.checked) + .map((x) => choices[+x.dataset.index]) + } + function update() { + const p = picked() + const pf = p.filter((c) => c.type === 'file') + const shown = new Map() + pf.forEach(({ kind, id }) => viewsShowing(kind, id).forEach((v) => shown.set(v.id, v))) + if (opts.view && p.some((c) => c.type === 'view')) shown.delete(opts.view.id) + const lines = [] + if (pf.length) + lines.push( + `${pf.length === 1 ? 'The file' : 'The files'} will be removed from Files, from the Grouping menu and every other list, together with everything NORMA read from ${pf.length === 1 ? 'it' : 'them'}.` + ) + if (shown.size) { + lines.push( + `${shown.size === 1 ? 'This view shows' : 'These views show'} ${pf.length === 1 ? 'it' : 'them'} and will be updated: ${[...shown.values()].map((v) => `"${v.name}"`).join(', ')}. ${pf.some((c) => c.kind === 'network') ? 'A view left without networks becomes empty; other views keep their remaining networks and node positions.' : 'They keep their networks and node positions but lose these groups or colors.'}` + ) + } + if (p.some((c) => c.type === 'view')) + lines.push( + views.length === 1 + ? 'This is the only view; a new empty view takes its place.' + : 'The view is closed and the previous view is shown.' + ) + lines.push('This cannot be undone. Your files on disk are not touched.') + document.getElementById('deleteText').innerHTML = lines + .map((t) => `

    ${escapeHtml(t)}

    `) + .join('') + confirm.disabled = !p.length + confirm.textContent = p.length > 1 ? `Yes, remove ${p.length}` : 'Yes, remove' + } + update() + confirm.onclick = () => { + const p = picked() + if (!p.length) return + closeDeleteDialog() + const pf = p.filter((c) => c.type === 'file').map(({ kind, id }) => ({ kind, id })) + const closeView = p.some((c) => c.type === 'view') && opts.view + if (pf.length) deleteLibEntries(pf) + if (closeView) { + if (S.activeViewId !== closeView.id && views.some((v) => v.id === closeView.id)) { + captureActiveView() + S.activeViewId = closeView.id + } + const name = closeView.name + closeActiveView() + renderLibraryLists() + renderGroupingSelect() + updateContextInfo() + updateEmptyState() + toast(`Removed the view "${name}"${pf.length ? ` and ${plural(pf.length, 'file')}` : ''}.`) + } + } + if (typeof dlg.showModal === 'function') dlg.showModal() + else dlg.setAttribute('open', '') + document.getElementById('btnDeleteCancel').focus() +} + +function closeDeleteDialog() { + const dlg = document.getElementById('deleteDialog') + if (typeof dlg.close === 'function' && dlg.open) dlg.close() + else dlg.removeAttribute('open') +} + +export function deleteLibEntries(items) { + const entries = items + .map(({ kind, id }) => ({ kind, id, entry: libEntry(kind, id) })) + .filter((x) => x.entry) + if (!entries.length) return + captureActiveView() + const affected = new Set() + const lostNetwork = new Set() + entries.forEach(({ kind, id }) => { + views.forEach((v) => { + const sel = v.selection || (v.selection = { networks: [], annotation: '', colors: '' }) + if (kind === 'network') sel.networks = (sel.networks || []).filter((x) => x !== id) + else if (sel[SEL_FIELD[kind]] === id) sel[SEL_FIELD[kind]] = '' + const lv = v.state && v.state.libView + if (libViewUses(lv, kind, id)) { + affected.add(v) + if (kind === 'network') { + lv.nets = String(lv.nets) + .split('|') + .filter((x) => x !== id) + .join('|') + lostNetwork.add(v) + } else { + lv[SEL_FIELD[kind]] = '' + } + } + }) + normaLibrary[kind] = normaLibrary[kind].filter((e) => e.id !== id) + // groupings fetched for a deleted network stay usable on their own + if (kind === 'network') + normaLibrary.annotation.forEach((e) => { + if (e.forNetwork === id) delete e.forNetwork + }) + }) + affected.forEach((v) => { + v.history = { undo: [], redo: [], committed: null } // no way back to deleted data + const lv = v.state.libView + if (!lv.nets) { + v.data = null + if (v.autoName) v.name = uniqueViewName('Untitled view') + v.state = { config: v.state.config || { ...S.DEFAULT_VIEW_CONFIG } } + v.selection = { networks: [], annotation: '', colors: '' } + v.needsRefresh = null + } else { + // the saved picture still holds the deleted data: drop it now and + // rebuild the view from its remaining files when it is shown + v.data = null + v.needsRefresh = { reload: true } + } + }) + // drop analysis results that used deleted networks or changed views + const deletedNets = new Set(entries.filter((e) => e.kind === 'network').map((e) => e.id)) + const staleView = (id) => [...affected].some((v) => v.id === id) + let droppedResults = 0 + if (profilerState.results.length) { + const keep = profilerState.results.filter( + (r) => + !( + deletedNets.has(r.sourceValue) || + (r.sourceValue === 'view' && affected.has(activeView())) + ) + ) + droppedResults += profilerState.results.length - keep.length + if (keep.length !== profilerState.results.length) { + profilerState.results = keep + if (keep.length) renderProfilerResults(keep) + else { + document.getElementById('profResults').innerHTML = '' + document.getElementById('btnProfileTsv').disabled = true + } + } + } + if (compareState.results) { + const used = compareState.results.some((r) => + (r.sourceValue || '').startsWith('lib:') + ? deletedNets.has(r.sourceValue.slice(4)) + : staleView((r.sourceValue || '').slice(5)) + ) + if (used) { + droppedResults += 1 + compareState.results = null + document.getElementById('cmpResults').innerHTML = '' + ;['btnCompareTsv', 'btnCompareNet'].forEach((b) => { + document.getElementById(b).disabled = true + }) + document.getElementById('cmpArena').hidden = true + } + } + // show the current view again + const act = activeView() + S.historySuspended++ + try { + restoreView(act) + } finally { + S.historySuspended-- + } + S.dataCache = { version: dataVersion, data: act.data } + setHistoryBaseline() + renderViewBar() + updateUndoButtons() + renderLibraryLists() + renderGroupingSelect() + updateContextInfo() + updateEmptyState() + if (typeof refreshEnrichmentChoices === 'function') refreshEnrichmentChoices() + if (currentTab === 'profiler') renderProfilerNetworkList() + if (currentTab === 'compare') renderCompareList() + const names = entries.map((e) => `"${e.entry.name}"`) + const notes = [ + { + level: 'ok', + text: `Deleted ${entries.length === 1 ? names[0] : plural(entries.length, 'file')}${affected.size ? `; updated ${plural(affected.size, 'view')} that showed ${entries.length === 1 ? 'it' : 'them'}` : ''}.`, + }, + ] + if (droppedResults) + notes.push({ + level: 'ok', + text: 'Profiler or comparison results that used the deleted data were removed.', + }) + setStatus('normaStatus', notes) + toast(notes[0].text) +} + +// Brings a view up to date after files it showed were deleted: rebuilds it +// from its remaining files with its saved node positions, group colors and +// ticked groups and channels. +export function refreshAfterDelete(v) { + v.needsRefresh = null + const st = v.state || {} + const lv = st.libView + if (!lv) return + libSelection.networks = new Set( + String(lv.nets) + .split('|') + .filter((id) => libEntry('network', id)) + ) + libSelection.annotation = + lv.annotation && libEntry('annotation', lv.annotation) ? lv.annotation : '' + libSelection.colors = lv.colors && libEntry('colors', lv.colors) ? lv.colors : '' + if (!libSelection.networks.size) return + nextLoadPositions = st.positions && Object.keys(st.positions).length ? st.positions : null + S.currentLibView = null + const status = document.getElementById('normaStatus').innerHTML + try { + refreshLibraryView() + } finally { + nextLoadPositions = null + } + document.getElementById('normaStatus').innerHTML = status + // keep what the view looked like + if (st.groupColors) { + Object.entries(st.groupColors).forEach(([g, c]) => { + if (S.nodeColorMap[g]) { + const sc = sanitizeColor(c) + if (sc) S.nodeColorMap[g] = sc + } + }) + } + const used = new Set(getUsedGroups()) + if (Array.isArray(st.activeGroups)) { + const was = new Set(st.activeGroups) + used.forEach((g) => { + if (g in (st.groupColors || {}) && !was.has(g)) S.activeGroups.delete(g) + }) + } + if (Array.isArray(st.activeTypes)) { + const was = new Set(st.activeTypes) + getUsedTypes().forEach((t) => { + if (!was.has(t) && (st.typeColors || {})[t]) S.activeTypes.delete(t) + }) + } + cy.batch(() => cy.nodes().forEach((n) => refreshNodeVisual(n))) + if (typeof applyGroupVisibility === 'function') applyGroupVisibility() + if (typeof applyTypeVisibility === 'function') applyTypeVisibility() + buildGroupLegend() + buildLegend(getUsedTypes()) + if (st.positions && typeof st.zoom === 'number') cy.viewport({ zoom: st.zoom, pan: st.pan }) + v.selection = { + networks: [...libSelection.networks], + annotation: libSelection.annotation, + colors: libSelection.colors, + } + if (v.autoName) { + const base = nameForSelection() + if (base && base !== v.name) v.name = uniqueViewName(base) + renderViewBar() + } + captureActiveView() +} + +// page wiring, run by main.ts in the original order +export function init() { + ;(function setupApiTab() { + const box = document.getElementById('apiPayload') + box.value = JSON.stringify(API_EXAMPLE, null, 2) + document.getElementById('btnApiReset').addEventListener('click', () => { + box.value = JSON.stringify(API_EXAMPLE, null, 2) + }) + document.getElementById('btnApiLoad').addEventListener('click', async () => { + try { + await normaApiLoad(apiPayloadFromBox(), 'the API tab') + } catch (err) { + setStatus('apiStatus', [{ level: 'error', text: err.message }]) + } + }) + document.getElementById('btnApiLink').addEventListener('click', async () => { + try { + const url = `${apiBase()}#json=${b64urlEncode(JSON.stringify(apiPayloadFromBox()))}` + document.getElementById('apiResultUrl').value = url + const note = + url.length > 8000 + ? ' This link is long; browsers and chat programs may cut it. For larger networks use the server (Send to server) or a data URL.' + : '' + setStatus('apiStatus', [ + { + level: url.length > 8000 ? 'warn' : 'ok', + text: `Made a link of ${url.length.toLocaleString()} characters that opens this payload.${note}`, + }, + ]) + try { + await navigator.clipboard.writeText(url) + } catch (e) {} + } catch (err) { + setStatus('apiStatus', [{ level: 'error', text: err.message }]) + } + }) + document.getElementById('btnApiServer').addEventListener('click', async () => { + try { + const body = JSON.stringify(apiPayloadFromBox()) + const r = await fetch('api/external', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }) + const text = await r.text() + let j + try { + j = JSON.parse(text) + } catch (e) { + throw new Error( + 'This page is not served by server.py, so the REST API is not available here. Run NORMA with server.py, or use Make a link.' + ) + } + if (!r.ok) throw new Error(j.message || `The server answered ${r.status}.`) + document.getElementById('apiResultUrl').value = j.url + setStatus('apiStatus', [ + { + level: 'ok', + text: `The server stored the payload (token ${j.token}, kept for ${j.expiresInHours} hours).`, + action: { label: 'Open link', run: () => window.open(j.url, '_blank') }, + }, + ]) + } catch (err) { + setStatus('apiStatus', [ + { + level: 'error', + text: err.message.includes('fetch') + ? 'This page is not served by server.py, so the REST API is not available here.' + : err.message, + }, + ]) + } + }) + document.getElementById('btnApiCopy').addEventListener('click', async () => { + const v = document.getElementById('apiResultUrl').value + if (!v) return + try { + await navigator.clipboard.writeText(v) + toast('Copied the link.') + } catch (e) { + document.getElementById('apiResultUrl').select() + } + }) + // show the address this copy of NORMA answers on + const served = /^https?:$/.test(location.protocol) + const base = served + ? apiBase().replace(/norma\.html$|index\.html$/, '') + : 'http://localhost:8000/' + document.querySelectorAll('[data-api-base]').forEach((el) => { + el.textContent = base + }) + if (!served) { + document.querySelector('.api-base').innerHTML = + 'This copy of NORMA was opened from a file, so the examples use http://localhost:8000/, where server.py runs by default. Links with #json= and postMessage work from a file too.' + } + document.querySelectorAll('.api-code').forEach((pre) => { + const btn = document.createElement('button') + btn.type = 'button' + btn.className = 'api-copy' + btn.textContent = 'Copy' + btn.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(pre.innerText) + btn.textContent = 'Copied' + setTimeout(() => { + btn.textContent = 'Copy' + }, 1200) + } catch (e) {} + }) + pre.parentElement.insertBefore(btn, pre) + }) + })() + + document.getElementById('siteNoticeClose').addEventListener('click', () => { + document.getElementById('siteNotice').hidden = true + }) + + document.getElementById('btnDeleteCancel').addEventListener('click', closeDeleteDialog) +} diff --git a/frontend/src/api/wiring.ts b/frontend/src/api/wiring.ts new file mode 100644 index 0000000..df69f03 --- /dev/null +++ b/frontend/src/api/wiring.ts @@ -0,0 +1,453 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { + REACTOME_SPECIES, + dbCancel, + filterGoModels, + goAnnotateView, + goFetchModel, + goLoadModels, + intactFetch, + isSessionObject, + loadSession, + ndexFetch, + ndexSearch, + omnipathFetch, + reactomeFetch, + reactomeSearch, + toast, +} from '../enrichment' +import { UNGROUPED, getUsedGroups } from '../network_state' +import { + addNormaEntry, + libSelection, + plural, + renderLibraryLists, + setLayoutMode, + setStatus, +} from '../layouts/controls' +import { applyConfig, loadData } from '../hulls' +import { applyTheme } from '../themes' +import { cy } from '../cy' +import { openInNewView } from '../profiler' +import { refreshLibraryView } from '../library' +import { runLayout } from '../layouts/run' +import { switchTab } from '../wiring' + +/* ============================================================ + NORMA API + Other applications can open NORMA with their own networks and groups: + 1. REST (with server.py): POST a payload to /api/external; the answer + { token, url } holds a link that opens NORMA with the data loaded + (norma.html?session=TOKEN). + 2. Links: norma.html?data=URL (a payload file), or + norma.html?network=URL&annotation=URL&expression=URL (NORMA files), + or norma.html#json=BASE64URL (a small payload inside the link). + 3. postMessage: a page that opens NORMA in a window or iframe sends + { type: 'norma:load', payload, requestId } and gets back + { type: 'norma:loaded', ok, summary | error, requestId }. + Every route ends in normaApiLoad(payload). + ============================================================ */ +const NORMA_API_VERSION = '1.0' + +const API_MAX_BYTES = 50 * 1024 * 1024 + +function apiTextOf(v) { + if (v == null) return [] + return (Array.isArray(v) ? v : [v]) + .map((x) => (typeof x === 'string' ? { text: x } : x)) + .filter((x) => x && typeof x.text === 'string') +} + +function apiSafe(s) { + return String(s ?? '') + .replace(/[\t\r\n]+/g, ' ') + .trim() +} + +function apiNode(s) { + return apiSafe(s).replace(/,/g, ';') +} + +// { edges: [...] } -> NORMA network text +function apiEdgesText(edges) { + if (!Array.isArray(edges) || !edges.length) + throw new Error('"edges" must be a non-empty list of { source, target } objects.') + const weighted = edges.some((e) => typeof e.weight === 'number') + const typed = edges.some((e) => e.type) + const directed = edges.some((e) => e.directed) + const head = [ + 'Source', + 'Target', + ...(weighted ? ['Weight'] : []), + ...(typed ? ['Type'] : []), + ...(directed ? ['Direction'] : []), + ] + const rows = edges.map((e, i) => { + if (!e || e.source == null || e.target == null) + throw new Error(`Edge ${i + 1} needs a source and a target.`) + return [ + apiNode(e.source), + apiNode(e.target), + ...(weighted ? [typeof e.weight === 'number' ? e.weight : 1] : []), + ...(typed ? [apiSafe(e.type || 'link')] : []), + ...(directed ? [e.directed ? 'directed' : 'undirected'] : []), + ].join('\t') + }) + return [head.join('\t'), ...rows].join('\n') + '\n' +} + +// groups as { name: [members] } or [{ name, members, color, description }] +function apiGroupsText(groups) { + const list = Array.isArray(groups) + ? groups + : Object.entries(groups || {}).map(([name, members]) => ({ name, members })) + const meta = {} + const rows = list.map((g, i) => { + if (!g || !Array.isArray(g.members)) throw new Error(`Group ${i + 1} needs a "members" list.`) + const name = apiSafe(g.name || `Group ${i + 1}`) + const m = {} + if (g.color) m.color = String(g.color) + if (g.description) m.description = String(g.description) + if (Object.keys(m).length) meta[name] = m + return `${name}\t${g.members.map(apiNode).join(',')}` + }) + return { text: rows.join('\n') + '\n', meta } +} + +// expression as { node: '#color' } or { node: number } or { node: { col: number } } +function apiExpressionText(expr) { + const entries = Object.entries(expr || {}) + if (!entries.length) return null + const first = entries[0][1] + if (first && typeof first === 'object') { + const cols = [...new Set(entries.flatMap(([, v]) => Object.keys(v || {})))] + return ( + [ + 'Node\t' + cols.join('\t'), + ...entries.map(([n, v]) => + [apiNode(n), ...cols.map((c) => (v && v[c] != null ? v[c] : 'NA'))].join('\t') + ), + ].join('\n') + '\n' + ) + } + if (typeof first === 'number') + return ( + ['Node\tvalue', ...entries.map(([n, v]) => `${apiNode(n)}\t${v == null ? 'NA' : v}`)].join( + '\n' + ) + '\n' + ) + return entries.map(([n, v]) => `${apiNode(n)}\t${v}`).join('\n') + '\n' +} + +export async function normaApiLoad(payload, origin = 'API') { + if (typeof payload === 'string') { + if (payload.length > API_MAX_BYTES) throw new Error('The payload is larger than 50 MB.') + try { + payload = JSON.parse(payload) + } catch (e) { + throw new Error('The payload is not valid JSON.') + } + } + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) + throw new Error('The payload must be a JSON object.') + // a whole session + if (isSessionObject(payload) || isSessionObject(payload.session)) { + const r = loadSession(isSessionObject(payload) ? payload : payload.session) + switchTab('network') + return { kind: 'session', views: r.views, files: r.files } + } + const name = apiSafe(payload.name) || 'API network' + const settings = + payload.settings && typeof payload.settings === 'object' ? payload.settings : null + const files = payload.files || {} + const netTexts = [...apiTextOf(files.network)] + if (payload.edges) netTexts.push({ text: apiEdgesText(payload.edges), name }) + let summary + if (netTexts.length) { + const directed = payload.directed === true + const nets = netTexts.map((t, i) => + addNormaEntry( + 'network', + t.name || (netTexts.length > 1 ? `${name} ${i + 1}` : name), + t.text, + '', + null, + { directed } + ) + ) + // node attributes + if (Array.isArray(payload.nodes)) { + const attrs = {} + payload.nodes.forEach((n) => { + if (n && n.id != null) { + const { id, ...rest } = n + attrs[apiNode(id)] = rest + } + }) + nets.forEach((e) => { + e.nodeAttrs = attrs + }) + } + const anns = [] + apiTextOf(files.annotation).forEach((t, i) => + anns.push( + addNormaEntry('annotation', t.name || `${name}: grouping ${i + 1}`, t.text, '', null, {}) + ) + ) + if (payload.groups) { + const g = apiGroupsText(payload.groups) + const e = addNormaEntry('annotation', `${name}: groups`, g.text, '', null, {}) + e.groupMeta = g.meta + anns.push(e) + } + ;(Array.isArray(payload.annotations) ? payload.annotations : []).forEach((a, i) => { + const g = apiGroupsText(a.groups || {}) + const e = addNormaEntry( + 'annotation', + `${name}: ${apiSafe(a.name) || `grouping ${i + 1}`}`, + g.text, + '', + null, + {} + ) + e.groupMeta = g.meta + anns.push(e) + }) + let col = null + const exprText = + apiTextOf(files.expression)[0] || + (payload.expression ? { text: apiExpressionText(payload.expression) } : null) + if (exprText && exprText.text) + col = addNormaEntry( + 'colors', + `${name}: ${payload.expression && typeof Object.values(payload.expression)[0] !== 'string' ? 'values' : 'colors'}`, + exprText.text, + '', + null, + {} + ) + openInNewView(name, () => { + libSelection.networks = new Set(nets.map((e) => e.id)) + libSelection.annotation = anns[0] ? anns[0].id : '' + libSelection.colors = col ? col.id : '' + renderLibraryLists() + refreshLibraryView() + }) + summary = { + kind: 'files', + view: name, + networks: nets.length, + groupings: anns.length, + expression: !!col, + notes: [...nets, ...anns, ...(col ? [col] : [])].flatMap((e) => e.parsed.notes || []), + } + } else if (payload.network || Array.isArray(payload.nodes)) { + const data = payload.network || payload + if (!Array.isArray(data.nodes) || !Array.isArray(data.edges)) + throw new Error('A NORMA view needs "nodes" and "edges" lists (see the JSON format in Help).') + openInNewView(name, () => loadData(data)) + summary = { kind: 'view', view: name } + } else { + throw new Error( + 'Nothing to show: give "edges", "files.network", "network" (a NORMA view) or a session.' + ) + } + if (settings) { + applyConfig(settings) + if (settings.theme) applyTheme(settings.theme) + if (settings.layout) { + setLayoutMode('connections') + document.getElementById('layoutSelect').value = settings.layout + runLayout(settings.layout) + } + } + switchTab(payload.tab === '3d' ? 'network3d' : 'network') + summary.nodes = cy.nodes().length + summary.edges = cy.edges().length + summary.groups = getUsedGroups().filter((g) => g !== UNGROUPED).length + setStatus('apiStatus', [ + { + level: 'ok', + text: `${origin}: opened "${name}" with ${plural(summary.nodes, 'node')}, ${plural(summary.edges, 'edge')} and ${plural(summary.groups, 'group')}.`, + }, + ]) + toast(`Opened "${name}" from ${origin}.`) + return summary +} + +export function b64urlEncode(text) { + const bytes = new TextEncoder().encode(text) + let bin = '' + bytes.forEach((b) => { + bin += String.fromCharCode(b) + }) + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function b64urlDecode(s) { + const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((s.length + 3) % 4)) + return new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0))) +} + +async function apiFetchText(url) { + const r = await fetch(url) + if (!r.ok) throw new Error(`${url} answered ${r.status}.`) + return r.text() +} + +// what the address asks for +export async function apiFromLocation() { + const q = new URLSearchParams(location.search) + const hash = new URLSearchParams(location.hash.replace(/^#/, '')) + try { + if (q.get('example')) { + const sel = document.getElementById('sampleSelect') + const key = q.get('example') + if (![...sel.options].some((o) => o.value === key)) + throw new Error(`there is no example called "${key}".`) + sel.value = key + document.getElementById('btnSample').click() + switchTab(q.get('tab') === '3d' ? 'network3d' : 'network') + return { kind: 'example', example: key } + } + if (q.get('session')) { + const token = q.get('session') + const r = await fetch(`api/session/${encodeURIComponent(token)}`) + if (!r.ok) + throw new Error( + r.status === 404 + ? 'This link has expired or does not exist on this server.' + : `The server answered ${r.status}.` + ) + return await normaApiLoad(await r.text(), 'a shared link') + } + if (hash.get('json')) { + let text + try { + text = b64urlDecode(hash.get('json')) + } catch (e) { + throw new Error( + 'the data part of the link (after #json=) is damaged; it may have been cut when the link was copied.' + ) + } + return await normaApiLoad(text, 'the link') + } + if (q.get('data')) return await normaApiLoad(await apiFetchText(q.get('data')), q.get('data')) + if (q.get('network')) { + const files = { network: await apiFetchText(q.get('network')) } + if (q.get('annotation')) files.annotation = await apiFetchText(q.get('annotation')) + if (q.get('expression')) files.expression = await apiFetchText(q.get('expression')) + const settings = {} + if (q.get('layout')) settings.layout = q.get('layout') + if (q.get('theme')) settings.theme = q.get('theme') + return await normaApiLoad( + { name: q.get('name') || 'Linked network', files, settings, tab: q.get('tab') || '' }, + 'the link' + ) + } + } catch (err) { + switchTab('api') + setStatus('apiStatus', [ + { level: 'error', text: `The data in this link could not be opened: ${err.message}` }, + ]) + } + return null +} + +export function apiAnnounceReady() { + const msg = { type: 'norma:ready', version: NORMA_API_VERSION } + try { + if (window.opener) window.opener.postMessage(msg, '*') + } catch (e) {} + try { + if (window.parent && window.parent !== window) window.parent.postMessage(msg, '*') + } catch (e) {} +} + +// page wiring, run by main.ts in the original order +export function init() { + /* ---------- wiring ---------- */ + ;(function setupDbImporters() { + const rs = document.getElementById('reactomeSpecies') + REACTOME_SPECIES.forEach((s) => rs.add(new Option(s, s))) + const bind = (id, fn) => document.getElementById(id).addEventListener('click', fn) + bind('btnReactomeSearch', reactomeSearch) + bind('btnReactomeFetch', reactomeFetch) + bind('btnOmnipathFetch', omnipathFetch) + bind('btnNdexSearch', ndexSearch) + bind('btnNdexFetch', ndexFetch) + bind('btnIntactFetch', intactFetch) + bind('btnGoModels', goLoadModels) + bind('btnGoModelFetch', goFetchModel) + bind('btnGoAnnotate', goAnnotateView) + document.getElementById('goModelFilter').addEventListener('input', filterGoModels) + const sc = document.getElementById('intactScore') + sc.addEventListener('input', () => { + document.getElementById('intactScoreValue').textContent = parseFloat(sc.value).toFixed(2) + }) + ;['reactome', 'omnipath', 'ndex', 'intact', 'go'].forEach((k) => + bind(`${k}Cancel`, () => dbCancel(k)) + ) + document.getElementById('reactomeQuery').addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault() + reactomeSearch() + } + }) + document.getElementById('ndexQuery').addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault() + ndexSearch() + } + }) + // collapsible importer sections + // every sidebar section opens and closes from its title + document.querySelectorAll('.section.collapsible > h3').forEach((h) => { + const toggle = () => { + h.parentElement.classList.toggle('collapsed') + h.setAttribute('aria-expanded', String(!h.parentElement.classList.contains('collapsed'))) + } + h.addEventListener('click', (e) => { + if (!e.target.closest('button, a, input, select')) toggle() + }) + h.addEventListener('keydown', (e) => { + if (e.target !== h || (e.key !== 'Enter' && e.key !== ' ')) return + e.preventDefault() + toggle() + }) + }) + document + .querySelectorAll('.section.collapsible.collapsed > h3') + .forEach((h) => h.setAttribute('aria-expanded', 'false')) + })() + + // postMessage + window.addEventListener('message', async (e) => { + const m = e.data + if (!m || typeof m !== 'object' || typeof m.type !== 'string' || !m.type.startsWith('norma:')) + return + const reply = (obj) => { + try { + ;(e.source || window.parent).postMessage( + { ...obj, requestId: m.requestId }, + e.origin === 'null' ? '*' : e.origin + ) + } catch (err) {} + } + if (m.type === 'norma:ping') { + reply({ type: 'norma:ready', version: NORMA_API_VERSION }) + return + } + if (m.type !== 'norma:load') return + try { + const summary = await normaApiLoad( + m.payload, + e.origin && e.origin !== 'null' ? e.origin : 'another page' + ) + reply({ type: 'norma:loaded', ok: true, summary }) + } catch (err) { + reply({ type: 'norma:loaded', ok: false, error: err.message }) + } + }) +} diff --git a/frontend/src/arena3d.test.ts b/frontend/src/arena3d.test.ts new file mode 100644 index 0000000..69e78fb --- /dev/null +++ b/frontend/src/arena3d.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from 'vitest' +import { + benjaminiHochberg, + convertArena3dNetwork, + hypergeomUpper, + isArena3dNetworkText, +} from './arena3d' + +const ARENA = [ + 'SourceNode\tSourceLayer\tTargetNode\tTargetLayer\tWeight\tChannel', + 'A\tL1\tB\tL1\t2\tppi', + 'B\tL1\tC\tL2\t\tgenetic', + 'C\tL2\tD\tL2\t1\tppi', + '', +].join('\n') + +test('recognises Arena3D network files', () => { + expect(isArena3dNetworkText(ARENA)).toBe(true) + expect(isArena3dNetworkText('Source\tTarget\nA\tB\n')).toBe(false) + expect(isArena3dNetworkText('')).toBe(false) +}) + +test('converts layers to groups and keeps weights and channels', () => { + const r = convertArena3dNetwork(ARENA) + expect(r.layers).toBe(2) + expect(r.network.split('\n').filter(Boolean)).toEqual([ + 'Source\tTarget\tWeight\tType', + 'A\tB\t2\tppi', + 'B\tC\t1\tgenetic', + 'C\tD\t1\tppi', + ]) + expect(r.annotation).toBe('L1\tA,B\nL2\tC,D\n') +}) + +test('hypergeometric upper tail', () => { + // urn: 10 balls, 4 white, draw 3: P(X >= 3) = C(4,3)/C(10,3) = 4/120 + expect(hypergeomUpper(3, 10, 4, 3)).toBeCloseTo(4 / 120, 10) + expect(hypergeomUpper(0, 10, 4, 3)).toBeCloseTo(1, 10) + expect(hypergeomUpper(5, 10, 4, 3)).toBe(0) +}) + +test('Benjamini-Hochberg keeps order and is monotone', () => { + // ranks: 0.01, 0.03, 0.04, 0.5 -> q = min over higher ranks of p * n / rank + const q = benjaminiHochberg([0.01, 0.04, 0.03, 0.5]) + expect(q.map((x) => +x.toFixed(4))).toEqual([0.04, 0.0533, 0.0533, 0.5]) + expect(benjaminiHochberg([])).toEqual([]) +}) diff --git a/frontend/src/arena3d.ts b/frontend/src/arena3d.ts new file mode 100644 index 0000000..8e7d5b3 --- /dev/null +++ b/frontend/src/arena3d.ts @@ -0,0 +1,285 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { S } from './state' +import { + UNGROUPED, + effectiveGroupsFor, + escapeHtml, + getUsedGroups, + groupLabel, + refreshNodeVisual, + sanitizeColor, +} from './network_state' +import { activeView } from './profiler' +import { applyGroupVisibility, buildGroupLegend } from './hulls' +import { cy } from './cy' +import { downloadText, fileStem, normaCols, normaLines, plural } from './layouts/controls' +import { groupAnalysisState } from './enrichment' +import { partitionStats } from './welcome' +import { shownEdges, shownNodes } from './metrics' +import { simpleGraph } from './wiring' + +/* ---------- reading Arena3D network files ---------- */ +// SourceNode, SourceLayer, TargetNode, TargetLayer, Weight, Channel: +// becomes a NORMA network (one channel per Channel value when there are +// several) and an annotation with one group per layer. +export function isArena3dNetworkText(text) { + const first = normaLines(text).find((l) => l.trim()) + if (!first) return false + const h = normaCols(first).map((c) => c.trim().toLowerCase()) + return ( + h[0] === 'sourcenode' && + h[1] === 'sourcelayer' && + h[2] === 'targetnode' && + h[3] === 'targetlayer' + ) +} + +export function convertArena3dNetwork(text) { + const lines = normaLines(text).filter((l) => l.trim()) + const head = normaCols(lines[0]).map((c) => c.trim().toLowerCase()) + const wCol = head.indexOf('weight'), + cCol = head.indexOf('channel') + const members = new Map() + const channels = new Set() + const rows = [] + lines.slice(1).forEach((line) => { + const c = normaCols(line) + if (c.length < 4) return + const [sn, sl, tn, tl] = c.map((x) => x.trim()) + ;[ + [sn, sl], + [tn, tl], + ].forEach(([n, l]) => { + if (!members.has(l)) members.set(l, new Set()) + members.get(l).add(n) + }) + const ch = cCol >= 0 ? (c[cCol] || '').trim() : '' + if (ch) channels.add(ch) + rows.push([sn, tn, wCol >= 0 ? (c[wCol] || '').trim() : '', ch]) + }) + const typed = channels.size > 1 + const weighted = wCol >= 0 + const header = ['Source', 'Target', ...(weighted ? ['Weight'] : []), ...(typed ? ['Type'] : [])] + const network = + [ + header.join('\t'), + ...rows.map((r) => + [r[0], r[1], ...(weighted ? [r[2] || '1'] : []), ...(typed ? [r[3] || '1'] : [])].join('\t') + ), + ].join('\n') + '\n' + const annotation = [...members].map(([l, set]) => `${l}\t${[...set].join(',')}`).join('\n') + '\n' + return { network, annotation, layers: members.size } +} + +/* ============================================================ + GROUP ANALYSIS (Network Profiler page, current view) + - per-group statistics and the modularity of the grouping + - the group network: one node per group, edges weighted by the + connections between groups, opened as a new view + - enrichment: hypergeometric test of annotation terms in selected + nodes or in each group of another annotation, with + Benjamini-Hochberg FDR + All of it uses the shown part of the view (ticked groups and channels). + ============================================================ */ + +// log n! with a cached table +const LOG_FACT = [0] + +function logFact(n) { + for (let i = LOG_FACT.length; i <= n; i++) LOG_FACT[i] = LOG_FACT[i - 1] + Math.log(i) + return LOG_FACT[n] +} + +function logChoose(n, k) { + return k < 0 || k > n ? -Infinity : logFact(n) - logFact(k) - logFact(n - k) +} + +// P(X >= k) for X ~ Hypergeometric(N population, K successes, n draws) +export function hypergeomUpper(k, N, K, n) { + const hi = Math.min(K, n) + if (k > hi) return 0 + const lo = Math.max(k, 0) + const denom = logChoose(N, n) + const logs = [] + for (let x = lo; x <= hi; x++) logs.push(logChoose(K, x) + logChoose(N - K, n - x) - denom) + const m = Math.max(...logs) + const p = Math.exp(m) * logs.reduce((s, l) => s + Math.exp(l - m), 0) + return Math.min(1, p) +} + +export function benjaminiHochberg(ps) { + const idx = ps.map((p, i) => [p, i]).sort((a, b) => a[0] - b[0]) + const q = new Array(ps.length) + let min = 1 + for (let r = idx.length - 1; r >= 0; r--) { + const [p, i] = idx[r] + min = Math.min(min, (p * idx.length) / (r + 1)) + q[i] = min + } + return q +} + +// the shown part of the view as a simple undirected graph +function shownSimpleGraph() { + const nodes = shownNodes() + const ids = nodes.map((n) => n.id()) + const idSet = new Set(ids) + const pairs = [] + shownEdges(true).forEach((e) => { + const s = e.data('source'), + t = e.data('target') + if (s !== t && idSet.has(s) && idSet.has(t)) pairs.push([s, t]) + }) + return { nodes, ids, graph: simpleGraph(ids, pairs) } +} + +function groupStatistics() { + const { nodes, ids, graph } = shownSimpleGraph() + const index = new Map(ids.map((id, i) => [id, i])) + const groupsOf = ids.map((id) => effectiveGroupsFor(cy.$id(id)).filter((g) => g !== UNGROUPED)) + const order = getUsedGroups().filter((g) => S.activeGroups.has(g) && g !== UNGROUPED) + const members = new Map(order.map((g) => [g, []])) + groupsOf.forEach((gs, i) => + gs.forEach((g) => { + if (members.has(g)) members.get(g).push(i) + }) + ) + const m = graph.m, + twoM = 2 * m + const deg = graph.adj.map((a) => a.length) + const rows = [] + members.forEach((list, g) => { + if (!list.length) return + const inG = new Set(list) + let internal2 = 0, + boundary = 0, + vol = 0 + list.forEach((i) => { + vol += deg[i] + graph.adj[i].forEach((j) => { + if (inG.has(j)) internal2++ + else boundary++ + }) + }) + const internal = internal2 / 2 + const k = list.length + const pairsIn = (k * (k - 1)) / 2 + const volRest = twoM - vol + rows.push({ + group: g, + label: groupLabel(g), + size: k, + internal, + boundary, + density: pairsIn ? internal / pairsIn : NaN, + avgInternalDegree: k ? internal2 / k : NaN, + conductance: Math.min(vol, volRest) > 0 ? boundary / Math.min(vol, volRest) : NaN, + share: twoM ? internal / m - Math.pow(vol / twoM, 2) : NaN, // modularity term + }) + }) + // modularity of the grouping: nodes in several groups count for their first group, nodes in none are singletons + const raw = new Int32Array(ids.length) + const gIndex = new Map(order.map((g, i) => [g, i])) + groupsOf.forEach((gs, i) => { + raw[i] = gs.length ? gIndex.get(gs[0]) : order.length + i + }) + const part = partitionStats(graph, raw) + const overlapping = groupsOf.filter((gs) => gs.length > 1).length + const ungrouped = groupsOf.filter((gs) => !gs.length).length + // how much denser groups are inside than the network as a whole + const netDensity = ids.length > 1 ? m / ((ids.length * (ids.length - 1)) / 2) : NaN + return { + rows, + modularity: part.modularity, + overlapping, + ungrouped, + nodes: ids.length, + edges: m, + netDensity, + } +} + +export function renderGroupStats() { + const root = document.getElementById('gaStats') + if (!cy.nodes().length) { + root.innerHTML = '

    Show a network first.

    ' + return + } + const st = groupStatistics() + if (!st.rows.length) { + root.innerHTML = '

    The current view has no ticked groups.

    ' + return + } + const f = (v, d = 3) => (Number.isFinite(v) ? v.toFixed(d) : '—') + const notes = [] + if (st.overlapping) + notes.push( + `${plural(st.overlapping, 'node')} in several groups ${st.overlapping === 1 ? 'counts' : 'count'} for ${st.overlapping === 1 ? 'its' : 'their'} first group` + ) + if (st.ungrouped) + notes.push( + `${plural(st.ungrouped, 'node')} in no group ${st.ungrouped === 1 ? 'counts as a group of its own' : 'count as groups of their own'}` + ) + root.innerHTML = ` +

    Modularity of the grouping: ${f(st.modularity)} on ${plural(st.nodes, 'shown node')} and ${plural(st.edges, 'edge')} (network density ${f(st.netDensity, 4)})${notes.length ? `; ${notes.join(', ')}` : ''}. Values above about 0.3 mean the groups follow the network's structure.

    +
    + + ${st.rows + .map( + (r) => ` + + + + ` + ) + .join('')} +
    GroupNodesEdges insideEdges leavingDensity insideMean degree insideConductance ↓Modularity share
    ${escapeHtml(r.label)}${r.size}${r.internal}${r.boundary}${f(r.density)}${f(r.avgInternalDegree, 2)}${f(r.conductance)}${f(r.share, 4)}
    ` + document.getElementById('btnGaStatsTsv').disabled = false + groupAnalysisState.stats = st +} + +export function groupStatsTsv() { + const st = groupAnalysisState.stats + if (!st) return + const f = (v) => (Number.isFinite(v) ? String(Math.round(v * 1e6) / 1e6) : '') + const lines = [ + `# modularity of the grouping\t${f(st.modularity)}`, + [ + 'Group', + 'Nodes', + 'Edges inside', + 'Edges leaving', + 'Density inside', + 'Mean degree inside', + 'Conductance', + 'Modularity share', + ].join('\t'), + ...st.rows.map((r) => + [ + r.label, + r.size, + r.internal, + r.boundary, + f(r.density), + f(r.avgInternalDegree), + f(r.conductance), + f(r.share), + ].join('\t') + ), + ] + const v = activeView() + downloadText(`group-statistics${v ? '-' + fileStem(v.name) : ''}.tsv`, lines.join('\n') + '\n') +} + +// Group colors given with generated data win over the palette that a +// data config re-applies. +export function applyGroupColorOverrides(colors) { + Object.entries(colors || {}).forEach(([g, c]) => { + const sc = sanitizeColor(c) + if (sc) S.nodeColorMap[g] = sc + }) + cy.batch(() => cy.nodes().forEach((n) => refreshNodeVisual(n))) + buildGroupLegend() + applyGroupVisibility() +} diff --git a/frontend/src/benchmark.ts b/frontend/src/benchmark.ts new file mode 100644 index 0000000..88cc5dc --- /dev/null +++ b/frontend/src/benchmark.ts @@ -0,0 +1,274 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { S } from './state' +import { activeView } from './profiler' +import { + applyLocalLayoutsAfter, + blockGroupLayout, + collectLayoutGraph, + strategyGravity, + strategySuperNodes, + strategyVirtualNodes, +} from './layouts/input' +import { + computeSubLayout, + computeSubLayoutAsync, + makeLayoutProgress, + normalizeSpacing, + targetNodeSpacing, +} from './metrics' +import { + downloadText, + fileStem, + nextPaint, + plural, + setLayoutMode, + setStatus, +} from './layouts/controls' +import { escapeHtml } from './network_state' +import { groupSeparation } from './clustering/mapping' +import { runActiveLayout } from './layouts/run' +import { switchTab } from './wiring' + +/* ---------- layout benchmark (Network Profiler) ---------- */ +const BENCH_LAYOUTS = [ + ['conn', 'cose', 'Force-directed (cose)'], + ['conn', 'fr', 'Force-directed, weighted'], + ['conn', 'kk', 'Kamada–Kawai'], + ['conn', 'stress', 'Stress majorization'], + ['conn', 'circle', 'Circle'], + ['conn', 'concentric', 'Concentric'], + ['conn', 'breadthfirst', 'Hierarchical'], + ['conn', 'grid', 'Grid'], + ['conn', 'random', 'Random (baseline)'], + ['groups', 'fr', 'Groups: force-directed, weighted'], + ['groups', 'cose', 'Groups: force-directed (cose)'], + ['groups', 'circle', 'Groups: circle'], + ['groups', 'grid', 'Groups: grid'], + ['groups', 'breadthfirst', 'Groups: hierarchical'], + ['groups', 'concentric', 'Groups: concentric'], + ['groups', 'virtual', 'NORMA-2.0 strategy 1: virtual nodes'], + ['groups', 'gravity', 'NORMA-2.0 strategy 2: group gravity'], + ['groups', 'supernodes', 'NORMA-2.0 strategy 3: super nodes'], +] + +const benchState = { rows: [], running: false, cancel: false } + +async function benchmarkPositions(kind, name, graph) { + const spacing = targetNodeSpacing() + if (kind === 'conn') { + const pos = ['fr', 'kk', 'stress'].includes(name) + ? await computeSubLayoutAsync(graph.nodeIds, graph.edges, name) + : computeSubLayout(graph.nodeIds, graph.edges, name) + return pos ? normalizeSpacing(pos, spacing) : null + } + const algorithm = document.getElementById('strategyAlgorithm').value + const force = parseFloat(document.getElementById('groupForce').value) || 10 + const clusterScale = parseFloat(document.getElementById('groupClusterRadius').value) || 1 + let localName = document.getElementById('localGroupLayout').value + const keepAllowed = name === 'virtual' || name === 'gravity' + if (localName === 'keep' && !keepAllowed) localName = 'circle' + if (name === 'virtual') + return applyLocalLayoutsAfter( + graph, + await strategyVirtualNodes(graph, algorithm), + localName, + force, + clusterScale + ) + if (name === 'gravity') + return applyLocalLayoutsAfter( + graph, + await strategyGravity(graph, algorithm, force), + localName, + force, + clusterScale + ) + if (name === 'supernodes') + return strategySuperNodes(graph, algorithm, localName, force, clusterScale) + return blockGroupLayout(graph, name, localName, force, clusterScale) +} + +export async function runLayoutBenchmark() { + if (benchState.running) { + benchState.cancel = true + return + } + const graph = collectLayoutGraph() + const btn = document.getElementById('btnBench') + if (graph.nodeIds.length < 3) { + setStatus('benchStatus', [ + { level: 'error', text: 'Show a network with at least three nodes first.' }, + ]) + return + } + if (graph.groupMembers.size < 2) { + setStatus('benchStatus', [ + { + level: 'error', + text: 'The current view needs at least two ticked groups to measure separation.', + }, + ]) + return + } + const repeats = parseInt(document.getElementById('benchRepeats').value, 10) || 1 + benchState.running = true + benchState.cancel = false + btn.textContent = 'Stop' + const rows = [] + const started = performance.now() + try { + for (const [li, [kind, name, label]] of BENCH_LAYOUTS.entries()) { + if (benchState.cancel) break + const text = `Running ${label} (${li + 1} of ${BENCH_LAYOUTS.length})…` + const show = (f) => setStatus('benchStatus', [{ level: 'busy', text, progress: f }]) + show(li / BENCH_LAYOUTS.length) + await nextPaint() + const runs = [] + for (let k = 0; k < repeats; k++) { + const t0 = performance.now() + let pos + S.layoutProgress = makeLayoutProgress((f) => + show((li + (k + f) / repeats) / BENCH_LAYOUTS.length) + ) + try { + pos = await benchmarkPositions(kind, name, graph) + } catch (err) { + pos = null + } finally { + S.layoutProgress = null + } + show((li + (k + 1) / repeats) / BENCH_LAYOUTS.length) + await nextPaint() + const ms = performance.now() - t0 + if (!pos) continue + runs.push({ ms, ...groupSeparation(pos, graph.groupsOf) }) + } + const agg = (key) => { + const vals = runs.map((r) => r[key]).filter(Number.isFinite) + if (!vals.length) return { mean: NaN, sd: NaN } + const mean = vals.reduce((s, v) => s + v, 0) / vals.length + const sd = + vals.length > 1 + ? Math.sqrt(vals.reduce((s, v) => s + (v - mean) ** 2, 0) / (vals.length - 1)) + : NaN + return { mean, sd } + } + rows.push({ + kind, + name, + label, + runs: runs.length, + ms: agg('ms'), + silhouette: agg('silhouette'), + intruders: agg('intruders'), + overlap: agg('overlap'), + }) + benchState.rows = rows + renderBenchmark(graph) + } + const secs = ((performance.now() - started) / 1000).toFixed(1) + setStatus('benchStatus', [ + { + level: benchState.cancel ? 'warn' : 'ok', + text: `${benchState.cancel ? 'Stopped after' : 'Benchmarked'} ${plural(rows.length, 'layout')} on ${plural(graph.nodeIds.length, 'node')} and ${plural(graph.groupMembers.size, 'group')} in ${secs} s. The view itself was not changed.`, + }, + ]) + document.getElementById('btnBenchTsv').disabled = !rows.length + } finally { + benchState.running = false + btn.textContent = 'Benchmark layouts' + } +} + +function renderBenchmark(graph) { + const root = document.getElementById('benchResults') + const rows = benchState.rows + if (!rows.length) { + root.innerHTML = '' + return + } + const best = (key) => { + const vals = rows.map((r) => r[key].mean).filter(Number.isFinite) + if (!vals.length) return null + return key === 'silhouette' ? Math.max(...vals) : Math.min(...vals) + } + const bS = best('silhouette'), + bI = best('intruders'), + bO = best('overlap') + const cell = (v, fmt, isBest) => + `${Number.isFinite(v.mean) ? fmt(v.mean) + (Number.isFinite(v.sd) ? ` ± ${fmt(v.sd)}` : '') : '—'}` + const pct = (v) => `${(v * 100).toFixed(1)}%` + const num = (v) => v.toFixed(3) + const ms = (v) => (v < 1000 ? `${Math.round(v)} ms` : `${(v / 1000).toFixed(1)} s`) + root.innerHTML = `
    + + ${rows + .map( + (r, i) => ` + + ${cell(r.ms, ms, false)} + ${cell(r.silhouette, num, r.silhouette.mean === bS)} + ${cell(r.intruders, pct, r.intruders.mean === bI)} + ${cell(r.overlap, pct, r.overlap.mean === bO)} + + ` + ) + .join('')}
    LayoutTimeSilhouette ↑Inside other outlines ↓Outline overlap ↓
    ${escapeHtml(r.label)}
    ` + root + .querySelectorAll('.bench-use') + .forEach((b) => b.addEventListener('click', () => useBenchLayout(rows[+b.dataset.row]))) +} + +function useBenchLayout(row) { + if (row.kind === 'conn') { + setLayoutMode('connections') + document.getElementById('layoutSelect').value = row.name + } else { + setLayoutMode('groups') + document.getElementById('groupArrangement').value = row.name + document + .getElementById('groupArrangement') + .dispatchEvent(new Event('change', { bubbles: true })) + } + switchTab('network') + runActiveLayout() +} + +export function benchmarkTsv() { + const rows = benchState.rows + if (!rows.length) return + const f = (v, d = 4) => (Number.isFinite(v) ? v.toFixed(d) : '') + const lines = [ + [ + 'Layout', + 'Runs', + 'Time ms (mean)', + 'Time ms (sd)', + 'Silhouette (mean)', + 'Silhouette (sd)', + 'Inside other outlines (mean)', + 'Inside other outlines (sd)', + 'Outline overlap (mean)', + 'Outline overlap (sd)', + ].join('\t'), + ] + rows.forEach((r) => + lines.push( + [ + r.label, + r.runs, + f(r.ms.mean, 1), + f(r.ms.sd, 1), + f(r.silhouette.mean), + f(r.silhouette.sd), + f(r.intruders.mean), + f(r.intruders.sd), + f(r.overlap.mean), + f(r.overlap.sd), + ].join('\t') + ) + ) + const v = typeof activeView === 'function' && activeView() + downloadText(`layout-benchmark${v ? '-' + fileStem(v.name) : ''}.tsv`, lines.join('\n') + '\n') +} diff --git a/frontend/src/clustering/clustering.test.ts b/frontend/src/clustering/clustering.test.ts new file mode 100644 index 0000000..23f6252 --- /dev/null +++ b/frontend/src/clustering/clustering.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'vitest' +import { runCommunityAlgorithm } from './mcl' + +// Two 4-cliques joined by a single edge: every algorithm must find the two cliques. +function twoCliques() { + const edges: [number, number][] = [] + for (const base of [0, 4]) + for (let i = 0; i < 4; i++) for (let j = i + 1; j < 4; j++) edges.push([base + i, base + j]) + edges.push([3, 4]) + const adj: number[][] = Array.from({ length: 8 }, () => []) + for (const [a, b] of edges) { + adj[a].push(b) + adj[b].push(a) + } + return { n: 8, m: edges.length, adj } +} + +describe.each(['louvain', 'leiden', 'lpa', 'walktrap', 'mcl'])('%s', (algo) => { + test('separates two cliques', () => { + const r = runCommunityAlgorithm(twoCliques(), algo) + expect(r.count).toBe(2) + const m = Array.from(r.membership as Int32Array) + expect(new Set(m.slice(0, 4)).size).toBe(1) + expect(new Set(m.slice(4)).size).toBe(1) + expect(m[0]).not.toBe(m[7]) + expect(r.sizes).toEqual([4, 4]) + // 13 edges; each clique: 12/26 - (13/26)^2 + expect(r.modularity).toBeCloseTo(2 * (12 / 26 - (13 / 26) ** 2), 6) + }) + + test('is deterministic', () => { + const a = runCommunityAlgorithm(twoCliques(), algo) + const b = runCommunityAlgorithm(twoCliques(), algo) + expect(Array.from(a.membership as Int32Array)).toEqual(Array.from(b.membership as Int32Array)) + }) +}) + +test('a graph without edges is one community per node', () => { + const r = runCommunityAlgorithm({ n: 3, m: 0, adj: [[], [], []] }, 'leiden') + expect(r.count).toBe(3) + expect(Number.isNaN(r.modularity)).toBe(true) +}) + +test('unknown algorithm throws', () => { + expect(() => runCommunityAlgorithm(twoCliques(), 'nope')).toThrow(/Unknown community algorithm/) +}) diff --git a/frontend/src/clustering/label_propagation.ts b/frontend/src/clustering/label_propagation.ts new file mode 100644 index 0000000..a4541c5 --- /dev/null +++ b/frontend/src/clustering/label_propagation.ts @@ -0,0 +1,39 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { mulberry32 } from '../sample_data' +import { partitionStats, splitDisconnected } from '../welcome' + +/* ---------- label propagation ---------- */ +export function labelPropagation(g, opts = {}) { + const rand = mulberry32(opts.seed ?? 7) + const n = g.n + const label = Int32Array.from({ length: n }, (_, i) => i) + const order = Array.from({ length: n }, (_, i) => i) + for (let it = 0; it < 200; it++) { + for (let i = n - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)) + ;[order[i], order[j]] = [order[j], order[i]] + } + let changed = false + for (const v of order) { + const a = g.adj[v] + if (!a.length) continue + const counts = new Map() + let best = 0 + for (let k = 0; k < a.length; k++) { + const c = (counts.get(label[a[k]]) || 0) + 1 + counts.set(label[a[k]], c) + if (c > best) best = c + } + const top = [] + counts.forEach((c, l) => { + if (c === best) top.push(l) + }) + if (top.includes(label[v])) continue // already a most frequent label + label[v] = top[Math.floor(rand() * top.length)] + changed = true + } + if (!changed) break + } + return partitionStats(g, splitDisconnected(g, label)) +} diff --git a/frontend/src/clustering/leiden.ts b/frontend/src/clustering/leiden.ts new file mode 100644 index 0000000..4e01099 --- /dev/null +++ b/frontend/src/clustering/leiden.ts @@ -0,0 +1,193 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { mulberry32 } from '../sample_data' +import { partitionStats } from '../welcome' + +/* ---------- Leiden ---------- */ +export function leidenCommunities(g, opts = {}) { + const gamma = opts.resolution ?? 1 + const theta = opts.theta ?? 0.01 + const rand = mulberry32(opts.seed ?? 42) + const n = g.n + if (!g.m) + return partitionStats( + g, + Int32Array.from({ length: n }, (_, i) => i) + ) + // weighted graph: nbr[i] = Map(j -> w), kw[i] = weighted degree + let nbr = g.adj.map((a) => { + const mp = new Map() + a.forEach((v) => mp.set(v, 1)) + return mp + }) + let N = n + const twoM = 2 * g.m + let nodeOf = Int32Array.from({ length: n }, (_, i) => i) // original node -> aggregate node + let part = Int32Array.from({ length: n }, (_, i) => i) // aggregate node -> community + + const shuffle = (arr) => { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)) + ;[arr[i], arr[j]] = [arr[j], arr[i]] + } + return arr + } + + for (let level = 0; level < 50; level++) { + const kw = new Float64Array(N) + nbr.forEach((mp, i) => { + let s = 0 + mp.forEach((w, j) => { + s += j === i ? 2 * w : w + }) + kw[i] = s + }) + // --- move nodes fast + const tot = new Float64Array(N) + for (let i = 0; i < N; i++) tot[part[i]] += kw[i] + const queue = shuffle(Array.from({ length: N }, (_, i) => i)) + const inQueue = new Uint8Array(N).fill(1) + let head = 0 + let changed = false + while (head < queue.length) { + const v = queue[head++] + inQueue[v] = 0 + const cv = part[v] + const links = new Map() + nbr[v].forEach((w, u) => { + if (u !== v) { + const cu = part[u] + links.set(cu, (links.get(cu) || 0) + w) + } + }) + tot[cv] -= kw[v] + let best = cv + let bestGain = (links.get(cv) || 0) - (gamma * kw[v] * tot[cv]) / twoM + links.forEach((w, c) => { + const gain = w - (gamma * kw[v] * tot[c]) / twoM + if (gain > bestGain + 1e-12) { + bestGain = gain + best = c + } + }) + tot[best] += kw[v] + if (best !== cv) { + part[v] = best + changed = true + nbr[v].forEach((w, u) => { + if (u !== v && part[u] !== best && !inQueue[u]) { + inQueue[u] = 1 + queue.push(u) + } + }) + } + } + // communities of the current level + const commMembers = new Map() + for (let i = 0; i < N; i++) { + if (!commMembers.has(part[i])) commMembers.set(part[i], []) + commMembers.get(part[i]).push(i) + } + if (commMembers.size === N) break // every community is a single node: done + // --- refinement: well-connected sub-communities inside each community + const refined = Int32Array.from({ length: N }, (_, i) => i) + const rTot = Float64Array.from(kw) + const rSingle = new Uint8Array(N).fill(1) + commMembers.forEach((members, c) => { + if (members.length === 1) return + const totC = members.reduce((s, i) => s + kw[i], 0) + const inC = new Set(members) + // links from each node to the rest of its community + const kIn = new Map() + members.forEach((v) => { + let s = 0 + nbr[v].forEach((w, u) => { + if (u !== v && inC.has(u)) s += w + }) + kIn.set(v, s) + }) + // links from each refined community to the rest of C (start: singletons) + const extC = new Map(members.map((v) => [v, kIn.get(v)])) + shuffle([...members]).forEach((v) => { + if (!rSingle[v]) return + const kv = kw[v] + if (kIn.get(v) < (gamma * kv * (totC - kv)) / twoM) return // v not well connected + const links = new Map() + nbr[v].forEach((w, u) => { + if (u !== v && inC.has(u)) { + const r = refined[u] + links.set(r, (links.get(r) || 0) + w) + } + }) + const own = refined[v] + const candidates = [] + let maxGain = 0 + links.forEach((w, r) => { + if (r === own) return + const totR = rTot[r] + // only well-connected refined communities are eligible + if (extC.get(r) < (gamma * totR * (totC - totR)) / twoM) return + const gain = w - (gamma * kv * totR) / twoM + if (gain >= 0) { + candidates.push([r, gain]) + if (gain > maxGain) maxGain = gain + } + }) + if (!candidates.length) return + // choose randomly, favouring larger gains (theta controls randomness) + const weights = candidates.map(([, gain]) => Math.exp((gain - maxGain) / theta)) + let pick = rand() * weights.reduce((s, w) => s + w, 0) + let chosen = candidates[candidates.length - 1][0] + for (let i = 0; i < candidates.length; i++) { + pick -= weights[i] + if (pick <= 0) { + chosen = candidates[i][0] + break + } + } + // move v from its singleton into `chosen` + const linksToChosen = links.get(chosen) || 0 + extC.set(chosen, extC.get(chosen) + kIn.get(v) - 2 * linksToChosen) + extC.delete(own) + rTot[chosen] += kv + rTot[own] -= kv + refined[v] = chosen + rSingle[v] = 0 + rSingle[chosen] = 0 // refined ids are node indices, so this is the community's first node + }) + }) + // --- aggregate on the refined partition; each aggregate node keeps its + // (non-refined) community as its starting community + const rmap = new Map() + for (let i = 0; i < N; i++) if (!rmap.has(refined[i])) rmap.set(refined[i], rmap.size) + const nextN = rmap.size + if (nextN === N && !changed) break + const agg = Array.from({ length: nextN }, () => new Map()) + const nextPart = new Int32Array(nextN) + for (let i = 0; i < N; i++) { + const ri = rmap.get(refined[i]) + nextPart[ri] = part[i] + nbr[i].forEach((w, j) => { + const rj = rmap.get(refined[j]) + // self-loops are stored once with their full weight + if (ri === rj) { + if (j >= i) agg[ri].set(ri, (agg[ri].get(ri) || 0) + (i === j ? w : w)) + } else agg[ri].set(rj, (agg[ri].get(rj) || 0) + w) + }) + } + nodeOf = nodeOf.map((x) => rmap.get(refined[x])) + // community ids of the aggregate start partition + const cmap = new Map() + for (let i = 0; i < nextN; i++) { + if (!cmap.has(nextPart[i])) cmap.set(nextPart[i], cmap.size) + nextPart[i] = cmap.get(nextPart[i]) + } + nbr = agg + part = nextPart + N = nextN + if (nextN === cmap.size) break // aggregation changes nothing more + } + const raw = new Int32Array(n) + for (let i = 0; i < n; i++) raw[i] = part[nodeOf[i]] + return partitionStats(g, raw) +} diff --git a/frontend/src/clustering/mapping.ts b/frontend/src/clustering/mapping.ts new file mode 100644 index 0000000..eda41c6 --- /dev/null +++ b/frontend/src/clustering/mapping.ts @@ -0,0 +1,814 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { EDGE_TYPES } from '../palette' +import { S } from '../state' +import { + UNGROUPED, + effectiveGroupsFor, + formatAttrValue, + getUsedGroups, + groupLabel, + nodeFillMode, + refreshNodeVisual, +} from '../network_state' +import { VALUE_SCALES, VALUE_TRANSFORMS } from './mcl' +import { applyNodeSizing, shownNodes } from '../metrics' +import { convexHull, displayGroups, getUsedTypes, legendExtra } from '../hulls' +import { currentTheme } from '../themes' +import { cy } from '../cy' +import { mulberry32 } from '../sample_data' +import { plural } from '../layouts/controls' +import { rgbOf } from '../view3d/state' +import { stopFramePlay, updateFrameControls } from '../enrichment' +import { updateContextInfo } from '../recording' + +/* ---------- mapping ---------- */ +export function viewHasValues() { + return cy.nodes().some((n) => n.data('values')) +} + +export function valueColumns() { + const cols = new Set() + cy.nodes().forEach((n) => { + const v = n.data('values') + if (v) Object.keys(v).forEach((k) => cols.add(k)) + }) + return [...cols] +} + +function valueSettings() { + const scale = + VALUE_SCALES[document.getElementById('valueScale').value] || VALUE_SCALES['div-rdbu'] + return { + column: document.getElementById('valueColumn').value, + transform: + VALUE_TRANSFORMS[document.getElementById('valueTransform').value] || VALUE_TRANSFORMS.none, + scaleKey: document.getElementById('valueScale').value, + scale, + center: parseFloat(document.getElementById('valueCenter').value) || 0, + range: document.getElementById('valueRange').value, + min: parseFloat(document.getElementById('valueMin').value), + max: parseFloat(document.getElementById('valueMax').value), + missing: document.getElementById('valueMissing').value || '#c8c8c8', + } +} + +function nodeValue(node, st) { + const v = node.data('values') + if (!v || v[st.column] === null || v[st.column] === undefined) return null + const t = st.transform.fn(v[st.column]) + return Number.isFinite(t) ? t : null +} + +// Range of the scale: [lo, hi], plus the center for diverging scales. +function valueDomain(st) { + const vals = [] + const cols = document.getElementById('valueSameRange').checked ? valueColumns() : [st.column] + cy.nodes().forEach((n) => { + if (n.hasClass('hidden-group')) return + cols.forEach((c) => { + const x = nodeValue(n, { ...st, column: c }) + if (x !== null) vals.push(x) + }) + }) + if (!vals.length) return { lo: 0, hi: 1, center: st.center, count: 0 } + let lo = Math.min(...vals), + hi = Math.max(...vals) + if ( + st.range === 'custom' && + Number.isFinite(st.min) && + Number.isFinite(st.max) && + st.max > st.min + ) { + lo = st.min + hi = st.max + } else if (st.scale.diverging && st.range === 'symmetric') { + const m = Math.max(Math.abs(lo - st.center), Math.abs(hi - st.center)) || 1 + lo = st.center - m + hi = st.center + m + } + if (hi === lo) { + lo -= 1 + hi += 1 + } + return { + lo, + hi, + center: st.center, + count: vals.length, + dataMin: Math.min(...vals), + dataMax: Math.max(...vals), + } +} + +function interpolateStops(stops, t) { + t = Math.max(0, Math.min(1, t)) + const pos = t * (stops.length - 1) + const i = Math.min(stops.length - 2, Math.floor(pos)) + const f = pos - i + const a = rgbOf(stops[i]), + b = rgbOf(stops[i + 1]) + const h = (x) => Math.round(x).toString(16).padStart(2, '0') + return ( + '#' + h(a[0] + (b[0] - a[0]) * f) + h(a[1] + (b[1] - a[1]) * f) + h(a[2] + (b[2] - a[2]) * f) + ) +} + +function valueToColor(x, st, dom) { + if (x === null) return st.missing + let t + if (st.scale.diverging) { + const c = Math.min(Math.max(dom.center, dom.lo), dom.hi) + t = x < c ? (0.5 * (x - dom.lo)) / (c - dom.lo || 1) : 0.5 + (0.5 * (x - c)) / (dom.hi - c || 1) + } else { + t = (x - dom.lo) / (dom.hi - dom.lo) + } + return interpolateStops(st.scale.stops, t) +} + +// Size by value: distance from the center on diverging scales, the value itself otherwise. +export function valueForSize(node) { + const st = valueSettings() + const x = nodeValue(node, st) + if (x === null) return 0 + return st.scale.diverging ? Math.abs(x - st.center) : x +} + +export function applyValueColors() { + const has = viewHasValues() + document.getElementById('valueControls').hidden = !has + const fillSel = document.getElementById('nodeFillSelect') + fillSel.querySelector('option[value="values"]').disabled = !has + if (!has && fillSel.value === 'values') fillSel.value = 'groups' + if (!has) { + scheduleLegend() + if (typeof updateFrameControls === 'function') { + stopFramePlay() + updateFrameControls() + } + return + } + // keep the column list current + const colSel = document.getElementById('valueColumn') + const cols = valueColumns() + const current = colSel.value + if ([...colSel.options].map((o) => o.value).join('\u0000') !== cols.join('\u0000')) { + colSel.innerHTML = '' + cols.forEach((c) => colSel.add(new Option(c, c))) + } + if (!cols.includes(colSel.value)) colSel.value = cols.includes(current) ? current : cols[0] + const st = valueSettings() + document.getElementById('valueCenterRow').hidden = !st.scale.diverging + document.getElementById('valueCustomRow').hidden = st.range !== 'custom' + const symOpt = document.getElementById('valueRange').querySelector('option[value="symmetric"]') + symOpt.disabled = !st.scale.diverging + if (!st.scale.diverging && st.range === 'symmetric') + document.getElementById('valueRange').value = 'data' + const dom = valueDomain(st) + cy.batch(() => { + cy.nodes().forEach((n) => { + const c = valueToColor(nodeValue(n, st), st, dom) + if (n.data('valueColor') !== c) n.data('valueColor', c) + }) + if (nodeFillMode() === 'values') cy.nodes().forEach((n) => refreshNodeVisual(n)) + }) + const fmt = (v) => formatAttrValue(Math.round(v * 1000) / 1000) + const own = cy + .nodes() + .filter((n) => !n.hasClass('hidden-group') && nodeValue(n, st) !== null) + .map((n) => nodeValue(n, st)) + document.getElementById('valueSummary').textContent = own.length + ? `${plural(own.length, 'shown node')} with a value, from ${fmt(Math.min(...own))} to ${fmt(Math.max(...own))}${st.transform.fn === VALUE_TRANSFORMS.none.fn ? '' : ' after the transform'}${valueColumns().length > 1 && document.getElementById('valueSameRange').checked ? '; the scale covers all columns' : ''}.` + : 'No shown node has a value in this column.' + if (document.getElementById('sizeMetric').value === 'value') applyNodeSizing() + scheduleLegend() + updateContextInfo() + if (typeof updateFrameControls === 'function') updateFrameControls() +} + +/* ============================================================ + LEGEND + One layout, drawn three ways: on screen (inline SVG), into exported + raster images (canvas) and into exported SVG files. + ============================================================ */ +const LEGEND_W = 230 + +function legendOptions() { + return { + show: document.getElementById('legendShow').checked, + scale: document.getElementById('legendScale').checked, + groups: document.getElementById('legendGroups').checked, + shapes: document.getElementById('legendShapes').checked, + channels: document.getElementById('legendChannels').checked, + title: document.getElementById('legendTitle').value.trim(), + } +} + +export function legendModel() { + const o = legendOptions() + const model = { title: o.title, sections: [], scale: null } + if (legendExtra) + legendExtra.forEach((sec) => + model.sections.push({ + title: sec.title, + items: sec.items.map((it) => ({ + kind: it.kind || 'glyph', + label: it.label, + color: it.color, + shape: 'ellipse', + })), + }) + ) + if ( + o.scale && + viewHasValues() && + (nodeFillMode() === 'values' || document.getElementById('sizeMetric').value === 'value') + ) { + const st = valueSettings() + const dom = valueDomain(st) + const missing = cy.nodes().some((n) => !n.hasClass('hidden-group') && nodeValue(n, st) === null) + model.scale = { + title: st.transform.title(st.column), + stops: st.scale.stops, + diverging: st.scale.diverging, + lo: dom.lo, + hi: dom.hi, + center: Math.min(Math.max(dom.center, dom.lo), dom.hi), + missing: missing ? st.missing : null, + colored: nodeFillMode() === 'values', + sized: document.getElementById('sizeMetric').value === 'value', + } + } + if (o.groups) { + const groups = displayGroups(getUsedGroups()).filter( + (g) => S.activeGroups.has(g) && g !== UNGROUPED + ) + const showing = + nodeFillMode() === 'groups' || + document.getElementById('showGroupHulls').checked || + Object.keys(S.groupShapes).length + if (groups.length && showing) { + const items = groups.slice(0, 40).map((g) => ({ + kind: 'glyph', + label: groupLabel(g), + color: S.nodeColorMap[g] || '#888888', + shape: o.shapes && S.groupShapes[g] ? S.groupShapes[g] : 'ellipse', + })) + if (groups.length > 40) + items.push({ kind: 'note', label: `and ${groups.length - 40} more groups` }) + model.sections.push({ title: legendExtra ? 'Nodes (in which networks)' : 'Groups', items }) + } + } + if (o.channels) { + const types = getUsedTypes().filter((t) => S.activeTypes.has(t)) + if (types.length > 1) { + const items = types.slice(0, 30).map((t) => ({ + kind: 'line', + label: (EDGE_TYPES[t] || { label: t }).label, + color: (EDGE_TYPES[t] || { color: '#888' }).color, + })) + if (types.length > 30) + items.push({ kind: 'note', label: `and ${types.length - 30} more channels` }) + model.sections.push({ + title: legendExtra ? 'Edges (in which networks)' : 'Edge channels', + items, + }) + } + } + return model +} + +export function legendIsEmpty(m) { + return !m.scale && !m.sections.length +} + +function niceTicks(lo, hi, count = 5) { + const span = hi - lo + const step0 = span / (count - 1) + const mag = Math.pow(10, Math.floor(Math.log10(step0))) + const norm = step0 / mag + const step = (norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10) * mag + const ticks = [] + for (let v = Math.ceil(lo / step - 1e-9) * step; v <= hi + 1e-9; v += step) + ticks.push(Math.abs(v) < step * 1e-9 ? 0 : v) + return ticks +} + +function tickLabel(v) { + const a = Math.abs(v) + if (a !== 0 && (a >= 1e5 || a < 1e-3)) return v.toExponential(1) + return String(Math.round(v * 1000) / 1000) +} + +// Layout in unit pixels: a list of drawing operations. +export function layoutLegend(m) { + const ops = [] + const pad = 12, + w = LEGEND_W + let y = pad + const text = (str, x, yy, size, opts = {}) => + ops.push({ t: 'text', str, x, y: yy, size, ...opts }) + if (m.title) { + text(m.title, pad, y + 12, 14, { bold: true }) + y += 24 + } + if (m.scale) { + const s = m.scale + text(s.title, pad, y + 11, 12, { bold: true }) + y += 18 + const barW = w - 2 * pad, + barH = 12 + ops.push({ + t: 'grad', + x: pad, + y, + w: barW, + h: barH, + stops: s.diverging + ? divergingStopsFor(s) + : s.stops.map((c, i) => [i / (s.stops.length - 1), c]), + }) + ops.push({ t: 'rect', x: pad, y, w: barW, h: barH, stroke: true }) + const ticks = niceTicks(s.lo, s.hi) + ticks.forEach((v) => { + const tx = pad + ((v - s.lo) / (s.hi - s.lo)) * barW + ops.push({ t: 'line', x1: tx, y1: y + barH, x2: tx, y2: y + barH + 4, muted: true }) + text(tickLabel(v), tx, y + barH + 15, 10, { anchor: 'middle', muted: true }) + }) + y += barH + 22 + const notes = [] + if (s.colored && s.sized) notes.push('node color and size') + else if (s.sized) notes.push('node size') + if (notes.length) { + text(`Shown as ${notes.join(', ')}`, pad, y + 8, 10, { muted: true }) + y += 14 + } + if (s.missing) { + ops.push({ t: 'glyph', shape: 'ellipse', x: pad + 6, y: y + 7, r: 6, fill: s.missing }) + text('no value', pad + 18, y + 11, 11, {}) + y += 18 + } + y += 6 + } + m.sections.forEach((sec) => { + text(sec.title, pad, y + 11, 12, { bold: true }) + y += 18 + sec.items.forEach((it) => { + if (it.kind === 'glyph') + ops.push({ t: 'glyph', shape: it.shape, x: pad + 7, y: y + 7, r: 6.5, fill: it.color }) + else if (it.kind === 'line') + ops.push({ + t: 'line', + x1: pad, + y1: y + 7, + x2: pad + 16, + y2: y + 7, + color: it.color, + width: 3, + }) + const label = it.label.length > 30 ? it.label.slice(0, 29) + '…' : it.label + text(label, it.kind === 'note' ? pad : pad + 22, y + 11, 11, { + muted: it.kind === 'note', + full: it.label, + }) + y += 17 + }) + y += 6 + }) + return { w, h: y + pad - 6, ops } +} + +function divergingStopsFor(s) { + // put the middle stop at the center value + const c = (s.center - s.lo) / (s.hi - s.lo) + const n = s.stops.length, + mid = (n - 1) / 2 + return s.stops.map((col, i) => [i <= mid ? (c * i) / mid : c + ((1 - c) * (i - mid)) / mid, col]) +} + +function legendSvg(layout, scale, colors, x0 = 0, y0 = 0) { + const f = (v) => (Math.round(v * 100) / 100).toString() + const esc = (v) => + String(v).replace( + /[&<>"]/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] + ) + const id = 'lg' + Math.random().toString(36).slice(2, 8) + let defs = '', + body = '' + let gi = 0 + layout.ops.forEach((op) => { + if (op.t === 'text') { + body += `${op.full && op.full !== op.str ? `${esc(op.full)}` : ''}${esc(op.str)}` + } else if (op.t === 'grad') { + const gid = `${id}g${gi++}` + defs += `${op.stops.map(([o, c]) => ``).join('')}` + body += `` + } else if (op.t === 'rect') { + body += `` + } else if (op.t === 'line') { + body += `` + } else if (op.t === 'glyph') { + body += glyphSvg(op.shape, op.x, op.y, op.r, op.fill, colors.text) + } + }) + return `${defs ? `${defs}` : ''}${body}` +} + +function glyphSvg(shape, x, y, r, fill, stroke) { + const f = (v) => (Math.round(v * 100) / 100).toString() + const def = shape !== 'ellipse' && cy.renderer().nodeShapes[shape] + if (def && def.points) { + const pts = [] + for (let i = 0; i < def.points.length; i += 2) + pts.push(`${f(x + def.points[i] * r)},${f(y + def.points[i + 1] * r)}`) + return `` + } + return `` +} + +function drawLegendOnCanvas(ctx, layout, scale, colors, x0, y0, bg) { + ctx.save() + ctx.translate(x0, y0) + ctx.scale(scale, scale) + if (bg) { + ctx.fillStyle = bg + ctx.fillRect(0, 0, layout.w, layout.h) + } + layout.ops.forEach((op) => { + if (op.t === 'text') { + ctx.font = `${op.bold ? '700 ' : ''}${op.size}px Inter, Helvetica, Arial, sans-serif` + ctx.fillStyle = op.muted ? colors.muted : colors.text + ctx.textAlign = op.anchor === 'middle' ? 'center' : 'left' + ctx.textBaseline = 'alphabetic' + ctx.fillText(op.str, op.x, op.y) + } else if (op.t === 'grad') { + const g = ctx.createLinearGradient(op.x, 0, op.x + op.w, 0) + op.stops.forEach(([o, c]) => g.addColorStop(Math.max(0, Math.min(1, o)), c)) + ctx.fillStyle = g + ctx.fillRect(op.x, op.y, op.w, op.h) + } else if (op.t === 'rect') { + ctx.strokeStyle = colors.line + ctx.lineWidth = 1 + ctx.strokeRect(op.x, op.y, op.w, op.h) + } else if (op.t === 'line') { + ctx.strokeStyle = op.color || colors.muted + ctx.lineWidth = op.width || 1 + ctx.lineCap = 'round' + ctx.beginPath() + ctx.moveTo(op.x1, op.y1) + ctx.lineTo(op.x2, op.y2) + ctx.stroke() + } else if (op.t === 'glyph') { + ctx.fillStyle = op.fill + ctx.strokeStyle = colors.text + ctx.globalAlpha = 1 + ctx.lineWidth = 0.8 + const def = op.shape !== 'ellipse' && cy.renderer().nodeShapes[op.shape] + ctx.beginPath() + if (def && def.points) { + for (let i = 0; i < def.points.length; i += 2) { + const px = op.x + def.points[i] * op.r, + py = op.y + def.points[i + 1] * op.r + i ? ctx.lineTo(px, py) : ctx.moveTo(px, py) + } + ctx.closePath() + } else ctx.arc(op.x, op.y, op.r, 0, Math.PI * 2) + ctx.fill() + ctx.globalAlpha = 0.35 + ctx.stroke() + ctx.globalAlpha = 1 + } + }) + ctx.restore() +} + +function legendColorsFor(bg) { + // readable text on the export background (or the theme on screen) + const dark = bg + ? (() => { + const [r, g, b] = rgbOf(bg) + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 < 0.45 + })() + : isDarkTheme() + return dark + ? { text: '#e5e7eb', muted: '#9ca3af', line: '#6b7280' } + : { text: '#111827', muted: '#6b7280', line: '#9ca3af' } +} + +function isDarkTheme() { + const [r, g, b] = rgbOf(currentTheme.bg) + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 < 0.45 +} + +// On-screen legend +let legendQueued = false + +export function scheduleLegend() { + if (legendQueued) return + legendQueued = true + requestAnimationFrame(() => { + legendQueued = false + renderLegendPanel() + }) +} + +function renderLegendPanel() { + const el = document.getElementById('legendPanel') + if (!el) return + const o = legendOptions() + const m = o.show ? legendModel() : null + if (!m || legendIsEmpty(m) || !cy.nodes().length) { + el.hidden = true + return + } + const layout = layoutLegend(m) + const colors = { text: currentTheme.text, muted: currentTheme.muted, line: currentTheme.line } + el.innerHTML = `${legendSvg(layout, 1, colors)}` + el.hidden = false +} + +// Adds the legend to the right of an exported picture. +export function exportLegendLayout(o) { + if (!o.legend) return null + const m = legendModel() + return legendIsEmpty(m) ? null : layoutLegend(m) +} + +export function composeCanvasWithLegend(canvas, layout, scale, bg) { + if (!layout) return canvas + const lw = Math.round(layout.w * scale), + lh = Math.round(layout.h * scale) + const out = document.createElement('canvas') + out.width = canvas.width + lw + out.height = Math.max(canvas.height, lh) + const ctx = out.getContext('2d') + if (bg) { + ctx.fillStyle = bg + ctx.fillRect(0, 0, out.width, out.height) + } + ctx.drawImage(canvas, 0, 0) + drawLegendOnCanvas(ctx, layout, scale, legendColorsFor(bg || '#ffffff'), canvas.width, 0, null) + return out +} + +export function composeSvgWithLegend(svgText, layout, scale, bg) { + if (!layout) return svgText + const body = svgText.replace(/^<\?xml[^>]*>\s*/, '') + const wm = /]*\swidth="([\d.]+)"/.exec(body), + hm = /]*\sheight="([\d.]+)"/.exec(body) + const W = wm ? parseFloat(wm[1]) : 1000, + H = hm ? parseFloat(hm[1]) : 800 + const lw = layout.w * scale, + lh = layout.h * scale + const totalW = Math.ceil(W + lw), + totalH = Math.ceil(Math.max(H, lh)) + const inner = body.replace(/\n\n` + + (bg ? `\n` : '') + + inner + + '\n' + + `${legendSvg(layout, scale, legendColorsFor(bg || '#ffffff'), W, 0)}\n` + ) +} + +/* ============================================================ + GROUP SEPARATION + How well a layout separates the ticked groups: + silhouette mean silhouette width of nodes in exactly one group, + from their positions (-1..1, higher = better separated) + intruders share of shown nodes lying inside the convex outline of + a group they don't belong to (lower = better) + overlap share of the area covered by group outlines that is + covered by two or more of them (lower = better) + Scores are computed after every layout and by the layout benchmark. + ============================================================ */ +const SEPARATION_SAMPLE = 1500 + +export function groupSeparation(positions, groupsOf) { + const ids = Object.keys(groupsOf).filter((id) => positions[id]) + const out = { + nodes: ids.length, + groups: 0, + silhouette: NaN, + intruders: NaN, + overlap: NaN, + silhouetteNodes: 0, + } + const members = new Map() + ids.forEach((id) => + groupsOf[id].forEach((g) => { + if (!members.has(g)) members.set(g, []) + members.get(g).push(id) + }) + ) + out.groups = members.size + if (members.size < 2) return out + + // --- silhouette (nodes in exactly one group) + const single = ids.filter((id) => groupsOf[id].length === 1) + const byGroup = new Map() + single.forEach((id) => { + const g = groupsOf[id][0] + if (!byGroup.has(g)) byGroup.set(g, []) + byGroup.get(g).push(id) + }) + if (byGroup.size >= 2) { + const rand = mulberry32(99) + let sample = single + if (single.length > SEPARATION_SAMPLE) { + sample = [...single] + for (let i = sample.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)) + ;[sample[i], sample[j]] = [sample[j], sample[i]] + } + sample = sample.slice(0, SEPARATION_SAMPLE) + } + // reference points per group (all single-group nodes, capped per group), as flat arrays + const refs = new Map() + byGroup.forEach((list, g) => { + let pts = list + if (list.length > 400) { + pts = [] + const stepK = list.length / 400 + for (let k = 0; k < 400; k++) pts.push(list[Math.floor(k * stepK)]) + } + const xs = new Float64Array(pts.length), + ys = new Float64Array(pts.length) + pts.forEach((id, k) => { + xs[k] = positions[id].x + ys[k] = positions[id].y + }) + refs.set(g, { xs, ys, ids: new Set(pts) }) + }) + const groupKeys = [...refs.keys()] + let total = 0 + sample.forEach((id) => { + const g = groupsOf[id][0] + const px = positions[id].x, + py = positions[id].y + const own = refs.get(g) + let a = 0, + na = 0 + for (let k = 0; k < own.xs.length; k++) { + const dx = px - own.xs[k], + dy = py - own.ys[k] + const d = Math.sqrt(dx * dx + dy * dy) + if (d === 0 && own.ids.has(id)) continue // the node itself + a += d + na++ + } + if (!na) return // a group of one: silhouette 0 + a /= na + let b = Infinity + for (const h of groupKeys) { + if (h === g) continue + const r = refs.get(h) + let sum = 0 + for (let k = 0; k < r.xs.length; k++) { + const dx = px - r.xs[k], + dy = py - r.ys[k] + sum += Math.sqrt(dx * dx + dy * dy) + } + const mean = sum / r.xs.length + if (mean < b) b = mean + } + const m = Math.max(a, b) + total += m > 0 ? (b - a) / m : 0 + }) + out.silhouette = total / sample.length + out.silhouetteNodes = sample.length + } + + // --- outlines: convex hulls of groups with three or more members + const hulls = [] + members.forEach((list, g) => { + if (list.length < 3) return + const hull = convexHull(list.map((id) => positions[id])) + if (hull.length < 3) return + const xs = hull.map((p) => p.x), + ys = hull.map((p) => p.y) + hulls.push({ + g, + hull, + set: new Set(list), + x1: Math.min(...xs), + x2: Math.max(...xs), + y1: Math.min(...ys), + y2: Math.max(...ys), + }) + }) + if (!hulls.length) return out + const inside = (hull, x, y) => { + // convex polygon in either orientation + let sign = 0 + for (let i = 0; i < hull.length; i++) { + const a = hull[i], + b = hull[(i + 1) % hull.length] + const cr = (b.x - a.x) * (y - a.y) - (b.y - a.y) * (x - a.x) + if (Math.abs(cr) < 1e-9) continue + const s = cr > 0 ? 1 : -1 + if (!sign) sign = s + else if (s !== sign) return false + } + return true + } + let intr = 0 + ids.forEach((id) => { + const p = positions[id] + for (const h of hulls) { + if (h.set.has(id) || p.x < h.x1 || p.x > h.x2 || p.y < h.y1 || p.y > h.y2) continue + if (inside(h.hull, p.x, p.y)) { + intr++ + break + } + } + }) + out.intruders = intr / ids.length + + // --- overlap by rasterising the outlines + const X1 = Math.min(...hulls.map((h) => h.x1)), + X2 = Math.max(...hulls.map((h) => h.x2)) + const Y1 = Math.min(...hulls.map((h) => h.y1)), + Y2 = Math.max(...hulls.map((h) => h.y2)) + const G = 160 + const sx = (X2 - X1) / G || 1, + sy = (Y2 - Y1) / G || 1 + const cover = new Uint16Array(G * G) + hulls.forEach((h) => { + const i1 = Math.max(0, Math.floor((h.x1 - X1) / sx)), + i2 = Math.min(G - 1, Math.floor((h.x2 - X1) / sx)) + const j1 = Math.max(0, Math.floor((h.y1 - Y1) / sy)), + j2 = Math.min(G - 1, Math.floor((h.y2 - Y1) / sy)) + for (let j = j1; j <= j2; j++) { + const y = Y1 + (j + 0.5) * sy + for (let i = i1; i <= i2; i++) { + if (inside(h.hull, X1 + (i + 0.5) * sx, y)) cover[j * G + i]++ + } + } + }) + let any = 0, + multi = 0 + for (let k = 0; k < cover.length; k++) { + if (cover[k]) any++ + if (cover[k] > 1) multi++ + } + out.overlap = any ? multi / any : 0 + return out +} + +// Current 2D positions and ticked groups of the shown nodes. +function currentSeparationInput() { + const positions = {}, + groupsOf = {} + shownNodes().forEach((n) => { + positions[n.id()] = n.position() + groupsOf[n.id()] = effectiveGroupsFor(n).filter((g) => g !== UNGROUPED) + }) + return { positions, groupsOf } +} + +function formatSeparation(r) { + const pct = (v) => (Number.isFinite(v) ? `${(v * 100).toFixed(v < 0.1 ? 1 : 0)}%` : '—') + return { + silhouette: Number.isFinite(r.silhouette) ? r.silhouette.toFixed(2) : '—', + intruders: pct(r.intruders), + overlap: pct(r.overlap), + } +} + +let separationTimer = null + +export function scheduleSeparation() { + clearTimeout(separationTimer) + separationTimer = setTimeout(updateSeparationBox, 350) +} + +function updateSeparationBox() { + const box = document.getElementById('sepValues') + if (!box) return + if (!cy.nodes().length) { + box.innerHTML = 'No network shown.' + return + } + const { positions, groupsOf } = currentSeparationInput() + const r = groupSeparation(positions, groupsOf) + if (r.groups < 2) { + box.innerHTML = 'Needs at least two ticked groups.' + return + } + const f = formatSeparation(r) + const bar = Number.isFinite(r.silhouette) + ? `` + : '' + box.innerHTML = ` +
    Silhouette${f.silhouette}${bar}
    +
    Inside other outlines${f.intruders}
    +
    Outline overlap${f.overlap}
    ` +} + +// page wiring, run by main.ts in the original order +export function init() { + cy.on('layoutstop dragfree', scheduleSeparation) +} diff --git a/frontend/src/clustering/mcl.ts b/frontend/src/clustering/mcl.ts new file mode 100644 index 0000000..21071bc --- /dev/null +++ b/frontend/src/clustering/mcl.ts @@ -0,0 +1,257 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { labelPropagation } from './label_propagation' +import { leidenCommunities } from './leiden' +import { listSample, normaCols, normaLines, plural } from '../layouts/controls' +import { louvain } from '../wiring' +import { partitionStats, splitDisconnected } from '../welcome' +import { walktrap } from './walktrap' + +/* ---------- Markov clustering ---------- */ +function markovClustering(g, opts = {}) { + const inflation = opts.inflation ?? 2 + const n = g.n + const keep = opts.keep ?? 60 // largest entries kept per column + const eps = 1e-5 + // column-stochastic matrix with self-loops; cols[j] = Map(i -> value) + let cols = new Array(n) + for (let j = 0; j < n; j++) { + const a = g.adj[j] + const w = 1 / (a.length + 1) + const m = new Map([[j, w]]) + for (let k = 0; k < a.length; k++) m.set(a[k], w) + cols[j] = m + } + const prune = (m) => { + let entries = [...m].filter(([, v]) => v > eps) + if (entries.length > keep) { + entries.sort((x, y) => y[1] - x[1]) + entries = entries.slice(0, keep) + } + let s = 0 + entries.forEach((e) => { + s += e[1] + }) + return new Map(entries.map(([i, v]) => [i, v / s])) + } + for (let it = 0; it < 100; it++) { + // expansion: M * M, column by column + const next = new Array(n) + let delta = 0 + for (let j = 0; j < n; j++) { + const out = new Map() + cols[j].forEach((vkj, k) => { + cols[k].forEach((vik, i) => { + out.set(i, (out.get(i) || 0) + vik * vkj) + }) + }) + // inflation + out.forEach((v, i) => out.set(i, Math.pow(v, inflation))) + const pruned = prune(out) + pruned.forEach((v, i) => { + delta = Math.max(delta, Math.abs(v - (cols[j].get(i) || 0))) + }) + next[j] = pruned + } + cols = next + if (delta < 1e-6) break + } + // each node joins its attractor (strongest row in its column); attractors + // that point at each other form one cluster + const parent = Int32Array.from({ length: n }, (_, i) => i) + const find = (x) => { + while (parent[x] !== x) { + parent[x] = parent[parent[x]] + x = parent[x] + } + return x + } + const union = (a, b) => { + const ra = find(a), + rb = find(b) + if (ra !== rb) parent[ra] = rb + } + for (let j = 0; j < n; j++) { + let best = j, + bestV = -1 + cols[j].forEach((v, i) => { + if (v > bestV + 1e-12) { + bestV = v + best = i + } + }) + union(j, best) + } + const raw = new Int32Array(n) + for (let i = 0; i < n; i++) raw[i] = find(i) + return partitionStats(g, splitDisconnected(g, raw)) +} + +export function runCommunityAlgorithm(g, algo, params = {}) { + if (algo === 'louvain') return louvain(g, params.resolution ?? 1) + if (algo === 'leiden') return leidenCommunities(g, { resolution: params.resolution ?? 1 }) + if (algo === 'lpa') return labelPropagation(g) + if (algo === 'walktrap') return walktrap(g, { steps: params.steps ?? 4 }) + if (algo === 'mcl') return markovClustering(g, { inflation: params.inflation ?? 2 }) + throw new Error('Unknown community algorithm: ' + algo) +} + +/* ============================================================ + NUMERIC NODE VALUES + Expression files may hold numbers (log2 fold changes, p-values, ...) + in one or more named columns instead of colors. Each node then carries + data('values') = { column: number | null }, and the chosen column is + mapped to a diverging or sequential color scale and, optionally, to + node size. + ============================================================ */ +export const VALUE_SCALES = { + 'div-rdbu': { + label: 'Blue – white – red', + diverging: true, + stops: ['#2166ac', '#67a9cf', '#f7f7f7', '#ef8a62', '#b2182b'], + }, + 'div-puor': { + label: 'Purple – white – orange', + diverging: true, + stops: ['#5e3c99', '#b2abd2', '#f7f7f7', '#fdb863', '#e66101'], + }, + 'div-prgn': { + label: 'Purple – white – green', + diverging: true, + stops: ['#762a83', '#af8dc3', '#f7f7f7', '#7fbf7b', '#1b7837'], + }, + 'div-brbg': { + label: 'Brown – white – teal', + diverging: true, + stops: ['#8c510a', '#d8b365', '#f5f5f5', '#5ab4ac', '#01665e'], + }, + 'seq-viridis': { + label: 'Viridis', + diverging: false, + stops: ['#440154', '#3b528b', '#21918c', '#5ec962', '#fde725'], + }, + 'seq-magma': { + label: 'Magma', + diverging: false, + stops: ['#000004', '#51127c', '#b73779', '#fc8961', '#fcfdbf'], + }, + 'seq-blues': { + label: 'Light to dark blue', + diverging: false, + stops: ['#f7fbff', '#c6dbef', '#6baed6', '#2171b5', '#08306b'], + }, + 'seq-reds': { + label: 'Light to dark red', + diverging: false, + stops: ['#fff5f0', '#fcbba1', '#fb6a4a', '#cb181d', '#67000d'], + }, +} + +export const VALUE_TRANSFORMS = { + none: { label: 'as they are', fn: (v) => v, title: (c) => c }, + neglog10: { + label: '−log10 (for p-values)', + fn: (v) => (v > 0 ? -Math.log10(v) : null), + title: (c) => `−log10(${c})`, + }, + abs: { label: 'absolute values', fn: (v) => Math.abs(v), title: (c) => `|${c}|` }, +} + +const NUMERIC_MISSING = new Set([ + '', + 'na', + 'n/a', + 'nan', + 'null', + 'none', + '-', + '.', + '?', + 'inf', + '-inf', +]) + +export function parseNumber(s) { + const t = String(s ?? '').trim() + if (NUMERIC_MISSING.has(t.toLowerCase())) return null + // allow decimal commas when there is no dot + const x = Number(/^[-+]?\d+,\d+(e[-+]?\d+)?$/i.test(t) ? t.replace(',', '.') : t) + return Number.isFinite(x) ? x : undefined // undefined: not a number at all +} + +// Does a table look numeric (node name, then numbers)? Used by detection and parsing. +export function numericShare(lines) { + let rows = 0, + numeric = 0 + lines.forEach((line) => { + const cols = normaCols(line) + if (cols.length < 2) return + rows++ + if ( + cols.slice(1).every((c) => parseNumber(c) !== undefined) && + cols.slice(1).some((c) => parseNumber(c) !== null) + ) + numeric++ + }) + return rows ? numeric / rows : 0 +} + +export function parseNumericValues(text) { + const lines = normaLines(text).filter((l) => l.trim()) + let header = null + const first = normaCols(lines[0] || '') + if (first.length >= 2 && first.slice(1).some((c) => parseNumber(c) === undefined)) header = first + const body = header ? lines.slice(1) : lines + const width = Math.max(...body.map((l) => normaCols(l).length), header ? header.length : 2) + const names = [] + for (let c = 1; c < width; c++) { + const raw = header && header[c] ? header[c].trim() : width === 2 ? 'value' : `value ${c}` + let name = raw || `value ${c}` + while (names.includes(name)) name += '′' + names.push(name) + } + const values = new Map() + const bad = [], + repeated = [] + body.forEach((line, i) => { + const cols = normaCols(line) + const node = (cols[0] || '').trim() + if (!node) return + const row = {} + let any = false + names.forEach((name, k) => { + const v = parseNumber(cols[k + 1]) + if (v === undefined) { + bad.push(i + 1 + (header ? 1 : 0)) + row[name] = null + } else { + row[name] = v + if (v !== null) any = true + } + }) + if (values.has(node)) repeated.push(node) + if (any || !values.has(node)) values.set(node, row) + }) + if (!values.size) + throw new Error( + 'No numeric values were found. Each line should be a node name, a tab, then one or more numbers.' + ) + const notes = [] + if (header) notes.push(`Read the first line as column names: ${names.join(', ')}.`) + if (bad.length) + notes.push( + `Read ${plural(bad.length, 'value')} that ${bad.length === 1 ? "isn't a number" : "aren't numbers"} as missing: line ${listSample([...new Set(bad)])}.` + ) + if (repeated.length) + notes.push( + `Kept the last line for ${plural(repeated.length, 'repeated node')}: ${listSample(repeated)}.` + ) + return { + colors: new Map(), + numeric: true, + columns: names, + values, + notes, + summary: `${plural(values.size, 'node')}, ${names.length === 1 ? `values (${names[0]})` : `${names.length} value columns`}`, + } +} diff --git a/frontend/src/clustering/values.test.ts b/frontend/src/clustering/values.test.ts new file mode 100644 index 0000000..b4f9a5c --- /dev/null +++ b/frontend/src/clustering/values.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from 'vitest' +import { numericShare, parseNumber, parseNumericValues } from './mcl' + +test('parseNumber: numbers, decimal commas, missing markers, junk', () => { + expect(parseNumber('1.5')).toBe(1.5) + expect(parseNumber('-2,5')).toBe(-2.5) + expect(parseNumber(' 3e2 ')).toBe(300) + expect(parseNumber('NA')).toBeNull() + expect(parseNumber('')).toBeNull() + expect(parseNumber('abc')).toBeUndefined() +}) + +test('numericShare counts rows whose value columns are all numbers', () => { + expect(numericShare(['A\t1', 'B\t2', 'C\tred'])).toBeCloseTo(2 / 3) + expect(numericShare(['A'])).toBe(0) +}) + +test('parseNumericValues reads a header, names columns and reports bad values', () => { + const r = parseNumericValues('node\tlog2fc\tpval\nA\t1.5\t0.01\nB\tx\t0.2\nB\t-1\t0.3\n') + expect(r.values.get('A')).toEqual({ log2fc: 1.5, pval: 0.01 }) + expect(r.values.get('B')).toEqual({ log2fc: -1, pval: 0.3 }) + expect(r.notes.join(' ')).toMatch(/column names: log2fc, pval/) + expect(r.notes.join(' ')).toMatch(/isn't a number/) + expect(r.notes.join(' ')).toMatch(/repeated node/) +}) + +test('parseNumericValues without a header names the single column "value"', () => { + const r = parseNumericValues('A\t1\nB\t2\n') + expect(r.values.get('B')).toEqual({ value: 2 }) +}) + +test('parseNumericValues throws when nothing is numeric', () => { + expect(() => parseNumericValues('A\tred\n')).toThrow(/No numeric values/) +}) diff --git a/frontend/src/clustering/walktrap.ts b/frontend/src/clustering/walktrap.ts new file mode 100644 index 0000000..4657c8d --- /dev/null +++ b/frontend/src/clustering/walktrap.ts @@ -0,0 +1,198 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { WALKTRAP_NODE_LIMIT, partitionStats } from '../welcome' + +/* ---------- Walktrap ---------- */ +export function walktrap(g, opts = {}) { + const t = opts.steps ?? 4 + const n = g.n + if (n > WALKTRAP_NODE_LIMIT) + throw new Error( + `Walktrap is limited to ${WALKTRAP_NODE_LIMIT.toLocaleString('en-US')} nodes in the browser.` + ) + if (!g.m) + return partitionStats( + g, + Int32Array.from({ length: n }, (_, i) => i) + ) + // random walk with a self-loop on every node (as in the original method) + const deg = g.adj.map((a) => a.length + 1) + const step = (vec) => { + const out = new Map() + vec.forEach((p, u) => { + const share = p / deg[u] + out.set(u, (out.get(u) || 0) + share) + const a = g.adj[u] + for (let k = 0; k < a.length; k++) out.set(a[k], (out.get(a[k]) || 0) + share) + }) + return out + } + // P^t for every node (sparse) + const P = new Array(n) + for (let i = 0; i < n; i++) { + let v = new Map([[i, 1]]) + for (let s = 0; s < t; s++) v = step(v) + // negligible probabilities are dropped to keep memory in check + v.forEach((p, k) => { + if (p < 1e-7) v.delete(k) + }) + P[i] = v + } + const dist2 = (a, b) => { + // sum_k (a_k - b_k)^2 / d_k + let s = 0 + a.forEach((x, k) => { + const y = b.get(k) || 0 + s += ((x - y) * (x - y)) / deg[k] + }) + b.forEach((y, k) => { + if (!a.has(k)) s += (y * y) / deg[k] + }) + return s + } + const size = new Int32Array(2 * n).fill(0) + for (let i = 0; i < n; i++) size[i] = 1 + const vec = P // community vectors, extended as merges happen + const nbrs = Array.from({ length: n }, (_, i) => new Map()) // community -> Map(neighbour -> delta sigma) + const alive = new Uint8Array(2 * n) + for (let i = 0; i < n; i++) alive[i] = 1 + // min-heap of [delta, a, b] + const heap = [] + const push = (item) => { + heap.push(item) + let i = heap.length - 1 + while (i > 0) { + const p = (i - 1) >> 1 + if (heap[p][0] <= heap[i][0]) break + ;[heap[p], heap[i]] = [heap[i], heap[p]] + i = p + } + } + const pop = () => { + const top = heap[0] + const last = heap.pop() + if (heap.length) { + heap[0] = last + let i = 0 + for (;;) { + const l = 2 * i + 1, + r = l + 1 + let m = i + if (l < heap.length && heap[l][0] < heap[m][0]) m = l + if (r < heap.length && heap[r][0] < heap[m][0]) m = r + if (m === i) break + ;[heap[m], heap[i]] = [heap[i], heap[m]] + i = m + } + } + return top + } + const deltaSigma = (a, b) => + (((size[a] * size[b]) / (size[a] + size[b])) * dist2(vec[a], vec[b])) / n + for (let u = 0; u < n; u++) { + g.adj[u].forEach((v) => { + if (v <= u) return + const d = deltaSigma(u, v) + nbrs[u].set(v, d) + nbrs[v].set(u, d) + push([d, u, v]) + }) + } + // modularity bookkeeping on the original graph + const twoM = 2 * g.m + const inW = new Float64Array(2 * n), + totW = new Float64Array(2 * n) + for (let i = 0; i < n; i++) totW[i] = g.adj[i].length + const between = Array.from({ length: n }, (_, u) => { + const mp = new Map() + g.adj[u].forEach((v) => mp.set(v, (mp.get(v) || 0) + 1)) + return mp + }) + let q = 0 + for (let i = 0; i < n; i++) q -= Math.pow(totW[i] / twoM, 2) + const merges = [] + let bestQ = q, + bestStep = 0 + let next = n + while (heap.length) { + const [d, a, b] = pop() + if (!alive[a] || !alive[b] || nbrs[a].get(b) !== d) continue + const c = next++ + alive[a] = 0 + alive[b] = 0 + alive[c] = 1 + size[c] = size[a] + size[b] + const va = vec[a], + vb = vec[b] + const vc = new Map() + va.forEach((x, k) => vc.set(k, (x * size[a]) / size[c])) + vb.forEach((x, k) => vc.set(k, (vc.get(k) || 0) + (x * size[b]) / size[c])) + vec[c] = vc + vec[a] = null + vec[b] = null + // modularity change + const eab = between[a].get(b) || 0 + q += (2 * eab) / twoM - (2 * totW[a] * totW[b]) / (twoM * twoM) + inW[c] = inW[a] + inW[b] + 2 * eab + totW[c] = totW[a] + totW[b] + const bc = new Map() + ;[a, b].forEach((x) => + between[x].forEach((w, y) => { + if (y !== a && y !== b) bc.set(y, (bc.get(y) || 0) + w) + }) + ) + between[c] = bc + bc.forEach((w, y) => { + between[y].delete(a) + between[y].delete(b) + between[y].set(c, w) + }) + merges.push([a, b, c]) + if (q > bestQ + 1e-12) { + bestQ = q + bestStep = merges.length + } + // new distances to neighbouring communities (Lance-Williams where possible) + const nc = new Map() + const neighbours = new Set([...nbrs[a].keys(), ...nbrs[b].keys()]) + neighbours.delete(a) + neighbours.delete(b) + neighbours.forEach((x) => { + let dnew + if (nbrs[a].has(x) && nbrs[b].has(x)) { + dnew = + ((size[a] + size[x]) * nbrs[a].get(x) + + (size[b] + size[x]) * nbrs[b].get(x) - + size[x] * d) / + (size[a] + size[b] + size[x]) + } else { + dnew = deltaSigma(c, x) + } + nc.set(x, dnew) + nbrs[x].delete(a) + nbrs[x].delete(b) + nbrs[x].set(c, dnew) + push([dnew, Math.min(c, x), Math.max(c, x)]) + }) + nbrs[c] = nc + nbrs[a] = new Map() + nbrs[b] = new Map() + } + // replay merges up to the step with the highest modularity + const parent = Int32Array.from({ length: 2 * n }, (_, i) => i) + const find = (x) => { + while (parent[x] !== x) { + parent[x] = parent[parent[x]] + x = parent[x] + } + return x + } + for (let s = 0; s < bestStep; s++) { + const [a, b, c] = merges[s] + parent[a] = c + parent[b] = c + } + const raw = new Int32Array(n) + for (let i = 0; i < n; i++) raw[i] = find(i) + return partitionStats(g, raw) +} diff --git a/frontend/src/clustering/wiring.ts b/frontend/src/clustering/wiring.ts new file mode 100644 index 0000000..f1091ed --- /dev/null +++ b/frontend/src/clustering/wiring.ts @@ -0,0 +1,377 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { VALUE_SCALES } from './mcl' +import { applyValueColors, scheduleLegend, scheduleSeparation } from './mapping' +import { benchmarkTsv, runLayoutBenchmark } from '../benchmark' +import { buildGroupLegend } from '../hulls' +import { cy, onCommitStyle } from '../cy' + +/* ---------- wiring: values, legend, separation, benchmark ---------- */ +export function updateValueScalePreview() { + const sc = VALUE_SCALES[document.getElementById('valueScale').value] || VALUE_SCALES['div-rdbu'] + document.getElementById('valueScalePreview').style.background = + `linear-gradient(90deg, ${sc.stops.join(', ')})` +} + +/* ============================================================ + BUBBLE SETS + Group contours that hug their members (after Collins, Penn and + Carpendale, "Bubble Sets", IEEE TVCG 2009). For each group an energy + field is built on a grid: positive around member nodes and along + "virtual edges" that tie the members together (a minimum spanning + tree, so the contour stays one piece), negative around nodes that are + not members, so the contour bends away from them. The outline is the + iso-line of that field (marching squares), smoothed. + Works in any coordinate system: pass positions and radii in the units + the result should be drawn in. + ============================================================ */ +const BUBBLE_THRESHOLD = 0.45 + +// members / others: [{ x, y, r }]; opts: { margin, edgeRadius, avoid, cell } +export function bubbleContour(members, others, opts) { + if (!members.length) return [] + const margin = opts.margin, + edgeR = opts.edgeRadius, + avoid = opts.avoid + let x1 = Infinity, + y1 = Infinity, + x2 = -Infinity, + y2 = -Infinity, + maxR = 0 + members.forEach((p) => { + x1 = Math.min(x1, p.x - p.r) + y1 = Math.min(y1, p.y - p.r) + x2 = Math.max(x2, p.x + p.r) + y2 = Math.max(y2, p.y + p.r) + maxR = Math.max(maxR, p.r) + }) + const pad = margin * 1.6 + 2 + x1 -= pad + y1 -= pad + x2 += pad + y2 += pad + // grid resolution: fine enough for the margin, capped for speed + let cell = opts.cell || Math.max(margin / 3, 0.5) + const maxCells = opts.maxCells || 250000 + while (((x2 - x1) / cell) * ((y2 - y1) / cell) > maxCells) cell *= 1.25 + const nx = Math.ceil((x2 - x1) / cell) + 1, + ny = Math.ceil((y2 - y1) / cell) + 1 + const F = new Float32Array(nx * ny) + + // positive energy around members: 1 inside the node, fading to 0 at `margin` + const splatDisc = (p, reach, weight) => { + const R = p.r + reach + const i0 = Math.max(0, Math.floor((p.x - R - x1) / cell)), + i1 = Math.min(nx - 1, Math.ceil((p.x + R - x1) / cell)) + const j0 = Math.max(0, Math.floor((p.y - R - y1) / cell)), + j1 = Math.min(ny - 1, Math.ceil((p.y + R - y1) / cell)) + for (let j = j0; j <= j1; j++) { + const gy = y1 + j * cell, + dy = gy - p.y + for (let i = i0; i <= i1; i++) { + const gx = x1 + i * cell, + dx = gx - p.x + const d = Math.sqrt(dx * dx + dy * dy) - p.r + if (d >= reach) continue + const t = d <= 0 ? 1 : 1 - d / reach + F[j * nx + i] += weight * t * t + } + } + } + members.forEach((p) => splatDisc(p, margin, 1)) + + // virtual edges: a minimum spanning tree over the members (Prim) + const m = members.length + const segs = [] + if (m > 1 && m <= 2500) { + const inTree = new Uint8Array(m), + best = new Float64Array(m).fill(Infinity), + from = new Int32Array(m).fill(-1) + best[0] = 0 + for (let k = 0; k < m; k++) { + let u = -1, + bu = Infinity + for (let v = 0; v < m; v++) + if (!inTree[v] && best[v] < bu) { + bu = best[v] + u = v + } + if (u < 0) break + inTree[u] = 1 + if (from[u] >= 0) segs.push([members[from[u]], members[u]]) + const pu = members[u] + for (let v = 0; v < m; v++) { + if (inTree[v]) continue + const d = Math.hypot(members[v].x - pu.x, members[v].y - pu.y) + if (d < best[v]) { + best[v] = d + from[v] = u + } + } + } + } else if (opts.links) { + opts.links.forEach(([a, b]) => segs.push([a, b])) + } + segs.forEach(([a, b]) => { + const R = edgeR + const i0 = Math.max(0, Math.floor((Math.min(a.x, b.x) - R - x1) / cell)), + i1 = Math.min(nx - 1, Math.ceil((Math.max(a.x, b.x) + R - x1) / cell)) + const j0 = Math.max(0, Math.floor((Math.min(a.y, b.y) - R - y1) / cell)), + j1 = Math.min(ny - 1, Math.ceil((Math.max(a.y, b.y) + R - y1) / cell)) + const vx = b.x - a.x, + vy = b.y - a.y, + L2 = vx * vx + vy * vy || 1 + for (let j = j0; j <= j1; j++) { + const gy = y1 + j * cell + for (let i = i0; i <= i1; i++) { + const gx = x1 + i * cell + const t = Math.max(0, Math.min(1, ((gx - a.x) * vx + (gy - a.y) * vy) / L2)) + const d = Math.hypot(gx - (a.x + t * vx), gy - (a.y + t * vy)) + if (d >= R) continue + const s = 1 - d / R + // take the stronger of node and edge energy so tree links don't bloat the nodes + const idx = j * nx + i + F[idx] = Math.max(F[idx], s * s) + } + } + }) + + // negative energy around non-members, strongest at their rim + others.forEach((p) => { + if ( + p.x + p.r + avoid < x1 || + p.x - p.r - avoid > x2 || + p.y + p.r + avoid < y1 || + p.y - p.r - avoid > y2 + ) + return + const R = p.r + avoid + const i0 = Math.max(0, Math.floor((p.x - R - x1) / cell)), + i1 = Math.min(nx - 1, Math.ceil((p.x + R - x1) / cell)) + const j0 = Math.max(0, Math.floor((p.y - R - y1) / cell)), + j1 = Math.min(ny - 1, Math.ceil((p.y + R - y1) / cell)) + for (let j = j0; j <= j1; j++) { + const gy = y1 + j * cell, + dy = gy - p.y + for (let i = i0; i <= i1; i++) { + const gx = x1 + i * cell, + dx = gx - p.x + const d = Math.sqrt(dx * dx + dy * dy) - p.r + if (d >= avoid) continue + const t = d <= 0 ? 1 : 1 - d / avoid + F[j * nx + i] -= 0.9 * t * t + } + } + }) + // members always stay inside their own contour + members.forEach((p) => { + const i = Math.round((p.x - x1) / cell), + j = Math.round((p.y - y1) / cell) + for (let b = -1; b <= 1; b++) + for (let a = -1; a <= 1; a++) { + const ii = i + a, + jj = j + b + if (ii >= 0 && jj >= 0 && ii < nx && jj < ny) F[jj * nx + ii] = Math.max(F[jj * nx + ii], 1) + } + }) + + return marchingSquares(F, nx, ny, x1, y1, cell, BUBBLE_THRESHOLD) +} + +// Iso-lines of a grid as closed loops of points. +function marchingSquares(F, nx, ny, x0, y0, cell, T) { + // edge ids: horizontal edge (i, j)->(i+1, j) = 2 * (j * nx + i); vertical = +1 + const point = new Map() + const edgePoint = (i, j, horizontal) => { + const key = 2 * (j * nx + i) + (horizontal ? 0 : 1) + let p = point.get(key) + if (p) return key + const a = F[j * nx + i] + const b = horizontal ? F[j * nx + i + 1] : F[(j + 1) * nx + i] + const t = Math.abs(b - a) < 1e-9 ? 0.5 : (T - a) / (b - a) + p = horizontal + ? { x: x0 + (i + t) * cell, y: y0 + j * cell } + : { x: x0 + i * cell, y: y0 + (j + t) * cell } + point.set(key, p) + return key + } + const next = new Map() // directed segments: from edge -> to edge + for (let j = 0; j < ny - 1; j++) { + for (let i = 0; i < nx - 1; i++) { + const vtl = F[j * nx + i], + vtr = F[j * nx + i + 1], + vbr = F[(j + 1) * nx + i + 1], + vbl = F[(j + 1) * nx + i] + const tl = vtl >= T, + tr = vtr >= T, + br = vbr >= T, + bl = vbl >= T + const code = (tl ? 8 : 0) | (tr ? 4 : 0) | (br ? 2 : 0) | (bl ? 1 : 0) + if (code === 0 || code === 15) continue + const cx0 = x0 + i * cell, + cy0 = y0 + j * cell, + cx1 = cx0 + cell, + cy1 = cy0 + cell + // orient a segment with a reference corner whose side is known: the + // inside always ends up on the same side of the walking direction + const link = (a, b, px, py, inside) => { + const pa = point.get(a), + pb = point.get(b) + const cross = (pb.x - pa.x) * (py - pa.y) - (pb.y - pa.y) * (px - pa.x) + if (cross > 0 === inside) next.set(a, b) + else next.set(b, a) + } + const top = () => edgePoint(i, j, true), + bottom = () => edgePoint(i, j + 1, true) + const left = () => edgePoint(i, j, false), + right = () => edgePoint(i + 1, j, false) + const TL = (s) => (s ? link(left(), top(), cx0, cy0, tl) : null) + switch (code) { + case 1: + case 14: + link(left(), bottom(), cx0, cy1, bl) + break + case 2: + case 13: + link(bottom(), right(), cx1, cy1, br) + break + case 3: + case 12: + link(left(), right(), cx0, cy0, tl) + break + case 4: + case 11: + link(right(), top(), cx1, cy0, tr) + break + case 6: + case 9: + link(bottom(), top(), cx0, cy0, tl) + break + case 7: + case 8: + link(left(), top(), cx0, cy0, tl) + break + case 5: + case 10: { + const centre = (vtl + vtr + vbr + vbl) / 4 >= T + // saddle: the two corners that differ from the centre are cut off + if (centre === tl) { + link(left(), bottom(), cx0, cy1, bl) + link(top(), right(), cx1, cy0, tr) + } else { + link(left(), top(), cx0, cy0, tl) + link(bottom(), right(), cx1, cy1, br) + } + break + } + } + } + } + const loops = [] + const used = new Set() + next.forEach((_, start) => { + if (used.has(start)) return + const loop = [] + let cur = start + while (cur !== undefined && !used.has(cur)) { + used.add(cur) + loop.push(point.get(cur)) + cur = next.get(cur) + } + if (loop.length >= 3) { + loop.closed = cur === start + loops.push(loop) + } + }) + return loops +} + +// Smooth closed path (quadratic curves through edge midpoints), simplified first. +export function bubblePathD(loops, map) { + const f = (v) => Math.round(v * 100) / 100 + let d = '' + loops.forEach((loop) => { + // drop nearly collinear points to keep paths small + const pts = [] + for (let i = 0; i < loop.length; i++) { + const p = map ? map(loop[i]) : loop[i] + const last = pts[pts.length - 1] + if (!last || Math.hypot(p.x - last.x, p.y - last.y) > 0.8) pts.push(p) + } + if (pts.length < 3) return + const n = pts.length + const mid = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }) + const m0 = mid(pts[n - 1], pts[0]) + d += `M${f(m0.x)},${f(m0.y)}` + for (let i = 0; i < n; i++) { + const p = pts[i], + q = mid(p, pts[(i + 1) % n]) + d += `Q${f(p.x)},${f(p.y)} ${f(q.x)},${f(q.y)}` + } + d += 'Z' + }) + return d +} + +// page wiring, run by main.ts in the original order +export function init() { + ;[ + 'valueColumn', + 'valueTransform', + 'valueScale', + 'valueCenter', + 'valueRange', + 'valueMin', + 'valueMax', + 'valueMissing', + ].forEach((id) => { + const el = document.getElementById(id) + const run = () => { + updateValueScalePreview() + applyValueColors() + } + el.addEventListener('change', run) + el.addEventListener('input', run) + }) + + updateValueScalePreview() + + ;[ + 'legendShow', + 'legendScale', + 'legendGroups', + 'legendShapes', + 'legendChannels', + 'legendTitle', + ].forEach((id) => { + document.getElementById(id).addEventListener('input', scheduleLegend) + document.getElementById(id).addEventListener('change', scheduleLegend) + }) + + document.getElementById('sizeMetric').addEventListener('change', scheduleLegend) + + document.getElementById('showGroupHulls').addEventListener('change', scheduleLegend) + + document.getElementById('groupSortSelect').addEventListener('change', () => { + buildGroupLegend() + scheduleLegend() + }) + + document.getElementById('spreadSlider').addEventListener('input', scheduleSeparation) + + document.getElementById('btnBench').addEventListener('click', runLayoutBenchmark) + + document.getElementById('btnBenchTsv').addEventListener('click', benchmarkTsv) + + { + onCommitStyle(scheduleLegend) + } + + cy.on('data', () => { + if ( + !document.getElementById('legendPanel').hidden || + document.getElementById('legendShow').checked + ) + scheduleLegend() + }) +} diff --git a/frontend/src/config.test.ts b/frontend/src/config.test.ts new file mode 100644 index 0000000..8c7909f --- /dev/null +++ b/frontend/src/config.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from 'vitest' + +test('NORMA_CFG merges window.NORMA_CONFIG over the defaults', async () => { + ;(window as unknown as { NORMA_CONFIG: unknown }).NORMA_CONFIG = { + site: { name: 'Test NORMA' }, + app: { maxNodes: 10 }, + features: { relays: { string: false } }, + } + const { NORMA_CFG } = await import('./config') + expect(NORMA_CFG.site.name).toBe('Test NORMA') + expect(NORMA_CFG.site.contactName).toBe('Pavlopoulos Lab') + expect(NORMA_CFG.app.maxNodes).toBe(10) + expect(NORMA_CFG.app.theme).toBe('white') + expect(NORMA_CFG.features.relays).toEqual({ string: false, arena3d: true, databases: true }) + expect((NORMA_CFG as { fromServer?: boolean }).fromServer).toBe(true) +}) diff --git a/frontend/src/config.ts b/frontend/src/config.ts new file mode 100644 index 0000000..535ff06 --- /dev/null +++ b/frontend/src/config.ts @@ -0,0 +1,57 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import cytoscape from 'cytoscape' + +/* ============================================================ + SETTINGS + window.NORMA_CONFIG comes from server.py (/norma-config.js) or from a + norma-config.js file next to norma.html; everything has a default, so + NORMA also runs with no settings at all. + ============================================================ */ +export const NORMA_CFG = (() => { + const d = { + mode: 'local', + version: '3.0', + publicUrl: '', + features: { + restApi: null, + relays: { string: true, arena3d: true, databases: true }, + apiTtlHours: 24, + }, + site: { + name: 'NORMA', + institution: '', + contactName: 'Pavlopoulos Lab', + contactEmail: '', + contactUrl: 'https://github.com/PavlopoulosLab/NORMA/issues', + sourceUrl: 'https://github.com/PavlopoulosLab/NORMA', + licenceName: 'MIT License', + licenceUrl: 'https://opensource.org/licenses/MIT', + privacyUrl: '', + imprintUrl: '', + maintainedUntil: '', + notice: '', + testedBrowsers: [], + }, + app: { maxNodes: 10000, theme: 'white', startTab: 'welcome', cdnFallback: true }, + } + const c = (typeof window.NORMA_CONFIG === 'object' && window.NORMA_CONFIG) || {} + const merge = (a, b) => { + Object.keys(b || {}).forEach((k) => { + if ( + b[k] && + typeof b[k] === 'object' && + !Array.isArray(b[k]) && + a[k] && + typeof a[k] === 'object' + ) + merge(a[k], b[k]) + else if (b[k] !== undefined && b[k] !== null) a[k] = b[k] + }) + return a + } + merge(d, c) + d.served = /^https?:$/.test(location.protocol) + d.fromServer = d.served && !!c.features // server.py writes "features" + return d +})() diff --git a/frontend/src/contours.ts b/frontend/src/contours.ts new file mode 100644 index 0000000..52fc57a --- /dev/null +++ b/frontend/src/contours.ts @@ -0,0 +1,551 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { EDGE_TYPES } from './palette' +import { NORMA_CFG } from './config' +import { S } from './state' +import { + UNGROUPED, + effectiveGroupsFor, + getUsedGroups, + groupLabel, + nodeFillMode, +} from './network_state' +import { activeView } from './profiler' +import { bubbleContour, bubblePathD } from './clustering/wiring' +import { buildCompareArena3dModel } from './enrichment' +import { cy } from './cy' +import { dataVersion } from './demo_downloads' +import { downloadText, fileStem, plural, setStatus } from './layouts/controls' +import { drawGroupHulls, hexToRgba, hullCanvasCss } from './hulls' +import { edgeIsDirected } from './export/dialog' +import { resolveStringRoute } from './string/requests' +import { rgbOf } from './view3d/state' +import { shownEdges, shownNodes } from './metrics' + +/* ---------- 2D: cached contours in model coordinates ---------- */ +var bubbleCache = { key: '', paths: new Map(), computedAt: 0, timer: null } + +function bubbleSettings(zoom) { + // screen-pixel margins turned into model units at this zoom + const mScale = Math.max(0.15, Math.min(1, zoom)) + return { + margin: (36 * mScale) / zoom, // contour about 12 px outside the nodes + edgeRadius: (24 * mScale) / zoom, // links about 8 px wide on each side + avoid: (14 * mScale) / zoom, + } +} + +function bubbleKey(nodes, zoom) { + let h = 0 + nodes.forEach((n, i) => { + const p = n.position() + h = + (h * 31 + Math.round(p.x * 2) * 7 + Math.round(p.y * 2) * 13 + Math.round(n.width()) + i) % + 1000000007 + }) + const zb = Math.round(Math.log2(zoom) * 4) + return `${dataVersion}|${zb}|${h}|${nodes.length}|${[...S.activeGroups].join('\u0001')}` +} + +// Contours for every ticked group, as SVG path data in model coordinates. +export function bubblePathsModel(force) { + const shown = cy.nodes().filter((n) => !n.hasClass('hidden-group')) + const zoom = cy.zoom() + const key = bubbleKey(shown, zoom) + if (!force && key === bubbleCache.key) return bubbleCache.paths + // while things move, recompute at most every 120 ms (large networks: only + // once they stop) and redraw when they stop + const now = performance.now() + const big = shown.length > 1500 + const sameData = bubbleCache.key.split('|')[0] === String(dataVersion) + if (!force && bubbleCache.key && sameData && (big || now - bubbleCache.computedAt < 120)) { + clearTimeout(bubbleCache.timer) + // redraw inside an animation frame: a canvas drawn from a bare timer is + // not always shown until something else repaints (seen in Chrome on macOS) + bubbleCache.timer = setTimeout( + () => + requestAnimationFrame(() => { + bubbleCache.computedAt = 0 + bubbleCache.settled = true + drawGroupHulls() + }), + big ? 300 : 140 + ) + if (!(big && bubbleCache.settled)) return bubbleCache.paths + } + bubbleCache.settled = false + const zb = Math.pow(2, Math.round(Math.log2(zoom) * 4) / 4) + const opts = bubbleSettings(zb) + const all = shown.map((n) => ({ + id: n.id(), + x: n.position().x, + y: n.position().y, + r: n.width() / 2, + groups: effectiveGroupsFor(n), + })) + const paths = new Map() + getUsedGroups() + .filter((g) => S.activeGroups.has(g) && g !== UNGROUPED) + .forEach((g) => { + const members = all.filter((p) => p.groups.includes(g)) + if (!members.length) return + const others = all.filter((p) => !p.groups.includes(g)) + const loops = bubbleContour(members, others, opts) + paths.set(g, bubblePathD(loops)) + }) + bubbleCache.key = key + bubbleCache.paths = paths + bubbleCache.computedAt = now + return paths +} + +export function drawBubbleSets(ctx, opacity) { + const paths = bubblePathsModel(false) + const z = cy.zoom(), + pan = cy.pan() + // the scale the canvas was actually sized with, not a pixel ratio that + // may have changed since + const dpr = hullCanvasCss.dpr || 1 + ctx.save() + ctx.setTransform(z * dpr, 0, 0, z * dpr, pan.x * dpr, pan.y * dpr) + paths.forEach((d, g) => { + if (!d) return + const color = S.nodeColorMap[g] || '#888888' + const path = new Path2D(d) + ctx.fillStyle = hexToRgba(color, opacity) + ctx.fill(path, 'evenodd') + ctx.lineWidth = 1.6 / z + ctx.strokeStyle = hexToRgba(color, Math.min(1, opacity + 0.45)) + ctx.stroke(path) + }) + ctx.restore() +} + +/* ============================================================ + ARENA3D EXPORT + Arena3D (https://arena3d.org) shows multilayer networks in 3D. Each + ticked group of the current view becomes one layer; nodes keep their + 2D positions inside their layer, edges inside a group stay in its layer + and edges between groups run between layers. A node in several groups + appears once in each of its layers. + Three outputs: + - Arena3D JSON (Arena3D's own export format) + - Arena3D network file (SourceNode, SourceLayer, TargetNode, + TargetLayer, Weight, Channel) + - Open in Arena3D: the JSON is POSTed to /api/external, + which answers { token, url }; the url is opened in a new tab. + ============================================================ */ +export const ARENA3D_LAYER_SPACING = 960 + +export const ARENA3D_LAYER_WIDTH = 947 + +const ARENA3D_COPY_CHANNEL = 'same node' + +export const ARENA3D_MAX_LAYERS = 20 + +function arena3dBase() { + const raw = (document.getElementById('arenaAddress').value || 'https://arena3d.org') + .trim() + .replace(/\/+$/, '') + return /^https?:\/\//i.test(raw) ? raw : 'https://' + raw +} + +export function hexColor(c) { + const [r, g, b] = rgbOf(c) + const h = (x) => + Math.max(0, Math.min(255, Math.round(x))) + .toString(16) + .padStart(2, '0') + return '#' + h(r) + h(g) + h(b) +} + +// Arena3D layer names can't contain underscores (node ids are name_layer). +export function arenaSafe(s) { + return ( + String(s) + .replace(/_/g, '-') + .replace(/[\t\r\n]+/g, ' ') + .trim() || '-' + ) +} + +function buildArena3dModel() { + const connectCopies = document.getElementById('arenaCopies').checked + const colorMode = document.getElementById('arenaNodeColors').value + const nodes = shownNodes() + const edges = shownEdges(true).filter((e) => e.data('source') !== e.data('target')) + if (!nodes.length) throw new Error('Show a network first.') + + // layers: ticked groups in legend order, then one for nodes in no group + const groupOrder = getUsedGroups().filter((g) => S.activeGroups.has(g) && g !== UNGROUPED) + const layersOf = new Map() + let ungrouped = false + nodes.forEach((n) => { + let gs = effectiveGroupsFor(n).filter((g) => g !== UNGROUPED) + if (!gs.length) { + gs = [UNGROUPED] + ungrouped = true + } + layersOf.set(n.id(), gs) + }) + let layerKeys = [...groupOrder.filter((g) => nodes.some((n) => layersOf.get(n.id()).includes(g)))] + if (ungrouped) layerKeys.push(UNGROUPED) + // Arena3D takes up to 20 layers: keep the first 20 (group list order) + let droppedLayers = 0, + droppedNodes = 0 + if (layerKeys.length > ARENA3D_MAX_LAYERS) { + droppedLayers = layerKeys.length - ARENA3D_MAX_LAYERS + layerKeys = layerKeys.slice(0, ARENA3D_MAX_LAYERS) + const kept = new Set(layerKeys) + ;[...layersOf].forEach(([id, gs]) => { + const left = gs.filter((g) => kept.has(g)) + if (left.length) layersOf.set(id, left) + else { + layersOf.delete(id) + droppedNodes++ + } + }) + } + const usedNames = new Set() + const layerName = new Map( + layerKeys.map((g) => { + let name = arenaSafe(g === UNGROUPED ? 'No group' : groupLabel(g)) + while (usedNames.has(name)) name += '′' + usedNames.add(name) + return [g, name] + }) + ) + const nodeName = (id) => arenaSafe(id) + + // positions: each layer's nodes are fitted into the layer's square + const layers = layerKeys.map((g, i) => ({ + key: g, + name: layerName.get(g), + position_x: String((i - (layerKeys.length - 1) / 2) * ARENA3D_LAYER_SPACING), + position_y: '0', + position_z: '0', + last_layer_scale: '1', + rotation_x: '0', + rotation_y: '0', + rotation_z: '0', + floor_current_color: g === UNGROUPED ? '#777777' : hexColor(S.nodeColorMap[g] || '#777777'), + geometry_parameters_width: String(ARENA3D_LAYER_WIDTH), + })) + const outNodes = [] + const half = ARENA3D_LAYER_WIDTH * 0.42 + layers.forEach((layer) => { + const members = nodes.filter( + (n) => layersOf.has(n.id()) && layersOf.get(n.id()).includes(layer.key) + ) + const xs = members.map((n) => n.position('x')), + ys = members.map((n) => n.position('y')) + const cx = (Math.min(...xs) + Math.max(...xs)) / 2, + cy0 = (Math.min(...ys) + Math.max(...ys)) / 2 + const span = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys)) || 1 + const k = members.length > 1 ? (2 * half) / span : 0 + members.forEach((n) => { + // pie nodes (several groups) take the color of the layer they are drawn in + const multi = nodeFillMode() === 'groups' && effectiveGroupsFor(n).length > 1 + const color = + (colorMode === 'group' || multi) && layer.key !== UNGROUPED + ? S.nodeColorMap[layer.key] || '#777777' + : n.style('background-color') + const attrs = n.data('attrs') || {} + outNodes.push({ + name: nodeName(n.id()), + layer: layer.name, + position_x: '0', + position_y: String(-(n.position('y') - cy0) * k), + position_z: String((n.position('x') - cx) * k), + scale: String(Math.round(Math.max(0.3, Math.min(3, (n.width() || 42) / 42)) * 100) / 100), + color: hexColor(color), + url: + typeof attrs.url === 'string' + ? attrs.url + : attrs.stringId + ? `https://string-db.org/network/${attrs.stringId}` + : '', + descr: typeof attrs.description === 'string' ? attrs.description : '', + }) + }) + }) + + // edges + const weights = edges.map((e) => e.data('weight')).filter((w) => typeof w === 'number') + const weighted = weights.length > 0 + const wMin = weighted ? Math.min(...weights) : 0, + wMax = weighted ? Math.max(...weights) : 1 + const opacityOf = (w) => { + if (!weighted || typeof w !== 'number' || wMax === wMin) return 1 + return Math.round((0.2 + (0.8 * (w - wMin)) / (wMax - wMin)) * 1000) / 1000 + } + const multiChannel = new Set(edges.map((e) => e.data('type'))).size > 1 + const outEdges = [] + const rows = [] + const seen = new Set() + let interLayer = 0 + edges.forEach((e) => { + const s = e.data('source'), + t = e.data('target') + const ls = layersOf.get(s), + lt = layersOf.get(t) + if (!ls || !lt) return + const shared = ls.filter((g) => lt.includes(g)) + const pairs = shared.length ? shared.map((g) => [g, g]) : [[ls[0], lt[0]]] + if (!shared.length) interLayer++ + const channelLabel = (EDGE_TYPES[e.data('type')] || { label: e.data('type') }).label + const channel = multiChannel ? arenaSafe(channelLabel) : '' + pairs.forEach(([gs, gt]) => { + const src = `${nodeName(s)}_${layerName.get(gs)}`, + trg = `${nodeName(t)}_${layerName.get(gt)}` + const key = [src, trg, channel].join('\u0000') + if (seen.has(key)) return + seen.add(key) + outEdges.push({ + src, + trg, + opacity: String(opacityOf(e.data('weight'))), + color: hexColor(e.style('line-color') || e.data('color')), + channel, + }) + rows.push([ + nodeName(s), + layerName.get(gs), + nodeName(t), + layerName.get(gt), + typeof e.data('weight') === 'number' ? e.data('weight') : 1, + channel || '1', + ]) + }) + }) + // optional edges linking the copies of a node in different layers + let copyEdges = 0 + if (connectCopies) { + layersOf.forEach((gs, id) => { + for (let i = 0; i + 1 < gs.length; i++) { + const src = `${nodeName(id)}_${layerName.get(gs[i])}`, + trg = `${nodeName(id)}_${layerName.get(gs[i + 1])}` + outEdges.push({ + src, + trg, + opacity: '0.6', + color: '#FFFFFF', + channel: multiChannel ? ARENA3D_COPY_CHANNEL : '', + }) + rows.push([ + nodeName(id), + layerName.get(gs[i]), + nodeName(id), + layerName.get(gs[i + 1]), + 1, + multiChannel ? ARENA3D_COPY_CHANNEL : '1', + ]) + copyEdges++ + } + }) + } + const json = { + scene: { + position_x: '0', + position_y: '0', + scale: String( + layers.length > 4 ? Math.round(((0.6561 * 4) / layers.length) * 1e4) / 1e4 : 0.6561 + ), + color: '#000000', + rotation_x: '0.261799387799149', + rotation_y: '0.261799387799149', + rotation_z: '0.0872664625997165', + }, + layers: layers.map(({ key, ...rest }) => rest), + nodes: outNodes, + edges: outEdges, + universalLabelColor: '#FFFFFF', + direction: edges.some((e) => edgeIsDirected(e)), + edgeOpacityByWeight: weighted, + edgeWidthByWeight: false, + } + const multiLayerNodes = [...layersOf.values()].filter((gs) => gs.length > 1).length + return { + json, + rows, + stats: { + layers: layers.length, + nodes: outNodes.length, + edges: outEdges.length, + interLayer, + copyEdges, + multiLayerNodes, + distinctNodes: layersOf.size, + droppedLayers, + droppedNodes, + }, + } +} + +function arena3dNetworkText(rows) { + return ( + [ + 'SourceNode\tSourceLayer\tTargetNode\tTargetLayer\tWeight\tChannel', + ...rows.map((r) => r.join('\t')), + ].join('\n') + '\n' + ) +} + +function arena3dSummary(st) { + const parts = [ + `${plural(st.layers, 'layer')}, ${plural(st.nodes, 'node')} (${st.distinctNodes.toLocaleString()} distinct), ${plural(st.edges, 'edge')}`, + ] + if (st.interLayer) parts.push(`${plural(st.interLayer, 'edge')} between layers`) + if (st.multiLayerNodes) parts.push(`${plural(st.multiLayerNodes, 'node')} in several layers`) + if (st.droppedLayers) + parts.push( + `Arena3D takes up to ${ARENA3D_MAX_LAYERS} layers, so only the first ${ARENA3D_MAX_LAYERS} groups were exported (${plural(st.droppedLayers, 'group')} left out${st.droppedNodes ? `, with ${plural(st.droppedNodes, 'node')} that ${st.droppedNodes === 1 ? 'is' : 'are'} only in those` : ''})` + ) + return parts.join('; ') +} + +// The current view, or the Network Comparison (`src`), as an Arena3D network. +const ARENA3D_SOURCES = { + view: { + build: async () => buildArena3dModel(), + status: 'arenaStatus', + button: 'btnArenaOpen', + stem: () => { + const v = typeof activeView === 'function' && activeView() + return `arena3d-${v ? fileStem(v.name) : 'network'}` + }, + }, + compare: { + build: async () => buildCompareArena3dModel(), + status: 'cmpArenaStatus', + button: 'btnCmpArenaOpen', + stem: () => 'arena3d-comparison', + }, +} + +export async function exportArena3d(kind, src = 'view') { + const S = ARENA3D_SOURCES[src] + try { + setStatus(S.status, [{ level: 'busy', text: 'Preparing the Arena3D network…', progress: null }]) + const model = await S.build() + const stem = S.stem() + if (kind === 'json') { + downloadText(`${stem}.json`, JSON.stringify(model.json, null, 2)) + setStatus(S.status, [ + { level: 'ok', text: `Saved ${stem}.json: ${arena3dSummary(model.stats)}.` }, + ]) + } else { + downloadText(`${stem}.txt`, arena3dNetworkText(model.rows)) + setStatus(S.status, [ + { + level: 'ok', + text: `Saved ${stem}.txt (Arena3D network file): ${arena3dSummary(model.stats)}. Use Upload Network in Arena3D to open it.`, + }, + ]) + } + } catch (err) { + setStatus(S.status, [{ level: 'error', text: err.message }]) + } +} + +export async function openInArena3d(src = 'view') { + const S = ARENA3D_SOURCES[src] + // open the tab now, while the click still counts as a user action, so + // pop-up blockers let it through; it is pointed at Arena3D once the + // server answers + const tab = window.open('', '_blank') + if (tab) { + try { + tab.document.write( + 'Opening Arena3D…

    Sending the network to Arena3D…

    ' + ) + } catch (e) {} + } + const btn = document.getElementById(S.button) + btn.disabled = true + let model + try { + model = await S.build() + } catch (err) { + if (tab && !tab.closed) tab.close() + btn.disabled = false + setStatus(S.status, [{ level: 'error', text: err.message }]) + return + } + setStatus(S.status, [ + { level: 'busy', text: `Sending ${arena3dSummary(model.stats)} to Arena3D…`, progress: null }, + ]) + try { + const route = + NORMA_CFG.features.relays.arena3d === false ? 'direct' : await resolveStringRoute() + const body = JSON.stringify(model.json) + const url = + route === 'proxy' + ? `arena3d-api/external?upstream=${encodeURIComponent(arena3dBase())}` + : `${arena3dBase()}/api/external` + let response + try { + response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }) + } catch (err) { + throw new Error( + route === 'proxy' + ? `The server relay could not be reached (${err.message}). Check that server.py is still running.` + : `Arena3D could not be reached (${err.message}). Browsers may block direct calls to other sites; run NORMA with server.py, or save the Arena3D JSON and load it in Arena3D yourself.` + ) + } + const text = await response.text() + if (!response.ok) { + if (route === 'proxy' && !/^\s*[\[{]/.test(text) && response.status !== 502) + throw new Error( + 'This server has no Arena3D relay. Start NORMA with server.py, or save the Arena3D JSON instead.' + ) + throw new Error(`Arena3D answered with an error (${response.status}): ${text.slice(0, 200)}`) + } + let answer + try { + answer = JSON.parse(text) + } catch (e) { + throw new Error('Arena3D sent an answer that is not JSON.') + } + if (!answer || !answer.url) throw new Error('Arena3D did not return a link to the network.') + if (tab && !tab.closed) tab.location.href = answer.url + else window.open(answer.url, '_blank') + setStatus(S.status, [ + { level: 'ok', text: `Opened in Arena3D: ${arena3dSummary(model.stats)}.` }, + { + level: 'ok', + text: 'If no new tab appeared, open the link:', + action: { label: 'Open Arena3D', run: () => window.open(answer.url, '_blank') }, + }, + ]) + } catch (err) { + if (tab && !tab.closed) tab.close() + setStatus(S.status, [ + { level: 'error', text: err.message }, + { + level: 'ok', + text: 'You can still save the Arena3D JSON and load it in Arena3D.', + action: { label: 'Save Arena3D JSON', run: () => exportArena3d('json', src) }, + }, + ]) + } finally { + btn.disabled = false + } +} + +// page wiring, run by main.ts in the original order +export function init() { + document.getElementById('btnArenaOpen').addEventListener('click', () => openInArena3d('view')) + + document + .getElementById('btnArenaJson') + .addEventListener('click', () => exportArena3d('json', 'view')) + + document + .getElementById('btnArenaTsv') + .addEventListener('click', () => exportArena3d('tsv', 'view')) +} diff --git a/frontend/src/cy.ts b/frontend/src/cy.ts new file mode 100644 index 0000000..1e71443 --- /dev/null +++ b/frontend/src/cy.ts @@ -0,0 +1,181 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import cytoscape from 'cytoscape' +import { webglPreference } from './enrichment' + +/* ---------- cytoscape setup ---------- */ +/* ---------- stylesheet ---------- + BASE_STYLE is the fixed rule list. Settings change it only through + setStyle(selector, props), which merges into the rule for that selector. + The whole sheet is then rebuilt once per tick, so it never grows no + matter how often sliders move, and rule order (and therefore which rule + wins) stays fixed: node, node.dimmed, ..., edge, ..., edge.highlighted. */ +export const SELECTION_YELLOW = '#facc15' + +const BASE_STYLE = [ + { + selector: 'node', + style: { + 'background-color': 'data(color)', + 'pie-size': '100%', + // Cytoscape supports up to 16 pie slices per node + ...Object.fromEntries( + Array.from({ length: 16 }, (_, i) => [ + [`pie-${i + 1}-background-color`, `data(pieColor${i + 1})`], + [`pie-${i + 1}-background-size`, `data(pieSize${i + 1})`], + ]).flat() + ), + width: 'data(size)', + height: 'data(size)', + shape: 'data(shape)', + label: 'data(id)', + color: '#e7ebf5', + 'font-size': 'data(labelPx)', + 'font-family': 'Inter, sans-serif', + 'text-valign': 'center', + 'text-margin-y': 0, + 'text-outline-width': 3, + 'text-outline-color': '#0f1420', + 'border-width': 2, + 'border-color': 'rgba(255,255,255,0.35)', + 'overlay-opacity': 0, + }, + }, + { + selector: 'node.dimmed', + style: { opacity: 0.12 }, + }, + { + selector: 'node.highlighted', + style: { 'border-color': '#5fd3c4', 'border-width': 3 }, + }, + { + // several nodes can be selected (Shift/Ctrl/Cmd-click or Shift-drag); + // a thick border plus a soft halo keeps them visible at any zoom + selector: 'node:selected', + style: { + 'border-width': 5, + 'border-color': '#facc15', + 'underlay-color': '#facc15', + 'underlay-opacity': 0.35, + 'underlay-padding': 7, + 'underlay-shape': 'ellipse', + }, + }, + { + selector: 'edge', + style: { + 'curve-style': 'haystack', + 'haystack-radius': 0.4, + width: 2, + 'line-color': 'data(color)', + opacity: 0.85, + }, + }, + { + selector: 'edge.hidden-type', + style: { display: 'none' }, + }, + { + selector: 'edge.merged-hidden', + style: { display: 'none' }, + }, + { + selector: 'node.hidden-group', + style: { display: 'none' }, + }, + { + selector: 'edge.dimmed', + style: { opacity: 0.04 }, + }, + { + selector: 'edge.highlighted', + style: { opacity: 1, width: 3.2 }, + }, + { + selector: 'edge:selected', + style: { + 'underlay-color': '#facc15', + 'underlay-opacity': 0.45, + 'underlay-padding': 4, + opacity: 1, + }, + }, + { + selector: 'core', + style: { + 'selection-box-color': '#0f766e', + 'selection-box-opacity': 0.12, + 'selection-box-border-color': '#0f766e', + 'selection-box-border-width': 1, + 'active-bg-opacity': 0, + }, + }, +] + +const DYNAMIC_STYLE = {} + +let styleCommitQueued = false + +function buildStylesheet() { + const seen = new Set() + const sheet = BASE_STYLE.map((rule) => { + seen.add(rule.selector) + return { + selector: rule.selector, + style: { ...rule.style, ...(DYNAMIC_STYLE[rule.selector] || {}) }, + } + }) + Object.keys(DYNAMIC_STYLE).forEach((sel) => { + if (!seen.has(sel)) sheet.push({ selector: sel, style: { ...DYNAMIC_STYLE[sel] } }) + }) + return sheet +} + +// other modules (3D view, legend) react after every style commit +const styleHooks = [] + +export function onCommitStyle(fn) { + styleHooks.push(fn) +} + +function commitStyle() { + styleCommitQueued = false + cy.style().fromJson(buildStylesheet()).update() + styleHooks.forEach((fn) => fn()) +} + +export function setStyle(selector, props) { + DYNAMIC_STYLE[selector] = { ...(DYNAMIC_STYLE[selector] || {}), ...props } + if (!styleCommitQueued) { + styleCommitQueued = true + queueMicrotask(commitStyle) + } +} + +// WebGL drawing is chosen once, when the canvas is created (see Display → Performance). +export const WEBGL_ACTIVE = (() => { + try { + return webglPreference() + } catch (e) { + return false + } +})() + +export const cy = cytoscape({ + container: document.getElementById('cy'), + ...(WEBGL_ACTIVE ? { renderer: { name: 'canvas', webgl: true } } : {}), + elements: [], + // Effectively unlimited zoom. Cytoscape's own limits are 1e-50..1e50, + // but canvas drawing breaks down long before that; a millionfold either + // way is far past anything useful. Panning is kept in bounds by + // clampViewport() below, so the network can't be lost off-screen. + minZoom: 1e-6, + maxZoom: 1e6, + // with panning on, box selection needs Shift (or Ctrl/Cmd/Alt) while dragging + boxSelectionEnabled: true, + selectionType: 'single', + wheelSensitivity: 0.25, + style: BASE_STYLE, + layout: { name: 'grid' }, +}) diff --git a/frontend/src/demo_downloads.ts b/frontend/src/demo_downloads.ts new file mode 100644 index 0000000..6f5e205 --- /dev/null +++ b/frontend/src/demo_downloads.ts @@ -0,0 +1,323 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { EDGE_TYPES } from './palette' +import { S } from './state' +import { SAMPLE_GENERATORS } from './sample_data' +import { activeView, restoreView, snapshotData, spreadBase, viewSettings } from './profiler' +import { cy } from './cy' +import { downloadText, libSelection } from './layouts/controls' +import { escapeHtml } from './network_state' +import { getUsedTypes } from './hulls' + +/* ---------- built-in demo downloads (Help > Example files) ---------- */ +// Converts a demo in loadData's JSON shape to NORMA's text formats. +export function demoToNormaTexts(data) { + const weighted = data.edges.some((e) => typeof e.weight === 'number') + const typed = new Set(data.edges.map((e) => e.type || 'link')).size > 1 + const isDirected = (e) => (e.directed === undefined ? !!data.directed : !!e.directed) + const withDirection = data.edges.some(isDirected) + const header = ['Source', 'Target'] + if (weighted) header.push('Weight') + if (typed) header.push('Type') + if (withDirection) header.push('Direction') + const netLines = [header.join('\t')] + data.edges.forEach((e) => { + const row = [e.source, e.target] + if (weighted) row.push(String(typeof e.weight === 'number' ? e.weight : 1)) + if (typed) row.push(e.type || 'link') + if (withDirection) row.push(isDirected(e) ? 'directed' : 'undirected') + netLines.push(row.join('\t')) + }) + const members = new Map() + ;(data.groupOrder || []).forEach((g) => members.set(g, [])) + data.nodes.forEach((n) => { + const groups = n.groups && n.groups.length ? n.groups : n.group ? [n.group] : [] + groups.forEach((g) => { + if (!members.has(g)) members.set(g, []) + members.get(g).push(n.id) + }) + }) + const attrs = data.groupAttrs || {} + const annLines = [...members] + .filter(([, m]) => m.length) + .map(([g, m]) => `${(attrs[g] && attrs[g].label) || g}\t${m.join(',')}`) + return { network: netLines.join('\n') + '\n', annotation: annLines.join('\n') + '\n' } +} + +const DEMO_DOWNLOADS = [ + [ + 'trp', + 'E. coli trp operon, multi-edge', + 'Channels, weights, gene function and EC number attributes, group descriptions', + ], + ['modules50', '4 overlapping modules (50 nodes)', 'Attributes on nodes, edges and groups'], + ['modules100', '4 overlapping modules (100 nodes)', 'Attributes on nodes, edges and groups'], + [ + 'tiny', + 'Random network with edge labels (20 nodes)', + 'Each edge has an interaction attribute, shown as its label', + ], + [ + 'directed', + 'Random directed multi-edge network (40 nodes)', + 'Arrows from source to target, up to three channels per pair, some reciprocal edges; the network file has a Direction column', + ], + ['small', 'Random network (60 nodes)', ''], + ['medium', 'Random network (200 nodes)', ''], + ['large', 'Random network (800 nodes)', ''], + ['massive', 'Random network (5,000 nodes)', ''], +] + +function renderDemoDownloads() { + const root = document.getElementById('helpDemoFiles') + if (!root) return + const table = document.createElement('table') + table.className = 'help-table' + table.innerHTML = 'DemoDownload' + const body = document.createElement('tbody') + DEMO_DOWNLOADS.forEach(([key, title, note]) => { + const tr = document.createElement('tr') + const td1 = document.createElement('td') + td1.innerHTML = `${escapeHtml(title)}${note ? `${escapeHtml(note)}` : ''}` + const td2 = document.createElement('td') + td2.className = 'dl-buttons' + const make = (label, fileName, build) => { + const btn = document.createElement('button') + btn.type = 'button' + btn.className = 'dl-copy' + btn.textContent = label + btn.title = `Download ${fileName}` + btn.addEventListener('click', () => downloadText(fileName, build())) + td2.appendChild(btn) + } + const gen = () => SAMPLE_GENERATORS[key]() + make('Network', `demo-${key}-network.txt`, () => demoToNormaTexts(gen()).network) + make('Groups', `demo-${key}-annotation.txt`, () => demoToNormaTexts(gen()).annotation) + make('JSON', `demo-${key}.json`, () => JSON.stringify(gen(), null, 2)) + tr.append(td1, td2) + body.appendChild(tr) + }) + table.appendChild(body) + root.appendChild(table) +} + +/* ============================================================ + UNDO / REDO + Each view keeps its own history. An entry is the view's full visual + state (settings, node positions, ticked groups and channels, colors, + spread) plus a reference to its data; data snapshots are reused until + the data actually changes, so moving a node doesn't copy the network. + Recording: the first change after a quiet moment pushes the last + settled state onto the undo stack; further changes within the same + burst (slider drags, layout animations) join that entry, and the state + settles once nothing has changed for a moment. Entries that turn out to + change nothing are dropped. Zoom, pan, search and selection are not + part of the history, and neither is the theme. + ============================================================ */ +const HISTORY_LIMIT = 60 + +const HISTORY_SETTLE_MS = 450 + +export let historyTimer = null + +export let dataVersion = 0 + +export function bumpDataVersion() { + dataVersion++ +} + +function viewHistory() { + const v = activeView() + if (!v) return null + if (!v.history) v.history = { undo: [], redo: [], committed: null } + return v.history +} + +function currentDataSnapshot() { + if (S.dataCache.version !== dataVersion) + S.dataCache = { version: dataVersion, data: snapshotData() } + return S.dataCache.data +} + +function captureHistoryState() { + const positions = {} + cy.nodes().forEach((n) => { + const p = n.position() + positions[n.id()] = { x: p.x, y: p.y } + }) + const typeColors = {} + getUsedTypes().forEach((t) => { + if (EDGE_TYPES[t]) typeColors[t] = EDGE_TYPES[t].color + }) + return { + data: currentDataSnapshot(), + selection: { + networks: [...libSelection.networks], + annotation: libSelection.annotation, + colors: libSelection.colors, + }, + state: { + config: viewSettings(), + positions, + activeGroups: [...S.activeGroups], + activeTypes: [...S.activeTypes], + groupColors: { ...S.nodeColorMap }, + typeColors, + libView: S.currentLibView ? { ...S.currentLibView } : null, + spread: parseFloat(document.getElementById('spreadSlider').value) || 0, + // kept in memory only: lets Spread return to the exact positions after undo + spreadBase: spreadBase ? { ...spreadBase } : null, + groupFilter: document.getElementById('groupFilter').value, + }, + } +} + +function sameHistoryState(a, b) { + if (!a || !b || a.data !== b.data) return false + const pa = a.state.positions, + pb = b.state.positions + const ids = Object.keys(pa) + if (ids.length !== Object.keys(pb).length) return false + for (const id of ids) { + const p = pa[id], + q = pb[id] + if (!q || Math.abs(p.x - q.x) > 0.01 || Math.abs(p.y - q.y) > 0.01) return false + } + const strip = (e) => + JSON.stringify({ ...e.state, positions: null, spreadBase: null, sel: e.selection }) + return strip(a) === strip(b) +} + +function layoutStillRunning() { + return ( + S.cyLayoutsRunning > 0 || + document.getElementById('btnRunLayout').disabled || + cy.nodes().animated() + ) +} + +export function scheduleSettle() { + clearTimeout(historyTimer) + historyTimer = setTimeout(settleHistory, HISTORY_SETTLE_MS) +} + +function settleHistory() { + clearTimeout(historyTimer) + historyTimer = null + const h = viewHistory() + if (!h) { + S.historyGestureOpen = false + return + } + if (layoutStillRunning()) { + scheduleSettle() + return + } + const now = captureHistoryState() + if (S.historyGestureOpen && h.undo.length && sameHistoryState(h.undo[h.undo.length - 1], now)) { + h.undo.pop() + } + h.committed = now + S.historyGestureOpen = false + updateUndoButtons() +} + +// Settles right away (used before switching views and before undo/redo). +export function flushHistory() { + if (historyTimer || S.historyGestureOpen) { + clearTimeout(historyTimer) + historyTimer = null + const h = viewHistory() + if (h) { + const now = captureHistoryState() + if (S.historyGestureOpen && h.undo.length && sameHistoryState(h.undo[h.undo.length - 1], now)) + h.undo.pop() + h.committed = now + } + S.historyGestureOpen = false + } +} + +// Call after something the user did changed (or is about to change) the view. +export function noteChange() { + if (S.historySuspended) return + const h = viewHistory() + if (!h) return + if (!S.historyGestureOpen) { + if (h.committed) { + h.undo.push(h.committed) + if (h.undo.length > HISTORY_LIMIT) h.undo.shift() + h.redo.length = 0 + } + S.historyGestureOpen = true + } + scheduleSettle() + updateUndoButtons() +} + +// Records the current state as the starting point (no undo entry). +export function setHistoryBaseline() { + S.historyGestureOpen = false + scheduleSettle() +} + +function applyHistoryState(entry) { + S.historySuspended++ + try { + const reuse = entry.data === currentDataSnapshot() && cy.nodes().length > 0 + restoreView( + { data: entry.data, selection: entry.selection, state: { ...entry.state } }, + { reuseData: reuse } + ) + if (!reuse) S.dataCache = { version: dataVersion, data: entry.data } + } finally { + S.historySuspended-- + } +} + +export function undo() { + flushHistory() + const h = viewHistory() + if (!h || !h.undo.length) return + const target = h.undo.pop() + if (h.committed) h.redo.push(h.committed) + applyHistoryState(target) + h.committed = target + updateUndoButtons() +} + +export function redo() { + flushHistory() + const h = viewHistory() + if (!h || !h.redo.length) return + const target = h.redo.pop() + if (h.committed) h.undo.push(h.committed) + applyHistoryState(target) + h.committed = target + updateUndoButtons() +} + +export function updateUndoButtons() { + const h = viewHistory() + const u = document.getElementById('btnUndo'), + r = document.getElementById('btnRedo') + const canUndo = !!h && h.undo.length > 0 + const canRedo = !!h && h.redo.length > 0 && !S.historyGestureOpen + u.disabled = !canUndo + r.disabled = !canRedo + u.title = canUndo + ? `Undo (Ctrl+Z), ${h.undo.length} step${h.undo.length === 1 ? '' : 's'} available` + : 'Nothing to undo' + r.title = canRedo + ? `Redo (Ctrl+Shift+Z), ${h.redo.length} step${h.redo.length === 1 ? '' : 's'} available` + : 'Nothing to redo' +} + +// page wiring, run by main.ts in the original order +export function init() { + renderDemoDownloads() + + cy.on('layoutstart', () => { + S.cyLayoutsRunning++ + }) +} diff --git a/frontend/src/directed_stats.ts b/frontend/src/directed_stats.ts new file mode 100644 index 0000000..1ff1705 --- /dev/null +++ b/frontend/src/directed_stats.ts @@ -0,0 +1,184 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { PROFILE_PATH_LIMIT, formatStat } from './wiring' +import { escapeHtml } from './network_state' + +/* ---------- directed statistics (Profiler and Compare) ---------- */ +export const PROFILE_DIRECTED_STATS = [ + { key: 'dirEdges', label: 'Directed edges', desc: 'Distinct source → target connections.' }, + { + key: 'reciprocity', + label: 'Reciprocity', + desc: 'Share of directed edges whose reverse edge also exists.', + }, + { key: 'maxIn', label: 'Maximum in-degree', desc: 'Most incoming neighbors of any node.' }, + { key: 'maxOut', label: 'Maximum out-degree', desc: 'Most outgoing neighbors of any node.' }, + { key: 'sources', label: 'Source nodes', desc: 'Nodes with outgoing but no incoming edges.' }, + { key: 'sinks', label: 'Sink nodes', desc: 'Nodes with incoming but no outgoing edges.' }, + { + key: 'scc', + label: 'Strongly connected components', + desc: 'Groups of nodes that can all reach each other along edge directions.', + }, + { + key: 'largestScc', + label: 'Largest strongly connected component', + desc: 'Nodes in the biggest such group, with its share of all nodes.', + }, + { + key: 'reachable', + label: 'Reachable node pairs', + desc: 'Share of ordered pairs (A, B) where B can be reached from A along edge directions.', + }, + { + key: 'dirAvgPath', + label: 'Average directed path length', + desc: 'Mean shortest path following edge directions, over reachable pairs.', + }, + { + key: 'dirDiameter', + label: 'Directed diameter', + desc: 'Longest shortest path following edge directions.', + }, +] + +// edges: [source, target, directed]; undirected edges can be walked both ways +export function profileDirected(ids, edges) { + const n = ids.length + const index = new Map(ids.map((id, i) => [id, i])) + const out = Array.from({ length: n }, () => new Set()) + const arcKeys = new Set() + edges.forEach(([s, t, d]) => { + const a = index.get(s), + b = index.get(t) + if (a === undefined || b === undefined || a === b) return + out[a].add(b) + if (d) arcKeys.add(a + ',' + b) + else out[b].add(a) + }) + const r = {} + r.dirEdges = arcKeys.size + let recip = 0 + arcKeys.forEach((k) => { + const [a, b] = k.split(',') + if (arcKeys.has(b + ',' + a)) recip++ + }) + r.reciprocity = arcKeys.size ? recip / arcKeys.size : NaN + const indeg = new Int32Array(n) + const adj = out.map((set) => Int32Array.from(set)) + adj.forEach((list) => + list.forEach((w) => { + indeg[w]++ + }) + ) + r.maxIn = n ? Math.max(...indeg) : NaN + r.maxOut = n ? Math.max(...adj.map((l) => l.length)) : NaN + r.sources = 0 + r.sinks = 0 + for (let v = 0; v < n; v++) { + if (indeg[v] === 0 && adj[v].length > 0) r.sources++ + if (adj[v].length === 0 && indeg[v] > 0) r.sinks++ + } + + // strongly connected components (iterative Tarjan) + const idx = new Int32Array(n).fill(-1), + low = new Int32Array(n), + onStack = new Uint8Array(n) + const stack = [], + sccSizes = [] + let counter = 0 + for (let root = 0; root < n; root++) { + if (idx[root] >= 0) continue + const call = [[root, 0]] + idx[root] = low[root] = counter++ + stack.push(root) + onStack[root] = 1 + while (call.length) { + const frame = call[call.length - 1] + const v = frame[0] + if (frame[1] < adj[v].length) { + const w = adj[v][frame[1]++] + if (idx[w] < 0) { + idx[w] = low[w] = counter++ + stack.push(w) + onStack[w] = 1 + call.push([w, 0]) + } else if (onStack[w]) low[v] = Math.min(low[v], idx[w]) + } else { + call.pop() + if (call.length) { + const u = call[call.length - 1][0] + low[u] = Math.min(low[u], low[v]) + } + if (low[v] === idx[v]) { + let size = 0, + w + do { + w = stack.pop() + onStack[w] = 0 + size++ + } while (w !== v) + sccSizes.push(size) + } + } + } + } + r.scc = sccSizes.length + const big = sccSizes.length ? Math.max(...sccSizes) : 0 + r.largestScc = n ? `${big.toLocaleString('en-US')} (${((100 * big) / n).toFixed(1)}%)` : '—' + + // directed shortest paths + if (n <= PROFILE_PATH_LIMIT) { + const dist = new Int32Array(n), + queue = new Int32Array(Math.max(1, n)) + let pairs = 0, + total = 0, + diameter = 0 + for (let s = 0; s < n; s++) { + dist.fill(-1) + dist[s] = 0 + let head = 0, + tail = 0 + queue[tail++] = s + while (head < tail) { + const v = queue[head++] + const nb = adj[v] + for (let k = 0; k < nb.length; k++) { + const w = nb[k] + if (dist[w] < 0) { + dist[w] = dist[v] + 1 + queue[tail++] = w + pairs++ + total += dist[w] + if (dist[w] > diameter) diameter = dist[w] + } + } + } + } + r.reachable = n > 1 ? `${((100 * pairs) / (n * (n - 1))).toFixed(1)}%` : '—' + r.dirAvgPath = pairs ? total / pairs : NaN + r.dirDiameter = pairs ? diameter : NaN + } else { + r.reachable = r.dirAvgPath = r.dirDiameter = 'skipped' + } + return r +} + +export function directedStatsTable(results) { + let html = + '
    ' + + results.map((r) => ``).join('') + + '' + PROFILE_DIRECTED_STATS.forEach((st) => { + html += + `` + + results + .map( + (r) => + `` + ) + .join('') + + '' + }) + return html + '
    Statistic${escapeHtml(r.name)}
    ${escapeHtml(st.label)}${escapeHtml(st.desc)}${escapeHtml(r.directed ? formatStat(r.directed[st.key]) : '—')}
    ' +} diff --git a/frontend/src/enrichment.ts b/frontend/src/enrichment.ts new file mode 100644 index 0000000..0eb4925 --- /dev/null +++ b/frontend/src/enrichment.ts @@ -0,0 +1,3078 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import cytoscape from 'cytoscape' +import { + ARENA3D_LAYER_SPACING, + ARENA3D_LAYER_WIDTH, + ARENA3D_MAX_LAYERS, + arenaSafe, + exportArena3d, + hexColor, + openInArena3d, +} from './contours' +import { + BUNDLE_MAX_EDGES, + activeView, + captureActiveView, + createView, + openInNewView, + renderViewBar, + restoreView, + setControls, + snapshotData, + viewSettings, + views, +} from './profiler' +import { EDGE_TYPES, NODE_PALETTES, colorAtIndex } from './palette' +import { + MAX_NETWORK_NODES, + UNGROUPED, + effectiveGroupsFor, + escapeHtml, + groupLabel, + nextAutoEdgeColor, + nodeFillMode, + sanitizeColor, +} from './network_state' +import { NORMA_CFG } from './config' +import { + NORMA_PARSERS, + addNormaEntry, + downloadText, + fileStem, + libEntry, + libSelection, + listSample, + nextPaint, + normaLibrary, + parseNormaNetwork, + plural, + readTextWithProgress, + renderLibraryLists, + setStatus, + startProgress, +} from './layouts/controls' +import { S } from './state' +import { WEBGL_ACTIVE, cy } from './cy' +import { + applyGroupColorOverrides, + benjaminiHochberg, + groupStatsTsv, + hypergeomUpper, + renderGroupStats, +} from './arena3d' +import { applyGroupsAndColorsInPlace, refreshLibraryView } from './library' +import { applyTheme } from './themes' +import { + applyValueColors, + groupSeparation, + valueColumns, + viewHasValues, +} from './clustering/mapping' +import { + bundleAsync, + exportEdges, + exportNodes, + exportShownOnly, + frLayoutAsync, + shownEdges, + shownNodes, +} from './metrics' +import { compareState } from './label_colors' +import { currentTab, louvain, profileGraph, simpleGraph, switchTab } from './wiring' +import { + dataVersion, + demoToNormaTexts, + setHistoryBaseline, + updateUndoButtons, +} from './demo_downloads' +import { edgeIsDirected } from './export/dialog' +import { generateRandomNetwork } from './sample_data' +import { loadData, refreshAllDerivedUI, sortedByName } from './hulls' +import { net3d, requestRender3d, rgbOf } from './view3d/state' +import { openGroupNetwork } from './group_network' +import { readFileText } from './uploads' +import { resolveStringRoute } from './string/requests' + +/* ---------- enrichment ---------- */ +export const groupAnalysisState = { stats: null, enrichment: null } + +function annotationChoices() { + const out = [{ value: 'view', label: 'Groups of the current view' }] + sortedByName(normaLibrary.annotation, (e) => e.name).forEach((e) => + out.push({ value: e.id, label: e.name }) + ) + return out +} + +// Map term -> Set(member ids) for an annotation choice +function termSetsFor(choice) { + const terms = new Map() + if (choice === 'view') { + cy.nodes().forEach((n) => + (n.data('groups') || []).forEach((g) => { + if (g === UNGROUPED) return + const label = groupLabel(g) + if (!terms.has(label)) terms.set(label, new Set()) + terms.get(label).add(n.id()) + }) + ) + return terms + } + const entry = libEntry('annotation', choice) + if (!entry) return terms + entry.parsed.groups.forEach(({ name, members }) => terms.set(name, new Set(members))) + return terms +} + +export function refreshEnrichmentChoices() { + ;['gaTerms', 'gaSetsFrom'].forEach((id) => { + const sel = document.getElementById(id) + const current = sel.value + sel.innerHTML = '' + annotationChoices().forEach((o) => sel.add(new Option(o.label, o.value))) + if ([...sel.options].some((o) => o.value === current)) sel.value = current + else if (id === 'gaTerms') { + // default: terms from a file other than the view's own grouping + const own = S.currentLibView && S.currentLibView.annotation + const other = normaLibrary.annotation.find((e) => e.id !== own) + if (other) sel.value = other.id + } + }) + const groupsMode = document.getElementById('gaSets').value === 'groups' + document.getElementById('gaSetsFromRow').hidden = !groupsMode + document.getElementById('gaSetsFromLabel').hidden = !groupsMode + const selected = cy.nodes(':selected').length + document.getElementById('gaSelectedNote').textContent = + document.getElementById('gaSets').value === 'selected' + ? selected + ? `${plural(selected, 'node')} selected in the view.` + : 'Select nodes in the 2D or 3D view first (Shift-drag or Shift-click).' + : '' +} + +function runEnrichment() { + const setsMode = document.getElementById('gaSets').value + const termChoice = document.getElementById('gaTerms').value + const bgMode = document.getElementById('gaBackground').value + const minOverlap = Math.max(1, parseInt(document.getElementById('gaMinOverlap').value, 10) || 2) + const fdrMax = parseFloat(document.getElementById('gaFdr').value) || 0.05 + const shown = new Set(shownNodes().map((n) => n.id())) + if (!shown.size) { + setStatus('gaEnrichStatus', [{ level: 'error', text: 'Show a network first.' }]) + return + } + const terms = termSetsFor(termChoice) + if (!terms.size) { + setStatus('gaEnrichStatus', [{ level: 'error', text: 'The chosen annotation has no groups.' }]) + return + } + // universe + let universe + if (bgMode === 'view') universe = shown + else { + universe = new Set() + terms.forEach((set) => set.forEach((id) => universe.add(id))) + shown.forEach((id) => universe.add(id)) + } + // test sets + const tests = [] + if (setsMode === 'selected') { + const sel = cy + .nodes(':selected') + .map((n) => n.id()) + .filter((id) => universe.has(id)) + if (!sel.length) { + setStatus('gaEnrichStatus', [ + { + level: 'error', + text: 'Select nodes in the view first (Shift-drag or Shift-click), or test each group of an annotation.', + }, + ]) + return + } + tests.push({ name: 'Selected nodes', members: new Set(sel) }) + } else { + termSetsFor(document.getElementById('gaSetsFrom').value).forEach((set, name) => { + const members = new Set([...set].filter((id) => universe.has(id))) + if (members.size >= minOverlap) tests.push({ name, members }) + }) + } + const N = universe.size + const termList = [...terms] + .map(([name, set]) => ({ name, set: new Set([...set].filter((id) => universe.has(id))) })) + .filter((t) => t.set.size >= minOverlap) + const rows = [] + let tested = 0 + tests.forEach((t) => { + const n = t.members.size + const cand = termList + .map((term) => { + let k = 0 + term.set.forEach((id) => { + if (t.members.has(id)) k++ + }) + return { + term: term.name, + k, + K: term.set.size, + genes: [...term.set].filter((id) => t.members.has(id)), + } + }) + .filter( + (c) => + c.k >= 1 && + !( + setsMode === 'groups' && + c.term === t.name && + document.getElementById('gaSetsFrom').value === termChoice + ) + ) + const ps = cand.map((c) => hypergeomUpper(c.k, N, c.K, n)) + const qs = benjaminiHochberg(ps) + tested += cand.length + cand.forEach((c, i) => { + if (c.k < minOverlap || qs[i] > fdrMax) return + rows.push({ + set: t.name, + n, + term: c.term, + k: c.k, + K: c.K, + N, + fold: c.k / n / (c.K / N), + p: ps[i], + fdr: qs[i], + genes: c.genes, + }) + }) + }) + rows.sort((a, b) => a.fdr - b.fdr || a.p - b.p) + groupAnalysisState.enrichment = { rows, tests: tests.length, tested, N, termChoice, setsMode } + renderEnrichment() + setStatus('gaEnrichStatus', [ + { + level: rows.length ? 'ok' : 'warn', + text: `Tested ${plural(tested, 'term–set pair')} in ${plural(tests.length, 'set')} against a background of ${plural(N, 'node')}: ${rows.length ? `${plural(rows.length, 'enriched term')} with FDR ≤ ${fdrMax}` : `no term reaches FDR ≤ ${fdrMax}`}.`, + }, + ]) + document.getElementById('btnGaEnrichTsv').disabled = !rows.length +} + +function renderEnrichment() { + const st = groupAnalysisState.enrichment + const root = document.getElementById('gaEnrichResults') + if (!st || !st.rows.length) { + root.innerHTML = '' + return + } + const fp = (v) => (v < 1e-3 ? v.toExponential(2) : v.toFixed(4)) + const shown = st.rows.slice(0, 300) + root.innerHTML = `
    + ${st.setsMode === 'groups' ? '' : ''} + ${shown + .map( + (r, i) => ` + ${st.setsMode === 'groups' ? `` : ''} + + + ` + ) + .join('')} +
    SetTermIn setTerm sizeFoldp-valueFDR
    ${escapeHtml(r.set)}${escapeHtml(r.term)}${r.k} / ${r.n}${r.K} / ${r.N}${r.fold.toFixed(2)}${fp(r.p)}${fp(r.fdr)}
    ${st.rows.length > shown.length ? `

    Showing the first ${shown.length} of ${st.rows.length}; the download has all.

    ` : ''}` + root.querySelectorAll('.ga-show').forEach((b) => + b.addEventListener('click', () => { + const r = shown[+b.dataset.row] + cy.batch(() => { + cy.$(':selected').unselect() + r.genes.forEach((id) => cy.$id(id).select()) + }) + switchTab('network') + const sel = cy.nodes(':selected') + if (sel.length) cy.animate({ fit: { eles: sel, padding: 80 } }, { duration: 300 }) + }) + ) +} + +function enrichmentTsv() { + const st = groupAnalysisState.enrichment + if (!st) return + const lines = [ + [ + 'Set', + 'Set size', + 'Term', + 'Overlap', + 'Term size', + 'Background', + 'Fold enrichment', + 'p-value', + 'FDR (BH)', + 'Nodes', + ].join('\t'), + ...st.rows.map((r) => + [ + r.set, + r.n, + r.term, + r.k, + r.K, + r.N, + r.fold.toFixed(4), + r.p.toExponential(4), + r.fdr.toExponential(4), + r.genes.join(','), + ].join('\t') + ), + ] + downloadText('enrichment.tsv', lines.join('\n') + '\n') +} + +/* ============================================================ + COMPARISON AS A NETWORK + The compared networks merged into one view: each edge's channel says + which networks contain it (A only, B only, A + B, ...), and each node's + group says which networks contain the node. + ============================================================ */ +function patternLabel(mask, nets) { + const letters = nets.map((_, i) => String.fromCharCode(65 + i)).filter((_, i) => mask & (1 << i)) + if (letters.length === nets.length) return nets.length === 2 ? 'Shared' : 'In all' + return letters.length === 1 ? `${letters[0]} only` : letters.join(' + ') +} + +function openComparisonNetwork() { + const nets = compareState.results + if (!nets || nets.length < 2) return + const nodeMask = new Map() + nets.forEach((net, i) => + net.ids.forEach((id) => nodeMask.set(id, (nodeMask.get(id) || 0) | (1 << i))) + ) + const edgeMask = new Map() + nets.forEach((net, i) => + net.edgeSet.forEach((key) => edgeMask.set(key, (edgeMask.get(key) || 0) | (1 << i))) + ) + const full = (1 << nets.length) - 1 + const colorFor = (mask) => { + if (mask === full) return '#9ca3af' + const bits = nets.map((_, i) => i).filter((i) => mask & (1 << i)) + if (bits.length === 1) return nets[bits[0]].color + return colorAtIndex(NODE_PALETTES.vivid, mask + 3) + } + const edgeColors = {}, + nodeColors = {} + const edges = [...edgeMask].map(([key, mask], i) => { + const [a, b, dir] = key.split('\t') + const type = patternLabel(mask, nets) + edgeColors[type] = colorFor(mask) + return { + id: 'c' + i, + source: a, + target: b, + type, + directed: dir === 'directed', + 'in networks': type, + } + }) + const nodes = [...nodeMask].map(([id, mask]) => { + const g = `Nodes: ${patternLabel(mask, nets)}` + nodeColors[g] = colorFor(mask) + return { id, groups: [g] } + }) + // groups and channels in a readable order: single networks first, shared last + const order = (masks) => + [...new Set(masks)].sort( + (x, y) => (x === full) - (y === full) || popcount(x) - popcount(y) || x - y + ) + const popcount = (x) => { + let c = 0 + while (x) { + c += x & 1 + x >>= 1 + } + return c + } + const data = { + nodes, + edges, + nodeColors, + edgeColors, + groupOrder: order([...nodeMask.values()]).map((m) => `Nodes: ${patternLabel(m, nets)}`), + groupAttrs: Object.fromEntries( + nets.map((net, i) => [ + `Nodes: ${String.fromCharCode(65 + i)} only`, + { description: `Only in ${net.name}` }, + ]) + ), + config: { + layoutSelect: 'fr', + edgeCurveStyle: 'bezier', + legendShow: true, + legendGroups: true, + legendChannels: true, + legendTitle: 'Network comparison', + nodeFillSelect: 'groups', + }, + legendExtra: [ + { + title: 'Networks', + items: nets.map((net, i) => ({ + kind: 'glyph', + label: `${String.fromCharCode(65 + i)}: ${net.name}`, + color: net.color, + })), + }, + ], + } + const title = nets.map((n, i) => `${String.fromCharCode(65 + i)}`).join(' vs ') + openInNewView(`Comparison ${title}: ${nets.map((n) => n.name).join(' / ')}`.slice(0, 80), () => { + loadData(data) + applyGroupColorOverrides(data.nodeColors) + }) + switchTab('network') + const counts = order([...edgeMask.values()]).map( + (m) => `${patternLabel(m, nets)}: ${[...edgeMask.values()].filter((x) => x === m).length}` + ) + setStatus('cmpStatus', [ + { + level: 'ok', + text: `Opened the merged network with ${plural(nodes.length, 'node')} and ${plural(edges.length, 'edge')} (${counts.join(', ')}). Tick channels in the Display tab to show or hide each part.`, + }, + ]) +} + +/* ============================================================ + LOCAL EXPLORATION + Neighbourhood of a node (or the selected nodes) and all shortest paths + between two selected nodes, each opened as a new view that keeps the + nodes' positions, groups, colors and attributes. + ============================================================ */ +function adjacencyForPaths() { + // follows edge directions where edges are directed + const out = new Map(), + inn = new Map() + const add = (m, a, b) => { + if (!m.has(a)) m.set(a, new Set()) + m.get(a).add(b) + } + shownEdges(true).forEach((e) => { + const s = e.data('source'), + t = e.data('target') + if (s === t) return + add(out, s, t) + add(inn, t, s) + if (!edgeIsDirected(e)) { + add(out, t, s) + add(inn, s, t) + } + }) + return { out, inn } +} + +function bfsDistances(start, adj, limit = Infinity) { + const dist = new Map([[start, 0]]) + const q = [start] + for (let h = 0; h < q.length; h++) { + const v = q[h] + const d = dist.get(v) + if (d >= limit) continue + ;(adj.get(v) || []).forEach((w) => { + if (!dist.has(w)) { + dist.set(w, d + 1) + q.push(w) + } + }) + } + return dist +} + +function openSubnetworkView(name, keepIds, marks, keepEdge) { + const data = snapshotData() + const keep = new Set(keepIds) + data.nodes = data.nodes + .filter((n) => keep.has(n.id)) + .map((n) => (marks.has(n.id) ? { ...n, role: marks.get(n.id) } : n)) + const shownEdgeIds = new Set(shownEdges(true).map((e) => e.id())) + data.edges = data.edges.filter( + (e) => + shownEdgeIds.has(e.id) && + keep.has(e.source) && + keep.has(e.target) && + (!keepEdge || keepEdge(e)) + ) + const used = new Set(data.nodes.flatMap((n) => n.groups)) + data.groupOrder = data.groupOrder.filter((g) => used.has(g)) + const positions = {} + cy.nodes().forEach((n) => { + if (keep.has(n.id())) positions[n.id()] = { ...n.position() } + }) + const config = viewSettings() + openInNewView(name, () => { + setControls(config) + loadData(data, { positions }) + refreshAllDerivedUI() + }) + switchTab(net3dActiveTab()) + cy.batch(() => + cy.nodes().forEach((n) => { + if (marks.has(n.id())) n.select() + }) + ) + cy.fit(undefined, 60) +} + +function net3dActiveTab() { + return net3d.active ? 'network3d' : 'network' +} + +function openNeighbourhood(ids, steps) { + const { out, inn } = adjacencyForPaths() + // neighbours in either direction + const both = new Map() + ;[out, inn].forEach((m) => + m.forEach((set, k) => { + if (!both.has(k)) both.set(k, new Set()) + set.forEach((v) => both.get(k).add(v)) + }) + ) + const keep = new Set() + ids.forEach((id) => bfsDistances(id, both, steps).forEach((_, v) => keep.add(v))) + const marks = new Map(ids.map((id) => [id, 'center'])) + const label = ids.length === 1 ? ids[0] : `${ids.length} nodes` + openSubnetworkView(`${label}: ${steps}-step neighbourhood`, [...keep], marks) + toast(`Opened the ${steps}-step neighbourhood of ${label}: ${plural(keep.size, 'node')}.`) +} + +function openShortestPaths(a, b) { + const { out, inn } = adjacencyForPaths() + const ds = bfsDistances(a, out), + dt = bfsDistances(b, inn) + if (!ds.has(b)) { + toast(`No path leads from ${a} to ${b} in the shown part of the network.`, 'warn') + return + } + const d = ds.get(b) + const keep = [...ds.keys()].filter((v) => dt.has(v) && ds.get(v) + dt.get(v) === d) + const keepSet = new Set(keep) + // only edges that lie on a shortest path + const onPath = (e) => { + const s = e.source, + t = e.target + const fwd = ds.has(s) && dt.has(t) && ds.get(s) + 1 + dt.get(t) === d + const rev = !e.directed && ds.has(t) && dt.has(s) && ds.get(t) + 1 + dt.get(s) === d + return keepSet.has(s) && keepSet.has(t) && (fwd || rev) + } + const marks = new Map([ + [a, 'source'], + [b, 'target'], + ]) + openSubnetworkView(`Shortest paths ${a} → ${b}`, keep, marks, onPath) + const count = countShortestPaths(a, b, out, ds, d) + toast( + `Opened ${plural(count, 'shortest path')} of length ${d} from ${a} to ${b} (${plural(keep.length, 'node')}).` + ) +} + +function countShortestPaths(a, b, out, ds, d) { + const ways = new Map([[a, 1]]) + const layers = [...ds].filter(([, x]) => x <= d).sort((x, y) => x[1] - y[1]) + layers.forEach(([v, x]) => { + const w = ways.get(v) || 0 + ;(out.get(v) || []).forEach((u) => { + if (ds.get(u) === x + 1) ways.set(u, (ways.get(u) || 0) + w) + }) + }) + return ways.get(b) || 0 +} + +export function toast(text, level = 'ok') { + let el = document.getElementById('toast') + if (!el) { + el = document.createElement('div') + el.id = 'toast' + el.setAttribute('role', 'status') + el.setAttribute('aria-live', 'polite') + document.getElementById('canvas').appendChild(el) + } + el.className = `toast ${level}` + el.textContent = text + el.hidden = false + clearTimeout(toast.timer) + toast.timer = setTimeout(() => { + el.hidden = true + }, 6000) +} + +// remembers the order in which nodes were selected (paths go first -> second) +const selectionOrder = [] + +/* ============================================================ + TIME SERIES AND CONDITIONS + With several numeric columns (time points, conditions) the view can + step through them, by hand or as an animation, with the layout fixed + and, by default, one color scale for all steps. Columns of other + numeric expression files can be added as further steps. + ============================================================ */ +const frameState = { playing: false, timer: null } + +function frameColumns() { + return valueColumns() +} + +function numericLibraryEntries() { + return normaLibrary.colors.filter((e) => e.parsed.numeric) +} + +export function updateFrameControls() { + const box = document.getElementById('valueFrames') + if (!box) return + const cols = viewHasValues() ? frameColumns() : [] + const otherFiles = numericLibraryEntries().length + box.hidden = !(cols.length > 1 || (cols.length && otherFiles > 1)) + const slider = document.getElementById('frameSlider') + slider.max = Math.max(0, cols.length - 1) + const i = Math.max(0, cols.indexOf(document.getElementById('valueColumn').value)) + slider.value = i + document.getElementById('frameLabel').textContent = cols.length + ? `Step ${i + 1} of ${cols.length}: ${cols[i]}` + : '' + document.getElementById('btnFramePlay').textContent = frameState.playing ? '❚❚ Pause' : '▶ Play' + const badge = document.getElementById('frameBadge') + badge.hidden = + box.hidden || + (nodeFillMode() !== 'values' && document.getElementById('sizeMetric').value !== 'value') + badge.textContent = cols.length ? `${cols[i]} · ${i + 1} / ${cols.length}` : '' +} + +function showFrame(i) { + const cols = frameColumns() + if (!cols.length) return + const k = ((i % cols.length) + cols.length) % cols.length + document.getElementById('valueColumn').value = cols[k] + applyValueColors() + if (net3d.active) requestRender3d() +} + +function stepFrame(delta) { + const cols = frameColumns() + showFrame(Math.max(0, cols.indexOf(document.getElementById('valueColumn').value)) + delta) +} + +function toggleFramePlay() { + frameState.playing = !frameState.playing + clearInterval(frameState.timer) + if (frameState.playing) { + if (nodeFillMode() !== 'values') { + document.getElementById('nodeFillSelect').value = 'values' + document + .getElementById('nodeFillSelect') + .dispatchEvent(new Event('change', { bubbles: true })) + } + const ms = parseInt(document.getElementById('frameSpeed').value, 10) || 1000 + frameState.timer = setInterval(() => { + if (!cy.nodes().length || !viewHasValues()) { + toggleFramePlay() + return + } + stepFrame(1) + }, ms) + } + updateFrameControls() +} + +export function stopFramePlay() { + if (frameState.playing) toggleFramePlay() +} + +// Adds the columns of every numeric expression file in Files as steps. +function addFramesFromFiles() { + const entries = numericLibraryEntries() + if (!entries.length) return + const nodeIds = new Set(cy.nodes().map((n) => n.id())) + const existing = new Set(frameColumns()) + const added = [] + cy.batch(() => { + entries.forEach((entry) => { + entry.parsed.columns.forEach((col) => { + const name = entry.parsed.columns.length === 1 ? entry.name : `${entry.name}: ${col}` + // the view's own numeric file is already there under its plain column names + const ownFile = S.currentLibView && S.currentLibView.colors === entry.id + if (existing.has(name) || (ownFile && existing.has(col))) return + existing.add(name) + added.push(name) + cy.nodes().forEach((n) => { + const row = entry.parsed.values.get(n.id()) + const vals = { ...(n.data('values') || {}) } + vals[name] = row ? row[col] : null + n.data('values', vals) + }) + }) + }) + }) + applyValueColors() + setStatus('frameStatus', [ + { + level: added.length ? 'ok' : 'warn', + text: added.length + ? `Added ${plural(added.length, 'step')}: ${listSample(added)}.` + : 'All numeric files are already steps.', + }, + ]) + updateFrameControls() + void nodeIds +} + +/* ============================================================ + EXPORT TO OTHER TOOLS + GraphML (yEd, Cytoscape, igraph, NetworkX), GEXF 1.3 with colors, + sizes and positions (Gephi), SIF (Cytoscape) and Cytoscape JSON + (.cyjs, Cytoscape desktop and Cytoscape.js). + ============================================================ */ +function xmlEsc(v) { + return String(v).replace( + /[&<>"']/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] + ) +} + +function exportModel() { + const nodes = exportNodes() + const idSet = new Set(nodes.map((n) => n.id())) + const edges = exportEdges().filter( + (e) => idSet.has(e.data('source')) && idSet.has(e.data('target')) + ) + const nodeAttrKeys = new Set(), + edgeAttrKeys = new Set(), + valueKeys = new Set() + nodes.forEach((n) => { + Object.keys(n.data('attrs') || {}).forEach((k) => nodeAttrKeys.add(k)) + Object.keys(n.data('values') || {}).forEach((k) => valueKeys.add(k)) + }) + edges.forEach((e) => Object.keys(e.data('attrs') || {}).forEach((k) => edgeAttrKeys.add(k))) + const kind = (list, key, get) => + list.every((x) => { + const v = get(x) + return v === undefined || v === null || typeof v === 'number' + }) + ? 'double' + : 'string' + return { + nodes, + edges, + nodeAttrs: [...nodeAttrKeys].map((k) => ({ + key: k, + type: kind(nodes, k, (n) => (n.data('attrs') || {})[k]), + })), + edgeAttrs: [...edgeAttrKeys].map((k) => ({ + key: k, + type: kind(edges, k, (e) => (e.data('attrs') || {})[k]), + })), + valueKeys: [...valueKeys], + weighted: edges.some((e) => typeof e.data('weight') === 'number'), + anyDirected: edges.some((e) => edgeIsDirected(e)), + } +} + +function nodeGroupsText(n) { + return effectiveGroupsFor(n) + .filter((g) => g !== UNGROUPED) + .map(groupLabel) + .join(';') +} + +function attrText(v) { + return v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v) +} + +function channelLabel(t) { + return (EDGE_TYPES[t] || { label: t }).label +} + +function toGraphML() { + const m = exportModel() + const keys = [ + ['n_label', 'node', 'label', 'string'], + ['n_groups', 'node', 'groups', 'string'], + ['n_color', 'node', 'color', 'string'], + ['n_size', 'node', 'size', 'double'], + ['n_x', 'node', 'x', 'double'], + ['n_y', 'node', 'y', 'double'], + ...m.valueKeys.map((k, i) => [`n_v${i}`, 'node', `value: ${k}`, 'double']), + ...m.nodeAttrs.map((a, i) => [`n_a${i}`, 'node', a.key, a.type]), + ['e_channel', 'edge', 'channel', 'string'], + ['e_color', 'edge', 'color', 'string'], + ...(m.weighted ? [['e_weight', 'edge', 'weight', 'double']] : []), + ...m.edgeAttrs.map((a, i) => [`e_a${i}`, 'edge', a.key, a.type]), + ] + const lines = [ + '', + '', + ' ', + ...keys.map( + ([id, forWhat, name, type]) => + ` ` + ), + ` `, + ] + const d = (k, v) => + v === '' || v === null || v === undefined || (typeof v === 'number' && !Number.isFinite(v)) + ? '' + : `${xmlEsc(v)}` + m.nodes.forEach((n) => { + const p = n.position() + const vals = n.data('values') || {} + const attrs = n.data('attrs') || {} + lines.push( + ` ` + + d('n_label', n.id()) + + d('n_groups', nodeGroupsText(n)) + + d('n_color', hexColor(n.style('background-color'))) + + d('n_size', Math.round(n.width() * 100) / 100) + + d('n_x', Math.round(p.x * 100) / 100) + + d('n_y', Math.round(p.y * 100) / 100) + + m.valueKeys.map((k, i) => d(`n_v${i}`, vals[k])).join('') + + m.nodeAttrs.map((a, i) => d(`n_a${i}`, attrText(attrs[a.key]))).join('') + + '' + ) + }) + m.edges.forEach((e, i) => { + const attrs = e.data('attrs') || {} + lines.push( + ` ` + + d('e_channel', channelLabel(e.data('type'))) + + d('e_color', hexColor(e.style('line-color'))) + + (m.weighted ? d('e_weight', e.data('weight')) : '') + + m.edgeAttrs.map((a, k) => d(`e_a${k}`, attrText(attrs[a.key]))).join('') + + '' + ) + }) + lines.push(' ', '') + return lines.join('\n') + '\n' +} + +function toGEXF() { + const m = exportModel() + const nodeAttrDefs = [ + ['groups', 'string'], + ...m.valueKeys.map((k) => [`value: ${k}`, 'double']), + ...m.nodeAttrs.map((a) => [a.key, a.type]), + ] + const edgeAttrDefs = [['channel', 'string'], ...m.edgeAttrs.map((a) => [a.key, a.type])] + const lines = [ + '', + '', + ` NORMA 3.0${xmlEsc((activeView() || {}).name || 'NORMA network')}`, + ` `, + ' ', + ...nodeAttrDefs.map( + ([k, t], i) => ` ` + ), + ' ', + ' ', + ...edgeAttrDefs.map( + ([k, t], i) => ` ` + ), + ' ', + ' ', + ] + const att = (i, v) => + v === '' || v === null || v === undefined || (typeof v === 'number' && !Number.isFinite(v)) + ? '' + : `` + m.nodes.forEach((n) => { + const [r, g, b] = rgbOf(n.style('background-color')) + const p = n.position() + const vals = n.data('values') || {}, + attrs = n.data('attrs') || {} + const values = [ + nodeGroupsText(n), + ...m.valueKeys.map((k) => vals[k]), + ...m.nodeAttrs.map((a) => attrText(attrs[a.key])), + ] + lines.push( + ` ${values.map((v, i) => att(i, v)).join('')}` + + `` + ) + }) + lines.push(' ', ' ') + m.edges.forEach((e, i) => { + const [r, g, b] = rgbOf(e.style('line-color')) + const attrs = e.data('attrs') || {} + const values = [channelLabel(e.data('type')), ...m.edgeAttrs.map((a) => attrText(attrs[a.key]))] + const w = typeof e.data('weight') === 'number' ? ` weight="${e.data('weight')}"` : '' + lines.push( + ` ` + + `${values.map((v, k) => att(k, v)).join('')}` + ) + }) + lines.push(' ', ' ', '') + return lines.join('\n') + '\n' +} + +function toSIF() { + const m = exportModel() + const lines = [] + const connected = new Set() + const clean = (s) => String(s).replace(/[\t\r\n]+/g, ' ') + m.edges.forEach((e) => { + connected.add(e.data('source')) + connected.add(e.data('target')) + lines.push( + [ + clean(e.data('source')), + clean(channelLabel(e.data('type'))).replace(/\s+/g, '_') || 'pp', + clean(e.data('target')), + ].join('\t') + ) + }) + m.nodes.forEach((n) => { + if (!connected.has(n.id())) lines.push(clean(n.id())) + }) + return lines.join('\n') + '\n' +} + +function toCyJS() { + const m = exportModel() + const v = activeView() + const json = { + format_version: '1.0', + generated_by: 'NORMA 3.0', + target_cytoscapejs_version: '~3.0', + data: { + name: v ? v.name : 'NORMA network', + shared_name: v ? v.name : 'NORMA network', + selected: true, + }, + elements: { + nodes: m.nodes.map((n) => { + const p = n.position() + return { + data: { + id: n.id(), + name: n.id(), + shared_name: n.id(), + groups: nodeGroupsText(n), + color: hexColor(n.style('background-color')), + size: Math.round(n.width() * 100) / 100, + ...Object.fromEntries( + Object.entries(n.data('values') || {}).map(([k, x]) => [`value: ${k}`, x]) + ), + ...(n.data('attrs') || {}), + }, + position: { x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100 }, + } + }), + edges: m.edges.map((e, i) => ({ + data: { + id: `e${i}`, + source: e.data('source'), + target: e.data('target'), + interaction: channelLabel(e.data('type')), + shared_interaction: channelLabel(e.data('type')), + name: `${e.data('source')} (${channelLabel(e.data('type'))}) ${e.data('target')}`, + directed: edgeIsDirected(e), + color: hexColor(e.style('line-color')), + ...(typeof e.data('weight') === 'number' ? { weight: e.data('weight') } : {}), + ...(e.data('attrs') || {}), + }, + })), + }, + } + return JSON.stringify(json, null, 1) +} + +function exportOtherFormat(kind) { + if (!cy.nodes().length) { + setStatus('otherExportStatus', [{ level: 'error', text: 'Show a network first.' }]) + return + } + const v = activeView() + const stem = v ? fileStem(v.name) : 'network' + const spec = { + graphml: ['GraphML', 'graphml', toGraphML], + gexf: ['GEXF', 'gexf', toGEXF], + sif: ['SIF', 'sif', toSIF], + cyjs: ['Cytoscape JSON', 'cyjs', toCyJS], + }[kind] + const text = spec[2]() + downloadText(`${stem}.${spec[1]}`, text) + const m = exportModel() + setStatus('otherExportStatus', [ + { + level: 'ok', + text: `Saved ${stem}.${spec[1]} (${spec[0]}): ${plural(m.nodes.length, 'node')}, ${plural(m.edges.length, 'edge')}${exportShownOnly() ? ', ticked groups and channels only' : ''}.`, + }, + ]) +} + +/* ============================================================ + SESSIONS + One file with every view (data, positions, settings, 3D camera) and + every file in Files (including STRING extras), to continue later. + ============================================================ */ +const SESSION_FORMAT = 'norma3-session' + +function sessionObject() { + captureActiveView() + const lib = {} + Object.keys(normaLibrary).forEach((kind) => { + lib[kind] = normaLibrary[kind].map((e) => { + const { parsed, ...rest } = e + return rest + }) + }) + return { + format: SESSION_FORMAT, + version: 1, + savedAt: new Date().toISOString(), + app: 'NORMA 3.0', + activeViewId: S.activeViewId, + theme: document.getElementById('themeSelect').value, + views: views.map((v) => ({ + id: v.id, + name: v.name, + autoName: v.autoName, + data: v.data, + selection: v.selection, + state: v.state, + ...(v.needsRefresh ? { needsRefresh: v.needsRefresh } : {}), + })), + library: lib, + edgeTypes: JSON.parse(JSON.stringify(EDGE_TYPES)), + } +} + +function saveSession() { + const s = sessionObject() + const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-') + downloadText(`norma-session-${stamp}.json`, JSON.stringify(s)) + setStatus('sessionSaveStatus', [ + { + level: 'ok', + text: `Saved the session: ${plural(s.views.length, 'view')} and ${plural( + Object.values(s.library).reduce((n, l) => n + l.length, 0), + 'file' + )}.`, + }, + ]) +} + +export function isSessionObject(obj) { + return obj && obj.format === SESSION_FORMAT && Array.isArray(obj.views) +} + +export function loadSession(obj) { + if (!isSessionObject(obj)) throw new Error('This is not a NORMA session file.') + stopFramePlay() + // files + const kinds = Object.keys(normaLibrary) + const rebuilt = {} + let maxSeq = S.normaEntrySeq + kinds.forEach((kind) => { + rebuilt[kind] = [] + ;((obj.library && obj.library[kind]) || []).forEach((saved) => { + const parsed = NORMA_PARSERS[kind](saved.text, saved.options || {}) + rebuilt[kind].push({ ...saved, parsed }) + const n = parseInt(String(saved.id).replace(/\D/g, ''), 10) + if (Number.isFinite(n)) maxSeq = Math.max(maxSeq, n) + }) + }) + kinds.forEach((kind) => { + normaLibrary[kind].length = 0 + normaLibrary[kind].push(...rebuilt[kind]) + }) + S.normaEntrySeq = maxSeq + if (obj.edgeTypes) + Object.entries(obj.edgeTypes).forEach(([k, t]) => { + const safe = { + ...t, + color: + sanitizeColor(t && t.color) || + (EDGE_TYPES[k] && EDGE_TYPES[k].color) || + nextAutoEdgeColor(), + } + if (!EDGE_TYPES[k]) EDGE_TYPES[k] = safe + else Object.assign(EDGE_TYPES[k], safe) + }) + // views + views.length = 0 + let maxView = S.viewSeq + obj.views.forEach((v) => { + views.push({ + id: v.id, + name: v.name, + autoName: v.autoName, + data: v.data, + selection: v.selection || { networks: [], annotation: '', colors: '' }, + state: v.state || { config: { ...S.DEFAULT_VIEW_CONFIG } }, + needsRefresh: v.needsRefresh || null, + }) + const n = parseInt(String(v.id).replace(/\D/g, ''), 10) + if (Number.isFinite(n)) maxView = Math.max(maxView, n) + }) + S.viewSeq = maxView + if (!views.length) { + S.activeViewId = null + createView('Untitled view') + } else { + S.activeViewId = views.some((v) => v.id === obj.activeViewId) ? obj.activeViewId : views[0].id + const v = activeView() + libSelection.networks = new Set(v.selection.networks || []) + libSelection.annotation = v.selection.annotation || '' + libSelection.colors = v.selection.colors || '' + S.historySuspended++ + try { + restoreView(v) + } finally { + S.historySuspended-- + } + S.dataCache = { version: dataVersion, data: v.data } + } + if (obj.theme) { + document.getElementById('themeSelect').value = obj.theme + applyTheme(obj.theme) + } + renderLibraryLists() + setHistoryBaseline() + renderViewBar() + updateUndoButtons() + return { views: views.length, files: kinds.reduce((n, k) => n + normaLibrary[k].length, 0) } +} + +async function openSessionFile(file) { + try { + const obj = JSON.parse(await readFileText(file)) + const r = loadSession(obj) + setStatus('sessionStatus', [ + { + level: 'ok', + text: `Opened the session "${file.name}": ${plural(r.views, 'view')} and ${plural(r.files, 'file')}.`, + }, + ]) + } catch (err) { + setStatus('sessionStatus', [ + { level: 'error', text: `The session couldn't be opened: ${err.message}` }, + ]) + } +} + +/* ============================================================ + WEBGL DRAWING (experimental) + Cytoscape.js can draw with WebGL, which is much faster on large + networks. It is chosen before the network canvas is created, so the + setting takes effect after reloading the page. + ============================================================ */ +export function webglPreference() { + try { + const q = new URLSearchParams(location.search).get('webgl') + if (q !== null) return q === '1' || q === 'true' + return localStorage.getItem('norma3-webgl') === '1' + } catch (e) { + return false + } +} + +/* ============================================================ + RUNTIME TABLE (Network Profiler) + Times the main steps on random networks of growing size, for a + supplementary table: reading the network file, building the network, + weighted layout, edge bundling, profiling, Louvain and the group + separation score. Nothing on screen changes. + ============================================================ */ +const RUNTIME_SIZES = [100, 500, 1000, 2500, 5000, 10000] + +const runtimeState = { rows: [], running: false, cancel: false } + +async function runRuntimeTable() { + if (runtimeState.running) { + runtimeState.cancel = true + return + } + const btn = document.getElementById('btnRuntime') + const sizes = RUNTIME_SIZES.filter( + (n) => n <= (parseInt(document.getElementById('runtimeMax').value, 10) || 5000) + ) + runtimeState.running = true + runtimeState.cancel = false + btn.textContent = 'Stop' + runtimeState.rows = [] + const time = async (fn) => { + const t = performance.now() + const r = await fn() + return [performance.now() - t, r] + } + try { + // larger networks take much longer: weigh each size by n^1.5 + const weightOf = (n) => Math.pow(n, 1.5) + const totalWeight = sizes.reduce((a, n) => a + weightOf(n), 0) + let doneWeight = 0 + for (const n of sizes) { + if (runtimeState.cancel) break + const text = `Timing a network of ${n.toLocaleString()} nodes…` + const show = (f) => + setStatus('runtimeStatus', [ + { level: 'busy', text, progress: (doneWeight + weightOf(n) * f) / totalWeight }, + ]) + show(0) + await nextPaint() + const demo = generateRandomNetwork(n, { seed: n + 3 }) + const texts = demoToNormaTexts(demo) + const row = { nodes: n } + const [tParse, parsed] = await time(() => parseNormaNetwork(texts.network, {})) + row.edges = parsed.edges.length + row.parse = tParse + const [tBuild, headless] = await time(() => + cytoscape({ + headless: true, + styleEnabled: false, + elements: [ + ...demo.nodes.map((x) => ({ data: { id: x.id } })), + ...demo.edges.map((e, i) => ({ + data: { id: 'r' + i, source: e.source, target: e.target }, + })), + ], + }) + ) + row.build = tBuild + headless.destroy() + const edges = demo.edges.map((e) => ({ source: e.source, target: e.target, weight: 1 })) + const ids = demo.nodes.map((x) => x.id) + const [tLayout, pos] = await time(() => frLayoutAsync(ids, edges, (f) => show(0.1 + 0.4 * f))) + row.layout = tLayout + if (runtimeState.cancel) break + const segs = demo.edges.map((e) => ({ + sx: pos[e.source].x * 40, + sy: pos[e.source].y * 40, + tx: pos[e.target].x * 40, + ty: pos[e.target].y * 40, + })) + if (segs.length <= BUNDLE_MAX_EDGES) { + show(0.5) + const [tBundle] = await time(() => + bundleAsync(segs, { threshold: 0.6, iterations: 60 }, (f) => show(0.5 + 0.4 * f)) + ) + row.bundle = tBundle + } else row.bundle = NaN + show(0.9) + await nextPaint() + const g = simpleGraph( + ids, + demo.edges.map((e) => [e.source, e.target]) + ) + const [tProfile] = await time(() => profileGraph(g)) + row.profile = tProfile + const [tLouvain] = await time(() => louvain(g)) + row.louvain = tLouvain + const groupsOf = Object.fromEntries(demo.nodes.map((x) => [x.id, x.groups || []])) + const [tSep] = await time(() => groupSeparation(pos, groupsOf)) + row.separation = tSep + runtimeState.rows.push(row) + renderRuntimeTable() + doneWeight += weightOf(n) + await new Promise((r) => setTimeout(r, 30)) + } + setStatus('runtimeStatus', [ + { + level: runtimeState.cancel ? 'warn' : 'ok', + text: `${runtimeState.cancel ? 'Stopped' : 'Done'}: timed ${plural(runtimeState.rows.length, 'network size')} in this browser (${navigator.hardwareConcurrency || '?'} logical processors). Rendering time depends on the screen and is not included.`, + }, + ]) + document.getElementById('btnRuntimeTsv').disabled = !runtimeState.rows.length + } finally { + runtimeState.running = false + btn.textContent = 'Time the main steps' + } +} + +const RUNTIME_COLUMNS = [ + ['parse', 'Read file'], + ['build', 'Build network'], + ['layout', 'Weighted layout'], + ['bundle', 'Edge bundling'], + ['profile', 'Profile statistics'], + ['louvain', 'Louvain'], + ['separation', 'Separation score'], +] + +function renderRuntimeTable() { + const root = document.getElementById('runtimeResults') + const fmt = (v) => + !Number.isFinite(v) ? '—' : v < 1000 ? `${Math.round(v)} ms` : `${(v / 1000).toFixed(2)} s` + root.innerHTML = `
    + ${RUNTIME_COLUMNS.map(([, l]) => ``).join('')} + ${runtimeState.rows.map((r) => `${RUNTIME_COLUMNS.map(([k]) => ``).join('')}`).join('')} +
    NodesEdges${l}
    ${r.nodes.toLocaleString()}${r.edges.toLocaleString()}${fmt(r[k])}
    ` +} + +function runtimeTsv() { + const lines = [ + ['Nodes', 'Edges', ...RUNTIME_COLUMNS.map(([, l]) => `${l} (ms)`)].join('\t'), + ...runtimeState.rows.map((r) => + [ + r.nodes, + r.edges, + ...RUNTIME_COLUMNS.map(([k]) => (Number.isFinite(r[k]) ? r[k].toFixed(1) : '')), + ].join('\t') + ), + ] + lines.push( + `# ${navigator.userAgent}; ${navigator.hardwareConcurrency || '?'} logical processors; WebGL drawing ${WEBGL_ACTIVE ? 'on' : 'off'}` + ) + downloadText('norma-runtime.tsv', lines.join('\n') + '\n') +} + +/* ---------- Network Comparison -> Arena3D ---------- + Each compared network is one layer holding its own nodes and edges. + Every node keeps the same position in every layer (one layout of all + networks together), so layers line up. Between layers, the common + edges (and, if chosen, the copies of common nodes) link the layers: + an edge present in networks A and B also runs from its source in + layer A to its target in layer B. */ +export async function buildCompareArena3dModel() { + const nets = compareState.results + if (!nets || nets.length < 2) throw new Error('Compare at least two networks first.') + if (nets.length > ARENA3D_MAX_LAYERS) + throw new Error(`Arena3D takes up to ${ARENA3D_MAX_LAYERS} layers.`) + const between = document.getElementById('cmpArenaBetween').value + const letter = (i) => String.fromCharCode(65 + i) + const layerName = nets.map((n, i) => arenaSafe(`${letter(i)} ${n.name}`).slice(0, 60)) + // union network and one shared layout + const nodeMask = new Map(), + edgeMask = new Map() + nets.forEach((net, i) => { + net.ids.forEach((id) => nodeMask.set(id, (nodeMask.get(id) || 0) | (1 << i))) + net.edgeSet.forEach((k) => edgeMask.set(k, (edgeMask.get(k) || 0) | (1 << i))) + }) + const ids = [...nodeMask.keys()] + if (ids.length > MAX_NETWORK_NODES) + throw new Error( + `The compared networks have ${ids.length.toLocaleString('en-US')} nodes together; NORMA handles up to ${MAX_NETWORK_NODES.toLocaleString('en-US')}.` + ) + const unionEdges = [...edgeMask.keys()].map((k) => { + const [a, b] = k.split('\t') + return { source: a, target: b, weight: 1 } + }) + const raw = ids.length > 1 ? await frLayoutAsync(ids, unionEdges) : { [ids[0]]: { x: 0, y: 0 } } + const xs = ids.map((id) => raw[id].x), + ys = ids.map((id) => raw[id].y) + const cx = (Math.min(...xs) + Math.max(...xs)) / 2, + cy0 = (Math.min(...ys) + Math.max(...ys)) / 2 + const span = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys)) || 1 + const half = ARENA3D_LAYER_WIDTH * 0.42 + const k = ids.length > 1 ? (2 * half) / span : 0 + const pos = (id) => ({ y: String(-(raw[id].y - cy0) * k), z: String((raw[id].x - cx) * k) }) + const full = (1 << nets.length) - 1 + const bits = (m) => nets.map((_, i) => i).filter((i) => m & (1 << i)) + const COMMON = '#9CA3AF' + + const layers = nets.map((net, i) => ({ + name: layerName[i], + position_x: String((i - (nets.length - 1) / 2) * ARENA3D_LAYER_SPACING), + position_y: '0', + position_z: '0', + last_layer_scale: '1', + rotation_x: '0', + rotation_y: '0', + rotation_z: '0', + floor_current_color: hexColor(net.color), + geometry_parameters_width: String(ARENA3D_LAYER_WIDTH), + })) + const nodes = [] + nets.forEach((net, i) => + net.ids.forEach((id) => { + const m = nodeMask.get(id) + const p = pos(id) + nodes.push({ + name: arenaSafe(id), + layer: layerName[i], + position_x: '0', + position_y: p.y, + position_z: p.z, + scale: '1', + color: bits(m).length === 1 ? hexColor(net.color) : COMMON, + url: '', + descr: + bits(m).length === 1 ? `Only in ${net.name}` : `In ${bits(m).map(letter).join(', ')}`, + }) + }) + ) + const edges = [], + rows = [] + const add = (a, la, b, lb, color, channel, opacity = '1') => { + edges.push({ + src: `${arenaSafe(a)}_${la}`, + trg: `${arenaSafe(b)}_${lb}`, + opacity, + color, + channel, + }) + rows.push([arenaSafe(a), la, arenaSafe(b), lb, 1, channel]) + } + let within = 0, + across = 0, + nodeLinks = 0, + directed = false + // edges inside each layer + nets.forEach((net, i) => + net.edgeSet.forEach((key) => { + const [a, b, dir] = key.split('\t') + if (dir === 'directed') directed = true + const m = edgeMask.get(key) + const only = bits(m).length === 1 + add( + a, + layerName[i], + b, + layerName[i], + only ? hexColor(net.color) : COMMON, + only ? `${letter(i)} only` : 'common', + '1' + ) + within++ + }) + ) + // common edges between consecutive layers that contain them + if (between === 'edges' || between === 'both') { + edgeMask.forEach((m, key) => { + const bs = bits(m) + if (bs.length < 2) return + const [a, b] = key.split('\t') + for (let j = 0; j + 1 < bs.length; j++) { + add( + a, + layerName[bs[j]], + b, + layerName[bs[j + 1]], + '#FFFFFF', + 'common edge between layers', + '0.7' + ) + across++ + } + }) + } + // copies of common nodes + if (between === 'nodes' || between === 'both') { + nodeMask.forEach((m, id) => { + const bs = bits(m) + for (let j = 0; j + 1 < bs.length; j++) { + add(id, layerName[bs[j]], id, layerName[bs[j + 1]], '#FACC15', 'same node', '0.5') + nodeLinks++ + } + }) + } + const json = { + scene: { + position_x: '0', + position_y: '0', + scale: String( + nets.length > 4 ? Math.round(((0.6561 * 4) / nets.length) * 1e4) / 1e4 : 0.6561 + ), + color: '#000000', + rotation_x: '0.261799387799149', + rotation_y: '0.261799387799149', + rotation_z: '0.0872664625997165', + }, + layers, + nodes, + edges, + universalLabelColor: '#FFFFFF', + direction: directed, + edgeOpacityByWeight: false, + edgeWidthByWeight: false, + } + const commonEdges = [...edgeMask.values()].filter((m) => bits(m).length > 1).length + const commonNodes = [...nodeMask.values()].filter((m) => bits(m).length > 1).length + return { + json, + rows, + stats: { + layers: layers.length, + nodes: nodes.length, + edges: edges.length, + interLayer: across + nodeLinks, + copyEdges: nodeLinks, + multiLayerNodes: commonNodes, + distinctNodes: ids.length, + droppedLayers: 0, + droppedNodes: 0, + compare: { + within, + across, + nodeLinks, + commonEdges, + full: [...edgeMask.values()].filter((m) => m === full).length, + }, + }, + } +} + +/* ============================================================ + DATABASE IMPORTERS + Reactome, OmniPath, NDEx, IntAct and the Gene Ontology, next to + STRING. Each one builds a network and/or groupings and adds them to + Files like any uploaded file. Requests go through server.py's relay + when NORMA runs on it (no cross-site restrictions), otherwise straight + to the service. Bodies are always strings (see the STRING importer). + ============================================================ */ +const DB_URLS = { + reactome: 'https://reactome.org/ContentService', + omnipath: 'https://omnipathdb.org', + ndex: 'https://www.ndexbio.org', + intact: 'https://www.ebi.ac.uk/Tools/webservices/psicquic/intact/webservices/current/search', + quickgo: 'https://www.ebi.ac.uk/QuickGO/services', + goapi: 'https://api.geneontology.org/api', +} + +const DB_LABELS = { + reactome: 'Reactome', + omnipath: 'OmniPath', + ndex: 'NDEx', + intact: 'IntAct', + go: 'Gene Ontology', +} + +const dbState = { busy: {}, abort: {}, task: {}, mapping: {} } + +async function dbFetch( + key, + url, + { + method = 'GET', + body = null, + contentType = null, + accept = 'application/json', + text = false, + } = {} +) { + const route = + NORMA_CFG.features.relays.databases === false ? 'direct' : await resolveStringRoute() + const target = route === 'proxy' ? `db-api/fetch?url=${encodeURIComponent(url)}` : url + const headers = { Accept: accept } + if (contentType) headers['Content-Type'] = contentType + const ctl = new AbortController() + dbState.abort[key] = ctl + const timer = setTimeout(() => ctl.abort(), 120000) + let response + try { + response = await fetch(target, { method, headers, body, signal: ctl.signal }) + } catch (err) { + if (err.name === 'AbortError') + throw new Error( + dbState.cancelled && dbState.cancelled[key] + ? 'Cancelled.' + : `${DB_LABELS[key]} did not answer within 2 minutes.` + ) + throw new Error( + route === 'proxy' + ? `The server relay could not be reached (${err.message}). Check that server.py is still running.` + : `${DB_LABELS[key]} could not be reached (${err.message}). Browsers may block direct calls to other sites; running NORMA with server.py avoids that (see Help).` + ) + } finally { + clearTimeout(timer) + } + // download progress, except for the many small requests of dbMap (counted there) + const task = dbState.task[key] + const body2 = await readTextWithProgress( + response, + task && !dbState.mapping[key] ? (got, total) => task.bytes(got, total) : null + ) + if (!response.ok) { + if (route === 'proxy' && !response.headers.get('X-Norma-Relay')) + throw new Error( + 'This server has no database relay. Start NORMA with server.py, or choose Connect: Directly under Import from STRING → STRING server.' + ) + let detail = body2 + .slice(0, 200) + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim() + try { + const j = JSON.parse(body2) + detail = j.message || j.errorMessage || j.error || detail + } catch (e) {} + throw new Error( + `${DB_LABELS[key]} answered with an error (${response.status})${detail ? ': ' + detail : ''}.` + ) + } + if (text) return body2 + try { + return body2.trim() ? JSON.parse(body2) : null + } catch (e) { + throw new Error(`${DB_LABELS[key]} sent an answer that is not JSON.`) + } +} + +// runs fn over items with a few requests at a time +async function dbMap(key, items, limit, fn, progress) { + const out = new Array(items.length) + let next = 0, + done = 0 + dbState.mapping[key] = (dbState.mapping[key] || 0) + 1 + const worker = async () => { + while (next < items.length) { + if (dbState.cancelled[key]) throw new Error('Cancelled.') + const i = next++ + try { + out[i] = await fn(items[i], i) + } catch (err) { + if (/Cancelled/.test(err.message)) throw err + out[i] = { error: err } + } + done++ + if (progress) progress(done, items.length) + if (dbState.task[key]) dbState.task[key].sub(done / items.length) + } + } + try { + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) + } finally { + dbState.mapping[key]-- + } + return out +} + +function dbStatus(key, notes) { + setStatus(`${key}Status`, notes) +} + +// Progress of an import: dbPlan sets its weighted steps, dbStep moves on, +// dbProgress changes the text of the current step. +function dbPlan(key, weights) { + dbState.task[key] = startProgress(`${key}Status`, weights) + return dbState.task[key] +} + +function dbStep(key, i, text) { + if (!dbState.task[key] || !dbState.task[key].active) dbPlan(key, [1]) + dbState.task[key].step(i, text) +} + +function dbProgress(key, text) { + const task = dbState.task[key] + if (task && task.active) task.say(text) + else dbStep(key, 0, text) +} + +function dbBusy(key, busy) { + dbState.busy[key] = busy + if (busy) dbState.cancelled[key] = false + document.querySelectorAll(`[data-db-run="${key}"]`).forEach((b) => { + b.disabled = busy + }) + const cancel = document.getElementById(`${key}Cancel`) + if (cancel) cancel.hidden = !busy +} + +async function dbRun(key, fn) { + if (dbState.busy[key]) return + dbBusy(key, true) + dbState.mapping[key] = 0 + dbStep(key, 0, `Contacting ${DB_LABELS[key]}…`) + try { + await fn() + } catch (err) { + dbStatus(key, [{ level: 'error', text: err.message }]) + } finally { + if (dbState.task[key]) dbState.task[key].stop() + dbState.task[key] = null + dbBusy(key, false) + } +} + +export function dbCancel(key) { + dbState.cancelled[key] = true + if (dbState.abort[key]) dbState.abort[key].abort() +} + +const dbSafe = (s) => + String(s ?? '') + .replace(/[\t\r\n]+/g, ' ') + .replace(/,/g, ';') + .trim() + +function dbSplitList(v) { + return String(v || '') + .split(/[\s,;]+/) + .map((x) => x.trim()) + .filter(Boolean) +} + +const UNIPROT_RE = /^([OPQ][0-9][A-Z0-9]{3}[0-9]|[A-NR-Z][0-9]([A-Z][A-Z0-9]{2}[0-9]){1,2})(-\d+)?$/ + +/* Adds a fetched network and its groupings to Files and opens them. + edges: [{ source, target, type, weight, directed }] with node names + nodeAttrs: { name: {...} }, groupings: [{ label, groups: [{ name, members, meta }] }] */ +function dbAddImport( + key, + { + name, + edges, + nodeAttrs = {}, + groupings = [], + positions = null, + notes = [], + summary = '', + preferGrouping = 0, + } +) { + if (!edges.length) throw new Error('No connections were found with these settings.') + const weighted = edges.some((e) => typeof e.weight === 'number') + const typed = + new Set(edges.map((e) => e.type || '')).size > 1 || + edges.some((e) => e.type && e.type !== 'link') + const anyDirected = edges.some((e) => e.directed) + const header = [ + 'Source', + 'Target', + ...(weighted ? ['Weight'] : []), + ...(typed ? ['Type'] : []), + ...(anyDirected ? ['Direction'] : []), + ] + const lines = edges.map((e) => + [ + dbSafe(e.source), + dbSafe(e.target), + ...(weighted ? [typeof e.weight === 'number' ? Math.round(e.weight * 1e4) / 1e4 : 1] : []), + ...(typed ? [dbSafe(e.type || 'link')] : []), + ...(anyDirected ? [e.directed ? 'directed' : 'undirected'] : []), + ].join('\t') + ) + const netEntry = addNormaEntry( + 'network', + name, + [header.join('\t'), ...lines].join('\n') + '\n', + '', + null, + {} + ) + const cleanAttrs = {} + Object.entries(nodeAttrs).forEach(([k, v]) => { + cleanAttrs[dbSafe(k)] = v + }) + netEntry.nodeAttrs = cleanAttrs + netEntry.dbSource = key + if (positions) { + const pos = {} + Object.entries(positions).forEach(([k, p]) => { + pos[dbSafe(k)] = p + }) + netEntry.nodePositions = pos + } + const nodeSet = new Set(netEntry.parsed.nodes) + const annEntries = [] + groupings.forEach((gr) => { + const used = new Set() + const meta = {} + const rows = [] + gr.groups.forEach((g) => { + const members = [...new Set(g.members.map(dbSafe))].filter((m) => nodeSet.has(m)) + if (members.length < (gr.minSize || 1)) return + let gname = + String(g.name) + .replace(/[\t\r\n]+/g, ' ') + .trim() || 'group' + while (used.has(gname)) gname += '′' + used.add(gname) + if (g.meta) meta[gname] = g.meta + rows.push(`${gname}\t${members.join(',')}`) + }) + if (!rows.length) return + const entry = addNormaEntry( + 'annotation', + `${name}: ${gr.label}`, + rows.join('\n') + '\n', + '', + null, + {} + ) + entry.groupMeta = meta + entry.forNetwork = netEntry.id + annEntries.push(entry) + }) + const chosen = annEntries[Math.min(preferGrouping, annEntries.length - 1)] + openInNewView(name, () => { + libSelection.networks = new Set([netEntry.id]) + libSelection.annotation = chosen ? chosen.id : '' + libSelection.colors = '' + renderLibraryLists() + refreshLibraryView() + if (anyDirected) { + // directed databases (OmniPath, GO-CAM) open with arrows + const dirSel = document.getElementById('edgeDirection') + dirSel.value = 'data' + dirSel.dispatchEvent(new Event('change', { bubbles: true })) + } + }) + switchTab('network') + dbStatus(key, [ + { + level: 'ok', + text: `Imported "${name}": ${netEntry.parsed.summary}${summary ? `. ${summary}` : ''}.`, + }, + ...(annEntries.length + ? [ + { + level: 'ok', + text: `Added ${plural(annEntries.length, 'grouping')}: ${annEntries.map((e) => e.name.slice(name.length + 2)).join(', ')}. Switch between them with the Grouping list at the top.`, + }, + ] + : []), + ...netEntry.parsed.notes.map((t) => ({ level: 'warn', text: t })), + ...notes, + ]) + return { netEntry, annEntries } +} + +// Adds groupings for the network already shown (GO terms, ...). +function dbAddGroupingsToView(key, groupings, label) { + const entries = [] + groupings.forEach((gr) => { + const rows = [], + meta = {}, + used = new Set() + gr.groups.forEach((g) => { + let gname = String(g.name) + .replace(/[\t\r\n]+/g, ' ') + .trim() + while (used.has(gname)) gname += '′' + used.add(gname) + if (g.meta) meta[gname] = g.meta + rows.push(`${gname}\t${g.members.join(',')}`) + }) + if (!rows.length) return + const entry = addNormaEntry( + 'annotation', + `${label}: ${gr.label}`, + rows.join('\n') + '\n', + '', + null, + {} + ) + entry.groupMeta = meta + entries.push(entry) + }) + if (!entries.length) return entries + if (S.currentLibView) { + libSelection.annotation = entries[0].id + renderLibraryLists() + refreshLibraryView() + } else { + // views from examples or JSON: apply the first grouping directly + const groupsOf = {} + entries[0].parsed.groups.forEach((g) => + g.members.forEach((m) => (groupsOf[m] = groupsOf[m] || []).push(g.name)) + ) + document.getElementById('nodeFillSelect').value = 'groups' + applyGroupsAndColorsInPlace( + groupsOf, + new Map(), + entries[0].parsed.groups.map((g) => g.name), + entries[0].groupMeta + ) + renderLibraryLists() + } + return entries +} + +/* --------------------------------------------------------------- + REACTOME: the proteins of a pathway's reactions, linked when they + take part in the same reaction; groups are its sub-pathways. + --------------------------------------------------------------- */ +export const REACTOME_SPECIES = [ + 'Homo sapiens', + 'Mus musculus', + 'Rattus norvegicus', + 'Danio rerio', + 'Drosophila melanogaster', + 'Caenorhabditis elegans', + 'Saccharomyces cerevisiae', + 'Gallus gallus', + 'Sus scrofa', + 'Bos taurus', + 'Canis familiaris', + 'Xenopus tropicalis', + 'Dictyostelium discoideum', + 'Plasmodium falciparum', + 'Schizosaccharomyces pombe', +] + +const REACTION_CLASSES = new Set([ + 'Reaction', + 'BlackBoxEvent', + 'Polymerisation', + 'Depolymerisation', + 'FailedReaction', + 'ReactionLikeEvent', + 'CellLineagePath', + 'CellDevelopmentStep', +]) + +const stripTags = (s) => String(s || '').replace(/<[^>]+>/g, '') + +export async function reactomeSearch() { + await dbRun('reactome', async () => { + const q = document.getElementById('reactomeQuery').value.trim() + const species = document.getElementById('reactomeSpecies').value + if (!q) throw new Error('Type a pathway name or a Reactome identifier such as R-HSA-69278.') + const sel = document.getElementById('reactomePathway') + sel.innerHTML = '' + if (/^R-[A-Z]{3}-\d+(\.\d+)?$/i.test(q)) { + const p = await dbFetch('reactome', `${DB_URLS.reactome}/data/query/${encodeURIComponent(q)}`) + sel.add(new Option(`${p.displayName} (${p.stId || q})`, p.stId || q)) + } else { + dbProgress('reactome', 'Searching Reactome…') + const r = await dbFetch( + 'reactome', + `${DB_URLS.reactome}/search/query?query=${encodeURIComponent(q)}&species=${encodeURIComponent(species)}&types=Pathway&cluster=true` + ) + const entries = ((r && r.results) || []).flatMap((g) => g.entries || []) + entries + .slice(0, 40) + .forEach((e) => + sel.add(new Option(`${stripTags(e.name)} (${e.stId || e.id})`, e.stId || e.id)) + ) + } + document.getElementById('reactomePickRow').hidden = !sel.options.length + dbStatus( + 'reactome', + sel.options.length + ? [ + { + level: 'ok', + text: `Found ${plural(sel.options.length, 'pathway')}. Choose one and fetch its network.`, + }, + ] + : [{ level: 'warn', text: 'No pathway matched. Try another name or check the species.' }] + ) + }) +} + +export async function reactomeFetch() { + await dbRun('reactome', async () => { + const pid = document.getElementById('reactomePathway').value + if (!pid) throw new Error('Search for a pathway and choose one first.') + const maxReactions = Math.max( + 5, + Math.min(500, parseInt(document.getElementById('reactomeMaxReactions').value, 10) || 150) + ) + const maxSize = Math.max( + 2, + parseInt(document.getElementById('reactomeMaxSize').value, 10) || 25 + ) + const smallMolecules = document.getElementById('reactomeSmall').checked + // steps: pathway, sub-pathways, reactions, building + dbPlan('reactome', [2, 1, 6, 1]) + dbStep('reactome', 0, 'Reading the pathway…') + const top = await dbFetch( + 'reactome', + `${DB_URLS.reactome}/data/query/${encodeURIComponent(pid)}` + ) + const contained = + (await dbFetch( + 'reactome', + `${DB_URLS.reactome}/data/pathway/${encodeURIComponent(pid)}/containedEvents` + )) || [] + const byDbId = new Map( + contained.filter((e) => e && typeof e === 'object').map((e) => [e.dbId, e]) + ) + const resolve = (x) => (typeof x === 'object' ? x : byDbId.get(x)) + const reactions = [ + ...new Map( + contained.filter((e) => e && REACTION_CLASSES.has(e.schemaClass)).map((e) => [e.stId, e]) + ).values(), + ] + const notes = [] + let used = reactions + if (reactions.length > maxReactions) { + used = reactions.slice(0, maxReactions) + notes.push({ + level: 'warn', + text: `The pathway has ${reactions.length} reactions; the first ${maxReactions} were read (raise "Most reactions" for more).`, + }) + } + if (!used.length) throw new Error('This pathway has no reactions to read.') + // sub-pathways and the reactions each contains + const children = (top.hasEvent || []) + .map(resolve) + .filter((e) => e && e.schemaClass === 'Pathway') + .slice(0, 40) + dbStep('reactome', 1, `Reading ${plural(children.length, 'sub-pathway')}…`) + const childReactions = await dbMap('reactome', children, 4, async (ch) => { + const evs = + (await dbFetch( + 'reactome', + `${DB_URLS.reactome}/data/pathway/${encodeURIComponent(ch.stId)}/containedEvents` + )) || [] + return new Set(evs.filter((e) => e && typeof e === 'object').map((e) => e.stId)) + }) + // participants of each reaction + dbStep('reactome', 2, `Reading reactions: 0 of ${used.length}…`) + const parts = await dbMap( + 'reactome', + used, + 4, + (r) => + dbFetch('reactome', `${DB_URLS.reactome}/data/participants/${encodeURIComponent(r.stId)}`), + (d, n) => dbProgress('reactome', `Reading reactions: ${d} of ${n}…`) + ) + const nodeAttrs = {} + const nameOf = new Map() // reference identifier -> node name + const usedNames = new Map() + const reactionMembers = new Map() + let skippedBig = 0, + failed = 0 + parts.forEach((p, i) => { + if (!p || p.error) { + failed++ + return + } + const members = new Set() + ;(Array.isArray(p) ? p : []).forEach((part) => + (part.refEntities || []).forEach((ref) => { + const cls = ref.schemaClass || '' + const isProtein = + /ReferenceGeneProduct|ReferenceIsoform|ReferenceDNASequence|ReferenceRNASequence/.test( + cls + ) || UNIPROT_RE.test(ref.identifier || '') + if (!isProtein && !(smallMolecules && /ReferenceMolecule/.test(cls))) return + const id = String(ref.identifier || ref.dbId) + if (!nameOf.has(id)) { + const disp = String(ref.displayName || '') + let label = disp.includes(' ') + ? disp.split(/\s+/).slice(1).join(' ') + : (Array.isArray(ref.name) ? ref.name[0] : ref.name) || id + label = label || id + if (usedNames.has(label) && usedNames.get(label) !== id) label = `${label} (${id})` + usedNames.set(label, id) + nameOf.set(id, label) + nodeAttrs[label] = { + ...(isProtein ? { uniprot: id } : { chebi: id }), + reactome: `https://reactome.org/content/query?q=${encodeURIComponent(id)}`, + } + } + members.add(nameOf.get(id)) + }) + ) + if (members.size > maxSize) { + skippedBig++ + return + } + reactionMembers.set(used[i].stId, { name: used[i].displayName, members: [...members] }) + }) + if (failed) + notes.push({ level: 'warn', text: `${plural(failed, 'reaction')} could not be read.` }) + if (skippedBig) + notes.push({ + level: 'ok', + text: `Left out ${plural(skippedBig, 'reaction')} with more than ${maxSize} molecules, which would link them all to each other.`, + }) + // edges: molecules sharing a reaction, weighted by how many they share + const pairs = new Map() + reactionMembers.forEach(({ members }) => { + for (let a = 0; a < members.length; a++) + for (let b = a + 1; b < members.length; b++) { + const [x, y] = + members[a] < members[b] ? [members[a], members[b]] : [members[b], members[a]] + const k = x + '\t' + y + pairs.set(k, (pairs.get(k) || 0) + 1) + } + }) + const edges = [...pairs].map(([k, w]) => { + const [s, t] = k.split('\t') + return { source: s, target: t, weight: w, type: 'same reaction' } + }) + const groupings = [] + if (document.getElementById('reactomeGroupSub').checked && children.length) { + const groups = children.map((ch, i) => { + const set = childReactions[i] && !childReactions[i].error ? childReactions[i] : new Set() + const members = new Set() + reactionMembers.forEach((r, rid) => { + if (set.has(rid)) r.members.forEach((m) => members.add(m)) + }) + return { + name: stripTags(ch.displayName), + members: [...members], + meta: { description: `Reactome ${ch.stId}`, stId: ch.stId }, + } + }) + groupings.push({ label: 'sub-pathways', groups }) + } + if (document.getElementById('reactomeGroupReactions').checked) { + const groups = [...reactionMembers] + .filter(([, r]) => r.members.length >= 2) + .sort((a, b) => b[1].members.length - a[1].members.length) + .slice(0, 40) + .map(([rid, r]) => ({ + name: stripTags(r.name), + members: r.members, + meta: { description: `Reactome reaction ${rid}`, stId: rid }, + })) + groupings.push({ label: 'reactions', groups }) + } + dbStep('reactome', 3, 'Building and opening the network…') + await nextPaint() + dbAddImport('reactome', { + name: `Reactome ${stripTags(top.displayName)} (${pid})`, + edges, + nodeAttrs, + groupings, + notes, + summary: `from ${plural(reactionMembers.size, 'reaction')}; edges join molecules of the same reaction, weighted by how many they share`, + }) + }) +} + +/* --------------------------------------------------------------- + OMNIPATH: signalling, TF-target and ligand-receptor interactions + of the given proteins; groups from complexes, intercellular roles + or pathway annotations. + --------------------------------------------------------------- */ +export async function omnipathFetch() { + await dbRun('omnipath', async () => { + const names = dbSplitList(document.getElementById('omnipathQuery').value) + if (!names.length) + throw new Error('Type one or more gene symbols or UniProt accessions, for example EGFR.') + const organism = document.getElementById('omnipathOrganism').value + const datasets = [...document.querySelectorAll('#omnipathDatasets input:checked')].map( + (i) => i.value + ) + if (!datasets.length) throw new Error('Tick at least one dataset.') + const among = document.getElementById('omnipathAmong').checked + const maxPartners = Math.max(0, parseInt(document.getElementById('omnipathMax').value, 10) || 0) + const channels = document.getElementById('omnipathChannels').value + const wantedGroups = [...document.querySelectorAll('#omnipathGroups input:checked')].map( + (i) => i.value + ) + const annResource = document.getElementById('omnipathAnnotation').value.trim() + // steps: interactions, complexes, intercellular roles, annotations, building + const omniSteps = [ + 6, + wantedGroups.includes('complexes') ? 2 : 0, + wantedGroups.includes('intercell') ? 2 : 0, + wantedGroups.includes('annotations') && annResource ? 2 : 0, + 1, + ] + dbPlan('omnipath', omniSteps) + dbStep('omnipath', 0, 'Fetching interactions from OmniPath…') + const url = `${DB_URLS.omnipath}/interactions?partners=${encodeURIComponent(names.join(','))}&genesymbols=yes&organisms=${organism}&datasets=${datasets.join(',')}&fields=sources,references,curation_effort,type${among ? '&source_target=AND' : ''}&format=json` + const rows = (await dbFetch('omnipath', url)) || [] + if (!Array.isArray(rows) || !rows.length) + throw new Error('OmniPath has no interactions for these proteins with the ticked datasets.') + const query = new Set(names.map((n) => n.toUpperCase())) + const nodeAttrs = {} + const nodeOf = (id, sym) => { + const name = sym || id + if (!nodeAttrs[name]) + nodeAttrs[name] = { + ...(UNIPROT_RE.test(id) ? { uniprot: id } : { omnipath_id: id }), + query: query.has(String(name).toUpperCase()) || query.has(String(id).toUpperCase()), + } + return name + } + let edges = rows + .map((r) => { + const s = nodeOf(r.source, r.source_genesymbol), + t = nodeOf(r.target, r.target_genesymbol) + const stim = +r.is_stimulation === 1 || r.is_stimulation === true, + inh = +r.is_inhibition === 1 || r.is_inhibition === true + const type = + channels === 'sign' + ? stim && inh + ? 'stimulation and inhibition' + : stim + ? 'stimulation' + : inh + ? 'inhibition' + : 'unsigned' + : channels === 'type' + ? String(r.type || 'interaction').replace(/_/g, ' ') + : 'interaction' + const sources = Array.isArray(r.sources) + ? r.sources + : String(r.sources || '') + .split(';') + .filter(Boolean) + return { + source: s, + target: t, + directed: +r.is_directed === 1 || r.is_directed === true, + type, + weight: Number.isFinite(+r.curation_effort) ? +r.curation_effort : sources.length || 1, + } + }) + .filter((e) => e.source !== e.target) + const notes = [] + // keep the best-supported partners + if (!among && maxPartners > 0) { + const support = new Map() + edges.forEach((e) => + [e.source, e.target].forEach((n) => { + if (!nodeAttrs[n].query) support.set(n, (support.get(n) || 0) + e.weight) + }) + ) + if (support.size > maxPartners) { + const keep = new Set( + [...support] + .sort((a, b) => b[1] - a[1]) + .slice(0, maxPartners) + .map((x) => x[0]) + ) + const before = support.size + edges = edges.filter((e) => + [e.source, e.target].every((n) => nodeAttrs[n].query || keep.has(n)) + ) + notes.push({ + level: 'ok', + text: `Kept the ${maxPartners} best-supported of ${before} partners (by curation effort).`, + }) + } + } + // one edge per pair, channel and direction + const merged = new Map() + edges.forEach((e) => { + const k = [ + e.directed ? e.source : [e.source, e.target].sort().join('\u0000'), + e.directed ? e.target : '', + e.type, + e.directed, + ].join('\t') + const m = merged.get(k) + if (m) m.weight = Math.max(m.weight, e.weight) + else merged.set(k, { ...e }) + }) + edges = [...merged.values()] + const present = new Set(edges.flatMap((e) => [e.source, e.target])) + const uniprots = [...present].map((n) => nodeAttrs[n].uniprot).filter(Boolean) + const symOf = new Map( + [...present].filter((n) => nodeAttrs[n].uniprot).map((n) => [nodeAttrs[n].uniprot, n]) + ) + const groupings = [] + const wanted = [...document.querySelectorAll('#omnipathGroups input:checked')].map( + (i) => i.value + ) + const batches = [] + for (let i = 0; i < uniprots.length; i += 150) batches.push(uniprots.slice(i, i + 150)) + const fetchAll = async (path) => + ( + await dbMap('omnipath', batches, 2, (b) => + dbFetch( + 'omnipath', + `${DB_URLS.omnipath}/${path}${path.includes('?') ? '&' : '?'}proteins=${b.join(',')}&format=json` + ) + ) + ).flatMap((x) => (Array.isArray(x) ? x : [])) + if (wanted.includes('complexes') && uniprots.length) { + dbStep('omnipath', 1, 'Fetching complexes…') + const cx = await fetchAll('complexes') + const groups = new Map() + cx.forEach((c) => { + const comps = Array.isArray(c.components) + ? c.components + : String(c.components || '').split('_') + const members = comps.map((u) => symOf.get(u)).filter(Boolean) + if (members.length < 2) return + const name = + c.name || String(c.components_genesymbols || '').replace(/_/g, ':') || comps.join(':') + if (!groups.has(name)) + groups.set(name, { + name, + members, + meta: { + description: `Complex; sources: ${Array.isArray(c.sources) ? c.sources.join(', ') : c.sources || ''}`, + }, + }) + }) + groupings.push({ + label: 'complexes', + minSize: 2, + groups: [...groups.values()] + .sort((a, b) => b.members.length - a.members.length) + .slice(0, 60), + }) + } + if (wanted.includes('intercell') && uniprots.length) { + dbStep('omnipath', 2, 'Fetching intercellular roles…') + const ic = await fetchAll('intercell?scope=generic') + const groups = new Map() + ic.forEach((r) => { + const name = String(r.parent || r.category || '').replace(/_/g, ' ') + const sym = symOf.get(r.uniprot) + if (!name || !sym) return + if (!groups.has(name)) groups.set(name, { name, members: [] }) + groups.get(name).members.push(sym) + }) + groupings.push({ label: 'intercellular roles', groups: [...groups.values()] }) + } + const resource = document.getElementById('omnipathAnnotation').value.trim() + if (wanted.includes('annotations') && resource && uniprots.length) { + dbStep('omnipath', 3, `Fetching ${resource} annotations…`) + const an = await fetchAll(`annotations?resources=${encodeURIComponent(resource)}`) + const LABELS = [ + 'pathway', + 'location', + 'state', + 'function', + 'category', + 'mainclass', + 'classification', + 'family', + 'disease', + ] + const groups = new Map() + an.forEach((r) => { + const sym = symOf.get(r.uniprot) + if (!sym || r.value === undefined || r.value === null || r.value === '') return + if (!LABELS.includes(String(r.label).toLowerCase())) return + const name = String(r.value) + if (!groups.has(name)) + groups.set(name, { + name, + members: new Set(), + meta: { description: `${resource}: ${r.label}` }, + }) + groups.get(name).members.add(sym) + }) + if (!groups.size) + notes.push({ + level: 'warn', + text: `${resource} has no pathway-like annotations for these proteins.`, + }) + groupings.push({ + label: resource, + groups: [...groups.values()].map((g) => ({ ...g, members: [...g.members] })), + }) + } + dbStep('omnipath', omniSteps.length - 1, 'Building and opening the network…') + await nextPaint() + dbAddImport('omnipath', { + name: `OmniPath ${names.slice(0, 3).join(', ')}${names.length > 3 ? ` +${names.length - 3}` : ''}`, + edges, + nodeAttrs, + groupings, + notes, + summary: `datasets ${datasets.join(', ')}; channels by ${channels === 'sign' ? 'effect sign' : channels === 'type' ? 'interaction type' : 'nothing'}; weights are curation effort`, + }) + }) +} + +/* --------------------------------------------------------------- + NDEx: public networks by search or UUID, in CX2; node attributes + become groupings and NDEx positions are kept. + --------------------------------------------------------------- */ +export async function ndexSearch() { + await dbRun('ndex', async () => { + const q = document.getElementById('ndexQuery').value.trim() + if (!q) throw new Error('Type search words (for example "TP53 signaling") or a network UUID.') + const sel = document.getElementById('ndexNetwork') + sel.innerHTML = '' + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(q)) { + sel.add(new Option(`Network ${q}`, q)) + } else { + dbProgress('ndex', 'Searching NDEx…') + const r = await dbFetch('ndex', `${DB_URLS.ndex}/v2/search/network?start=0&size=40`, { + method: 'POST', + body: JSON.stringify({ searchString: q }), + contentType: 'application/json', + }) + ;((r && r.networks) || []).forEach((n) => { + const big = (n.nodeCount || 0) > MAX_NETWORK_NODES ? ' — larger than NORMA shows' : '' + sel.add( + new Option( + `${n.name} · ${(n.nodeCount || 0).toLocaleString()} nodes, ${(n.edgeCount || 0).toLocaleString()} edges · ${n.owner || ''}${big}`, + n.externalId + ) + ) + }) + } + document.getElementById('ndexPickRow').hidden = !sel.options.length + dbStatus( + 'ndex', + sel.options.length + ? [ + { + level: 'ok', + text: `Found ${plural(sel.options.length, 'network')}. Choose one and fetch it.`, + }, + ] + : [{ level: 'warn', text: 'No public network matched.' }] + ) + }) +} + +// Reads a CX2 document: attribute aliases and defaults are resolved. +function readCx2(cx) { + const aspects = {} + ;(Array.isArray(cx) ? cx : []).forEach((part) => + Object.entries(part).forEach(([k, v]) => { + if (Array.isArray(v)) (aspects[k] = aspects[k] || []).push(...v) + }) + ) + const decl = (aspects.attributeDeclarations || [])[0] || {} + const resolver = (kind) => { + const d = decl[kind] || {} + const alias = {}, + defaults = {} + Object.entries(d).forEach(([name, spec]) => { + if (spec && spec.a) alias[spec.a] = name + if (spec && spec.v !== undefined) defaults[name] = spec.v + }) + return (v) => { + const out = { ...defaults } + Object.entries(v || {}).forEach(([k, val]) => { + out[alias[k] || k] = val + }) + return out + } + } + const rn = resolver('nodes'), + re = resolver('edges') + const netAttrs = Object.assign({}, ...(aspects.networkAttributes || [])) + return { + name: netAttrs.name || '', + nodes: (aspects.nodes || []).map((n) => ({ id: n.id, v: rn(n.v), x: n.x, y: n.y })), + edges: (aspects.edges || []).map((e) => ({ id: e.id, s: e.s, t: e.t, v: re(e.v) })), + declarations: decl, + } +} + +export async function ndexFetch() { + await dbRun('ndex', async () => { + const uuid = document.getElementById('ndexNetwork').value + if (!uuid) throw new Error('Search and choose a network first.') + dbPlan('ndex', [8, 1]) + dbStep('ndex', 0, 'Downloading the network from NDEx…') + const cx = await dbFetch('ndex', `${DB_URLS.ndex}/v3/networks/${encodeURIComponent(uuid)}`) + const net = readCx2(cx) + if (!net.nodes.length) throw new Error('This network has no nodes, or is not public.') + const names = new Map(), + used = new Set() + net.nodes.forEach((n) => { + let name = String(n.v.name ?? n.v.n ?? n.v.label ?? n.id).trim() || String(n.id) + if (used.has(name)) name = `${name} (${n.id})` + used.add(name) + names.set(n.id, name) + }) + const weightKey = ['weight', 'Weight', 'score', 'Score', 'confidence'].find((k) => + net.edges.some((e) => typeof e.v[k] === 'number') + ) + const edges = net.edges + .filter((e) => names.has(e.s) && names.has(e.t) && e.s !== e.t) + .map((e) => ({ + source: names.get(e.s), + target: names.get(e.t), + type: String(e.v.interaction ?? e.v.i ?? 'interacts'), + ...(weightKey && typeof e.v[weightKey] === 'number' ? { weight: e.v[weightKey] } : {}), + directed: e.v.directed === true, + })) + // node attributes and groupings from categorical attributes + const SKIP = /^(name|n|represents|r|alias|aliases|id|x|y|z|label|description|url|uri|link)$/i + const keys = new Set() + net.nodes.forEach((n) => Object.keys(n.v).forEach((k) => keys.add(k))) + const nodeAttrs = {} + net.nodes.forEach((n) => { + const a = {} + Object.entries(n.v).forEach(([k, v]) => { + if ( + !/^(name|n)$/.test(k) && + (typeof v !== 'object' || v === null) && + Object.keys(a).length < 25 + ) + a[k] = v + }) + const rep = String(n.v.represents || n.v.r || '') + const m = rep.match(/uniprot(?:kb)?:([A-Z0-9-]+)/i) + if (m) a.uniprot = m[1] + nodeAttrs[names.get(n.id)] = a + }) + const groupings = [] + ;[...keys] + .filter((k) => !SKIP.test(k)) + .forEach((k) => { + const groups = new Map() + let covered = 0 + net.nodes.forEach((n) => { + const v = n.v[k] + const vals = Array.isArray(v) + ? v + : typeof v === 'string' || typeof v === 'boolean' + ? [String(v)] + : [] + const clean = vals.map((x) => String(x).trim()).filter((x) => x && x.length <= 80) + if (clean.length) covered++ + clean.forEach((x) => { + if (!groups.has(x)) groups.set(x, []) + groups.get(x).push(names.get(n.id)) + }) + }) + if (groups.size < 2 || groups.size > 60 || covered < net.nodes.length * 0.3) return + groupings.push({ + label: `groups by ${k}`, + groups: [...groups].map(([name, members]) => ({ name, members })), + score: covered, + }) + }) + groupings.sort((a, b) => b.score - a.score) + const positions = net.nodes.every((n) => Number.isFinite(n.x) && Number.isFinite(n.y)) + ? Object.fromEntries(net.nodes.map((n) => [names.get(n.id), { x: n.x, y: n.y }])) + : null + const notes = [] + if (!groupings.length) + notes.push({ + level: 'ok', + text: 'The network has no node attribute that makes useful groups (2 to 60 values covering at least 30% of the nodes); add an annotation or use communities.', + }) + if (positions) notes.push({ level: 'ok', text: 'The layout saved in NDEx is kept.' }) + dbStep('ndex', 1, 'Building and opening the network…') + await nextPaint() + dbAddImport('ndex', { + name: `NDEx ${net.name || uuid}`.slice(0, 90), + edges, + nodeAttrs, + groupings: groupings.slice(0, 6), + positions, + notes, + summary: `network ${uuid}`, + }) + }) +} + +/* --------------------------------------------------------------- + INTACT: molecular interactions from the IntAct PSICQUIC service + (PSI-MITAB 2.5), filtered by the IntAct MI-score. + --------------------------------------------------------------- */ +function mitabName(idField, altField, aliasField) { + const all = [aliasField, altField].join('|').split('|') + const pick = (re) => { + for (const x of all) { + const m = x.match(re) + if (m) return m[1] + } + return null + } + return ( + pick(/^uniprotkb:([^(]+)\(gene name\)/) || + pick(/^[^:]+:([^(]+)\(gene name\)/) || + pick(/^psi-mi:([^(]+)\(display_short\)/) || + String(idField) + .split('|')[0] + .replace(/^[^:]+:/, '') + ) +} + +function mitabTerm(field) { + const m = String(field).match(/\(([^)]+)\)/) + return m ? m[1] : String(field).replace(/^[^:]+:/, '') +} + +export async function intactFetch() { + await dbRun('intact', async () => { + const names = dbSplitList(document.getElementById('intactQuery').value) + if (!names.length) + throw new Error('Type one or more gene names or UniProt accessions, for example TP53.') + const taxon = document.getElementById('intactTaxon').value + const minScore = parseFloat(document.getElementById('intactScore').value) || 0 + const maxRows = Math.max( + 50, + Math.min(5000, parseInt(document.getElementById('intactMaxRows').value, 10) || 1000) + ) + const among = document.getElementById('intactAmong').checked + const maxPartners = Math.max(0, parseInt(document.getElementById('intactMax').value, 10) || 0) + const ids = names.map((n) => (/[\s():]/.test(n) ? `"${n}"` : n)).join(' OR ') + let miql = `identifier:(${ids})` + if (taxon) miql += ` AND taxidA:${taxon} AND taxidB:${taxon}` + dbPlan('intact', [8, 1]) + dbStep('intact', 0, 'Fetching interactions from IntAct…') + const text = await dbFetch( + 'intact', + `${DB_URLS.intact}/query/${encodeURIComponent(miql)}?format=tab25&firstResult=0&maxResults=${maxRows}`, + { accept: 'text/plain', text: true } + ) + const lines = String(text || '') + .split('\n') + .filter((l) => l.trim()) + if (!lines.length) + throw new Error('IntAct has no interactions for these names in this organism.') + const nodeAttrs = {} + const query = new Set(names.map((n) => n.toUpperCase())) + const agg = new Map() + let lowScore = 0 + lines.forEach((line) => { + const c = line.split('\t') + if (c.length < 15) return + const nameA = mitabName(c[0], c[2], c[4]), + nameB = mitabName(c[1], c[3], c[5]) + if (!nameA || !nameB || nameA === nameB) return + const score = (String(c[14]).match(/intact-miscore:([\d.]+)/) || [])[1] + const s = score === undefined ? NaN : parseFloat(score) + if (Number.isFinite(s) && s < minScore) { + lowScore++ + return + } + ;[ + [nameA, c[0]], + [nameB, c[1]], + ].forEach(([nm, idf]) => { + if (nodeAttrs[nm]) return + const acc = (String(idf).match(/uniprotkb:([A-Z0-9-]+)/) || [])[1] + nodeAttrs[nm] = { + ...(acc ? { uniprot: acc } : { intact_id: String(idf).split('|')[0] }), + query: query.has(nm.toUpperCase()) || (acc && query.has(acc.toUpperCase())), + } + }) + const type = mitabTerm(String(c[11]).split('|')[0]) || 'interaction' + const [x, y] = nameA < nameB ? [nameA, nameB] : [nameB, nameA] + const k = `${x}\t${y}\t${type}` + const cur = agg.get(k) || { source: x, target: y, type, weight: 0, evidence: 0 } + cur.weight = Math.max(cur.weight, Number.isFinite(s) ? s : 0) + cur.evidence++ + agg.set(k, cur) + }) + let edges = [...agg.values()] + if (among) edges = edges.filter((e) => nodeAttrs[e.source].query && nodeAttrs[e.target].query) + const notes = [] + if (lines.length >= maxRows) + notes.push({ + level: 'warn', + text: `Read the first ${maxRows} interaction records; raise "Most records" for more.`, + }) + if (lowScore) + notes.push({ + level: 'ok', + text: `Left out ${plural(lowScore, 'record')} with an MI-score below ${minScore}.`, + }) + if (!among && maxPartners > 0) { + const best = new Map() + edges.forEach((e) => + [e.source, e.target].forEach((n) => { + if (!nodeAttrs[n].query) best.set(n, Math.max(best.get(n) || 0, e.weight)) + }) + ) + if (best.size > maxPartners) { + const keep = new Set( + [...best] + .sort((a, b) => b[1] - a[1]) + .slice(0, maxPartners) + .map((x) => x[0]) + ) + notes.push({ + level: 'ok', + text: `Kept the ${maxPartners} highest-scoring of ${best.size} partners.`, + }) + edges = edges.filter((e) => + [e.source, e.target].every((n) => nodeAttrs[n].query || keep.has(n)) + ) + } + } + const present = new Set(edges.flatMap((e) => [e.source, e.target])) + const groupings = [ + { + label: 'query and partners', + groups: [ + { name: 'Query proteins', members: [...present].filter((n) => nodeAttrs[n].query) }, + { + name: 'Interaction partners', + members: [...present].filter((n) => !nodeAttrs[n].query), + }, + ], + }, + ] + dbStep('intact', 1, 'Building and opening the network…') + await nextPaint() + dbAddImport('intact', { + name: `IntAct ${names.slice(0, 3).join(', ')}${names.length > 3 ? ` +${names.length - 3}` : ''}`, + edges: edges.map(({ evidence, ...e }) => e), + nodeAttrs, + groupings, + notes, + summary: `MI-score at least ${minScore}; channels are interaction types, weights the best MI-score`, + }) + }) +} + +/* --------------------------------------------------------------- + GENE ONTOLOGY: a GO-CAM model as a causal gene-to-gene network, + and GO terms (QuickGO annotations) as groupings for the view. + --------------------------------------------------------------- */ +const RO_LABELS = { + 'RO:0002629': 'directly positively regulates', + 'RO:0002630': 'directly negatively regulates', + 'RO:0002413': 'directly provides input for', + 'RO:0002211': 'regulates', + 'RO:0002213': 'positively regulates', + 'RO:0002212': 'negatively regulates', + 'RO:0002304': 'causally upstream of, positive effect', + 'RO:0002305': 'causally upstream of, negative effect', + 'RO:0002411': 'causally upstream of', + 'RO:0002418': 'causally upstream of or within', + 'RO:0012009': 'constitutively upstream of', + 'RO:0012010': 'removes input for', + 'RO:0002407': 'indirectly positively regulates', + 'RO:0002409': 'indirectly negatively regulates', +} + +const termOf = (x) => (x == null ? null : typeof x === 'string' ? x : x.term || x.id || null) + +export async function goLoadModels() { + await dbRun('go', async () => { + const taxon = document.getElementById('goTaxon').value + dbProgress('go', 'Listing GO-CAM models…') + const r = await dbFetch('go', `${DB_URLS.goapi}/taxon/${encodeURIComponent(taxon)}/models`) + const list = (Array.isArray(r) ? r : (r && (r.models || r.results)) || []).map((m) => + typeof m === 'string' + ? { id: m, title: m } + : { id: m.id || m.gocam || m.model_id, title: m.title || m.name || m.id || m.gocam } + ) + goState.models = list.filter((m) => m.id) + filterGoModels() + dbStatus('go', [ + { + level: goState.models.length ? 'ok' : 'warn', + text: goState.models.length + ? `Found ${plural(goState.models.length, 'GO-CAM model')}. Filter and choose one.` + : 'No GO-CAM models for this organism.', + }, + ]) + }) +} + +const goState = { models: [] } + +export function filterGoModels() { + const f = document.getElementById('goModelFilter').value.trim().toLowerCase() + const sel = document.getElementById('goModel') + sel.innerHTML = '' + goState.models + .filter( + (m) => + !f || String(m.title).toLowerCase().includes(f) || String(m.id).toLowerCase().includes(f) + ) + .slice(0, 300) + .forEach((m) => sel.add(new Option(m.title, m.id))) + document.getElementById('goPickRow').hidden = !goState.models.length +} + +export async function goFetchModel() { + await dbRun('go', async () => { + const typed = document.getElementById('goModelId').value.trim() + const id = (typed || document.getElementById('goModel').value || '').replace(/^gomodel:/, '') + if (!id) throw new Error('Choose a GO-CAM model or type its identifier.') + dbPlan('go', [6, 1]) + dbStep('go', 0, 'Downloading the GO-CAM model…') + const m = await dbFetch('go', `${DB_URLS.goapi}/gocam-model/${encodeURIComponent(id)}`) + const labels = new Map((m.objects || []).map((o) => [o.id, o.label || o.id])) + const label = (t) => { + const l = labels.get(t) || t + // gene product labels carry the organism, e.g. "Tp53 Mmus" + return String(l).replace(/\s+[A-Z][a-z]{3}$/, '') + } + const acts = new Map((m.activities || []).map((a) => [a.id, a])) + const enabler = (a) => termOf(a && a.enabled_by) + const nodeAttrs = {} + const nodeName = (t) => { + const n = label(t) + if (!nodeAttrs[n]) + nodeAttrs[n] = { + go_cam_id: t, + ...(/UniProtKB:/.test(t) ? { uniprot: t.replace('UniProtKB:', '') } : {}), + } + return n + } + const edges = [] + acts.forEach((a) => { + const src = enabler(a) + if (!src) return + nodeName(src) + ;(a.causal_associations || []).forEach((ca) => { + const down = acts.get(termOf(ca.downstream_activity)) + const tgt = enabler(down) + if (!tgt) return + const pred = termOf(ca.predicate) + edges.push({ + source: nodeName(src), + target: nodeName(tgt), + directed: true, + type: labels.get(pred) || RO_LABELS[pred] || pred || 'causally related', + }) + }) + }) + const byAspect = (field, lbl) => { + const groups = new Map() + acts.forEach((a) => { + const src = enabler(a) + const raw = a[field] + const terms = (Array.isArray(raw) ? raw : raw ? [raw] : []).map(termOf).filter(Boolean) + if (!src) return + terms.forEach((t) => { + const n = labels.get(t) || t + if (!groups.has(n)) + groups.set(n, { name: n, members: [], meta: { description: `${lbl} ${t}`, term: t } }) + groups.get(n).members.push(label(src)) + }) + }) + return { label: lbl, groups: [...groups.values()] } + } + const groupings = [ + byAspect('part_of', 'biological process'), + byAspect('occurs_in', 'cellular component'), + byAspect('molecular_function', 'molecular function'), + ] + if (!edges.length) throw new Error('This model has no causal links between gene products.') + dbStep('go', 1, 'Building and opening the network…') + await nextPaint() + dbAddImport('go', { + name: `GO-CAM ${m.title || id}`.slice(0, 90), + edges, + nodeAttrs, + groupings, + summary: `model ${id}; edges are causal relations between activities, pointing downstream`, + }) + }) +} + +export async function goAnnotateView() { + await dbRun('go', async () => { + if (!cy.nodes().length) throw new Error('Show a network first.') + const aspects = [...document.querySelectorAll('#goAspects input:checked')].map((i) => i.value) + if (!aspects.length) throw new Error('Tick at least one GO aspect.') + const noIEA = document.getElementById('goNoIea').checked + const minSize = Math.max(1, parseInt(document.getElementById('goMinSize').value, 10) || 2) + const maxGroups = Math.max(1, parseInt(document.getElementById('goMaxGroups').value, 10) || 30) + // UniProt accessions from node attributes or node names + const accOf = new Map() + cy.nodes().forEach((n) => { + const a = n.data('attrs') || {} + const acc = a.uniprot || (UNIPROT_RE.test(n.id()) ? n.id() : null) + if (acc) accOf.set(String(acc).replace(/-\d+$/, ''), n.id()) + }) + if (!accOf.size) + throw new Error( + 'No node has a UniProt accession (as its name or in a "uniprot" attribute). Networks from Reactome, OmniPath, IntAct and many NDEx networks carry them.' + ) + const accs = [...accOf.keys()] + const batches = [] + for (let i = 0; i < accs.length; i += 100) batches.push(accs.slice(i, i + 100)) + const groupings = [] + // steps: one per aspect, then adding the groupings + dbPlan('go', [...aspects.map(() => 3), 1]) + for (const [ai, aspect] of aspects.entries()) { + dbStep( + 'go', + ai, + `Reading ${aspect.replace('_', ' ')} annotations: 0 of ${batches.length} batches…` + ) + const terms = new Map() + const results = await dbMap( + 'go', + batches, + 2, + async (b) => { + const all = [] + for (let page = 1; page <= 25; page++) { + const r = await dbFetch( + 'go', + `${DB_URLS.quickgo}/annotation/search?geneProductId=${b.join(',')}&aspect=${aspect}&limit=200&page=${page}&includeFields=goName` + ) + all.push(...((r && r.results) || [])) + const info = r && r.pageInfo + if (!info || page >= (info.total || 1)) break + } + return all + }, + (d, n) => + dbProgress('go', `Reading ${aspect.replace('_', ' ')} annotations: ${d} of ${n} batches…`) + ) + results.flat().forEach((r) => { + if (!r || r.error) return + if (noIEA && (r.goEvidence === 'IEA' || r.evidenceCode === 'ECO:0000501')) return + if (r.qualifier && /^NOT/i.test(r.qualifier)) return + const acc = String(r.geneProductId || '') + .replace(/^UniProtKB:/, '') + .replace(/-\d+$/, '') + const node = accOf.get(acc) + if (!node) return + const name = r.goName || r.goId + if (!terms.has(name)) + terms.set(name, { + name, + members: new Set(), + meta: { description: `GO ${aspect.replace('_', ' ')}`, term: r.goId }, + }) + terms.get(name).members.add(node) + }) + const groups = [...terms.values()] + .map((t) => ({ ...t, members: [...t.members] })) + .filter((t) => t.members.length >= minSize && t.members.length < accOf.size) + .sort((a, b) => b.members.length - a.members.length) + .slice(0, maxGroups) + groupings.push({ label: `GO ${aspect.replace('_', ' ')}`, groups }) + } + dbStep('go', aspects.length, 'Adding the groupings…') + await nextPaint() + const v = activeView() + const entries = dbAddGroupingsToView('go', groupings, v ? v.name : 'View') + dbStatus( + 'go', + entries.length + ? [ + { + level: 'ok', + text: `Added ${plural(entries.length, 'GO grouping')} for ${plural(accOf.size, 'node')} with a UniProt accession: ${entries.map((e) => `${e.name.split(': ').pop()} (${e.parsed.summary})`).join(', ')}.`, + }, + ] + : [{ level: 'warn', text: 'No GO terms with enough members were found.' }] + ) + }) +} + +// page wiring, run by main.ts in the original order +export function init() { + document.getElementById('btnGaStats').addEventListener('click', renderGroupStats) + + document.getElementById('btnGaStatsTsv').addEventListener('click', groupStatsTsv) + + document.getElementById('btnGaGroupNet').addEventListener('click', openGroupNetwork) + + document.getElementById('btnGaEnrich').addEventListener('click', runEnrichment) + + document.getElementById('btnGaEnrichTsv').addEventListener('click', enrichmentTsv) + + document.getElementById('gaSets').addEventListener('change', refreshEnrichmentChoices) + + cy.on('select unselect', () => { + if (currentTab === 'profiler') refreshEnrichmentChoices() + }) + + document.getElementById('btnCompareNet').addEventListener('click', openComparisonNetwork) + + document.querySelectorAll('#infoExplore [data-steps]').forEach((btn) => + btn.addEventListener('click', () => { + if (S.infoNodeId && cy.$id(S.infoNodeId).length) + openNeighbourhood([S.infoNodeId], +btn.dataset.steps) + }) + ) + + document.getElementById('btnSelNeighbours').addEventListener('click', () => { + const ids = cy.nodes(':selected').map((n) => n.id()) + if (ids.length) openNeighbourhood(ids, 1) + }) + + document.getElementById('btnSelPaths').addEventListener('click', () => { + const ids = cy.nodes(':selected').map((n) => n.id()) + if (ids.length !== 2) return + // paths go from the node selected first to the one selected second + const rank = (id) => { + const i = selectionOrder.indexOf(id) + return i < 0 ? Infinity : i + } + const [a, b] = rank(ids[0]) <= rank(ids[1]) ? ids : [ids[1], ids[0]] + openShortestPaths(a, b) + }) + + cy.on('select', 'node', (e) => { + const id = e.target.id() + const i = selectionOrder.indexOf(id) + if (i >= 0) selectionOrder.splice(i, 1) + selectionOrder.push(id) + if (selectionOrder.length > 50) selectionOrder.shift() + }) + + cy.on('unselect', 'node', (e) => { + const i = selectionOrder.indexOf(e.target.id()) + if (i >= 0) selectionOrder.splice(i, 1) + }) + + document + .querySelectorAll('[data-export-format]') + .forEach((b) => b.addEventListener('click', () => exportOtherFormat(b.dataset.exportFormat))) + + document.getElementById('btnSessionSave').addEventListener('click', saveSession) + + document + .getElementById('btnSessionOpen') + .addEventListener('click', () => document.getElementById('sessionFileInput').click()) + + document.getElementById('sessionFileInput').addEventListener('change', (e) => { + const f = e.target.files[0] + if (f) openSessionFile(f) + e.target.value = '' + }) + + ;(function setupWebglToggle() { + const box = document.getElementById('webglToggle') + const note = document.getElementById('webglNote') + box.checked = WEBGL_ACTIVE + let stored = true + try { + localStorage.getItem('x') + } catch (e) { + stored = false + } + const describe = () => { + const wanted = box.checked + if (wanted === WEBGL_ACTIVE) { + note.textContent = WEBGL_ACTIVE ? 'WebGL drawing is on.' : '' + document.getElementById('btnWebglReload').hidden = true + } else { + note.textContent = stored + ? 'Reload the page to switch; open views are lost unless you save the session first.' + : 'This page can\u2019t store the setting; open it with ?webgl=1 at the end of the address instead.' + document.getElementById('btnWebglReload').hidden = !stored + } + } + box.addEventListener('change', () => { + try { + localStorage.setItem('norma3-webgl', box.checked ? '1' : '0') + } catch (e) {} + describe() + }) + document.getElementById('btnWebglReload').addEventListener('click', () => location.reload()) + describe() + })() + + document.getElementById('btnRuntime').addEventListener('click', runRuntimeTable) + + document.getElementById('btnRuntimeTsv').addEventListener('click', runtimeTsv) + + /* frames wiring */ + document.getElementById('btnFramePrev').addEventListener('click', () => { + stopFramePlay() + stepFrame(-1) + }) + + document.getElementById('btnFrameNext').addEventListener('click', () => { + stopFramePlay() + stepFrame(1) + }) + + document.getElementById('btnFramePlay').addEventListener('click', toggleFramePlay) + + document.getElementById('frameSlider').addEventListener('input', (e) => { + stopFramePlay() + showFrame(parseInt(e.target.value, 10) || 0) + }) + + document.getElementById('frameSpeed').addEventListener('change', () => { + if (frameState.playing) { + toggleFramePlay() + toggleFramePlay() + } + }) + + document.getElementById('btnFramesFromFiles').addEventListener('click', addFramesFromFiles) + + document.getElementById('valueSameRange').addEventListener('change', applyValueColors) + + document + .getElementById('btnCmpArenaOpen') + .addEventListener('click', () => openInArena3d('compare')) + + document + .getElementById('btnCmpArenaJson') + .addEventListener('click', () => exportArena3d('json', 'compare')) + + document + .getElementById('btnCmpArenaTsv') + .addEventListener('click', () => exportArena3d('tsv', 'compare')) + + dbState.cancelled = {} +} diff --git a/frontend/src/examples.ts b/frontend/src/examples.ts new file mode 100644 index 0000000..76c0f94 --- /dev/null +++ b/frontend/src/examples.ts @@ -0,0 +1,78 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { NORMA_EXAMPLE_SETS } from './wiring' +import { + addNormaEntry, + libSelection, + normaLibrary, + plural, + renderLibraryLists, + setLayoutMode, +} from './layouts/controls' +import { applyEdgeCurveStyle } from './profiler' +import { applyEdgeDirection } from './export/dialog' +import { applyValueColors, scheduleLegend } from './clustering/mapping' +import { refreshLibraryView } from './library' + +/* ---------- NORMA example sets ---------- */ +// A layout to use the next time a network loads (set by showcase examples). +let nextLoadLayout = null + +export function takeNextLoadLayout() { + const name = nextLoadLayout + nextLoadLayout = null + if (name) { + setLayoutMode('connections') + document.getElementById('layoutSelect').value = name + } + return name +} + +export function loadNormaExampleSet(key) { + const set = NORMA_EXAMPLE_SETS[key] + if (!set) return + const entries = set.files.map((f, i) => { + if (!normaLibrary[f.kind]) return null // e.g. original-format copies, offered only as downloads + const sourceKey = `${key}/${i}` + const existing = normaLibrary[f.kind].find((e) => e.sourceKey === sourceKey) + return ( + existing || + addNormaEntry(f.kind, f.name, f.text, f.fileName, sourceKey, { directed: !!f.directed }) + ) + }) + const real = entries.filter(Boolean) + const net = real.find((e) => e.kind === 'network') + const ann = real.find((e) => e.kind === 'annotation') + const col = real.find((e) => e.kind === 'colors') + libSelection.networks = new Set( + set.showAll ? real.filter((e) => e.kind === 'network').map((e) => e.id) : [net.id] + ) + libSelection.annotation = ann ? ann.id : '' + libSelection.colors = col ? col.id : '' + renderLibraryLists() + nextLoadLayout = (set.display && set.display.layoutSelect) || null + refreshLibraryView() + nextLoadLayout = null + // showcase examples switch on the features they are meant to show + if (set.display) { + Object.entries(set.display).forEach(([id, value]) => { + const el = document.getElementById(id) + if (!el) return + if (el.type === 'checkbox') el.checked = !!value + else el.value = value + if (id !== 'layoutSelect') el.dispatchEvent(new Event('change', { bubbles: true })) + }) + applyEdgeDirection() + applyEdgeCurveStyle() + applyValueColors() + scheduleLegend() + } + const others = real.filter((e) => e.kind === 'annotation').length - 1 + if (others > 0) { + const el = document.getElementById('normaStatus') + const note = document.createElement('div') + note.className = 'note ok' + note.textContent = `This example has ${plural(others, 'more annotation')} in the Annotations list. Tick one and choose Show in this view to switch; node positions are kept.` + el.insertBefore(note, el.children[1] || null) + } +} diff --git a/frontend/src/export/dialog.ts b/frontend/src/export/dialog.ts new file mode 100644 index 0000000..4b1b760 --- /dev/null +++ b/frontend/src/export/dialog.ts @@ -0,0 +1,611 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { S } from '../state' +import { activeView, applyEdgeCurveStyle } from '../profiler' +import { applyEdgeMerge } from '../parallel_edges' +import { + applyNodeSizing, + frPending, + getFrWorker, + invalidateFullMetrics, + updateStats, + workStep, +} from '../metrics' +import { buildSvg } from './svg' +import { buildSvg3d, export3dSize, renderExportCanvas3d } from '../view3d/export' +import { canvasToBlob, pdfFromCanvas, renderExportCanvas, withDpi } from './raster' +import { + composeCanvasWithLegend, + composeSvgWithLegend, + exportLegendLayout, + legendIsEmpty, + legendModel, +} from '../clustering/mapping' +import { currentTheme } from '../themes' +import { cy, setStyle } from '../cy' +import { exportFrame, exportPixelSize, exportSizeText, withLegendSize } from './draw' +import { markDirty3d, net3d } from '../view3d/state' +import { mulberry32 } from '../sample_data' +import { nextPaint, setStatus } from '../layouts/controls' + +/* ---------- dialog ---------- */ +const EXPORT_FORMATS = { + png: { label: 'PNG', ext: 'png', raster: true, transparent: true }, + jpeg: { label: 'JPEG', ext: 'jpg', raster: true, transparent: false }, + webp: { label: 'WebP', ext: 'webp', raster: true, transparent: true }, + svg: { label: 'SVG', ext: 'svg', raster: false, transparent: true }, + pdf: { label: 'PDF', ext: 'pdf', raster: true, transparent: false }, +} + +function exportOptions() { + const format = document.querySelector('input[name="imgFormat"]:checked').value + const bgMode = document.getElementById('imgBackground').value + const fmtInfo = EXPORT_FORMATS[format] + let bg = null + if (bgMode === 'theme') bg = currentTheme.bg + else if (bgMode === 'white') bg = '#ffffff' + else if (bgMode === 'custom') bg = document.getElementById('imgBgColor').value + if (!bg && !fmtInfo.transparent) bg = '#ffffff' + return { + format, + area: document.getElementById('imgArea').value, + scale: parseFloat(document.getElementById('imgScale').value) || 2, + bg, + hulls: + document.getElementById('imgHulls').checked && + document.getElementById('showGroupHulls').checked, + clean: document.getElementById('imgClean').checked, + legend: document.getElementById('imgLegend').checked, + fileName: (document.getElementById('imgFileName').value.trim() || 'network').replace( + /[\\/:*?"<>|]+/g, + '_' + ), + } +} + +function updateExportDialog() { + const o = exportOptions() + const info = EXPORT_FORMATS[o.format] + const bgSel = document.getElementById('imgBackground') + const transparentOpt = bgSel.querySelector('option[value="transparent"]') + transparentOpt.disabled = !info.transparent + if (!info.transparent && bgSel.value === 'transparent') bgSel.value = 'white' + document.getElementById('imgBgColor').hidden = bgSel.value !== 'custom' + document.getElementById('imgHullsRow').hidden = !document.getElementById('showGroupHulls').checked + document.getElementById('imgLegendRow').hidden = legendIsEmpty(legendModel()) + document.getElementById('imgScaleLabel').textContent = info.raster + ? 'Resolution' + : 'Size in the file' + document.getElementById('exportTitle').textContent = net3d.active + ? 'Export 3D image' + : 'Export image' + const size = document.getElementById('imgSize') + if (net3d.active) { + document.getElementById('btnImgSave').disabled = !cy.nodes().length + const s3 = export3dSize(o.scale) + const all3 = withLegendSize( + s3.outW, + s3.outH, + o.format === 'svg' ? Math.min(o.scale, 8) : s3.k, + o + ) + let t3 = exportSizeText(all3.W, all3.H, s3.k, o.format) + if (all3.W !== s3.outW) t3 += ' · legend included' + t3 += ' · 3D view from the current angle' + if (s3.limited) t3 += ' · reduced to the largest size this browser can draw' + size.textContent = t3 + document.getElementById('imgExt').textContent = '.' + info.ext + return + } + const frame = exportFrame(o.area) + if (!frame) { + size.textContent = 'Nothing is shown to export.' + document.getElementById('btnImgSave').disabled = true + return + } + document.getElementById('btnImgSave').disabled = false + const { W, H, k, limited } = exportPixelSize(frame, o.scale) + const all = withLegendSize(W, H, k / frame.unitPx, o) + let text = exportSizeText(all.W, all.H, k / frame.unitPx, o.format) + if (all.W !== W) text += ' · legend included' + if (limited) text += ' · reduced to the largest size this browser can draw' + size.textContent = text + document.getElementById('imgExt').textContent = '.' + info.ext +} + +function openExportDialog() { + const dlg = document.getElementById('exportDialog') + const v = typeof activeView === 'function' && activeView() + const nameInput = document.getElementById('imgFileName') + nameInput.value = (v ? v.name : 'network').replace(/[\\/:*?"<>|]+/g, '_') + setStatus('imgStatus', []) + updateExportDialog() + if (typeof dlg.showModal === 'function') dlg.showModal() + else dlg.setAttribute('open', '') +} + +async function saveExportImage() { + const o = exportOptions() + const info = EXPORT_FORMATS[o.format] + const btn = document.getElementById('btnImgSave') + btn.disabled = true + setStatus('imgStatus', [{ level: 'busy', text: 'Drawing the image…', progress: null }]) + await nextPaint() + // optionally hide selection and search/click highlighting while drawing + let restore = null + if (o.clean) { + const selected = cy.elements(':selected') + const dimmed = cy.elements('.dimmed'), + highlighted = cy.elements('.highlighted') + cy.batch(() => { + selected.unselect() + dimmed.removeClass('dimmed') + highlighted.removeClass('highlighted') + }) + restore = () => + cy.batch(() => { + selected.select() + dimmed.addClass('dimmed') + highlighted.addClass('highlighted') + }) + } + try { + let blob + // the legend (optional) is drawn to the right, at the picture's scale + const legendLayout = exportLegendLayout(o) + const mime = + o.format === 'jpeg' ? 'image/jpeg' : o.format === 'webp' ? 'image/webp' : 'image/png' + if (net3d.active) { + net3d.cacheDirty = true + const s3 = export3dSize(o.scale) + if (o.format === 'svg') { + const text = await buildSvg3d({ ...o, title: o.fileName }).text() + blob = new Blob([composeSvgWithLegend(text, legendLayout, Math.min(o.scale, 8), o.bg)], { + type: 'image/svg+xml', + }) + } else { + const canvas = composeCanvasWithLegend(renderExportCanvas3d(o), legendLayout, s3.k, o.bg) + blob = + o.format === 'pdf' + ? await pdfFromCanvas(canvas, canvas.width / s3.k, canvas.height / s3.k) + : await withDpi(await canvasToBlob(canvas, mime, 0.95), 96 * s3.k) + } + } else if (o.format === 'svg') { + cy.forceRender && cy.forceRender() + const frame = exportFrame(o.area) + const { k } = exportPixelSize(frame, o.scale) + const text = await buildSvg({ ...o, title: o.fileName }).text() + blob = new Blob([composeSvgWithLegend(text, legendLayout, k / frame.unitPx, o.bg)], { + type: 'image/svg+xml', + }) + } else { + cy.forceRender && cy.forceRender() + const frame = exportFrame(o.area) + const { k } = exportPixelSize(frame, o.scale) + const legendScale = k / frame.unitPx + const canvas = composeCanvasWithLegend(renderExportCanvas(o), legendLayout, legendScale, o.bg) + if (o.format === 'pdf') { + // one screen pixel = one point, so the page matches the on-screen size + blob = await pdfFromCanvas(canvas, canvas.width / legendScale, canvas.height / legendScale) + } else { + blob = await withDpi(await canvasToBlob(canvas, mime, 0.95), 96 * legendScale) + } + } + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${o.fileName}.${info.ext}` + document.body.appendChild(a) + a.click() + a.remove() + setTimeout(() => URL.revokeObjectURL(url), 2000) + setStatus('imgStatus', [ + { + level: 'ok', + text: `Saved ${o.fileName}.${info.ext} (${(blob.size / 1024 / 1024).toFixed(blob.size > 1024 * 1024 ? 1 : 2)} MB).`, + }, + ]) + } catch (err) { + setStatus('imgStatus', [ + { level: 'error', text: `The image couldn't be saved: ${err.message}` }, + ]) + } finally { + if (restore) restore() + if (net3d.active) markDirty3d() + btn.disabled = false + } +} + +/* ============================================================ + EDGE DIRECTION + Each edge may be directed (source -> target) or undirected, from its + file or JSON. The view's Direction setting decides how edges are + handled: as in the data, all as directed, or all as undirected. + Arrows, merging, node degrees and centralities, the breadthfirst + layout, node and edge details, the Profiler, Compare and exports all + follow the effective direction. + ============================================================ */ +export function directionMode() { + const el = document.getElementById('edgeDirection') + return el ? el.value : 'data' +} + +export function edgeIsDirected(e) { + const mode = directionMode() + if (mode === 'directed') return true + if (mode === 'undirected') return false + return !!e.data('directed') +} + +// 'undirected', 'directed' or 'mixed' for a collection of edges +export function directionOf(edges) { + let d = 0, + n = 0 + edges.forEach((e) => { + n++ + if (edgeIsDirected(e)) d++ + }) + return d === 0 ? 'undirected' : d === n ? 'directed' : 'mixed' +} + +export function anyEdgeDirected(edges) { + return (edges || cy.edges()).some((e) => edgeIsDirected(e)) +} + +export function arrowsShown() { + return directionMode() !== 'undirected' && anyEdgeDirected(cy.edges()) +} + +export function applyEdgeDirection() { + const shape = document.getElementById('arrowShape').value + const scale = parseFloat(document.getElementById('arrowScale').value) || 1 + document.getElementById('arrowScaleValue').textContent = scale.toFixed(1) + '×' + const anyDirectedData = cy.edges().some((e) => e.data('directed')) + document.getElementById('edgeDirectionHint').textContent = + directionMode() === 'data' + ? anyDirectedData + ? 'Arrows show the edges marked as directed in the data.' + : 'No edge in this view is marked as directed.' + : directionMode() === 'directed' + ? 'Every edge is treated as pointing from its source to its target.' + : anyDirectedData + ? 'Direction is ignored (the default). This network has directed edges: choose "As in the data" to show them.' + : 'Direction is ignored everywhere (the default), as in earlier versions of NORMA.' + cy.batch(() => { + cy.edges().forEach((e) => { + const arrow = edgeIsDirected(e) ? shape : 'none' + if (e.data('arrow') !== arrow) e.data('arrow', arrow) + }) + }) + document.getElementById('arrowControls').hidden = !arrowsShown() + setStyle('edge', { + 'target-arrow-shape': 'data(arrow)', + 'target-arrow-color': 'data(color)', + 'arrow-scale': scale, + }) + // merged edges and haystack edges depend on direction + applyEdgeMerge() + applyEdgeCurveStyle() + invalidateFullMetrics() + if (document.getElementById('sizeMetric').value !== 'fixed') applyNodeSizing() + updateStats() +} + +/* ============================================================ + 3D NETWORK + A second view of the same network, drawn in 3D on a plain canvas. + Everything visual is read from the 2D (Cytoscape) elements, so groups, + colors, pies, shapes, labels, edge labels, arrows, selection, search + highlighting and ticked groups/channels look and behave the same. + Only node positions (x, y, z) and the camera are 3D-specific; both are + kept per view. + Coordinates: x to the right, y down (as in 2D), z away from the viewer. + ============================================================ */ + +/* ---------- 3D force-directed layout (weighted Fruchterman-Reingold) ---------- + Self-contained so it can also run in the background worker. + edges: [{ s, t, w }] with node ids; init: optional { id: [x, y, z] }. */ +export function fr3dLayout(ids, edges, opts) { + opts = opts || {} + const n = ids.length + const out = {} + if (!n) return out + const rand = mulberry32(opts.seed || 123) + const idx = new Map(ids.map((id, i) => [id, i])) + const K = opts.spacing || 60 // ideal edge length + const E = [] + let maxW = 0 + edges.forEach((e) => { + const a = idx.get(e.s), + b = idx.get(e.t) + if (a === undefined || b === undefined || a === b) return + const w = Number.isFinite(e.w) && e.w > 0 ? e.w : 1 + if (w > maxW) maxW = w + E.push([a, b, w]) + }) + // connected components (union-find); each is laid out on its own, as in + // the 2D weighted layout, so unconnected pieces don't drift away + const parent = Int32Array.from({ length: n }, (_, i) => i) + const find = (x) => { + while (parent[x] !== x) { + parent[x] = parent[parent[x]] + x = parent[x] + } + return x + } + E.forEach(([a, b]) => { + const ra = find(a), + rb = find(b) + if (ra !== rb) parent[ra] = rb + }) + const compMap = new Map() + for (let i = 0; i < n; i++) { + const r = find(i) + if (!compMap.has(r)) compMap.set(r, []) + compMap.get(r).push(i) + } + const comps = [...compMap.values()].sort((x, y) => y.length - x.length) + const compOf = new Int32Array(n), + local = new Int32Array(n) + comps.forEach((m, c) => + m.forEach((g, i) => { + compOf[g] = c + local[g] = i + }) + ) + const compEdges = comps.map(() => []) + E.forEach(([a, b, w]) => compEdges[compOf[a]].push([local[a], local[b], w])) + + function layoutComponent(m, CE) { + const N = m + const X = new Float64Array(N), + Y = new Float64Array(N), + Z = new Float64Array(N) + const R0 = K * Math.cbrt(N) * 0.9 + for (let i = 0; i < N; i++) { + let x, y, z + do { + x = rand() * 2 - 1 + y = rand() * 2 - 1 + z = rand() * 2 - 1 + } while (x * x + y * y + z * z > 1) + X[i] = x * R0 + Y[i] = y * R0 + Z[i] = z * R0 + } + if (N === 1) { + X[0] = Y[0] = Z[0] = 0 + return { X, Y, Z, R: K * 0.5 } + } + const iters = opts.iterations || (N <= 300 ? 400 : N <= 1500 ? 250 : 120) + const DX = new Float64Array(N), + DY = new Float64Array(N), + DZ = new Float64Array(N) + const K2 = K * K + const useGrid = N > 1500 + let temp = R0 * 0.5 + const cool = temp / (iters + 1) + for (let it = 0; it < iters; it++) { + if ((it & 3) === 0) workStep(it / iters) + DX.fill(0) + DY.fill(0) + DZ.fill(0) + if (!useGrid) { + for (let i = 0; i < N; i++) { + for (let j = i + 1; j < N; j++) { + let dx = X[i] - X[j], + dy = Y[i] - Y[j], + dz = Z[i] - Z[j] + let d2 = dx * dx + dy * dy + dz * dz + if (d2 < 0.01) { + dx = rand() - 0.5 + dy = rand() - 0.5 + dz = rand() - 0.5 + d2 = 0.01 + } + const f = K2 / d2 + DX[i] += dx * f + DY[i] += dy * f + DZ[i] += dz * f + DX[j] -= dx * f + DY[j] -= dy * f + DZ[j] -= dz * f + } + } + } else { + // repulsion only from nodes in the neighbouring grid cells + const cell = 2 * K + const grid = new Map() + const key = (p, q, r) => p + ',' + q + ',' + r + for (let i = 0; i < N; i++) { + const k = key(Math.floor(X[i] / cell), Math.floor(Y[i] / cell), Math.floor(Z[i] / cell)) + const list = grid.get(k) + if (list) list.push(i) + else grid.set(k, [i]) + } + for (let i = 0; i < N; i++) { + const cx = Math.floor(X[i] / cell), + cy = Math.floor(Y[i] / cell), + cz = Math.floor(Z[i] / cell) + for (let p = -1; p <= 1; p++) + for (let q = -1; q <= 1; q++) + for (let r = -1; r <= 1; r++) { + const list = grid.get(key(cx + p, cy + q, cz + r)) + if (!list) continue + for (let t = 0; t < list.length; t++) { + const j = list[t] + if (j === i) continue + let dx = X[i] - X[j], + dy = Y[i] - Y[j], + dz = Z[i] - Z[j] + let d2 = dx * dx + dy * dy + dz * dz + if (d2 < 0.01) { + dx = rand() - 0.5 + dy = rand() - 0.5 + dz = rand() - 0.5 + d2 = 0.01 + } + const f = K2 / d2 + DX[i] += dx * f + DY[i] += dy * f + DZ[i] += dz * f + } + } + } + } + for (let e = 0; e < CE.length; e++) { + const [p, q, w] = CE[e] + const dx = X[p] - X[q], + dy = Y[p] - Y[q], + dz = Z[p] - Z[q] + const d = Math.sqrt(dx * dx + dy * dy + dz * dz) || 0.01 + const f = (d / K) * (w / maxW) // (d^2 / K) / d, scaled by weight + DX[p] -= dx * f + DY[p] -= dy * f + DZ[p] -= dz * f + DX[q] += dx * f + DY[q] += dy * f + DZ[q] += dz * f + } + for (let i = 0; i < N; i++) { + DX[i] -= X[i] * 0.01 + DY[i] -= Y[i] * 0.01 + DZ[i] -= Z[i] * 0.01 + const d = Math.sqrt(DX[i] * DX[i] + DY[i] * DY[i] + DZ[i] * DZ[i]) || 1 + const step = Math.min(d, temp) + X[i] += (DX[i] / d) * step + Y[i] += (DY[i] / d) * step + Z[i] += (DZ[i] / d) * step + } + temp = Math.max(K * 0.02, temp - cool) + } + let mx = 0, + my = 0, + mz = 0 + for (let i = 0; i < N; i++) { + mx += X[i] + my += Y[i] + mz += Z[i] + } + mx /= N + my /= N + mz /= N + let R = 0 + for (let i = 0; i < N; i++) { + X[i] -= mx + Y[i] -= my + Z[i] -= mz + R = Math.max(R, Math.sqrt(X[i] * X[i] + Y[i] * Y[i] + Z[i] * Z[i])) + } + return { X, Y, Z, R: R + K * 0.5 } + } + + let laidOut = 0 + const parts = comps.map((m, c) => { + S.workRange = [laidOut / n, (laidOut + m.length) / n] + laidOut += m.length + return layoutComponent(m.length, compEdges[c]) + }) + S.workRange = [0, 1] + // pack: the largest piece in the middle, the others on a shell around it + const centers = [[0, 0, 0]] + if (parts.length > 1) { + const others = parts.slice(1) + const maxR = Math.max(...others.map((p) => p.R)) + const area = others.reduce((s, p) => s + Math.PI * (p.R + K * 0.5) ** 2, 0) * 1.4 + const shell = Math.max(parts[0].R + maxR + K, Math.sqrt(area / (4 * Math.PI))) + const golden = Math.PI * (3 - Math.sqrt(5)) + others.forEach((p, i) => { + const m = others.length + const y = m === 1 ? 0 : 1 - (i / (m - 1)) * 2 + const r = Math.sqrt(Math.max(0, 1 - y * y)) + const t = golden * i + centers.push([Math.cos(t) * r * shell, y * shell, Math.sin(t) * r * shell]) + }) + } + comps.forEach((m, c) => { + const p = parts[c], + ctr = centers[c] + m.forEach((g, i) => { + out[ids[g]] = [p.X[i] + ctr[0], p.Y[i] + ctr[1], p.Z[i] + ctr[2]] + }) + }) + return out +} + +export function fr3dAsync(ids, edges, opts, onProgress) { + const worker = ids.length > 150 ? getFrWorker() : null + if (!worker) return Promise.resolve(fr3dLayout(ids, edges, opts)) + const id = ++S.frRequestSeq + return new Promise((resolve) => { + frPending.set(id, { resolve, onProgress, local: () => fr3dLayout(ids, edges, opts) }) + worker.postMessage({ id, kind: 'fr3d', ids, edges, opts }) + }) +} + +// Points spread evenly on a unit sphere (Fibonacci lattice). +export function fibonacciSphere(count) { + const pts = [] + const golden = Math.PI * (3 - Math.sqrt(5)) + for (let i = 0; i < count; i++) { + const y = count === 1 ? 0 : 1 - (i / (count - 1)) * 2 + const r = Math.sqrt(Math.max(0, 1 - y * y)) + const t = golden * i + pts.push([Math.cos(t) * r, y, Math.sin(t) * r]) + } + return pts +} + +// Points filling a ball of the given radius, first ones in the middle. +export function fibonacciBall(count, radius) { + const shell = fibonacciSphere(count) + return shell.map((p, i) => { + const r = radius * Math.cbrt((i + 0.5) / count) + return [p[0] * r, p[1] * r, p[2] * r] + }) +} + +// page wiring, run by main.ts in the original order +export function init() { + ;['imgArea', 'imgScale', 'imgBackground', 'imgHulls', 'imgClean', 'imgLegend'].forEach((id) => { + document.getElementById(id).addEventListener('change', updateExportDialog) + }) + + document + .querySelectorAll('input[name="imgFormat"]') + .forEach((r) => r.addEventListener('change', updateExportDialog)) + + document.getElementById('btnImgSave').addEventListener('click', saveExportImage) + + document + .getElementById('btnImgCancel') + .addEventListener('click', () => document.getElementById('exportDialog').close()) + + document.getElementById('btnExportImage').addEventListener('click', openExportDialog) + + document.getElementById('btnExportImage2').addEventListener('click', openExportDialog) + + ;(function () { + const tag = document.getElementById('statDirection') + const useData = () => { + if (!tag.classList.contains('stat-dir-off')) return + const sel = document.getElementById('edgeDirection') + sel.value = 'data' + // a real change event, so undo and the view's settings follow as usual + sel.dispatchEvent(new Event('change', { bubbles: true })) + } + tag.addEventListener('click', useData) + tag.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault() + useData() + } + }) + })() + + ;['edgeDirection', 'arrowShape'].forEach((id) => { + document.getElementById(id).addEventListener('change', applyEdgeDirection) + }) + + document.getElementById('arrowScale').addEventListener('input', applyEdgeDirection) +} diff --git a/frontend/src/export/draw.ts b/frontend/src/export/draw.ts new file mode 100644 index 0000000..22526fd --- /dev/null +++ b/frontend/src/export/draw.ts @@ -0,0 +1,516 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { PROFILE_DIRECTED_STATS } from '../directed_stats' +import { PROFILE_STATS, formatStat } from '../wiring' +import { + UPSET_MAX_COLUMNS, + compareState, + exclusiveCounts, + jaccard, + membership, + runComparison, + spearman, +} from '../label_colors' +import { cy } from '../cy' +import { downloadText } from '../layouts/controls' +import { escapeHtml } from '../network_state' +import { layoutLegend, legendIsEmpty, legendModel } from '../clustering/mapping' +import { profSection } from '../profiler' + +/* ---------- drawing ---------- */ +function legendHtml(nets) { + return `
    ${nets + .map( + (n, i) => + `${String.fromCharCode(65 + i)}: ${escapeHtml(n.name)}` + ) + .join('')}
    ` +} + +// Venn diagram for 2 or 3 sets. Regions are labelled with exact counts; +// circle sizes are schematic, not proportional. +function vennSvg(nets, sets) { + const k = sets.length + const counts = exclusiveCounts(membership(sets)) + const c = (m) => (counts.get(m) || 0).toLocaleString() + const W = 360, + H = k === 2 ? 220 : 300 + const circles = + k === 2 + ? [ + { x: 140, y: 110, r: 88 }, + { x: 220, y: 110, r: 88 }, + ] + : [ + { x: 145, y: 115, r: 88 }, + { x: 215, y: 115, r: 88 }, + { x: 180, y: 180, r: 88 }, + ] + const regions = + k === 2 + ? [ + [1, 95, 114], + [2, 265, 114], + [3, 180, 114], + ] + : [ + [1, 105, 100], + [2, 255, 100], + [4, 180, 240], + [3, 180, 72], + [5, 128, 178], + [6, 232, 178], + [7, 180, 140], + ] + let svg = `` + circles.forEach((ci, i) => { + svg += `` + }) + const labelPos = + k === 2 + ? [ + [12, 18, 'start'], + [348, 18, 'end'], + ] + : [ + [12, 18, 'start'], + [348, 18, 'end'], + [180, 294, 'middle'], + ] + labelPos.forEach(([x, y, anchor], i) => { + svg += `${String.fromCharCode(65 + i)} (${sets[i].size.toLocaleString()})` + }) + regions.forEach(([m, x, y]) => { + svg += `${c(m)}` + }) + return svg + '' +} + +// UpSet plot: every non-empty exclusive intersection as a column. +function upsetSvg(nets, sets) { + const k = sets.length + const allCounts = [...exclusiveCounts(membership(sets))].sort( + (a, b) => b[1] - a[1] || a[0] - b[0] + ) + // with many networks there can be hundreds of combinations: show the largest + const counts = allCounts.slice(0, UPSET_MAX_COLUMNS) + const colW = k > 6 ? 22 : 26, + rowH = k > 6 ? 18 : 22, + leftW = 220, + barH = 130, + top = 16 + const W = leftW + counts.length * colW + 20 + const H = top + barH + 10 + k * rowH + 10 + const maxC = Math.max(...counts.map((x) => x[1]), 1) + const maxS = Math.max(...sets.map((s) => s.size), 1) + let svg = `` + // intersection bars + counts.forEach(([m, n], j) => { + const x = leftW + j * colW + 5 + const h = (n / maxC) * barH + const y = top + barH - h + svg += `${n.toLocaleString()} in exactly: ${sets + .map((_, i) => ((m >> i) & 1 ? String.fromCharCode(65 + i) : '')) + .filter(Boolean) + .join(' + ')}` + svg += `${n.toLocaleString()}` + }) + svg += `` + // set rows: size bars (right-aligned toward the matrix) and names + const matrixTop = top + barH + 10 + sets.forEach((set, i) => { + const y = matrixTop + i * rowH + if (i % 2 === 0) + svg += `` + const bw = (set.size / maxS) * 60 + svg += `${set.size.toLocaleString()}` + const short = nets[i].name.length > 20 ? nets[i].name.slice(0, 19) + '…' : nets[i].name + svg += `${escapeHtml(nets[i].name)}${String.fromCharCode(65 + i)} ${escapeHtml(short)}` + }) + // dot matrix + counts.forEach(([m], j) => { + const cx = leftW + j * colW + colW / 2 + const on = [] + for (let i = 0; i < k; i++) { + const cy0 = matrixTop + i * rowH + rowH / 2 + const member = (m >> i) & 1 + if (member) on.push(cy0) + svg += `` + } + if (on.length > 1) + svg += `` + }) + svg += '' + if (allCounts.length > counts.length) { + const rest = allCounts.slice(counts.length).reduce((t, c) => t + c[1], 0) + svg += `

    Showing the ${counts.length} largest of ${allCounts.length} combinations; the other ${allCounts.length - counts.length} hold ${rest.toLocaleString()} items in total (all are in the downloaded table).

    ` + } + return svg +} + +// Square matrix with shaded cells; values in [0, 1] unless `signed`. +function matrixHtml(nets, value, format, signed) { + let html = + '' + + nets + .map( + (n, i) => + `` + ) + .join('') + + '' + nets.forEach((a, i) => { + html += `` + nets.forEach((b, j) => { + const v = value(i, j) + if (v == null || Number.isNaN(v.v)) { + html += '' + return + } + const t = signed ? Math.abs(v.v) : v.v + const hue = signed && v.v < 0 ? '#dc2626' : '#2563eb' + html += `` + }) + html += '' + }) + return html + '
    ${String.fromCharCode(65 + i)}
    ${String.fromCharCode(65 + i)}–${format(v)}
    ' +} + +// Complementary cumulative degree distribution, log-log, one line per network. +function ccdfSvg(nets) { + const W = 520, + H = 260, + L = 48, + R = 12, + T = 12, + B = 34 + const pw = W - L - R, + ph = H - T - B + let maxDeg = 1 + nets.forEach((n) => + n.stats.degree.forEach((d) => { + if (d > maxDeg) maxDeg = d + }) + ) + const lx = Math.log10(Math.max(maxDeg, 10)) + const minP = Math.min( + ...nets.map((n) => 1 / Math.max(1, n.stats.degree.filter((d) => d > 0).length)) + ) + const ly = Math.log10(minP) + const X = (d) => L + (Math.log10(d) / lx) * pw + const Y = (p) => T + (Math.log10(p) / ly) * ph + let svg = `` + svg += `` + for (let e = 0; e <= Math.ceil(lx); e++) { + const d = 10 ** e + if (d > 10 ** lx * 1.001) break + svg += `${d.toLocaleString()}` + } + for (let e = 0; e >= Math.ceil(ly - 1e-9); e--) { + const p = 10 ** e + svg += `${p >= 0.01 ? p : p.toExponential(0)}` + } + svg += `degree k` + svg += `share of nodes with degree ≥ k` + nets.forEach((n) => { + const degs = n.stats.degree.filter((d) => d > 0).sort((a, b) => a - b) + if (!degs.length) return + const pts = [] + const total = degs.length + for (let i = 0; i < degs.length; i++) { + if (i > 0 && degs[i] === degs[i - 1]) continue + pts.push([degs[i], (total - i) / total]) + } + const path = pts + .map(([d, p], i) => `${i ? 'L' : 'M'}${X(d).toFixed(1)},${Y(p).toFixed(1)}`) + .join('') + svg += `` + }) + return svg + '' +} + +export function renderComparison(nets) { + const root = document.getElementById('cmpResults') + root.innerHTML = '' + const letter = (i) => String.fromCharCode(65 + i) + const nodeSets = nets.map((n) => n.nodeSet) + const edgeSets = nets.map((n) => n.edgeSet) + + // overview + const ov = profSection( + 'Networks compared', + document.getElementById('cmpDirected').checked + ? 'Nodes are matched by name, edges by the nodes they join and their direction (channels ignored): A→B, B→A and an undirected A–B are different edges.' + : 'Nodes are matched by name, edges by the pair of nodes they join (channels and direction ignored).' + ) + ov.insertAdjacentHTML( + 'beforeend', + `
    ${nets + .map( + (n, i) => ` +
    +
    ${letter(i)}
    +
    ${escapeHtml(n.name)}
    +
    ${n.kind === 'view' ? 'view' : 'file'} · ${n.nodeSet.size.toLocaleString()} nodes · ${n.edgeSet.size.toLocaleString()} edges
    +
    ` + ) + .join('')}
    ` + ) + const shareNodes = [...membership(nodeSets).values()].filter( + (m) => m === (1 << nets.length) - 1 + ).length + const shareEdges = [...membership(edgeSets).values()].filter( + (m) => m === (1 << nets.length) - 1 + ).length + ov.insertAdjacentHTML( + 'beforeend', + `

    ${shareNodes.toLocaleString()} nodes and ${shareEdges.toLocaleString()} edges are in all ${nets.length} networks.

    ` + ) + root.appendChild(ov) + + // overlaps + ;[ + ['Node overlap', nodeSets, 'nodes'], + ['Edge overlap', edgeSets, 'edges'], + ].forEach(([title, sets, what]) => { + const sec = profSection( + title, + nets.length <= 3 + ? `Left: Venn diagram with the number of ${what} in each region (circle sizes are schematic). Right: the same regions as an UpSet plot, largest first.` + : `UpSet plot: each column is a group of ${what} found in exactly the networks marked below it, largest first. Venn diagrams are shown for two or three networks.` + ) + sec.insertAdjacentHTML('beforeend', legendHtml(nets)) + const cards = document.createElement('div') + cards.className = 'cmp-row' + if (nets.length <= 3) + cards.insertAdjacentHTML( + 'beforeend', + `
    ${vennSvg(nets, sets)}
    ` + ) + cards.insertAdjacentHTML( + 'beforeend', + `
    ${upsetSvg(nets, sets)}
    ` + ) + sec.appendChild(cards) + sec.insertAdjacentHTML( + 'beforeend', + `

    Jaccard similarity of ${what}

    Shared ${what} divided by ${what} in either network; hover a cell for the counts.

    ` + + matrixHtml( + nets, + (i, j) => { + const r = jaccard(sets[i], sets[j]) + return { + v: r.j, + title: `${letter(i)} and ${letter(j)}: ${r.inter.toLocaleString()} shared of ${r.union.toLocaleString()}`, + } + }, + (v) => v.v.toFixed(2) + ) + ) + root.appendChild(sec) + }) + + // degree agreement + const dsec = profSection( + 'Degree agreement on shared nodes', + 'Spearman correlation between the degrees a node has in two networks, over the nodes both contain. Near 1: hubs stay hubs; near 0: unrelated; negative: roles swap.' + ) + dsec.insertAdjacentHTML( + 'beforeend', + matrixHtml( + nets, + (i, j) => { + if (i === j) return { v: 1, title: 'same network' } + const shared = [...nets[i].nodeSet].filter((x) => nets[j].nodeSet.has(x)) + const rho = spearman( + shared.map((x) => nets[i].degreeOf.get(x)), + shared.map((x) => nets[j].degreeOf.get(x)) + ) + return { v: rho, title: `${shared.length.toLocaleString()} shared nodes` } + }, + (v) => v.v.toFixed(2), + true + ) + ) + root.appendChild(dsec) + + // degree distributions + const ccdf = profSection( + 'Degree distributions', + 'Share of nodes with at least a given degree, on log–log axes. Straight, shallow lines indicate heavy-tailed (hub-dominated) networks.' + ) + ccdf.insertAdjacentHTML( + 'beforeend', + legendHtml(nets) + `
    ${ccdfSvg(nets)}
    ` + ) + root.appendChild(ccdf) + + // topology table + const tsec = profSection( + 'Topology side by side', + 'The profiler\u2019s statistics for each network. The highest value in each row is marked.' + ) + let html = + '
    ' + + nets + .map( + (n, i) => + `` + ) + .join('') + + '' + PROFILE_STATS.forEach((st) => { + const vals = nets.map((n) => n.stats[st.key]) + const nums = vals.filter((v) => typeof v === 'number' && Number.isFinite(v)) + const max = nums.length > 1 && new Set(nums).size > 1 ? Math.max(...nums) : null + html += + `` + + vals + .map( + (v) => + `` + ) + .join('') + + '' + }) + if (nets.some((n) => n.directed)) { + html += `` + PROFILE_DIRECTED_STATS.forEach((st) => { + const vals = nets.map((n) => (n.directed ? n.directed[st.key] : undefined)) + const nums = vals.filter((v) => typeof v === 'number' && Number.isFinite(v)) + const max = nums.length > 1 && new Set(nums).size > 1 ? Math.max(...nums) : null + html += + `` + + vals + .map( + (v) => + `` + ) + .join('') + + '' + }) + } + html += '
    Statistic${letter(i)}: ${escapeHtml(n.name)}
    ${escapeHtml(st.label)}${escapeHtml(st.desc)}${escapeHtml(formatStat(v))}
    Direction
    ${escapeHtml(st.label)}${escapeHtml(st.desc)}${v === undefined ? '—' : escapeHtml(formatStat(v))}
    ' + tsec.insertAdjacentHTML('beforeend', html) + root.appendChild(tsec) +} + +function comparisonTsv() { + const nets = compareState.results + if (!nets) return + const letter = (i) => String.fromCharCode(65 + i) + const header = nets.map((n, i) => `${letter(i)} ${n.name}`) + const nodeMask = membership(nets.map((n) => n.nodeSet)) + const edgeMask = membership(nets.map((n) => n.edgeSet)) + const lines = ['# Node membership (1 = present)', ['Node', ...header].join('\t')] + ;[...nodeMask] + .sort((a, b) => (a[0] < b[0] ? -1 : 1)) + .forEach(([id, m]) => lines.push([id, ...nets.map((_, i) => (m >> i) & 1)].join('\t'))) + lines.push( + '', + '# Edge membership (1 = present)', + ['Source', 'Target', 'Direction', ...header].join('\t') + ) + ;[...edgeMask] + .sort((a, b) => (a[0] < b[0] ? -1 : 1)) + .forEach(([key, m]) => + lines.push([...key.split('\t'), ...nets.map((_, i) => (m >> i) & 1)].join('\t')) + ) + lines.push('', '# Topology', ['Statistic', ...header].join('\t')) + PROFILE_STATS.forEach((st) => + lines.push([st.label, ...nets.map((n) => formatStat(n.stats[st.key]))].join('\t')) + ) + if (nets.some((n) => n.directed)) + PROFILE_DIRECTED_STATS.forEach((st) => + lines.push( + [st.label, ...nets.map((n) => (n.directed ? formatStat(n.directed[st.key]) : ''))].join( + '\t' + ) + ) + ) + downloadText('network-comparison.tsv', lines.join('\n') + '\n') +} + +/* ============================================================ + IMAGE EXPORT + PNG, JPEG and WebP are drawn by Cytoscape's own renderer onto a + canvas of the chosen resolution, with group shading drawn underneath + (it lives on a separate layer on screen). SVG is written element by + element, so it stays sharp at any size and can be edited. PDF wraps + a high-resolution JPEG in a single page sized to the image. + Area: the whole shown network, or exactly what is on screen. + ============================================================ */ +export const EXPORT_MAX_SIDE = 16384 +// common browser canvas limit +export const EXPORT_MAX_PIXELS = (16384 * 16384) / 2.2 + +export function exportFrame(area) { + if (area === 'view') { + const z = cy.zoom(), + pan = cy.pan() + return { x1: -pan.x / z, y1: -pan.y / z, w: cy.width() / z, h: cy.height() / z, unitPx: z } + } + const eles = cy.elements().filter((e) => e.visible()) + if (!eles.length) return null + const bb = eles.boundingBox({ includeLabels: true, includeOverlays: false }) + // room for group shading around the outermost nodes + const shading = document.getElementById('showGroupHulls').checked ? hullMarginModel() + 8 : 0 + const pad = Math.max(16, shading) + return { x1: bb.x1 - pad, y1: bb.y1 - pad, w: bb.w + 2 * pad, h: bb.h + 2 * pad, unitPx: 1 } +} + +// Shading margin in model units, matching what the screen shows. +export function hullMarginModel() { + const z = cy.zoom() + return (30 * Math.max(0.15, Math.min(1, z))) / z +} + +// Size of the whole exported picture, including the legend when it is added. +export function withLegendSize(W, H, factor, o) { + if (!o.legend || document.getElementById('imgLegendRow').hidden) return { W, H } + const m = legendModel() + if (legendIsEmpty(m)) return { W, H } + const lay = layoutLegend(m) + return { W: W + Math.round(lay.w * factor), H: Math.max(H, Math.round(lay.h * factor)) } +} + +// "W × H px · N dpi at on-screen size · print size at 300 dpi" for the export dialog. +export function exportSizeText(W, H, factor, format) { + const px = `${W.toLocaleString('en-US')} × ${H.toLocaleString('en-US')} px` + if (format === 'svg') + return `${px} (the picture's size in the file); vector graphics stay sharp at any size and print resolution` + const dpi = Math.round(96 * factor) + const inch = (v) => v.toFixed(v < 10 ? 1 : 0) + const at = (d) => + `${inch(W / d)} × ${inch(H / d)} in (${((W / d) * 2.54).toFixed(1)} × ${((H / d) * 2.54).toFixed(1)} cm)` + let text = `${px} · ${dpi} dpi when printed at the on-screen size, ${at(dpi)} · ${at(300)} at 300 dpi` + if (format === 'pdf') text += ' · one page at the on-screen size' + return text +} + +// Pixel size for a frame at a given scale, reduced to stay within canvas limits. +export function exportPixelSize(frame, scale) { + let k = frame.unitPx * scale + const W = frame.w * k, + H = frame.h * k + const shrink = Math.min( + 1, + EXPORT_MAX_SIDE / W, + EXPORT_MAX_SIDE / H, + Math.sqrt(EXPORT_MAX_PIXELS / (W * H)) + ) + k *= shrink + return { + k, + W: Math.max(1, Math.round(frame.w * k)), + H: Math.max(1, Math.round(frame.h * k)), + limited: shrink < 1, + } +} + +// page wiring, run by main.ts in the original order +export function init() { + document.getElementById('btnCompare').addEventListener('click', runComparison) + + document.getElementById('btnCompareTsv').addEventListener('click', comparisonTsv) +} diff --git a/frontend/src/export/raster.ts b/frontend/src/export/raster.ts new file mode 100644 index 0000000..6ab443e --- /dev/null +++ b/frontend/src/export/raster.ts @@ -0,0 +1,145 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { cy } from '../cy' +import { drawHullsOnCanvas, hullShapes } from './shading' +import { exportFrame, exportPixelSize } from './draw' + +/* ---------- raster ---------- */ +export function renderExportCanvas(opts) { + const frame = exportFrame(opts.area) + if (!frame) throw new Error('Nothing is shown to export.') + const { k, W, H } = exportPixelSize(frame, opts.scale) + const canvas = document.createElement('canvas') + canvas.width = W + canvas.height = H + const ctx = canvas.getContext('2d') + if (opts.bg) { + ctx.fillStyle = opts.bg + ctx.fillRect(0, 0, W, H) + } + const toOut = (p) => ({ x: (p.x - frame.x1) * k, y: (p.y - frame.y1) * k }) + if (opts.hulls) drawHullsOnCanvas(ctx, hullShapes(toOut, k), k) + const R = cy.renderer() + if (R && typeof R.drawElements === 'function' && typeof R.getCachedZSortedEles === 'function') { + ctx.save() + ctx.setTransform(k, 0, 0, k, -frame.x1 * k, -frame.y1 * k) + R.drawElements(ctx, R.getCachedZSortedEles()) + ctx.restore() + } else { + throw new Error('This browser build of Cytoscape cannot draw images.') + } + return canvas +} + +// Writes the resolution into PNG (pHYs chunk) and JPEG (JFIF density) +// files, so layout and print programs open them at the right size. +const CRC_TABLE = (() => { + const t = new Uint32Array(256) + for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + t[n] = c >>> 0 + } + return t +})() + +function crc32(bytes) { + let c = 0xffffffff + for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8) + return (c ^ 0xffffffff) >>> 0 +} + +export async function withDpi(blob, dpi) { + if (!blob || !(dpi > 0)) return blob + const buf = new Uint8Array(await blob.arrayBuffer()) + if (blob.type === 'image/png' && buf[12] === 0x49 && buf[13] === 0x48) { + // IHDR first + const ppm = Math.round(dpi / 0.0254) + const chunk = new Uint8Array(21) + const dv = new DataView(chunk.buffer) + dv.setUint32(0, 9) + chunk.set([0x70, 0x48, 0x59, 0x73], 4) // "pHYs" + dv.setUint32(8, ppm) + dv.setUint32(12, ppm) + chunk[16] = 1 // per metre + dv.setUint32(17, crc32(chunk.subarray(4, 17))) + const ihdrEnd = 8 + 25 + const out = new Uint8Array(buf.length + chunk.length) + out.set(buf.subarray(0, ihdrEnd), 0) + out.set(chunk, ihdrEnd) + out.set(buf.subarray(ihdrEnd), ihdrEnd + chunk.length) + return new Blob([out], { type: 'image/png' }) + } + if ( + blob.type === 'image/jpeg' && + buf[2] === 0xff && + buf[3] === 0xe0 && + buf[6] === 0x4a && + buf[7] === 0x46 + ) { + // JFIF APP0 + const d = Math.min(65535, Math.round(dpi)) + buf[13] = 1 // dots per inch + buf[14] = d >> 8 + buf[15] = d & 255 + buf[16] = d >> 8 + buf[17] = d & 255 + return new Blob([buf], { type: 'image/jpeg' }) + } + return blob +} + +export function canvasToBlob(canvas, type, quality) { + return new Promise((resolve, reject) => { + canvas.toBlob( + (b) => + b + ? resolve(b) + : reject( + new Error('The image is too large for this browser. Choose a smaller resolution.') + ), + type, + quality + ) + }) +} + +/* ---------- PDF (single page, embedded JPEG) ---------- */ +export async function pdfFromCanvas(canvas, pageWidthPt, pageHeightPt) { + const jpeg = new Uint8Array(await (await canvasToBlob(canvas, 'image/jpeg', 0.95)).arrayBuffer()) + const enc = new TextEncoder() + const parts = [] + const offsets = [] + let length = 0 + const push = (chunk) => { + const bytes = typeof chunk === 'string' ? enc.encode(chunk) : chunk + parts.push(bytes) + length += bytes.length + } + const w = pageWidthPt.toFixed(2), + h = pageHeightPt.toFixed(2) + const content = `q ${w} 0 0 ${h} 0 0 cm /Im0 Do Q` + push('%PDF-1.4\n%\u00e2\u00e3\u00cf\u00d3\n') + const obj = (n, body) => { + offsets[n] = length + push(`${n} 0 obj\n${body}\nendobj\n`) + } + obj(1, '<< /Type /Catalog /Pages 2 0 R >>') + obj(2, '<< /Type /Pages /Kids [3 0 R] /Count 1 >>') + obj( + 3, + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${w} ${h}] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>` + ) + offsets[4] = length + push( + `4 0 obj\n<< /Type /XObject /Subtype /Image /Width ${canvas.width} /Height ${canvas.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpeg.length} >>\nstream\n` + ) + push(jpeg) + push('\nendstream\nendobj\n') + obj(5, `<< /Length ${content.length} >>\nstream\n${content}\nendstream`) + const xref = length + let table = `xref\n0 6\n0000000000 65535 f \n` + for (let i = 1; i <= 5; i++) table += `${String(offsets[i]).padStart(10, '0')} 00000 n \n` + push(table + `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`) + return new Blob(parts, { type: 'application/pdf' }) +} diff --git a/frontend/src/export/shading.ts b/frontend/src/export/shading.ts new file mode 100644 index 0000000..cf47cc0 --- /dev/null +++ b/frontend/src/export/shading.ts @@ -0,0 +1,128 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { S } from '../state' +import { UNGROUPED, effectiveGroupsFor, getUsedGroups } from '../network_state' +import { bubblePathsModel } from '../contours' +import { convexHull, hexToRgba, inflateHull } from '../hulls' +import { cy } from '../cy' +import { hullMarginModel } from './draw' + +/* ---------- group shading geometry, in output coordinates ---------- */ +export function hullShapes(toOut, k) { + if (!document.getElementById('showGroupHulls').checked) return [] + const style = document.getElementById('hullStyle').value + const opacity = parseFloat(document.getElementById('hullOpacity').value) || 0.25 + const margin = hullMarginModel() * k + const shapes = [] + if (style === 'bubble') { + const paths = bubblePathsModel(true) + paths.forEach((d, g) => { + if (d) + shapes.push({ + kind: 'path', + color: S.nodeColorMap[g] || '#888888', + d: transformPathD(d, toOut), + }) + }) + return shapes.map((s) => ({ ...s, style, opacity })) + } + getUsedGroups() + .filter((g) => S.activeGroups.has(g) && g !== UNGROUPED) + .forEach((g) => { + const nodes = cy + .nodes() + .filter((n) => !n.hasClass('hidden-group') && effectiveGroupsFor(n).includes(g)) + if (!nodes.length) return + const pts = nodes.map((n) => { + const p = toOut(n.position()) + return { x: p.x, y: p.y, r: (n.width() * k) / 2 } + }) + const color = S.nodeColorMap[g] || '#888888' + if (pts.length === 1) { + shapes.push({ + kind: 'circle', + color, + cx: pts[0].x, + cy: pts[0].y, + r: pts[0].r + (margin * 28) / 30, + }) + } else if (pts.length === 2) { + shapes.push({ + kind: 'capsule', + color, + a: pts[0], + b: pts[1], + pad: Math.max(pts[0].r, pts[1].r) + margin, + }) + } else { + const maxR = Math.max(...pts.map((p) => p.r)) + shapes.push({ kind: 'blob', color, hull: inflateHull(convexHull(pts), margin + maxR) }) + } + }) + return shapes.map((s) => ({ ...s, style, opacity })) +} + +export function capsulePath(a, b, pad) { + const dx = b.x - a.x, + dy = b.y - a.y + const d = Math.hypot(dx, dy) || 1 + const nx = (-dy / d) * pad, + ny = (dx / d) * pad + return ( + `M${a.x + nx},${a.y + ny}L${b.x + nx},${b.y + ny}` + + `A${pad},${pad} 0 0 0 ${b.x - nx},${b.y - ny}` + + `L${a.x - nx},${a.y - ny}A${pad},${pad} 0 0 0 ${a.x + nx},${a.y + ny}Z` + ) +} + +export function blobPath(points) { + const n = points.length + const mid = (p, q) => ({ x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 }) + const m0 = mid(points[n - 1], points[0]) + let d = `M${m0.x.toFixed(2)},${m0.y.toFixed(2)}` + for (let i = 0; i < n; i++) { + const p = points[i], + m = mid(p, points[(i + 1) % n]) + d += `Q${p.x.toFixed(2)},${p.y.toFixed(2)} ${m.x.toFixed(2)},${m.y.toFixed(2)}` + } + return d + 'Z' +} + +// Maps every coordinate pair of an absolute path (M, L, Q, Z commands). +function transformPathD(d, map) { + return d.replace(/(-?\d*\.?\d+(?:e[-+]?\d+)?),(-?\d*\.?\d+(?:e[-+]?\d+)?)/gi, (_, x, y) => { + const p = map({ x: parseFloat(x), y: parseFloat(y) }) + return `${Math.round(p.x * 100) / 100},${Math.round(p.y * 100) / 100}` + }) +} + +export function hullPathData(s) { + if (s.kind === 'path') return s.d + if (s.kind === 'circle') + return `M${s.cx - s.r},${s.cy}a${s.r},${s.r} 0 1 0 ${2 * s.r},0a${s.r},${s.r} 0 1 0 ${-2 * s.r},0Z` + if (s.kind === 'capsule') return capsulePath(s.a, s.b, s.pad) + return blobPath(s.hull) +} + +export function drawHullsOnCanvas(ctx, shapes, k) { + shapes.forEach((s) => { + const path = new Path2D(hullPathData(s)) + ctx.save() + ctx.fillStyle = hexToRgba(s.color, s.opacity) + if (s.style === 'bubble') { + ctx.fill(path, 'evenodd') + ctx.lineWidth = 1.6 * Math.max(1, k) + ctx.strokeStyle = hexToRgba(s.color, Math.min(1, s.opacity + 0.45)) + ctx.stroke(path) + } else if (s.style === 'fog') { + ctx.filter = `blur(${Math.max(1, ((16 * k) / Math.max(cy.zoom(), 1e-6)) * Math.max(0.15, Math.min(1, cy.zoom())))}px)` + ctx.fill(path) + } else { + ctx.fill(path) + ctx.lineWidth = 1.5 * Math.max(1, k) + ctx.strokeStyle = hexToRgba(s.color, Math.min(1, s.opacity + 0.35)) + ctx.stroke(path) + } + ctx.restore() + }) +} diff --git a/frontend/src/export/svg.ts b/frontend/src/export/svg.ts new file mode 100644 index 0000000..e5ff507 --- /dev/null +++ b/frontend/src/export/svg.ts @@ -0,0 +1,319 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { PIE_MAX_SLICES } from '../network_state' +import { cy } from '../cy' +import { exportFrame, exportPixelSize, hullMarginModel } from './draw' +import { hullPathData, hullShapes } from './shading' + +/* ---------- SVG ---------- */ +const svgEsc = (v) => + String(v).replace( + /[&<>"]/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] + ) + +const fmt = (v) => (Math.round(v * 100) / 100).toString() + +function nodeShapeSvg(n, fill, strokeAttrs) { + const shape = n.style('shape') + const p = n.position(), + w = n.width(), + h = n.height() + if (shape === 'ellipse') + return `` + if ( + shape === 'rectangle' || + shape === 'square' || + shape === 'barrel' || + /round-?rectangle/.test(shape) + ) { + const rx = /round/.test(shape) + ? Math.min(w / 4, h / 4, 8) + : shape === 'barrel' + ? Math.min(w, h) * 0.15 + : 0 + return `` + } + const def = cy.renderer().nodeShapes[shape] + if (def && def.points) { + const pts = [] + for (let i = 0; i < def.points.length; i += 2) { + pts.push(`${fmt(p.x + (def.points[i] * w) / 2)},${fmt(p.y + (def.points[i + 1] * h) / 2)}`) + } + return `` + } + return `` +} + +function pieSvg(n) { + if (n.style('shape') !== 'ellipse') return '' + const p = n.position() + const r = Math.min(n.width(), n.height()) / 2 + let start = -Math.PI / 2 + let out = '' + for (let i = 1; i <= PIE_MAX_SLICES; i++) { + const size = parseFloat(n.data('pieSize' + i)) || 0 + if (size <= 0) continue + const color = n.data('pieColor' + i) + if (size >= 99.999) { + out += `` + break + } + const end = start + (size / 100) * 2 * Math.PI + const large = end - start > Math.PI ? 1 : 0 + out += + `` + start = end + } + return out +} + +function edgePathSvg(e) { + const rs = e._private.rscratch || {} + const pts = rs.allpts + if (pts && pts.length >= 4 && pts.every(Number.isFinite)) { + if (['bezier', 'multibezier', 'self', 'compound'].includes(rs.edgeType) && pts.length >= 6) { + let d = `M${fmt(pts[0])},${fmt(pts[1])}` + for (let i = 2; i + 3 < pts.length; i += 4) + d += `Q${fmt(pts[i])},${fmt(pts[i + 1])} ${fmt(pts[i + 2])},${fmt(pts[i + 3])}` + return { d, mid: bezierMid(pts) } + } + let d = `M${fmt(pts[0])},${fmt(pts[1])}` + for (let i = 2; i + 1 < pts.length; i += 2) d += `L${fmt(pts[i])},${fmt(pts[i + 1])}` + const m = Math.floor((pts.length / 2 - 1) / 2) * 2 + return { + d, + mid: { + x: (pts[m] + pts[m + 2]) / 2, + y: (pts[m + 1] + pts[m + 3]) / 2, + angle: Math.atan2(pts[m + 3] - pts[m + 1], pts[m + 2] - pts[m]), + }, + } + } + const a = e.source().position(), + b = e.target().position() + return { + d: `M${fmt(a.x)},${fmt(a.y)}L${fmt(b.x)},${fmt(b.y)}`, + mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, angle: Math.atan2(b.y - a.y, b.x - a.x) }, + } +} + +function bezierMid(pts) { + // middle quadratic segment, evaluated at t = 0.5 + const segs = Math.floor((pts.length - 2) / 4) + const s = Math.floor((segs - 1) / 2) * 4 + const x0 = pts[s], + y0 = pts[s + 1], + cx = pts[s + 2], + cy0 = pts[s + 3], + x1 = pts[s + 4], + y1 = pts[s + 5] + const t = segs % 2 ? 0.5 : 1 + const x = (1 - t) * (1 - t) * x0 + 2 * (1 - t) * t * cx + t * t * x1 + const y = (1 - t) * (1 - t) * y0 + 2 * (1 - t) * t * cy0 + t * t * y1 + const dx = 2 * (1 - t) * (cx - x0) + 2 * t * (x1 - cx), + dy = 2 * (1 - t) * (cy0 - y0) + 2 * t * (y1 - cy0) + return { x, y, angle: Math.atan2(dy, dx) } +} + +// Arrowhead at an edge's target, drawn like Cytoscape does: its outline in +// unit coordinates (tip at the origin, back toward negative y), scaled with +// the edge width and turned along the edge's last segment. +function arrowSvg(e, shape, width) { + const rs = e._private.rscratch || {} + const def = cy.renderer().arrowShapes && cy.renderer().arrowShapes[shape] + if (!def || !def.points || !Number.isFinite(rs.arrowEndX)) return '' + const pts = rs.allpts || [] + const endX = Number.isFinite(rs.endX) ? rs.endX : pts[pts.length - 2] + const endY = Number.isFinite(rs.endY) ? rs.endY : pts[pts.length - 1] + let dx = rs.arrowEndX - endX, + dy = rs.arrowEndY - endY + let len = Math.hypot(dx, dy) + if (len < 1e-6) { + // fall back to the direction of the last path segment + const px = pts[pts.length - 4], + py = pts[pts.length - 3] + dx = rs.arrowEndX - px + dy = rs.arrowEndY - py + len = Math.hypot(dx, dy) || 1 + } + const ux = dx / len, + uy = dy / len // forward along the edge + const size = Math.max(Math.pow(width * 13.37, 0.9), 29) * (e.pstyle('arrow-scale').value || 1) + const poly = [] + for (let i = 0; i < def.points.length; i += 2) { + const px = def.points[i] * size, + py = def.points[i + 1] * size + poly.push(`${fmt(rs.arrowEndX - uy * px + ux * py)},${fmt(rs.arrowEndY + ux * px + uy * py)}`) + } + return `` +} + +function textSvg(text, x, y, attrs, fontPx, color, halo, haloWidth, rotate) { + const upright = rotate + ? rotate > Math.PI / 2 || rotate < -Math.PI / 2 + ? rotate + Math.PI + : rotate + : 0 + const transform = upright + ? ` transform="rotate(${fmt((upright * 180) / Math.PI)} ${fmt(x)} ${fmt(y)})"` + : '' + const haloAttr = + haloWidth > 0 + ? ` stroke="${halo}" stroke-width="${fmt(haloWidth * 2)}" stroke-linejoin="round" paint-order="stroke"` + : '' + return `${svgEsc(text)}` +} + +export function buildSvg(opts) { + const frame = exportFrame(opts.area) + if (!frame) throw new Error('Nothing is shown to export.') + const { W, H } = exportPixelSize(frame, opts.scale) + const out = [] + out.push(``) + out.push( + `` + ) + out.push(`${svgEsc(opts.title || 'NORMA network')}`) + if (opts.bg) + out.push( + `` + ) + + if (opts.hulls) { + const shapes = hullShapes((p) => p, 1) + if (shapes.length) { + const fog = shapes[0].style === 'fog' + if (fog) + out.push( + `` + ) + out.push('') + shapes.forEach((s) => { + const stroke = fog + ? '' + : ` stroke="${s.color}" stroke-opacity="${fmt(Math.min(1, s.opacity + 0.35))}" stroke-width="${fmt(1.5 / Math.max(cy.zoom(), 0.01))}"` + const rule = s.kind === 'path' ? ' fill-rule="evenodd"' : '' + out.push( + `` + ) + }) + out.push('') + } + } + + const visible = (ele) => ele.visible() && ele.effectiveOpacity() > 0 + const inFrame = (bb) => + !( + bb.x2 < frame.x1 || + bb.y2 < frame.y1 || + bb.x1 > frame.x1 + frame.w || + bb.y1 > frame.y1 + frame.h + ) + + out.push('') + const edgeLabels = [] + cy.edges().forEach((e) => { + if (!visible(e) || !inFrame(e.boundingBox({ includeLabels: false }))) return + const { d, mid } = edgePathSvg(e) + const width = e.pstyle('width').pfValue + const arrowShape = e.style('target-arrow-shape') + const hasArrow = arrowShape && arrowShape !== 'none' + out.push( + `${svgEsc(`${e.data('source')} ${hasArrow ? '→' : '–'} ${e.data('target')} (${e.data('type')})`)}` + ) + if (hasArrow) { + const arrow = arrowSvg(e, arrowShape, width) + if (arrow) out.push(arrow) + } + const label = e.style('label') + if (label) { + const rs = e._private.rscratch || {} + const pos = Number.isFinite(rs.labelX) ? { x: rs.labelX, y: rs.labelY } : mid + const rot = + e.style('text-rotation') === 'autorotate' + ? Number.isFinite(rs.labelAutoAngle) + ? rs.labelAutoAngle + : mid.angle + : 0 + edgeLabels.push( + textSvg( + label, + pos.x, + pos.y, + ' text-anchor="middle" dominant-baseline="central"', + e.pstyle('font-size').pfValue, + e.style('color'), + e.style('text-background-color'), + 2, + rot + ) + ) + } + }) + out.push('') + + out.push('') + const nodeLabels = [] + cy.nodes().forEach((n) => { + if (!visible(n) || !inFrame(n.boundingBox({ includeLabels: false }))) return + const bw = n.pstyle('border-width').pfValue + const strokeAttrs = + bw > 0 ? ` stroke="${n.style('border-color')}" stroke-width="${fmt(bw)}"` : '' + const opacity = n.effectiveOpacity() + out.push( + `${svgEsc(n.id())}` + ) + out.push(nodeShapeSvg(n, n.style('background-color'), '')) + out.push(pieSvg(n)) + if (bw > 0) out.push(nodeShapeSvg(n, 'none', strokeAttrs)) + out.push('') + const label = n.style('label') + if (label) { + const p = n.position(), + w = n.width(), + h = n.height() + const valign = n.style('text-valign'), + halign = n.style('text-halign') + const mx = n.pstyle('text-margin-x').pfValue || 0, + my = n.pstyle('text-margin-y').pfValue || 0 + let x = p.x + mx, + y = p.y + my, + anchor = 'middle', + baseline = 'central' + if (valign === 'top') { + y = p.y - h / 2 + my + baseline = 'text-after-edge' + } else if (valign === 'bottom') { + y = p.y + h / 2 + my + baseline = 'text-before-edge' + } + if (halign === 'left') { + x = p.x - w / 2 + mx + anchor = 'end' + } else if (halign === 'right') { + x = p.x + w / 2 + mx + anchor = 'start' + } + nodeLabels.push( + textSvg( + label, + x, + y, + ` text-anchor="${anchor}" dominant-baseline="${baseline}"`, + n.pstyle('font-size').pfValue, + n.style('color'), + n.style('text-outline-color'), + n.pstyle('text-outline-width').pfValue, + 0 + ) + ) + } + }) + out.push('') + if (edgeLabels.length) out.push('', ...edgeLabels, '') + if (nodeLabels.length) out.push('', ...nodeLabels, '') + out.push('') + return new Blob([out.join('\n')], { type: 'image/svg+xml' }) +} diff --git a/frontend/src/export_norma.ts b/frontend/src/export_norma.ts new file mode 100644 index 0000000..6cafb10 --- /dev/null +++ b/frontend/src/export_norma.ts @@ -0,0 +1,257 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { S } from './state' +import { UNGROUPED, getUsedGroups, groupLabel } from './network_state' +import { cy } from './cy' +import { downloadText, listSample, normalizeCssColor, plural, setStatus } from './layouts/controls' +import { edgeIsDirected } from './export/dialog' +import { exportEdges, exportNodes, exportShownOnly } from './metrics' +import { getUsedTypes } from './hulls' + +/* ---------- NORMA export ---------- */ +// NORMA can't read tabs or line breaks inside names, and node names can't +// contain commas (the annotation separator). Group names may keep commas. +function normaSafeName(name, renamed, isNode = true) { + let safe = String(name).replace(/[\t\r\n]+/g, ' ') + if (isNode) safe = safe.replace(/,/g, ';') + if (safe !== String(name)) renamed.add(String(name)) + return safe +} + +function renamedNote(renamed) { + return renamed.size + ? [ + { + level: 'warn', + text: `Changed ${plural(renamed.size, 'name')} that NORMA can't read (commas in node names became semicolons, tabs became spaces): ${listSample([...renamed])}.`, + }, + ] + : [] +} + +function connectedNodeIds() { + const ids = new Set() + exportEdges().forEach((e) => { + if (e.data('source') !== e.data('target')) { + ids.add(e.data('source')) + ids.add(e.data('target')) + } + }) + return ids +} + +function requireLoadedNetwork() { + if (exportEdges().length) return true + setStatus('saveStatus', [ + { + level: 'error', + text: cy.edges().length + ? 'No edges are shown: tick at least one group and channel, or untick "Only ticked groups and channels".' + : 'Load a network with at least one edge before saving NORMA files.', + }, + ]) + return false +} + +export function exportNormaNetwork() { + if (!requireLoadedNetwork()) return + const renamed = new Set() + const types = getUsedTypes() + const keepTypes = document.getElementById('normaKeepTypes').checked && types.length > 1 + const rowsByKey = new Map() + let selfLoops = 0, + merged = 0 + exportEdges().forEach((e) => { + const s = e.data('source'), + t = e.data('target') + if (s === t) { + selfLoops++ + return + } + const type = e.data('type') + const directed = edgeIsDirected(e) + const pair = directed ? 'D\t' + s + '\t' + t : 'U\t' + (s < t ? s + '\t' + t : t + '\t' + s) + const key = pair + (keepTypes ? '\t' + type : '') + const w = e.data('weight') + const row = rowsByKey.get(key) + if (row) { + merged++ + if (typeof w === 'number') row.weight = row.weight === undefined ? w : Math.max(row.weight, w) + } else { + rowsByKey.set(key, { + source: s, + target: t, + type, + directed, + weight: typeof w === 'number' ? w : undefined, + }) + } + }) + const rows = [...rowsByKey.values()] + const withDirection = rows.some((r) => r.directed) + const weighted = rows.some((r) => r.weight !== undefined) + const unweighted = weighted ? rows.filter((r) => r.weight === undefined).length : 0 + const header = ['Source', 'Target'] + if (weighted) header.push('Weight') + if (keepTypes) header.push('Type') + if (withDirection) header.push('Direction') + const lines = [header.join('\t')] + rows.forEach((r) => { + const cols = [normaSafeName(r.source, renamed), normaSafeName(r.target, renamed)] + if (weighted) cols.push(String(r.weight === undefined ? 1 : r.weight)) + if (keepTypes) cols.push(normaSafeName(r.type, renamed, false)) + if (withDirection) cols.push(r.directed ? 'directed' : 'undirected') + lines.push(cols.join('\t')) + }) + downloadText('norma-network.txt', lines.join('\n') + '\n') + + const notes = [ + { + level: 'ok', + text: `Saved norma-network.txt with ${plural(rows.length, 'row')}${weighted ? ', weighted' : ''}${keepTypes ? `, ${plural(types.length, 'type')} in a Type column` : ''}${withDirection ? ', with a Direction column' : ''}.`, + }, + ] + if (merged) + notes.push({ + level: 'ok', + text: keepTypes + ? `Merged ${plural(merged, 'repeated edge')} with the same pair and channel.` + : `Merged ${plural(merged, 'parallel edge')} into single connections${weighted ? ', keeping the highest weight' : ''}. Tick "Keep channels as a Type column" to keep them apart.`, + }) + if (document.getElementById('normaKeepTypes').checked && types.length <= 1) + notes.push({ + level: 'ok', + text: 'The view has a single channel, so no Type column was needed.', + }) + if (keepTypes || withDirection) + notes.push({ + level: 'warn', + text: `The ${[keepTypes && 'Type', withDirection && 'Direction'].filter(Boolean).join(' and ')} column${keepTypes && withDirection ? 's are' : ' is'} new in NORMA 3.0; earlier versions of NORMA read only Source, Target and Weight.`, + }) + if (unweighted) + notes.push({ + level: 'warn', + text: `${plural(unweighted, 'row')} had no weight and got weight 1.`, + }) + if (selfLoops) notes.push({ level: 'ok', text: `Left out ${plural(selfLoops, 'self-loop')}.` }) + const isolated = exportNodes().length - connectedNodeIds().size + if (isolated) + notes.push({ + level: 'warn', + text: `${plural(isolated, 'node has', 'nodes have')} no edges and can't appear in a network file.`, + }) + setStatus('saveStatus', [...notes, ...renamedNote(renamed)]) +} + +export function exportNormaAnnotation() { + if (!requireLoadedNetwork()) return + const groups = getUsedGroups().filter( + (g) => g !== UNGROUPED && (!exportShownOnly() || S.activeGroups.has(g)) + ) + if (!groups.length) { + setStatus('saveStatus', [ + { + level: 'error', + text: 'This view has no ticked groups, so there is no annotation to save.', + }, + ]) + return + } + const renamed = new Set() + const connected = connectedNodeIds() + // A group's display label is its NORMA name, unless two groups share a + // label; then the group key keeps them apart. + const labelCounts = {} + groups.forEach((g) => { + const l = groupLabel(g) + labelCounts[l] = (labelCounts[l] || 0) + 1 + }) + const membersOf = {} + exportNodes().forEach((n) => + (n.data('groups') || []).forEach((g) => (membersOf[g] = membersOf[g] || []).push(n.id())) + ) + const skippedGroups = [] + const droppedNodes = new Set() + const lines = [] + groups.forEach((g) => { + const members = membersOf[g] || [] + const present = members.filter((id) => connected.has(id)) + members.forEach((id) => { + if (!connected.has(id)) droppedNodes.add(id) + }) + const name = labelCounts[groupLabel(g)] > 1 ? g : groupLabel(g) + if (!present.length) { + skippedGroups.push(name) + return + } + lines.push( + `${normaSafeName(name, renamed, false)}\t${present.map((id) => normaSafeName(id, renamed)).join(',')}` + ) + }) + if (!lines.length) { + setStatus('saveStatus', [ + { + level: 'error', + text: 'None of the grouped nodes have edges, so the annotation would be empty.', + }, + ]) + return + } + downloadText('norma-annotation.txt', lines.join('\n') + '\n') + const notes = [ + { level: 'ok', text: `Saved norma-annotation.txt with ${plural(lines.length, 'group')}.` }, + ] + if (droppedNodes.size) + notes.push({ + level: 'warn', + text: `Left out ${plural(droppedNodes.size, 'node')} without edges, since NORMA needs every annotated node in the network file: ${listSample([...droppedNodes])}.`, + }) + if (skippedGroups.length) + notes.push({ + level: 'warn', + text: `Left out ${plural(skippedGroups.length, 'group')} whose nodes have no edges: ${listSample(skippedGroups)}.`, + }) + setStatus('saveStatus', [...notes, ...renamedNote(renamed)]) +} + +export function exportNormaColors() { + if (!requireLoadedNetwork()) return + const renamed = new Set() + const connected = connectedNodeIds() + const nodes = exportNodes().filter((n) => connected.has(n.id())) + const useOwn = nodes.some((n) => n.data('nodeColor')) + const lines = [] + nodes.forEach((n) => { + let color + if (useOwn) { + color = n.data('nodeColor') + } else { + const g = (n.data('groups') || []).find((x) => x !== UNGROUPED) + color = g ? S.nodeColorMap[g] : null + } + if (!color) return + // canvas-normalized rgba() values aren't NORMA colors; keep hex only + const hex = String(color).startsWith('#') ? color : normalizeCssColor(color) || '' + if (!hex.startsWith('#')) return + lines.push(`${normaSafeName(n.id(), renamed)}\t${hex}`) + }) + if (!lines.length) { + setStatus('saveStatus', [ + { + level: 'error', + text: 'No node has a color to save. Load an expression file, or give nodes groups so their group colors can be used.', + }, + ]) + return + } + downloadText('norma-expression.txt', lines.join('\n') + '\n') + setStatus('saveStatus', [ + { + level: 'ok', + text: useOwn + ? `Saved norma-expression.txt with the expression colors of ${plural(lines.length, 'node')}.` + : `Saved norma-expression.txt using each node's first group color (${plural(lines.length, 'node')}), since no expression colors are loaded.`, + }, + ...renamedNote(renamed), + ]) +} diff --git a/frontend/src/group_network.ts b/frontend/src/group_network.ts new file mode 100644 index 0000000..5814b4b --- /dev/null +++ b/frontend/src/group_network.ts @@ -0,0 +1,101 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { S } from './state' +import { UNGROUPED, effectiveGroupsFor, getUsedGroups, groupLabel } from './network_state' +import { activeView, openInNewView } from './profiler' +import { applyGroupColorOverrides } from './arena3d' +import { cy } from './cy' +import { loadData } from './hulls' +import { plural, setStatus } from './layouts/controls' +import { shownEdges, shownNodes } from './metrics' +import { switchTab } from './wiring' + +/* ---------- group network ---------- */ +export function openGroupNetwork() { + if (!cy.nodes().length) return + const order = getUsedGroups().filter((g) => S.activeGroups.has(g) && g !== UNGROUPED) + if (order.length < 2) { + setStatus('gaStatus', [ + { level: 'error', text: 'The group network needs at least two ticked groups.' }, + ]) + return + } + const nodes = shownNodes() + const groupsOf = new Map( + nodes.map((n) => [n.id(), effectiveGroupsFor(n).filter((g) => g !== UNGROUPED)]) + ) + const size = new Map(order.map((g) => [g, 0])) + groupsOf.forEach((gs) => gs.forEach((g) => size.set(g, (size.get(g) || 0) + 1))) + const inside = new Map(order.map((g) => [g, 0])) + const between = new Map() + const seenPairs = new Set() + shownEdges(true).forEach((e) => { + const s = e.data('source'), + t = e.data('target') + if (s === t || !groupsOf.has(s) || !groupsOf.has(t)) return + const pk = s < t ? s + '\t' + t : t + '\t' + s + if (seenPairs.has(pk)) return // parallel channels count once + seenPairs.add(pk) + const gs = groupsOf.get(s), + gt = groupsOf.get(t) + gs.forEach((a) => + gt.forEach((b) => { + if (a === b) { + inside.set(a, inside.get(a) + 1) + return + } + const key = a < b ? a + '\u0000' + b : b + '\u0000' + a + between.set(key, (between.get(key) || 0) + 1) + }) + ) + }) + const maxSize = Math.max(...size.values()) + const data = { + nodes: order + .filter((g) => size.get(g)) + .map((g) => ({ + id: groupLabel(g), + groups: [groupLabel(g)], + size: Math.round(30 + 70 * Math.sqrt(size.get(g) / maxSize)), + members: size.get(g), + 'edges inside': inside.get(g), + })), + edges: [...between].map(([key, w]) => { + const [a, b] = key.split('\u0000') + return { + source: groupLabel(a), + target: groupLabel(b), + weight: w, + type: 'connections', + connections: w, + } + }), + groupOrder: order.filter((g) => size.get(g)).map((g) => groupLabel(g)), + nodeColors: Object.fromEntries( + order.map((g) => [groupLabel(g), S.nodeColorMap[g] || '#888888']) + ), + edgeColors: { connections: '#64748b' }, + config: { + layoutSelect: 'fr', + edgeWidthMode: 'weight', + edgeWidthMin: '1', + edgeWidthMax: '10', + sizeMetric: 'fixed', + showEdgeLabels: true, + edgeLabelContent: 'attr:connections', + edgeLabelSize: '11', + }, + } + const v = activeView() + openInNewView(`${v ? v.name : 'View'}: group network`, () => { + loadData(data) + applyGroupColorOverrides(data.nodeColors) + }) + switchTab('network') + setStatus('gaStatus', [ + { + level: 'ok', + text: `Opened the group network: ${plural(data.nodes.length, 'group')}, ${plural(data.edges.length, 'connection')} between groups. Node size follows group size; edge width follows the number of connections.`, + }, + ]) +} diff --git a/frontend/src/hulls.test.ts b/frontend/src/hulls.test.ts new file mode 100644 index 0000000..1f9d904 --- /dev/null +++ b/frontend/src/hulls.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from 'vitest' +import { convexHull, hexToRgba, inflateHull } from './hulls' + +type Pt = { x: number; y: number } + +test('convexHull drops interior points and keeps counter-clockwise corners', () => { + const pts = [ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + { x: 2, y: 2 }, + { x: 0, y: 2 }, + { x: 1, y: 1 }, + { x: 1, y: 0.5 }, + ] + const hull = convexHull(pts) + expect(hull).toHaveLength(4) + expect(hull.map((p: Pt) => `${p.x},${p.y}`)).toEqual(['0,0', '2,0', '2,2', '0,2']) +}) + +test('convexHull leaves fewer than three points alone', () => { + expect(convexHull([{ x: 1, y: 1 }])).toEqual([{ x: 1, y: 1 }]) + expect(convexHull([])).toEqual([]) +}) + +test('inflateHull pushes every vertex away from the centroid by the padding', () => { + const square = [ + { x: -1, y: -1 }, + { x: 1, y: -1 }, + { x: 1, y: 1 }, + { x: -1, y: 1 }, + ] + const big = inflateHull(square, 2) + big.forEach((p: Pt, i: number) => { + const before = Math.hypot(square[i].x, square[i].y) + expect(Math.hypot(p.x, p.y)).toBeCloseTo(before + 2) + }) +}) + +test('hexToRgba', () => { + expect(hexToRgba('#ff8000', 0.5)).toBe('rgba(255,128,0,0.5)') + expect(hexToRgba('#zz', 1)).toBe('rgba(0,0,0,1)') +}) diff --git a/frontend/src/hulls.ts b/frontend/src/hulls.ts new file mode 100644 index 0000000..04fb50d --- /dev/null +++ b/frontend/src/hulls.ts @@ -0,0 +1,787 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { + EDGE_PALETTES, + EDGE_TYPES, + NODE_PALETTES, + ORIGINAL_EDGE_TYPES, + colorAtIndex, +} from './palette' +import { + LARGE_NETWORK_NODES, + applyNodeSizing, + cancelFrJobs, + invalidateFullMetrics, + updateStats, +} from './metrics' +import { + MAX_NETWORK_NODES, + RESERVED_EDGE_KEYS, + RESERVED_NODE_KEYS, + UNGROUPED, + UNGROUPED_LABEL, + buildAttrSchema, + cleanValues, + collectAttrs, + colorForGroup, + computeNodeVisualFields, + effectiveGroupsFor, + escapeHtml, + getUsedGroups, + groupDescription, + groupLabel, + nextAutoEdgeColor, + refreshNodeVisual, + renderAttrSchema, + sanitizeColor, + setGroupOrder, +} from './network_state' +import { S } from './state' +import { + SHAPE_BY_KEY, + applyEdgeCurveStyle, + applyEdgeOpacity, + applyEdgeWidth, + closeShapePopover, + hideEdgePopup, + hideInfo, + invalidateViewBox, + openShapePopover, + scheduleBundling, + shapePopoverGroup, + shapeSvg, + showGroupInfo, + updateLabelStyle, +} from './profiler' +import { applyEdgeDirection } from './export/dialog' +import { applyEdgeMerge, applyTypeVisibility } from './parallel_edges' +import { applyTheme } from './themes' +import { applyValueColors } from './clustering/mapping' +import { bumpDataVersion } from './demo_downloads' +import { currentTab, switchTab } from './wiring' +import { cy } from './cy' +import { drawBubbleSets } from './contours' +import { layoutMode, runLayout, runStrategyLayout } from './layouts/run' +import { plural, updateRefreshState, updateStrategyUI } from './layouts/controls' +import { reset3dForNewData } from './view3d/tab' +import { takeNextLoadLayout } from './examples' +import { toast } from './enrichment' +import { updateValueScalePreview } from './clustering/wiring' + +/* ---------- group highlighting: convex hulls / fog clouds ---------- */ +const hullCanvas = document.getElementById('groupHullCanvas') + +const hullCtx = hullCanvas.getContext('2d') + +// The shading canvas must match Cytoscape's drawing area and the current +// pixel ratio. The pixel ratio can change without a 'resize' event (a window +// dragged between a Retina and a standard display on macOS), and the canvas +// area can change size without one too, so drawGroupHulls() re-checks both. +export let hullCanvasCss = { w: 0, h: 0, dpr: 0 } + +export function resizeHullCanvas() { + const rect = document.getElementById('canvas').getBoundingClientRect() + const w = rect.width || cy.width(), + h = rect.height || cy.height() + const dpr = window.devicePixelRatio || 1 + hullCanvas.width = Math.max(1, Math.round(w * dpr)) + hullCanvas.height = Math.max(1, Math.round(h * dpr)) + hullCanvas.style.width = w + 'px' + hullCanvas.style.height = h + 'px' + hullCanvasCss = { w, h, dpr } + hullCtx.setTransform(dpr, 0, 0, dpr, 0, 0) +} + +function hullCanvasStale() { + return ( + hullCanvasCss.dpr !== (window.devicePixelRatio || 1) || + Math.abs(hullCanvasCss.w - cy.width()) > 0.5 || + Math.abs(hullCanvasCss.h - cy.height()) > 0.5 + ) +} + +// Andrew's monotone chain convex hull. +export function convexHull(points) { + const pts = points.slice().sort((a, b) => a.x - b.x || a.y - b.y) + const n = pts.length + if (n < 3) return pts + const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x) + const lower = [] + for (const p of pts) { + while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) + lower.pop() + lower.push(p) + } + const upper = [] + for (let i = n - 1; i >= 0; i--) { + const p = pts[i] + while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) + upper.pop() + upper.push(p) + } + upper.pop() + lower.pop() + return lower.concat(upper) +} + +// Pushes each hull vertex outward from the hull's centroid so the shape +// clears the nodes themselves rather than hugging their centers. +export function inflateHull(hull, padding) { + const cx = hull.reduce((s, p) => s + p.x, 0) / hull.length + const cy0 = hull.reduce((s, p) => s + p.y, 0) / hull.length + return hull.map((p) => { + const dx = p.x - cx, + dy = p.y - cy0 + const len = Math.sqrt(dx * dx + dy * dy) || 1 + return { x: p.x + (dx / len) * padding, y: p.y + (dy / len) * padding } + }) +} + +export function hexToRgba(hex, alpha) { + const r = parseInt(hex.slice(1, 3), 16) || 0 + const g = parseInt(hex.slice(3, 5), 16) || 0 + const b = parseInt(hex.slice(5, 7), 16) || 0 + return `rgba(${r},${g},${b},${alpha})` +} + +function paintHullPath(ctx, color, opacity, style) { + ctx.fillStyle = hexToRgba(color, opacity) + if (style === 'fog') { + ctx.filter = 'blur(16px)' + ctx.fill() + ctx.filter = 'none' + } else { + ctx.fill() + ctx.lineWidth = 1.5 + ctx.strokeStyle = hexToRgba(color, Math.min(1, opacity + 0.35)) + ctx.stroke() + } +} + +// Shading margin in screen pixels: full size from 100% zoom up, shrinking +// with the network when zoomed out so neighbouring groups stay apart. +let hullMarginScale = 1 + +export function drawGroupHulls() { + hullMarginScale = Math.max(0.15, Math.min(1, cy.zoom())) + if (!hullCtx) return + if (hullCanvasStale()) resizeHullCanvas() + hullCtx.setTransform(hullCanvasCss.dpr, 0, 0, hullCanvasCss.dpr, 0, 0) + hullCtx.clearRect(0, 0, hullCanvasCss.w, hullCanvasCss.h) + if (!document.getElementById('showGroupHulls').checked) return + + const style = document.getElementById('hullStyle').value + const opacity = parseFloat(document.getElementById('hullOpacity').value) || 0.25 + if (style === 'bubble') { + drawBubbleSets(hullCtx, opacity) + return + } + const groups = getUsedGroups().filter((g) => S.activeGroups.has(g) && g !== UNGROUPED) + + groups.forEach((g) => { + const nodes = cy + .nodes() + .filter((n) => !n.hasClass('hidden-group') && effectiveGroupsFor(n).includes(g)) + if (!nodes.length) return + const color = S.nodeColorMap[g] || '#888888' + const points = nodes.map((n) => { + const rp = n.renderedPosition() + return { x: rp.x, y: rp.y, r: (n.renderedWidth() || n.width() * cy.zoom()) / 2 } + }) + + hullCtx.save() + if (points.length === 1) { + const p = points[0] + hullCtx.beginPath() + hullCtx.arc(p.x, p.y, p.r + 28 * hullMarginScale, 0, Math.PI * 2) + hullCtx.closePath() + paintHullPath(hullCtx, color, opacity, style) + } else if (points.length === 2) { + const [a, b] = points + const dx = b.x - a.x, + dy = b.y - a.y + const dist = Math.sqrt(dx * dx + dy * dy) || 1 + const nx = -dy / dist, + ny = dx / dist + const pad = Math.max(a.r, b.r) + 30 * hullMarginScale + hullCtx.beginPath() + hullCtx.moveTo(a.x + nx * pad, a.y + ny * pad) + hullCtx.lineTo(b.x + nx * pad, b.y + ny * pad) + // end caps bulge outward: around b away from a, around a away from b + hullCtx.arc(b.x, b.y, pad, Math.atan2(ny, nx), Math.atan2(-ny, -nx), true) + hullCtx.lineTo(a.x - nx * pad, a.y - ny * pad) + hullCtx.arc(a.x, a.y, pad, Math.atan2(-ny, -nx), Math.atan2(ny, nx), true) + hullCtx.closePath() + paintHullPath(hullCtx, color, opacity, style) + } else { + const maxR = Math.max(...points.map((p) => p.r || 0)) + const hull = inflateHull(convexHull(points), 30 * hullMarginScale + maxR) + // Both styles must actually contain every member node, so trace the + // true polygon through the inflated hull points rather than rounding + // the corners: a rounded corner falls short of the padded vertex by + // an amount that grows with its distance from its neighbors, so a + // far-dragged node ends up outside the shape (too far for "fog"'s + // blur in paintHullPath to hide). "fog" gets its soft look from that + // blur filter, not from rounding this path. + hullCtx.beginPath() + hullCtx.moveTo(hull[0].x, hull[0].y) + for (let i = 1; i < hull.length; i++) hullCtx.lineTo(hull[i].x, hull[i].y) + hullCtx.closePath() + paintHullPath(hullCtx, color, opacity, style) + } + hullCtx.restore() + }) +} + +export function applyGroupVisibility() { + cy.batch(() => cy.nodes().forEach((n) => refreshNodeVisual(n))) + invalidateViewBox() + invalidateFullMetrics() + if (document.getElementById('sizeMetric').value !== 'fixed') applyNodeSizing() + updateStats() + if (typeof scheduleBundling === 'function') scheduleBundling() +} + +function groupFilterText() { + const el = document.getElementById('groupFilter') + return el ? el.value.trim().toLowerCase() : '' +} + +// Groups whose label or key contains the filter text (all groups if empty). +// A–Z with numbers in order (Group-2 before Group-10), ignoring case and accents +const NAME_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }) + +export const byName = (a, b) => NAME_COLLATOR.compare(String(a), String(b)) + +export function sortedByName(list, nameOf = (x) => x) { + return [...list].sort((a, b) => byName(nameOf(a), nameOf(b))) +} + +function groupSortMode() { + const el = document.getElementById('groupSortSelect') + return el ? el.value : 'az' +} + +// nodes per group, as the Node groups list counts them +function groupNodeCounts() { + const counts = {} + cy.nodes().forEach((n) => + (n.data('groups') || []).forEach((g) => { + counts[g] = (counts[g] || 0) + 1 + }) + ) + return counts +} + +// groups in the order lists and legends show them (colors keep their own order): +// A–Z, by number of nodes (either way, ties A–Z), or as in the file +export function displayGroups(groups) { + const mode = groupSortMode() + if (mode === 'file') return groups + let rest = sortedByName( + groups.filter((g) => g !== UNGROUPED), + groupLabel + ) + if (mode === 'size-desc' || mode === 'size-asc') { + const counts = groupNodeCounts() + const dir = mode === 'size-desc' ? -1 : 1 + rest = rest.sort( + (a, b) => dir * ((counts[a] || 0) - (counts[b] || 0)) || byName(groupLabel(a), groupLabel(b)) + ) + } + return groups.includes(UNGROUPED) ? rest.concat([UNGROUPED]) : rest +} + +export function filteredGroups() { + const q = groupFilterText() + const groups = displayGroups(getUsedGroups()) + return q + ? groups.filter((g) => groupLabel(g).toLowerCase().includes(q) || g.toLowerCase().includes(q)) + : groups +} + +function updateGroupButtons() { + const filtered = !!groupFilterText() + document.getElementById('btnGroupsAll').textContent = filtered + ? 'Activate matches' + : 'Activate all' + document.getElementById('btnGroupsNone').textContent = filtered + ? 'Deactivate matches' + : 'Deactivate all' +} + +export function buildGroupLegend() { + const el = document.getElementById('nodeGroupLegend') + if (!el) return + el.innerHTML = '' + const allGroups = getUsedGroups() + updateGroupButtons() + if (!allGroups.length) { + el.innerHTML = + '

    Load a network to see its groups.

    ' + return + } + const groups = filteredGroups() + if (!groups.length) { + el.innerHTML = + '

    No group matches the filter.

    ' + return + } + const counts = {} + cy.nodes().forEach((n) => + (n.data('groups') || []).forEach((g) => { + counts[g] = (counts[g] || 0) + 1 + }) + ) + groups.forEach((g) => { + const count = counts[g] || 0 + const label = groupLabel(g) + const description = groupDescription(g) + const row = document.createElement('label') + row.className = 'toggle-row' + if (description) row.title = description + row.innerHTML = ` + + + + ${escapeHtml(label)} + ${count} + + ` + row.querySelector('.ginfo').addEventListener('click', (ev) => { + ev.preventDefault() + ev.stopPropagation() + showGroupInfo(g) + }) + row.querySelector('.gshape').addEventListener('click', (ev) => { + ev.preventDefault() + ev.stopPropagation() + if (shapePopoverGroup === g) closeShapePopover(true) + else openShapePopover(g, ev.currentTarget) + }) + row.querySelector('input[type=checkbox]').addEventListener('change', (ev) => { + if (ev.target.checked) S.activeGroups.add(g) + else S.activeGroups.delete(g) + applyGroupVisibility() + }) + const picker = row.querySelector('.swatch-picker') + picker.addEventListener('click', (ev) => ev.stopPropagation()) + picker.addEventListener('input', (ev) => { + ev.stopPropagation() + S.nodeColorMap[g] = ev.target.value + cy.batch(() => cy.nodes().forEach((n) => refreshNodeVisual(n))) + }) + el.appendChild(row) + }) +} + +/* ============================================================ + CONFIG MODULE + Every user-adjustable visual/style control is listed once here. + getCurrentConfig() / applyConfig() are generated from this single + table, so adding a new control to the "settings" file format is a + one-line change here rather than hand-wiring an export and an + import path separately. This is what backs the "Settings" section + (export/import a standalone settings JSON) and the optional + top-level "config" object a network-data file may also carry. + ============================================================ */ +export const CONFIG_FIELDS = [ + { id: 'themeSelect', prop: 'value' }, + { id: 'nodePaletteSelect', prop: 'value' }, + { id: 'nodeLabelSize', prop: 'value' }, + { id: 'nodeLabelColorMode', prop: 'value' }, + { id: 'nodeLabelColor', prop: 'value' }, + { id: 'edgeLabelColorMode', prop: 'value' }, + { id: 'edgeLabelColor', prop: 'value' }, + { id: 'labelScaleWithNode', prop: 'checked' }, + { id: 'edgeLabelContent', prop: 'value' }, + { id: 'edgeLabelOrientation', prop: 'value' }, + { id: 'edgeLabelSize', prop: 'value' }, + { id: 'labelMinScreenSize', prop: 'value' }, + { id: 'edgeOpacity', prop: 'value' }, + { id: 'bundleStrength', prop: 'value' }, + { id: 'valueColumn', prop: 'value' }, + { id: 'valueTransform', prop: 'value' }, + { id: 'valueScale', prop: 'value' }, + { id: 'valueCenter', prop: 'value' }, + { id: 'valueRange', prop: 'value' }, + { id: 'valueMin', prop: 'value' }, + { id: 'valueMax', prop: 'value' }, + { id: 'valueMissing', prop: 'value' }, + { id: 'valueSameRange', prop: 'checked' }, + { id: 'groupSortSelect', prop: 'value' }, + { id: 'legendShow', prop: 'checked' }, + { id: 'legendScale', prop: 'checked' }, + { id: 'legendGroups', prop: 'checked' }, + { id: 'legendShapes', prop: 'checked' }, + { id: 'legendChannels', prop: 'checked' }, + { id: 'legendTitle', prop: 'value' }, + { id: 'layout3d', prop: 'value' }, + { id: 'layout3dChannels', prop: 'checked' }, + { id: 'layer3dSpacing', prop: 'value' }, + { id: 'persp3d', prop: 'value' }, + { id: 'style3d', prop: 'value' }, + { id: 'fog3d', prop: 'value' }, + { id: 'grid3d', prop: 'checked' }, + { id: 'autoRotate3d', prop: 'checked' }, + { id: 'autoSpeed3d', prop: 'value' }, + { id: 'edgeDirection', prop: 'value' }, + { id: 'arrowShape', prop: 'value' }, + { id: 'arrowScale', prop: 'value' }, + { id: 'exportShownOnly', prop: 'checked' }, + { id: 'nodeFillSelect', prop: 'value' }, + { id: 'edgePaletteSelect', prop: 'value' }, + { id: 'showNodeLabels', prop: 'checked' }, + { id: 'showEdgeLabels', prop: 'checked' }, + { id: 'labelPosition', prop: 'value' }, + { id: 'edgeCurveStyle', prop: 'value' }, + { id: 'edgeMergeMode', prop: 'value' }, + { id: 'edgeCurvature', prop: 'value' }, + { id: 'edgeWidthMode', prop: 'value' }, + { id: 'edgeWidthFixed', prop: 'value' }, + { id: 'edgeWidthMin', prop: 'value' }, + { id: 'edgeWidthMax', prop: 'value' }, + { id: 'sizeMetric', prop: 'value' }, + { id: 'sizeMin', prop: 'value' }, + { id: 'sizeMax', prop: 'value' }, + { id: 'sizeChannelOnly', prop: 'checked' }, + { id: 'nodeScale', prop: 'value' }, + { id: 'showGroupHulls', prop: 'checked' }, + { id: 'hullStyle', prop: 'value' }, + { id: 'hullOpacity', prop: 'value' }, + { id: 'layoutSelect', prop: 'value' }, + { id: 'layoutOnActiveOnly', prop: 'checked' }, + { id: 'layoutMode', prop: 'value' }, + { id: 'groupArrangement', prop: 'value' }, + { id: 'strategyAlgorithm', prop: 'value' }, + { id: 'localGroupLayout', prop: 'value' }, + { id: 'groupForce', prop: 'value' }, + { id: 'groupClusterRadius', prop: 'value' }, + { id: 'searchMode', prop: 'value' }, + { id: 'searchCase', prop: 'checked' }, + { id: 'searchAttrs', prop: 'checked' }, +] + +export function getCurrentConfig() { + const cfg = {} + CONFIG_FIELDS.forEach(({ id, prop }) => { + const el = document.getElementById(id) + if (!el) return + cfg[id] = prop === 'checked' ? el.checked : el.value + }) + return cfg +} + +// Sets every control named in cfg, then re-runs each style/layout +// function once so the change actually takes visual effect -- a control's +// own change-listener only fires on real user interaction, not on a value +// set programmatically, so this step can't be skipped. +// Settings files saved before the three NORMA-2.0 strategies had a single +// "layout by groups" toggle (the super-node strategy) with its own global +// layout and a 0.5-4 spacing slider. +function upgradeLegacyConfig(cfg) { + const out = { ...cfg } + if (out.groupStrategy === undefined && 'groupedLayoutToggle' in out) { + out.groupStrategy = out.groupedLayoutToggle ? 'supernodes' : 'none' + if (out.groupedLayoutToggle && out.globalGroupLayout && out.layoutSelect === undefined) { + out.layoutSelect = out.globalGroupLayout + } + } + // settings from before "by connections / by groups": a strategy select + // whose algorithm was the main layout choice + if (out.layoutMode === undefined && out.groupStrategy !== undefined) { + if (out.groupStrategy === 'none') { + out.layoutMode = 'connections' + } else { + out.layoutMode = 'groups' + out.groupArrangement = out.groupStrategy + if (out.layoutSelect) out.strategyAlgorithm = out.layoutSelect + } + } + if (out.groupForce === undefined && out.groupSpacing !== undefined) { + const spacing = parseFloat(out.groupSpacing) + if (Number.isFinite(spacing)) + out.groupForce = String(Math.min(20, Math.max(1, Math.round(spacing / 0.16)))) + } + return out +} + +export function applyConfig(cfg) { + if (!cfg || typeof cfg !== 'object') return + cfg = upgradeLegacyConfig(cfg) + CONFIG_FIELDS.forEach(({ id, prop }) => { + if (!(id in cfg)) return + const el = document.getElementById(id) + if (!el) return + if (prop === 'checked') el.checked = !!cfg[id] + else el.value = cfg[id] + }) + refreshAllDerivedUI() +} + +export function refreshAllDerivedUI() { + applyTheme(document.getElementById('themeSelect').value) + if (typeof applyValueColors === 'function') { + updateValueScalePreview() + applyValueColors() + } + applyNodePalette(document.getElementById('nodePaletteSelect').value, true) + applyEdgePalette(document.getElementById('edgePaletteSelect').value) + updateLabelStyle() + applyEdgeCurveStyle() + applyEdgeWidth() + applyNodeSizing() + applyEdgeOpacity() + applyEdgeDirection() + document.getElementById('hullControls').style.display = document.getElementById('showGroupHulls') + .checked + ? 'block' + : 'none' + updateStrategyUI() + drawGroupHulls() +} + +// Cuts data with too many nodes to the first MAX_NETWORK_NODES. +function capNetworkData(data) { + if (!data || !Array.isArray(data.nodes) || data.nodes.length <= MAX_NETWORK_NODES) return data + const total = data.nodes.length + const nodes = data.nodes.slice(0, MAX_NETWORK_NODES) + const keep = new Set(nodes.map((n) => n.id)) + const edges = (data.edges || []).filter((e) => keep.has(e.source) && keep.has(e.target)) + const text = `This network has ${total.toLocaleString('en-US')} nodes; NORMA shows up to ${MAX_NETWORK_NODES.toLocaleString('en-US')}, so only the first ${MAX_NETWORK_NODES.toLocaleString('en-US')} and the ${plural(edges.length, 'edge')} among them are shown.` + setTimeout(() => { + if (typeof toast === 'function') toast(text, 'warn') + const el = document.getElementById('normaStatus') + if (el) { + const note = document.createElement('div') + note.className = 'note warn' + note.textContent = text + el.prepend(note) + } + }, 0) + return { ...data, nodes, edges } +} + +// Extra legend sections that come with generated views (e.g. the comparison). +export let legendExtra = null + +export function loadData(data, opts = {}) { + data = capNetworkData(data) + legendExtra = Array.isArray(data.legendExtra) ? data.legendExtra : null + bumpDataVersion() + S.currentLibView = null + S.nodeColorMap = {} + S.groupShapes = + data.groupShapes && typeof data.groupShapes === 'object' ? { ...data.groupShapes } : {} + S.groupAttrs = + data.groupAttrs && typeof data.groupAttrs === 'object' ? { ...data.groupAttrs } : {} + setGroupOrder(data.groupOrder) + // give groups their palette colors in display order + if (Array.isArray(data.groupOrder)) { + data.groupOrder.forEach((g) => { + const explicit = sanitizeColor( + (data.nodeColors && data.nodeColors[g]) || (S.groupAttrs[g] && S.groupAttrs[g].color) + ) + if (explicit) S.nodeColorMap[g] = explicit + else colorForGroup(g) + }) + } + cy.elements().remove() + + const usedTypes = new Set() + const els = [] + + data.nodes.forEach((n) => { + const groupsArr = n.groups && n.groups.length ? n.groups : [n.group || UNGROUPED] + if (groupsArr.includes(UNGROUPED) && !S.groupAttrs[UNGROUPED]) { + S.groupAttrs[UNGROUPED] = { + label: UNGROUPED_LABEL, + description: 'Nodes that no group lists as a member', + } + } + groupsArr.forEach((g) => { + // explicit color precedence: nodeColors[g], then groupAttrs[g].color + const explicit = sanitizeColor( + (data.nodeColors && data.nodeColors[g]) || (S.groupAttrs[g] && S.groupAttrs[g].color) + ) + if (explicit) S.nodeColorMap[g] = explicit + }) + // a node's own "color" is kept separately and used when + // Colors -> Node fill is set to "Node colors from data" + const visual = computeNodeVisualFields(groupsArr) + els.push({ + group: 'nodes', + data: { + id: n.id, + size: n.size || 42, + baseSize: n.size || 42, + group: groupsArr.join(', '), + groups: groupsArr, + nodeColor: n.color || null, + values: n.values && typeof n.values === 'object' ? cleanValues(n.values) : null, + valueColor: null, + labelPx: 12, + labelColor: '#111827', + shape: 'ellipse', + attrs: collectAttrs(n, RESERVED_NODE_KEYS), + ...visual, + }, + }) + }) + + data.edges.forEach((e, i) => { + const type = e.type || 'link' + usedTypes.add(type) + const explicit = sanitizeColor((data.edgeColors && data.edgeColors[type]) || e.color) + if (!EDGE_TYPES[type]) { + EDGE_TYPES[type] = { color: explicit || nextAutoEdgeColor(), label: type } + } else if (explicit) { + EDGE_TYPES[type].color = explicit + } + els.push({ + group: 'edges', + data: { + id: e.id || 'e' + i, + source: e.source, + target: e.target, + type, + color: EDGE_TYPES[type].color, + elabel: '', + directed: e.directed === undefined ? !!data.directed : !!e.directed, + arrow: 'none', + attrs: collectAttrs(e, RESERVED_EDGE_KEYS), + ...(typeof e.weight === 'number' ? { weight: e.weight } : {}), + }, + }) + }) + + cy.add(els) + S.activeTypes = new Set(usedTypes.size ? usedTypes : Object.keys(EDGE_TYPES)) + S.activeGroups = new Set(getUsedGroups()) + buildLegend([...usedTypes]) + buildGroupLegend() + applyTypeVisibility() + applyGroupVisibility() + invalidateFullMetrics() + buildAttrSchema() + renderAttrSchema() + hideInfo() + hideEdgePopup() + if (currentTab === 'welcome' && typeof switchTab === 'function') switchTab('network') + if (data.config) { + applyConfig(data.config) // also covers applyEdgeWidth() and applyNodeSizing() + } else { + applyEdgeWidth() + applyNodeSizing() + } + const nodeCount = cy.nodes().length + updateRefreshState() + if (!opts.positions && typeof reset3dForNewData === 'function') reset3dForNewData() + if (opts.positions) { + // restoring a saved view: put nodes back where they were + cancelFrJobs() + S.layoutRunSeq++ + cy.nodes().positions((n) => opts.positions[n.id()] || n.position()) + } else if (layoutMode() === 'groups' && nodeCount <= LARGE_NETWORK_NODES) { + runStrategyLayout() + } else { + // a data file's settings may pick the opening layout + const preferred = (data.config && data.config.layoutSelect) || takeNextLoadLayout() + runLayout(nodeCount > LARGE_NETWORK_NODES ? 'grid' : preferred || 'cose') + } + applyEdgeDirection() // also merges parallel edges + if (typeof applyValueColors === 'function') applyValueColors() + updateStats() +} + +export function buildLegend(typesInData) { + const el = document.getElementById('edgeLegend') + el.innerHTML = '' + const list = typesInData.length ? typesInData : Object.keys(EDGE_TYPES) + list.forEach((type) => { + const meta = EDGE_TYPES[type] || { color: '#6c7793', label: type } + const count = cy.edges().filter((e) => e.data('type') === type).length + const row = document.createElement('label') + row.className = 'toggle-row' + row.innerHTML = ` + + + ${escapeHtml(meta.label)} + ${count} + ` + row.querySelector('input[type=checkbox]').addEventListener('change', (ev) => { + if (ev.target.checked) S.activeTypes.add(type) + else S.activeTypes.delete(type) + applyTypeVisibility() + }) + const picker = row.querySelector('.swatch-picker') + picker.addEventListener('click', (ev) => ev.stopPropagation()) + picker.addEventListener('input', (ev) => { + ev.stopPropagation() + const newColor = ev.target.value + if (!EDGE_TYPES[type]) EDGE_TYPES[type] = { color: newColor, label: type } + else EDGE_TYPES[type].color = newColor + cy.edges() + .filter((ed) => ed.data('type') === type) + .forEach((ed) => ed.data('color', newColor)) + applyEdgeMerge() + }) + el.appendChild(row) + }) +} + +export function getUsedTypes() { + return [...new Set(cy.edges().map((e) => e.data('type')))] +} + +// keepDataColors: groups whose data gives a color keep it (settings applied +// from files or the API); choosing a palette by hand recolors every group. +export function applyNodePalette(name, keepDataColors = false) { + S.currentNodePalette = name + const palette = NODE_PALETTES[name] || NODE_PALETTES.vivid + const groups = Object.keys(S.nodeColorMap).filter((g) => g !== UNGROUPED) + groups.forEach((g, i) => { + const own = keepDataColors && sanitizeColor(S.groupAttrs[g] && S.groupAttrs[g].color) + S.nodeColorMap[g] = own || colorAtIndex(palette, i) + }) + cy.batch(() => cy.nodes().forEach((n) => refreshNodeVisual(n))) + buildGroupLegend() +} + +export function applyEdgePalette(name) { + const types = getUsedTypes() + if (name === 'classic') { + types.forEach((t) => { + EDGE_TYPES[t] = ORIGINAL_EDGE_TYPES[t] + ? { ...ORIGINAL_EDGE_TYPES[t] } + : EDGE_TYPES[t] || { color: nextAutoEdgeColor(), label: t } + }) + } else { + const palette = EDGE_PALETTES[name] || EDGE_PALETTES.categorical + types.forEach((t, i) => { + const label = (EDGE_TYPES[t] && EDGE_TYPES[t].label) || t + EDGE_TYPES[t] = { color: colorAtIndex(palette, i), label } + }) + } + cy.edges().forEach((e) => { + const meta = EDGE_TYPES[e.data('type')] + if (meta) e.data('color', meta.color) + }) + buildLegend(types) + applyEdgeMerge() +} + +// page wiring, run by main.ts in the original order +export function init() { + window.addEventListener('resize', () => { + resizeHullCanvas() + drawGroupHulls() + }) + + if (typeof ResizeObserver !== 'undefined') { + new ResizeObserver(() => { + resizeHullCanvas() + drawGroupHulls() + }).observe(document.getElementById('canvas')) + } +} diff --git a/frontend/src/label_colors.ts b/frontend/src/label_colors.ts new file mode 100644 index 0000000..c0b5a82 --- /dev/null +++ b/frontend/src/label_colors.ts @@ -0,0 +1,332 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { S } from './state' +import { UNGROUPED, escapeHtml } from './network_state' +import { currentTheme } from './themes' +import { edgeIsDirected } from './export/dialog' +import { libEntry, nextPaint, normaLibrary, plural, setStatus } from './layouts/controls' +import { profileDirected } from './directed_stats' +import { profileGraph, simpleGraph } from './wiring' +import { renderComparison } from './export/draw' +import { setStyle } from './cy' +import { shownEdges, shownNodes } from './metrics' +import { sortedByName } from './hulls' +import { updateLabelStyle, views } from './profiler' + +/* ---------- label colors ---------- */ +function isLightColor(hex) { + const m = /^#?([0-9a-f]{6})$/i.exec(hex || '') + if (!m) return false + const n = parseInt(m[1], 16) + const r = (n >> 16) & 255, + g = (n >> 8) & 255, + b = n & 255 + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 > 0.72 +} + +function chosenLabelColor(modeId, pickerId) { + const mode = document.getElementById(modeId).value + const picker = document.getElementById(pickerId) + picker.hidden = mode !== 'custom' + if (mode === 'custom') return picker.value + return mode // 'auto', 'match' or a hex color +} + +export function applyLabelColors() { + const node = chosenLabelColor('nodeLabelColorMode', 'nodeLabelColor') + const edge = chosenLabelColor('edgeLabelColorMode', 'edgeLabelColor') + const theme = currentTheme + // light text gets a dark halo so it stays readable on a light canvas + const halo = (c) => (c !== 'auto' && c !== 'match' && isLightColor(c) ? '#1b2333' : theme.bg) + setStyle('node', { + color: node === 'auto' ? theme.text : node === 'match' ? 'data(labelColor)' : node, + 'text-outline-color': halo(node), + }) + setStyle('edge', { + color: edge === 'auto' ? theme.text : edge === 'match' ? 'data(color)' : edge, + 'text-background-color': isLightColor(edge) ? '#1b2333' : theme.bg, + }) +} + +/* ============================================================ + NETWORK COMPARISON + Up to ten networks, taken from open views or from the file library, + compared by node and edge overlap (Venn diagrams for two or three + networks, UpSet plots for any number), pairwise Jaccard similarity, + degree agreement on shared nodes, degree distributions, and the + profiler's topology statistics side by side. Nodes are matched by + name; edges by their unordered node pair, ignoring channels. + ============================================================ */ +const COMPARE_MAX = 10 + +const COMPARE_COLORS = [ + '#2563eb', + '#e0782b', + '#16a34a', + '#c026d3', + '#0891b2', + '#dc2626', + '#7c3aed', + '#ca8a04', + '#db2777', + '#475569', +] + +export const UPSET_MAX_COLUMNS = 40 + +export const compareState = { results: null } + +function compareSourceOptions() { + const opts = [] + views.forEach((v) => { + const n = v.id === S.activeViewId ? shownNodes().length : v.data ? v.data.nodes.length : 0 + if (n) + opts.push({ + value: 'view:' + v.id, + label: v.name, + meta: `view, ticked groups and channels`, + group: 'Open views', + }) + }) + sortedByName(normaLibrary.network, (e) => e.name).forEach((e) => { + opts.push({ + value: 'lib:' + e.id, + label: e.name, + meta: e.parsed.summary, + group: 'Network files', + }) + }) + return opts +} + +export function renderCompareList() { + const el = document.getElementById('cmpNetList') + if (!el) return + const previous = new Set([...el.querySelectorAll('input:checked')].map((i) => i.value)) + el.innerHTML = '' + const opts = compareSourceOptions() + if (!opts.length) { + el.innerHTML = '

    Open an example or upload networks first.

    ' + updateCompareLimit() + return + } + let lastGroup = null + opts.forEach((o) => { + if (o.group !== lastGroup) { + const h = document.createElement('div') + h.className = 'cmp-group-head' + h.textContent = o.group + el.appendChild(h) + lastGroup = o.group + } + const label = document.createElement('label') + label.className = 'cmp-option' + const input = document.createElement('input') + input.type = 'checkbox' + input.value = o.value + input.checked = previous.has(o.value) + input.addEventListener('change', updateCompareLimit) + const text = document.createElement('span') + text.innerHTML = `${escapeHtml(o.label)}${escapeHtml(o.meta)}` + label.append(input, text) + el.appendChild(label) + }) + updateCompareLimit() +} + +function updateCompareLimit() { + const boxes = [...document.querySelectorAll('#cmpNetList input')] + const checked = boxes.filter((b) => b.checked) + boxes.forEach((b) => { + b.disabled = !b.checked && checked.length >= COMPARE_MAX + }) + const btn = document.getElementById('btnCompare') + btn.disabled = checked.length < 2 + document.getElementById('cmpCount').textContent = + `${checked.length} of ${COMPARE_MAX} chosen` + + (checked.length < 2 ? ' (choose at least 2)' : '') +} + +function compareSource(value) { + if (value.startsWith('view:')) { + const v = views.find((x) => x.id === value.slice(5)) + if (!v) return null + let ids, pairs + if (v.id === S.activeViewId) { + ids = shownNodes().map((n) => n.id()) + pairs = shownEdges(true).map((e) => [e.data('source'), e.data('target'), edgeIsDirected(e)]) + } else { + if (!v.data) return null + // the part that view shows: its ticked groups and channels + const st = v.state || {} + const groupsOn = st.activeGroups ? new Set(st.activeGroups) : null + const typesOn = st.activeTypes ? new Set(st.activeTypes) : null + const shown = new Set( + v.data.nodes + .filter((n) => { + if (!groupsOn) return true + const gs = n.groups && n.groups.length ? n.groups : n.group ? [n.group] : [UNGROUPED] + return gs.some((g) => groupsOn.has(g)) + }) + .map((n) => n.id) + ) + ids = [...shown] + const mode = (st.config && st.config.edgeDirection) || 'data' + pairs = v.data.edges + .filter( + (e) => + shown.has(e.source) && + shown.has(e.target) && + (!typesOn || typesOn.has(e.type || 'link')) + ) + .map((e) => [ + e.source, + e.target, + mode === 'directed' ? true : mode === 'undirected' ? false : !!e.directed, + ]) + } + return { name: v.name, kind: 'view', ids, pairs } + } + const entry = libEntry('network', value.slice(4)) + if (!entry) return null + return { + name: entry.name, + kind: 'file', + ids: entry.parsed.nodes, + pairs: entry.parsed.edges.map((e) => [e.source, e.target, !!e.directed]), + } +} + +// Edge identity: an unordered pair, or with direction on, an ordered pair +// for directed edges (so A->B, B->A and undirected A-B all differ). +function pairKey(a, b, directed) { + if (directed) return a + '\t' + b + '\tdirected' + return (a < b ? a + '\t' + b : b + '\t' + a) + '\tundirected' +} + +// element -> bitmask of the sets containing it +export function membership(sets) { + const mask = new Map() + sets.forEach((set, i) => set.forEach((x) => mask.set(x, (mask.get(x) || 0) | (1 << i)))) + return mask +} + +export function exclusiveCounts(mask) { + const counts = new Map() + mask.forEach((m) => counts.set(m, (counts.get(m) || 0) + 1)) + return counts +} + +export function jaccard(a, b) { + let inter = 0 + const [small, big] = a.size < b.size ? [a, b] : [b, a] + small.forEach((x) => { + if (big.has(x)) inter++ + }) + const union = a.size + b.size - inter + return { inter, union, j: union ? inter / union : 0 } +} + +export function spearman(xs, ys) { + const rank = (arr) => { + const idx = arr.map((v, i) => [v, i]).sort((p, q) => p[0] - q[0]) + const r = new Array(arr.length) + for (let i = 0; i < idx.length;) { + let j = i + while (j + 1 < idx.length && idx[j + 1][0] === idx[i][0]) j++ + const avg = (i + j) / 2 + 1 + for (let k = i; k <= j; k++) r[idx[k][1]] = avg + i = j + 1 + } + return r + } + const n = xs.length + if (n < 3) return NaN + const rx = rank(xs), + ry = rank(ys) + const mx = (n + 1) / 2 + let num = 0, + dx = 0, + dy = 0 + for (let i = 0; i < n; i++) { + num += (rx[i] - mx) * (ry[i] - mx) + dx += (rx[i] - mx) ** 2 + dy += (ry[i] - mx) ** 2 + } + return dx && dy ? num / Math.sqrt(dx * dy) : NaN +} + +export function runComparison() { + const chosen = [...document.querySelectorAll('#cmpNetList input:checked')] + .map((i) => i.value) + .slice(0, COMPARE_MAX) + if (chosen.length < 2) { + setStatus('cmpStatus', [{ level: 'error', text: 'Choose at least two networks to compare.' }]) + return + } + // steps: one per network, then the overlaps and drawing + const cmpShow = (text, f) => setStatus('cmpStatus', [{ level: 'busy', text, progress: f }]) + cmpShow('Comparing networks…', 0) + document.getElementById('btnCompare').disabled = true + ;(async () => { + await nextPaint() + try { + const started = performance.now() + const nets = chosen + .map((value) => { + const n = compareSource(value) + if (n) n.sourceValue = value + return n + }) + .filter(Boolean) + const useDir = document.getElementById('cmpDirected').checked + for (const [i, net] of nets.entries()) { + cmpShow(`Profiling network ${i + 1} of ${nets.length}…`, i / (nets.length + 1)) + await nextPaint() + net.color = COMPARE_COLORS[i] + net.nodeSet = new Set(net.ids) + net.edgeSet = new Set() + net.pairs.forEach(([a, b, d]) => { + if (a !== b && net.nodeSet.has(a) && net.nodeSet.has(b)) + net.edgeSet.add(pairKey(a, b, useDir && d)) + }) + net.graph = simpleGraph( + net.ids, + net.pairs.map((p) => [p[0], p[1]]) + ) + net.stats = profileGraph(net.graph) + net.directed = + useDir && net.pairs.some((p) => p[2]) ? profileDirected(net.ids, net.pairs) : null + net.degreeOf = new Map(net.ids.map((id, k) => [id, net.stats.degree[k]])) + } + cmpShow('Computing overlaps and drawing…', nets.length / (nets.length + 1)) + await nextPaint() + compareState.results = nets + renderComparison(nets) + const secs = ((performance.now() - started) / 1000).toFixed(2) + setStatus('cmpStatus', [ + { level: 'ok', text: `Compared ${plural(nets.length, 'network')} in ${secs} s.` }, + ]) + document.getElementById('btnCompareTsv').disabled = false + document.getElementById('btnCompareNet').disabled = false + document.getElementById('cmpArena').hidden = false + } catch (err) { + setStatus('cmpStatus', [ + { level: 'error', text: `The comparison couldn't be computed: ${err.message}` }, + ]) + } finally { + updateCompareLimit() + } + })() +} + +// page wiring, run by main.ts in the original order +export function init() { + ;['nodeLabelColorMode', 'edgeLabelColorMode'].forEach((id) => { + document.getElementById(id).addEventListener('change', updateLabelStyle) + }) + + ;['nodeLabelColor', 'edgeLabelColor'].forEach((id) => { + document.getElementById(id).addEventListener('input', updateLabelStyle) + }) +} diff --git a/frontend/src/layouts/controls.ts b/frontend/src/layouts/controls.ts new file mode 100644 index 0000000..5aa19c7 --- /dev/null +++ b/frontend/src/layouts/controls.ts @@ -0,0 +1,1079 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { MAX_NETWORK_NODES, UNGROUPED, getUsedGroups } from '../network_state' +import { S } from '../state' +import { SAMPLE_GENERATORS } from '../sample_data' +import { STRATEGIES, groupArrangement, layoutMode, runActiveLayout } from './run' +import { + applyConfig, + applyGroupVisibility, + buildGroupLegend, + filteredGroups, + getCurrentConfig, + loadData, + sortedByName, +} from '../hulls' +import { askDelete, deleteLibEntries } from '../api/tester' +import { clearCanvas, fitView, openInNewView, renderProfilerNetworkList } from '../profiler' +import { cy } from '../cy' +import { edgeIsDirected } from '../export/dialog' +import { exportEdges, exportNodes } from '../metrics' +import { loadNormaExampleSet } from '../examples' +import { numericShare, parseNumericValues } from '../clustering/mcl' + +/* ---------- layout controls ---------- */ +const ARRANGEMENT_CAPTIONS = { + fr: 'Groups are placed by how strongly they connect to each other (weighted force-directed), then pushed apart so none overlap.', + cose: 'Groups are placed by how they connect to each other (Cytoscape force-directed), then pushed apart so none overlap.', + circle: 'Groups sit side by side on a ring, in legend order.', + grid: 'Groups are packed in rows, in legend order.', + breadthfirst: + 'Groups are stacked in levels: the best-connected group on top, the groups it links to below it, and so on.', + concentric: 'The largest group sits in the middle, with the others in rings around it by size.', + virtual: + 'Each group gets a hidden hub tied to all its members by heavy links (dashed), so the layout pulls each group together. The hubs are removed afterwards.', + gravity: + 'Links inside a group are strengthened by the force and links between groups weakened by it. Layout-only links (dashed) join every pair in a group.', + supernodes: + 'Each group is collapsed into one node and the collapsed network is laid out. Groups are then pushed apart by the force, and each group\u2019s nodes are arranged around its position. Nodes in several groups sit between them.', +} + +export function setLayoutMode(mode) { + document.getElementById('layoutMode').value = mode === 'groups' ? 'groups' : 'connections' + updateStrategyUI() +} + +export function updateStrategyUI() { + const mode = layoutMode() + const arrangement = groupArrangement() + const isStrategy = STRATEGIES.has(arrangement) + document.querySelectorAll('#layoutModeSwitch [role="radio"]').forEach((btn) => { + const on = btn.dataset.mode === mode + btn.setAttribute('aria-checked', on ? 'true' : 'false') + btn.tabIndex = on ? 0 : -1 + }) + document.getElementById('connLayoutSet').disabled = mode !== 'connections' + document.getElementById('groupLayoutSet').disabled = mode !== 'groups' + + const fig = document.getElementById('strategyFig') + fig + .querySelectorAll('svg') + .forEach((svg) => svg.classList.toggle('on', svg.dataset.strategy === arrangement)) + document.getElementById('strategyCaption').textContent = ARRANGEMENT_CAPTIONS[arrangement] || '' + document.getElementById('strategyAlgorithmRow').hidden = !isStrategy + document.getElementById('strategyAlgorithmLabel').textContent = + arrangement === 'supernodes' + ? 'Algorithm for the collapsed network' + : 'Algorithm the strategy runs' + + const keepOpt = document.querySelector('#localGroupLayout option[value="keep"]') + const keepAllowed = arrangement === 'virtual' || arrangement === 'gravity' + keepOpt.disabled = !keepAllowed + keepOpt.hidden = !keepAllowed + const localSel = document.getElementById('localGroupLayout') + if (!keepAllowed && localSel.value === 'keep') localSel.value = 'circle' + + document.getElementById('forceLabelText').textContent = isStrategy + ? 'Force strength' + : 'Space between groups' + document.getElementById('forceRow').hidden = + arrangement === 'virtual' && localSel.value === 'keep' + document.getElementById('clusterRow').hidden = localSel.value === 'keep' + document.getElementById('groupForceValue').textContent = + document.getElementById('groupForce').value + document.getElementById('groupClusterRadiusValue').textContent = ( + parseFloat(document.getElementById('groupClusterRadius').value) || 1 + ).toFixed(1) + document.getElementById('btnRunLayout').textContent = + mode === 'groups' ? 'Run group layout' : 'Run layout' +} + +/* ============================================================ + FILES: NORMA-FORMAT LIBRARY + Reads and writes the three tab-delimited inputs used by NORMA + (Koutrouli et al. 2022; Karatzas et al. 2022): + network -- header "SourceTarget[Weight][Type]", + undirected; self-loops and repeats are dropped. The + optional Type column is this tool's extension: it keeps + parallel edges, one per type. + annotation -- no header; "groupnode1,node2,..." + expression -- no header; "nodecolor" (name or hex) + Every uploaded file is kept in an in-memory library. Any set of networks + can be viewed together (each becomes an edge channel) with one + annotation and one expression file. Annotation and expression entries + for nodes missing from the chosen networks are discarded at view time + (the job of NORMA's companion R script); the corrected annotation can be + downloaded. + ============================================================ */ +export const NORMA_KINDS = ['network', 'annotation', 'colors'] + +export const NORMA_KIND_NAMES = { + network: 'network', + annotation: 'annotation', + colors: 'expression file', +} + +const LIB_LIST_IDS = { + network: 'libNetworks', + annotation: 'libAnnotations', + colors: 'libExpressions', +} + +export const normaLibrary = { network: [], annotation: [], colors: [] } + +export const libSelection = { networks: new Set(), annotation: '', colors: '' } + +export function plural(n, one, many) { + return `${n} ${n === 1 ? one : many || one + 's'}` +} + +export function listSample(items, max = 6) { + const shown = items.slice(0, max).join(', ') + return items.length > max ? `${shown} and ${items.length - max} more` : shown +} + +export function articleFor(word) { + return /^[aeiou]/i.test(word) ? 'an' : 'a' +} + +export function normaLines(text) { + return String(text) + .replace(/^\uFEFF/, '') + .split(/\r\n|\n|\r/) +} + +// Tab-separated columns. Files saved with spaces instead of tabs (common +// after copy-pasting) fall back to splitting on whitespace. +export function normaCols(line) { + let cols = line.split('\t') + if (cols.length === 1) cols = line.trim().split(/\s+/) + cols = cols.map((c) => c.trim()) + while (cols.length && cols[cols.length - 1] === '') cols.pop() + return cols +} + +const NETWORK_TYPE_HEADERS = new Set(['type', 'channel', 'layer', 'interaction']) + +const NETWORK_DIRECTION_HEADERS = new Set(['direction', 'directed', 'dir', 'arrow']) + +const DIRECTED_WORDS = new Set(['directed', 'yes', 'y', 'true', '1', '->', '-->', '→', 'forward']) + +const UNDIRECTED_WORDS = new Set([ + 'undirected', + 'no', + 'n', + 'false', + '0', + '-', + '--', + '—', + 'both', + 'none', + '', +]) + +// options.directed: how to read rows without a Direction value +export function parseNormaNetwork(text, options = {}) { + const defaultDirected = !!options.directed + const lines = normaLines(text) + const headerIdx = lines.findIndex((l) => l.trim() !== '') + if (headerIdx < 0) throw new Error('The file is empty.') + const header = normaCols(lines[headerIdx]).map((h) => h.toLowerCase()) + if (header[0] !== 'source' || header[1] !== 'target') { + throw new Error( + 'The first line must be the header "Source, Target" (optionally followed by Weight, Type and Direction), separated by tabs.' + ) + } + let weightCol = -1, + typeCol = -1, + dirCol = -1 + for (let c = 2; c < header.length; c++) { + if (header[c] === 'weight' && weightCol < 0) weightCol = c + else if (NETWORK_TYPE_HEADERS.has(header[c]) && typeCol < 0) typeCol = c + else if (NETWORK_DIRECTION_HEADERS.has(header[c]) && dirCol < 0) dirCol = c + else + throw new Error( + `The header column "${header[c]}" isn't recognised. After Source and Target, only Weight, Type and Direction are allowed.` + ) + } + const minCols = Math.max(2, weightCol + 1, typeCol + 1) + const badDirections = [] + + const nodes = new Set() + const seen = new Set() + const edges = [] + const types = new Set() + let selfLoops = 0, + repeats = 0, + directedCount = 0 + const shortLines = [], + badWeights = [] + for (let i = headerIdx + 1; i < lines.length; i++) { + if (!lines[i].trim()) continue + const cols = normaCols(lines[i]) + if (cols.length < minCols) { + shortLines.push(i + 1) + continue + } + const [source, target] = cols + nodes.add(source) + nodes.add(target) + if (source === target) { + selfLoops++ + continue + } + const type = typeCol >= 0 ? cols[typeCol] : '' + let directed = defaultDirected + if (dirCol >= 0 && cols[dirCol] !== undefined) { + const word = cols[dirCol].trim().toLowerCase() + if (DIRECTED_WORDS.has(word)) directed = true + else if (UNDIRECTED_WORDS.has(word)) directed = false + else badDirections.push(i + 1) + } + // A directed edge A->B differs from B->A; an undirected A-B doesn't. + const pair = directed + ? 'D\t' + source + '\t' + target + : 'U\t' + (source < target ? source + '\t' + target : target + '\t' + source) + const key = pair + '\t' + type + if (seen.has(key)) { + repeats++ + continue + } + seen.add(key) + const edge = { source, target } + if (directed) { + edge.directed = true + directedCount++ + } + if (type) { + edge.type = type + types.add(type) + } + if (weightCol >= 0) { + const w = Number(cols[weightCol]) + if (Number.isFinite(w)) edge.weight = w + else badWeights.push(i + 1) + } + edges.push(edge) + } + if (!edges.length) throw new Error('No connections were found below the header row.') + + const notes = [] + if (nodes.size > MAX_NETWORK_NODES) { + // keep the first nodes in the order they appear, and the edges among them + const keep = new Set([...nodes].slice(0, MAX_NETWORK_NODES)) + const total = nodes.size, + before = edges.length + const kept = edges.filter((e) => keep.has(e.source) && keep.has(e.target)) + edges.length = 0 + edges.push(...kept) + nodes.clear() + keep.forEach((n) => nodes.add(n)) + types.clear() + directedCount = 0 + edges.forEach((e) => { + if (e.type) types.add(e.type) + if (e.directed) directedCount++ + }) + notes.push( + `This network has ${total.toLocaleString('en-US')} nodes; NORMA shows up to ${MAX_NETWORK_NODES.toLocaleString('en-US')}, so only the first ${MAX_NETWORK_NODES.toLocaleString('en-US')} (in file order) and the ${plural(edges.length, 'edge')} among them were kept (${(before - edges.length).toLocaleString('en-US')} edges left out).` + ) + if (!edges.length) + throw new Error( + `The first ${MAX_NETWORK_NODES.toLocaleString('en-US')} nodes of this network have no connections among them.` + ) + } + if (selfLoops) notes.push(`Removed ${plural(selfLoops, 'self-loop')}.`) + if (repeats) + notes.push( + directedCount === edges.length + ? `Removed ${plural(repeats, 'repeated connection')} with the same source, target${typeCol >= 0 ? ' and type' : ''}.` + : typeCol >= 0 + ? `Removed ${plural(repeats, 'repeated connection')} with the same pair and type${directedCount ? ' (for undirected rows, A–B and B–A are the same pair)' : ''}.` + : `Removed ${plural(repeats, 'repeated connection')} (for undirected rows, A–B and B–A count as the same connection).` + ) + if (badDirections.length) + notes.push( + `Read ${plural(badDirections.length, 'Direction value')} that ${badDirections.length === 1 ? "isn't" : "aren't"} "directed" or "undirected" as ${defaultDirected ? 'directed' : 'undirected'}: line ${listSample(badDirections)}.` + ) + if (shortLines.length) + notes.push( + `Skipped ${plural(shortLines.length, 'line')} with fewer than ${minCols} columns: line ${listSample(shortLines)}.` + ) + if (badWeights.length) + notes.push( + `Left out ${plural(badWeights.length, 'weight')} that ${badWeights.length === 1 ? "isn't a number" : "aren't numbers"}: line ${listSample(badWeights)}.` + ) + const commaNames = [...nodes].filter((n) => n.includes(',')) + if (commaNames.length) + notes.push( + `${plural(commaNames.length, 'node name contains', 'node names contain')} a comma, so annotation files can't list ${commaNames.length === 1 ? 'it' : 'them'}: ${listSample(commaNames)}.` + ) + + const summaryParts = [plural(nodes.size, 'node'), plural(edges.length, 'edge')] + if (types.size) summaryParts.push(plural(types.size, 'type')) + if (weightCol >= 0) summaryParts.push('weighted') + const direction = + directedCount === 0 ? 'undirected' : directedCount === edges.length ? 'directed' : 'mixed' + if (direction !== 'undirected') + summaryParts.push( + direction === 'mixed' ? `mixed (${directedCount.toLocaleString()} directed)` : 'directed' + ) + return { + nodes: [...nodes], + edges, + weighted: weightCol >= 0, + types: [...types], + notes, + direction, + directedCount, + summary: summaryParts.join(', '), + } +} + +const ANNOTATION_HEADER_WORDS = + /^(groups?|annotations?|terms?|pathways?|names?|modules?|clusters?)$/i + +function parseNormaAnnotation(text) { + const lines = normaLines(text) + const groups = new Map() + let spacesStripped = 0, + merged = 0, + headerSkipped = false + const shortLines = [] + let firstDataLine = true + lines.forEach((raw, i) => { + if (!raw.trim()) return + let name, list + const tab = raw.indexOf('\t') + if (tab >= 0) { + name = raw.slice(0, tab).trim() + list = raw.slice(tab + 1).replace(/\t/g, '') + } else { + // no tab: only accept the unambiguous "namelist" shape + const parts = raw.trim().split(/\s+/) + if (parts.length === 2) { + name = parts[0] + list = parts[1] + } + } + if (!name || !list || !list.trim()) { + shortLines.push(i + 1) + firstDataLine = false + return + } + if (firstDataLine && ANNOTATION_HEADER_WORDS.test(name) && !list.includes(',')) { + headerSkipped = true + firstDataLine = false + return + } + firstDataLine = false + if (/\s/.test(list.trim())) spacesStripped++ + const members = list + .split(',') + .map((m) => m.trim()) + .filter(Boolean) + if (groups.has(name)) merged++ + const set = groups.get(name) || new Set() + members.forEach((m) => set.add(m)) + groups.set(name, set) + }) + if (!groups.size) { + throw new Error( + 'No groups were found. Each line should be a group name, a tab, then node names separated by commas.' + ) + } + const notes = [] + if (headerSkipped) + notes.push( + 'Skipped the first line because it looks like a header. Annotation files have no header row.' + ) + if (shortLines.length) + notes.push( + `Skipped ${plural(shortLines.length, 'line')} without a group name and node list: line ${listSample(shortLines)}.` + ) + if (spacesStripped) + notes.push( + `Removed spaces from the node lists on ${plural(spacesStripped, 'line')}. NORMA expects commas only.` + ) + if (merged) notes.push(`Merged ${plural(merged, 'repeated group name')} into one group each.`) + const list = [...groups].map(([name, set]) => ({ name, members: [...set] })) + return { groups: list, notes, summary: plural(list.length, 'group') } +} + +const colorProbeCtx = document.createElement('canvas').getContext('2d') + +// Returns a canvas-normalized color ("#rrggbb" or "rgba(...)"), or null +// if the browser doesn't recognise the value as a CSS color. +export function normalizeCssColor(value) { + const probe = new Option().style + probe.color = value + if (!probe.color) return null + colorProbeCtx.fillStyle = '#000000' + colorProbeCtx.fillStyle = value + return colorProbeCtx.fillStyle +} + +function parseNormaColors(text) { + // numbers instead of colors: a numeric expression file + const sample = normaLines(text) + .filter((l) => l.trim()) + .slice(0, 300) + if (sample.length && numericShare(sample.slice(1)) >= 0.8) return parseNumericValues(text) + const lines = normaLines(text) + const colors = new Map() + const invalid = [], + shortLines = [] + let headerSkipped = false, + firstDataLine = true + lines.forEach((raw, i) => { + if (!raw.trim()) return + const cols = normaCols(raw) + if (cols.length < 2) { + shortLines.push(i + 1) + firstDataLine = false + return + } + const [node, value] = cols + const color = normalizeCssColor(value) + if (!color) { + if (firstDataLine && /^(nodes?|genes?|names?|ids?|proteins?)$/i.test(node)) { + headerSkipped = true + } else invalid.push(`${node} (${value})`) + firstDataLine = false + return + } + firstDataLine = false + colors.set(node, color) + }) + if (!colors.size) { + throw new Error( + 'No node colors were found. Each line should be a node name, a tab, then a color such as red or #ff0000.' + ) + } + const notes = [] + if (headerSkipped) + notes.push( + 'Skipped the first line because it looks like a header. Node-color files have no header row.' + ) + if (shortLines.length) + notes.push( + `Skipped ${plural(shortLines.length, 'line')} without both a node and a color: line ${listSample(shortLines)}.` + ) + if (invalid.length) + notes.push( + `Ignored ${plural(invalid.length, 'unrecognised color')}: ${listSample(invalid)}. Use a color name like red or a hex code like #ff0000.` + ) + return { colors, notes, summary: plural(colors.size, 'colored node') } +} + +export const NORMA_PARSERS = { + network: parseNormaNetwork, + annotation: parseNormaAnnotation, + colors: parseNormaColors, +} + +// Guesses a file's kind: a Source/Target header means a network; a second +// column that is (almost) always a color means an expression file; +// anything else is read as an annotation. +export function detectNormaKind(text) { + const lines = normaLines(text) + .filter((l) => l.trim()) + .slice(0, 300) + if (!lines.length) return null + const head = normaCols(lines[0]).map((c) => c.toLowerCase()) + if (head[0] === 'source' && head[1] === 'target') return 'network' + if (numericShare(lines.slice(1)) >= 0.8) return 'colors' + let rows = 0, + colorish = 0, + commas = 0 + lines.forEach((line) => { + const tab = line.indexOf('\t') + const second = (tab >= 0 ? line.slice(tab + 1) : line.trim().split(/\s+/)[1] || '').trim() + if (!second) return + rows++ + if (second.includes(',')) commas++ + else if (normalizeCssColor(second)) colorish++ + }) + if (rows && !commas && colorish >= rows * 0.8) return 'colors' + return 'annotation' +} + +function uniqueNormaName(kind, name) { + const taken = new Set(normaLibrary[kind].map((e) => e.name)) + if (!taken.has(name)) return name + let n = 2 + while (taken.has(`${name} (${n})`)) n++ + return `${name} (${n})` +} + +// Parses and stores a file. Throws with a readable message if the file +// doesn't match the format; returns the new library entry otherwise. +export function addNormaEntry(kind, name, text, fileName, sourceKey, options) { + const parsed = NORMA_PARSERS[kind](text, options || {}) + const entry = { + id: 'lib' + ++S.normaEntrySeq, + kind, + name: uniqueNormaName(kind, name), + fileName: fileName || '', + sourceKey: sourceKey || null, + text, + options: options || {}, + parsed, + } + normaLibrary[kind].push(entry) + return entry +} + +export function libEntry(kind, id) { + return normaLibrary[kind].find((e) => e.id === id) || null +} + +export function selectedNetworks() { + return normaLibrary.network.filter((e) => libSelection.networks.has(e.id)) +} + +export function selectionKey() { + return { + nets: selectedNetworks() + .map((e) => e.id) + .join('|'), + annotation: libSelection.annotation, + colors: libSelection.colors, + } +} + +function selectionDiffersFromView() { + if (!S.currentLibView) return libSelection.networks.size > 0 + const k = selectionKey() + return ( + k.nets !== S.currentLibView.nets || + k.annotation !== S.currentLibView.annotation || + k.colors !== S.currentLibView.colors + ) +} + +export function updateRefreshState() { + const pending = selectionDiffersFromView() && libSelection.networks.size > 0 + document.getElementById('refreshHint').hidden = !pending + const btn = document.getElementById('btnRefreshView') + btn.classList.toggle('pending', pending) + btn.disabled = libSelection.networks.size === 0 + const nb = document.getElementById('btnOpenNewView') + if (nb) nb.disabled = libSelection.networks.size === 0 +} + +export function renderLibraryLists() { + const delAll = document.getElementById('btnDeleteAllFiles') + if (delAll) delAll.disabled = !NORMA_KINDS.some((k) => normaLibrary[k].length) + NORMA_KINDS.forEach((kind) => { + const el = document.getElementById(LIB_LIST_IDS[kind]) + el.innerHTML = '' + const entries = normaLibrary[kind] + const multi = kind === 'network' + const groupName = 'lib-' + kind + + if (!multi) { + el.appendChild( + libRow({ + kind, + inputType: 'radio', + groupName, + id: '', + name: 'None', + meta: '', + checked: (kind === 'annotation' ? libSelection.annotation : libSelection.colors) === '', + }) + ) + } + if (multi && !entries.length) { + const empty = document.createElement('div') + empty.className = 'lib-empty' + empty.textContent = 'Upload a network or load an example.' + el.appendChild(empty) + } + sortedByName(entries, (e) => e.name).forEach((e) => { + const checked = multi + ? libSelection.networks.has(e.id) + : (kind === 'annotation' ? libSelection.annotation : libSelection.colors) === e.id + el.appendChild( + libRow({ + kind, + inputType: multi ? 'checkbox' : 'radio', + groupName, + id: e.id, + name: e.name, + meta: e.parsed.summary, + checked, + removable: true, + title: e.fileName ? `${e.name} (${e.fileName})` : e.name, + }) + ) + }) + }) + updateRefreshState() + if (typeof renderProfilerNetworkList === 'function') renderProfilerNetworkList() +} + +function libRow({ kind, inputType, groupName, id, name, meta, checked, removable, title }) { + const row = document.createElement('label') + row.className = 'lib-row' + if (title) row.title = title + const input = document.createElement('input') + input.type = inputType + input.name = groupName + input.checked = checked + input.value = id + input.addEventListener('change', () => { + if (kind === 'network') { + if (input.checked) libSelection.networks.add(id) + else libSelection.networks.delete(id) + } else if (input.checked) { + if (kind === 'annotation') libSelection.annotation = id + else libSelection.colors = id + } + updateRefreshState() + }) + const text = document.createElement('span') + text.className = 'lib-text' + const nameEl = document.createElement('span') + nameEl.className = 'lib-name' + nameEl.textContent = name + text.appendChild(nameEl) + if (meta) { + const metaEl = document.createElement('span') + metaEl.className = 'lib-meta' + metaEl.textContent = meta + text.appendChild(metaEl) + } + row.append(input, text) + if (removable) { + const rm = document.createElement('button') + rm.type = 'button' + rm.className = 'lib-remove' + rm.textContent = '✕' + rm.setAttribute('aria-label', `Delete ${name}`) + rm.title = 'Delete this file from NORMA' + rm.addEventListener('click', (ev) => { + ev.preventDefault() + ev.stopPropagation() + askDelete([{ kind, id }]) + }) + row.appendChild(rm) + } + return row +} + +// kept for scripts: deletes without asking +function removeLibEntry(kind, id) { + deleteLibEntries([{ kind, id }]) +} + +// items: [{ level: 'ok' | 'warn' | 'error' | 'busy', text, action?: { label, run } }] +// opens the folded sidebar section that holds `el` +function revealSection(el) { + const sec = el && el.closest && el.closest('.section.collapsible.collapsed') + if (!sec) return + sec.classList.remove('collapsed') + const h = sec.querySelector(':scope > h3') + if (h) h.setAttribute('aria-expanded', 'true') +} + +// A busy item may carry `progress`: a fraction from 0 to 1, or null while +// the amount of work is unknown (an animated bar). Repeated progress updates +// change the bar in place, so it moves smoothly and screen readers are not +// sent every percentage. +export function setStatus(elId, items) { + const el = document.getElementById(elId) + if (!el) return + const withProgress = items.length === 1 && items[0].progress !== undefined + if (!withProgress && progressTasks && progressTasks.has(elId)) progressTasks.get(elId).stop() + if (withProgress) { + const note = el.children.length === 1 ? el.firstElementChild : null + if (note && note.classList.contains('busy') && note._progress) { + updateProgressNote(note, items[0]) + return + } + } + // sections start folded: an error opens the section so it is seen + if (items.some((i) => i.level === 'error')) revealSection(el) + el.innerHTML = '' + items.forEach((item) => { + const note = document.createElement('div') + note.className = 'note ' + (item.level || 'ok') + if (item.progress !== undefined) { + const label = document.createElement('span') + const row = document.createElement('div') + row.className = 'progress-row' + const bar = document.createElement('div') + bar.className = 'progress' + bar.setAttribute('role', 'progressbar') + bar.setAttribute('aria-valuemin', '0') + bar.setAttribute('aria-valuemax', '100') + bar.appendChild(document.createElement('span')) + const pct = document.createElement('span') + pct.className = 'progress-pct' + pct.setAttribute('aria-hidden', 'true') + row.append(bar, pct) + note.append(label, row) + note._progress = { label, bar, pct } + updateProgressNote(note, item) + el.appendChild(note) + return + } + note.textContent = item.text + if (item.action) { + const btn = document.createElement('button') + btn.type = 'button' + btn.textContent = item.action.label + btn.addEventListener('click', item.action.run) + note.appendChild(btn) + } + el.appendChild(note) + }) +} + +function updateProgressNote(note, item) { + const { label, bar, pct } = note._progress + if (label.textContent !== item.text) { + label.textContent = item.text + bar.setAttribute('aria-label', item.text) + } + const f = item.progress + if (typeof f === 'number' && Number.isFinite(f)) { + const v = Math.max(0, Math.min(1, f)) + bar.classList.remove('indeterminate') + bar.firstElementChild.style.width = (v * 100).toFixed(1) + '%' + bar.setAttribute('aria-valuenow', String(Math.round(v * 100))) + pct.textContent = Math.floor(v * 100) + '%' + } else { + bar.classList.add('indeterminate') + bar.firstElementChild.style.width = '' + bar.removeAttribute('aria-valuenow') + pct.textContent = '' + } +} + +/* ---------- progress of a task made of weighted steps ---------- + const task = startProgress('stringStatus', [1, 2, 5]); + task.step(0, 'Checking the version…'); // moves to a step + task.sub(0.4); // measured progress in the step + task.say('Reading 3 of 8…'); // new text, same step + task.bytes(received, total); // download progress in the step + Until a step reports measured progress, its bar creeps slowly toward the + end of the step (never reaching it), so a long single request still shows + that work goes on. Any status that is not a progress update, such as the + result or an error, ends the task. */ +var progressTasks = new Map() + +function formatBytes(n) { + if (n < 1024) return `${n} B` + if (n < 1048576) return `${(n / 1024).toFixed(0)} kB` + return `${(n / 1048576).toFixed(n < 10485760 ? 1 : 0)} MB` +} + +export function startProgress(elId, weights) { + const old = progressTasks.get(elId) + if (old) old.stop() + const w = weights.map((x) => Math.max(0, +x || 0)) + const total = w.reduce((a, b) => a + b, 0) || 1 + const starts = w.map((_, i) => w.slice(0, i).reduce((a, b) => a + b, 0)) + const task = { + index: 0, + text: '', + measured: null, + stepStart: performance.now(), + received: 0, + active: true, + timer: null, + last: 0, + fraction() { + const i = Math.min(this.index, w.length - 1) + let sub = this.measured + if (sub === null) { + // creep: about half of the step after ~6 s, never more than 85% + const t = (performance.now() - this.stepStart) / 1000 + sub = 0.85 * (1 - Math.exp(-t / 8)) + } + return (starts[i] + w[i] * Math.max(0, Math.min(1, sub))) / total + }, + render(force) { + if (!this.active) return + const now = performance.now() + if (!force && now - this.last < 100) return + this.last = now + const extra = this.received ? ` (${formatBytes(this.received)} received)` : '' + setStatus(elId, [{ level: 'busy', text: this.text + extra, progress: this.fraction() }]) + }, + step(i, text) { + this.index = i + this.text = text + this.measured = null + this.received = 0 + this.stepStart = performance.now() + this.render(true) + }, + say(text) { + this.text = text + this.render(true) + }, + sub(f) { + this.measured = Math.max(0, Math.min(1, f)) + this.render() + }, + bytes(received, totalBytes) { + this.received = received + if (totalBytes > 0 && received <= totalBytes) this.measured = received / totalBytes + this.render() + }, + stop() { + this.active = false + clearInterval(this.timer) + if (progressTasks.get(elId) === this) progressTasks.delete(elId) + }, + } + progressTasks.set(elId, task) + task.timer = setInterval(() => task.render(true), 400) + return task +} + +// Resolves once the browser has had a chance to paint (for example a +// progress note) before synchronous work; does not wait in a hidden tab. +export function nextPaint() { + return new Promise((resolve) => { + let done = false + const finish = () => { + if (!done) { + done = true + resolve() + } + } + requestAnimationFrame(() => setTimeout(finish, 0)) + setTimeout(finish, 60) + }) +} + +// Reads a response body as text, reporting the bytes received so far and, +// when the server gives it, the total. +export async function readTextWithProgress(response, onBytes) { + if (!onBytes || !response.body || typeof response.body.getReader !== 'function') + return response.text() + const total = Number(response.headers.get('Content-Length')) || 0 + const reader = response.body.getReader() + const decoder = new TextDecoder() + let received = 0, + out = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + received += value.length + out += decoder.decode(value, { stream: true }) + onBytes(received, total) + } + return out + decoder.decode() +} + +export function downloadText(fileName, text) { + const blob = new Blob([text], { type: 'text/plain' }) + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = fileName + document.body.appendChild(a) + a.click() + a.remove() + setTimeout(() => URL.revokeObjectURL(a.href), 1000) +} + +export function annotationText(groups) { + return groups.map((g) => `${g.name}\t${g.members.join(',')}`).join('\n') + '\n' +} + +export function fileStem(name) { + return ( + String(name) + .replace(/\.[^.]+$/, '') + .replace(/[^\w.-]+/g, '_') + .replace(/^_+|_+$/g, '') || 'file' + ) +} + +// page wiring, run by main.ts in the original order +export function init() { + document.getElementById('btnRunLayout').addEventListener('click', runActiveLayout) + + document.querySelectorAll('#layoutModeSwitch [role="radio"]').forEach((btn) => { + btn.addEventListener('click', () => setLayoutMode(btn.dataset.mode)) + btn.addEventListener('keydown', (e) => { + if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return + e.preventDefault() + const next = btn.dataset.mode === 'groups' ? 'connections' : 'groups' + setLayoutMode(next) + document.querySelector(`#layoutModeSwitch [data-mode="${next}"]`).focus() + }) + }) + + ;['groupArrangement', 'localGroupLayout'].forEach((id) => { + document.getElementById(id).addEventListener('change', updateStrategyUI) + }) + + ;['groupForce', 'groupClusterRadius'].forEach((id) => { + document.getElementById(id).addEventListener('input', updateStrategyUI) + }) + + document.getElementById('btnFit').addEventListener('click', () => fitView()) + + document.getElementById('btnSample').addEventListener('click', () => { + const sel = document.getElementById('sampleSelect') + const key = sel.value + const title = sel.options[sel.selectedIndex].text.replace(/\s*\([^)]*nodes\)\s*$/, '') + openInNewView(title, () => { + if (key.startsWith('norma:')) { + loadNormaExampleSet(key.slice(6)) + return + } + libSelection.networks = new Set() + libSelection.annotation = '' + libSelection.colors = '' + renderLibraryLists() + const gen = SAMPLE_GENERATORS[key] || SAMPLE_GENERATORS.trp + loadData(gen()) + setStatus('normaStatus', []) + }) + }) + + document.getElementById('btnClear').addEventListener('click', () => { + clearCanvas() + setStatus('normaStatus', []) + updateRefreshState() + buildGroupLegend() + }) + + // With a filter set, these act only on the matching groups. + document.getElementById('btnGroupsAll').addEventListener('click', () => { + filteredGroups().forEach((g) => S.activeGroups.add(g)) + buildGroupLegend() + applyGroupVisibility() + }) + + document.getElementById('btnGroupsNone').addEventListener('click', () => { + filteredGroups().forEach((g) => S.activeGroups.delete(g)) + buildGroupLegend() + applyGroupVisibility() + }) + + document.getElementById('groupFilter').addEventListener('input', buildGroupLegend) + + document.getElementById('btnImportToggle').addEventListener('click', () => { + const box = document.getElementById('importBox') + box.style.display = box.style.display === 'none' ? 'block' : 'none' + }) + + document.getElementById('btnImportGo').addEventListener('click', () => { + try { + const parsed = JSON.parse(document.getElementById('importArea').value) + if (!parsed.nodes || !parsed.edges) throw new Error('JSON needs "nodes" and "edges" arrays.') + openInNewView('Pasted JSON', () => loadData(parsed)) + document.getElementById('importBox').style.display = 'none' + } catch (err) { + alert('Could not parse JSON: ' + err.message) + } + }) + + document + .getElementById('btnFileTrigger') + .addEventListener('click', () => document.getElementById('fileInput').click()) + + document.getElementById('fileInput').addEventListener('change', (e) => { + const file = e.target.files[0] + if (!file) return + const reader = new FileReader() + reader.onload = (ev) => { + try { + const parsed = JSON.parse(ev.target.result) + if (!parsed.nodes || !parsed.edges) + throw new Error('JSON needs "nodes" and "edges" arrays.') + openInNewView(file.name.replace(/\.json$/i, ''), () => loadData(parsed)) + } catch (err) { + alert('Could not read file: ' + err.message) + } + } + reader.readAsText(file) + e.target.value = '' + }) + + document.getElementById('btnExport').addEventListener('click', () => { + const nodeColors = {} + cy.nodes().forEach((n) => { + ;(n.data('groups') || []).forEach((g) => { + nodeColors[g] = S.nodeColorMap[g] || nodeColors[g] + }) + }) + const edgeColors = {} + cy.edges().forEach((e) => { + edgeColors[e.data('type')] = e.data('color') + }) + const data = { + nodes: exportNodes().map((n) => ({ + id: n.data('id'), + groups: (n.data('groups') || []).filter((g) => g !== UNGROUPED), + size: n.data('baseSize'), + ...(n.data('nodeColor') ? { color: n.data('nodeColor') } : {}), + ...(n.data('values') ? { values: n.data('values') } : {}), + ...(n.data('attrs') || {}), + })), + edges: exportEdges().map((e) => { + const out = { + id: e.data('id'), + source: e.data('source'), + target: e.data('target'), + type: e.data('type'), + } + const w = e.data('weight') + if (typeof w === 'number') out.weight = w + if (edgeIsDirected(e)) out.directed = true + return { ...out, ...(e.data('attrs') || {}) } + }), + nodeColors, + edgeColors, + groupOrder: getUsedGroups().filter((g) => g !== UNGROUPED), + ...(Object.keys(S.groupShapes).length ? { groupShapes: S.groupShapes } : {}), + ...(Object.keys(S.groupAttrs).length ? { groupAttrs: S.groupAttrs } : {}), + } + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = 'network.json' + a.click() + }) + + /* settings (config) export / import -- separate from network data */ + document.getElementById('btnExportConfig').addEventListener('click', () => { + const blob = new Blob([JSON.stringify(getCurrentConfig(), null, 2)], { + type: 'application/json', + }) + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = 'norma-settings.json' + a.click() + }) + + document + .getElementById('btnImportConfigTrigger') + .addEventListener('click', () => document.getElementById('configFileInput').click()) + + document.getElementById('configFileInput').addEventListener('change', (e) => { + const file = e.target.files[0] + if (!file) return + const reader = new FileReader() + reader.onload = (ev) => { + try { + const parsed = JSON.parse(ev.target.result) + applyConfig(parsed) + } catch (err) { + alert('Could not read settings file: ' + err.message) + } + } + reader.readAsText(file) + e.target.value = '' + }) +} diff --git a/frontend/src/layouts/cose.worker.ts b/frontend/src/layouts/cose.worker.ts new file mode 100644 index 0000000..4419df6 --- /dev/null +++ b/frontend/src/layouts/cose.worker.ts @@ -0,0 +1,19 @@ +// Cytoscape's cose layout, headless, off the main thread. See coseInWorker in run.ts. +import cytoscape from 'cytoscape' + +self.onmessage = (e: MessageEvent) => { + const { elements, style, opts } = e.data + try { + const cy = cytoscape({ headless: true, styleEnabled: true, elements, style }) + cy.elements() + .layout({ ...opts, animate: false }) + .run() + const positions: Record = {} + cy.nodes().forEach((n) => { + positions[n.id()] = n.position() + }) + self.postMessage({ positions }) + } catch (err) { + self.postMessage({ error: String(err) }) + } +} diff --git a/frontend/src/layouts/input.ts b/frontend/src/layouts/input.ts new file mode 100644 index 0000000..1188648 --- /dev/null +++ b/frontend/src/layouts/input.ts @@ -0,0 +1,592 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { + EDGE_BLIND_LAYOUTS, + FR_SEED, + computeSubLayoutAsync, + normalizeSpacing, + targetNodeSpacing, +} from '../metrics' +import { S } from '../state' +import { UNGROUPED, effectiveGroupsFor } from '../network_state' +import { cy } from '../cy' +import { mulberry32 } from '../sample_data' +import { nextPaint } from './controls' + +/* ---------- layout input: what the strategies see ---------- */ +// Visible nodes, their active groups, and one weighted edge per node pair +// (parallel channels collapse, keeping the highest weight, as NORMA's +// simplify(edge.attr.comb = "max") does). Missing weights count as 1. +export function collectLayoutGraph() { + const channelOnly = document.getElementById('layoutOnActiveOnly').checked + const nodes = cy.nodes().filter((n) => !n.hasClass('hidden-group')) + const nodeIds = nodes.map((n) => n.id()) + const visible = new Set(nodeIds) + const groupsOf = {} + const groupMembers = new Map() + nodes.forEach((n) => { + const gs = effectiveGroupsFor(n).filter((g) => g !== UNGROUPED) + groupsOf[n.id()] = gs + gs.forEach((g) => { + if (!groupMembers.has(g)) groupMembers.set(g, []) + groupMembers.get(g).push(n.id()) + }) + }) + const pairs = new Map() + cy.edges().forEach((e) => { + if (channelOnly && !S.activeTypes.has(e.data('type'))) return + const s = e.data('source'), + t = e.data('target') + if (s === t || !visible.has(s) || !visible.has(t)) return + const key = s < t ? s + '\t' + t : t + '\t' + s + const w = typeof e.data('weight') === 'number' && e.data('weight') > 0 ? e.data('weight') : 1 + const p = pairs.get(key) + if (p) p.weight = Math.max(p.weight, w) + else pairs.set(key, { source: s, target: t, weight: w }) + }) + return { nodeIds, groupsOf, groupMembers, edges: [...pairs.values()] } +} + +function sharesGroup(a, b) { + return a.length > 0 && b.length > 0 && a.some((g) => b.includes(g)) +} + +// All-vs-all pairs inside a group. Very large groups get a sparse random +// subset per member instead of a full clique, which would otherwise add +// hundreds of thousands of layout-only edges. +const CLIQUE_FULL_LIMIT = 400 + +const CLIQUE_SPARSE_DEGREE = 60 + +function groupPairs(members, rand) { + const out = [] + if (members.length <= CLIQUE_FULL_LIMIT) { + for (let i = 0; i < members.length; i++) { + for (let j = i + 1; j < members.length; j++) out.push([members[i], members[j]]) + } + } else { + members.forEach((a) => { + for (let k = 0; k < CLIQUE_SPARSE_DEGREE; k++) { + const b = members[Math.floor(rand() * members.length)] + if (a !== b) out.push([a, b]) + } + }) + } + return out +} + +/* ============================================================ + NORMA-2.0 GROUP LAYOUT STRATEGIES + Karatzas et al. (2022) Bioinformatics Advances 2(1):vbac036, + Figure 1. Weights and steps follow NORMA's own implementation + (strategy1_virtualNodes, strategy2_gravity, strategy3_superNodes in + github.com/PavlopoulosLab/NORMA, functions/annotations.R). + ============================================================ */ + +// Strategy 1 -- virtual nodes (Fig. 1A). One hidden hub per group, tied to +// every member with a heavy edge (50); real edges get weight 5 and group +// members get light all-pairs edges (0.1). The chosen layout runs on this +// enlarged graph and the hubs are then dropped. +export async function strategyVirtualNodes(graph, layoutName) { + const rand = mulberry32(FR_SEED) + const ids = [...graph.nodeIds] + const edges = graph.edges.map((e) => ({ source: e.source, target: e.target, weight: 5 })) + graph.groupMembers.forEach((members, g) => { + const hub = '\u0000virtual:' + g + ids.push(hub) + if (members.length >= 2 && !EDGE_BLIND_LAYOUTS.has(layoutName)) { + groupPairs(members, rand).forEach(([a, b]) => + edges.push({ source: a, target: b, weight: 0.1 }) + ) + } + members.forEach((m) => edges.push({ source: hub, target: m, weight: 50 })) + }) + const pos = await computeSubLayoutAsync(ids, edges, layoutName) + if (!pos) return null + const real = {} + graph.nodeIds.forEach((id) => { + real[id] = pos[id] + }) + return normalizeSpacing(real, targetNodeSpacing()) +} + +// Strategy 2 -- group gravity (Fig. 1B). Groups become cliques with +// layout-only edges at the network's highest weight; real edges inside a +// group are multiplied by the force and edges that leave a group (or touch +// an ungrouped node) are divided by it. Parallel pairs keep the max. +export async function strategyGravity(graph, layoutName, force) { + const rand = mulberry32(FR_SEED) + const maxW = graph.edges.reduce((m, e) => Math.max(m, e.weight), 1) + const pairs = new Map() + const put = (a, b, w) => { + const key = a < b ? a + '\t' + b : b + '\t' + a + const p = pairs.get(key) + if (p) p.weight = Math.max(p.weight, w) + else pairs.set(key, { source: a, target: b, weight: w }) + } + graph.edges.forEach((e) => { + const inside = sharesGroup(graph.groupsOf[e.source], graph.groupsOf[e.target]) + put(e.source, e.target, inside ? e.weight * force : e.weight / force) + }) + if (!EDGE_BLIND_LAYOUTS.has(layoutName)) { + graph.groupMembers.forEach((members) => { + groupPairs(members, rand).forEach(([a, b]) => put(a, b, maxW)) + }) + } + const pos = await computeSubLayoutAsync(graph.nodeIds, [...pairs.values()], layoutName) + return pos ? normalizeSpacing(pos, targetNodeSpacing()) : null +} + +/* ---------- shared group placement ---------- + Radius a group's local layout gets, in pixels. A ring must fit every + member at the usual node spacing, so circles of big groups get a larger + radius than the area-based estimate used for the other local layouts. */ +function localGroupRadius(k, localName, spacing, clusterScale) { + const area = 0.95 * spacing * Math.sqrt(Math.max(1, k)) + const ring = localName === 'circle' ? (k * spacing) / (2 * Math.PI) : 0 + return Math.max(spacing / 2, area, ring) * clusterScale +} + +// Lays each group's members out with `localName`, scaled to `radii[g]` and +// centred on `centers[g]`. Returns { nodeId: [candidate positions] } so that +// a node in several groups can be averaged afterwards. +async function placeGroupsLocally(graph, groups, centers, radii, localName, force) { + const rand = mulberry32(FR_SEED) + const candidates = {} + const lp0 = S.layoutProgress + if (lp0) lp0.begin([...groups].reduce((a, [, m]) => a + m.length, 0)) + let lastPaint = performance.now() + for (const [g, members] of groups) { + if (lp0) lp0.part(members.length) + if (performance.now() - lastPaint > 60) { + await nextPaint() + lastPaint = performance.now() + } + const center = centers[g] + const radius = radii[g] + if (members.length === 1) { + ;(candidates[members[0]] = candidates[members[0]] || []).push({ x: center.x, y: center.y }) + continue + } + const memberSet = new Set(members) + const local = graph.edges + .filter((e) => memberSet.has(e.source) && memberSet.has(e.target)) + .map((e) => ({ ...e })) + if (!EDGE_BLIND_LAYOUTS.has(localName)) { + const minW = local.length ? local.reduce((m, e) => Math.min(m, e.weight), Infinity) : 1 + groupPairs(members, rand).forEach(([a, b]) => + local.push({ source: a, target: b, weight: minW / Math.max(1, force) }) + ) + } + const lp = await computeSubLayoutAsync(members, local, localName) + if (!lp) return null + let x1 = Infinity, + x2 = -Infinity, + y1 = Infinity, + y2 = -Infinity + members.forEach((id) => { + x1 = Math.min(x1, lp[id].x) + x2 = Math.max(x2, lp[id].x) + y1 = Math.min(y1, lp[id].y) + y2 = Math.max(y2, lp[id].y) + }) + const mx = (x1 + x2) / 2, + my = (y1 + y2) / 2 + // fit the farthest member onto the disc's edge, so every shape + // (square grids included) stays inside the group's disc + const reach = members.reduce( + (m, id) => Math.max(m, Math.hypot(lp[id].x - mx, lp[id].y - my)), + 0 + ) + const scale = reach > 1e-9 ? radius / reach : 0 + members.forEach((id) => { + ;(candidates[id] = candidates[id] || []).push({ + x: center.x + (lp[id].x - mx) * scale, + y: center.y + (lp[id].y - my) * scale, + }) + }) + } + return candidates +} + +function averageCandidates(candidates, fallback) { + const out = { ...(fallback || {}) } + Object.entries(candidates).forEach(([id, list]) => { + out[id] = { + x: list.reduce((a, p) => a + p.x, 0) / list.length, + y: list.reduce((a, p) => a + p.y, 0) / list.length, + } + }) + return out +} + +// Pushes discs apart until none overlap (keeping `gap` between them). +// Smaller discs give way more than big ones. With `compact`, discs are +// first drawn toward the middle while being kept apart, which packs +// scattered groups (for example unconnected ones) into a tidy cluster. +function resolveOverlaps(keys, centers, radii, gap, { compact = false } = {}) { + const n = keys.length + if (n < 2) return + const pullPasses = compact ? 250 : 0 + for (let pass = 0; pass < pullPasses + 400; pass++) { + if (pass < pullPasses) { + let mx = 0, + my = 0 + keys.forEach((k) => { + mx += centers[k].x + my += centers[k].y + }) + mx /= n + my /= n + const pull = 0.04 * (1 - pass / pullPasses) + keys.forEach((k) => { + centers[k].x += (mx - centers[k].x) * pull + centers[k].y += (my - centers[k].y) * pull + }) + } + let worst = 0 + for (let i = 0; i < n; i++) { + const a = centers[keys[i]], + ra = radii[keys[i]] + for (let j = i + 1; j < n; j++) { + const b = centers[keys[j]], + rb = radii[keys[j]] + let dx = b.x - a.x, + dy = b.y - a.y + let d = Math.hypot(dx, dy) + const overlap = ra + rb + gap - d + if (overlap <= 0) continue + if (d < 1e-6) { + dx = Math.cos(i * 2.399 + j) + dy = Math.sin(i * 2.399 + j) + d = 1 + } + const shareA = rb / (ra + rb), + shareB = ra / (ra + rb) + a.x -= (dx / d) * overlap * shareA + a.y -= (dy / d) * overlap * shareA + b.x += (dx / d) * overlap * shareB + b.y += (dy / d) * overlap * shareB + worst = Math.max(worst, overlap) + } + } + if (pass >= pullPasses && worst < 0.5) break + } +} + +// Places discs around a ring so neighbours are exactly far enough apart +// (straight-line distance, not arc length). Returns the ring radius. +function ringRadiusFor(radii, gap, minR) { + const needAngle = (R) => + radii.reduce((acc, r) => acc + 2 * Math.asin(Math.min(1, (r + gap / 2) / R)), 0) + let lo = Math.max(minR, Math.max(...radii) + gap / 2), + hi = lo + while (needAngle(hi) > 2 * Math.PI) hi *= 1.5 + if (needAngle(lo) <= 2 * Math.PI) return lo + for (let i = 0; i < 40; i++) { + const mid = (lo + hi) / 2 + if (needAngle(mid) > 2 * Math.PI) lo = mid + else hi = mid + } + return hi +} + +function placeOnRing(keys, radii, gap, R, centers, startAngle) { + const halves = keys.map((g) => Math.asin(Math.min(1, (radii[g] + gap / 2) / R))) + const used = halves.reduce((a, h) => a + 2 * h, 0) + const slack = keys.length ? Math.max(0, 2 * Math.PI - used) / keys.length : 0 + let angle = startAngle + keys.forEach((g, i) => { + angle += halves[i] + slack / 2 + centers[g] = { x: R * Math.cos(angle), y: R * Math.sin(angle) } + angle += halves[i] + slack / 2 + }) +} + +// The collapsed "group network": one node per group, linked when real +// edges cross between groups, weighted by how many do. +function groupSuperGraph(graph, groups) { + const memberOf = {} + groups.forEach(([g, members]) => + members.forEach((id) => (memberOf[id] = memberOf[id] || []).push(g)) + ) + const pairs = new Map() + graph.edges.forEach((e) => { + ;(memberOf[e.source] || []).forEach((a) => + (memberOf[e.target] || []).forEach((b) => { + if (a === b) return + const key = a < b ? a + '\t' + b : b + '\t' + a + const p = pairs.get(key) + if (p) p.count += 1 + else pairs.set(key, { source: a, target: b, count: 1 }) + }) + ) + }) + // log scale: a pair of groups with hundreds of links shouldn't collapse + return [...pairs.values()].map((p) => ({ + source: p.source, + target: p.target, + weight: 1 + Math.log(p.count), + })) +} + +/* ---------- group blocks ---------- + Each group (plus one block for nodes in no group) is a disc sized to its + local layout; the discs are arranged with the chosen scheme and never + overlap. `spacing` sets the gap between discs. */ +const UNGROUPED_BLOCK = '\u0000ungrouped' + +export async function blockGroupLayout(graph, arrangement, localName, force, clusterScale) { + const spacing = targetNodeSpacing() + const groups = [...graph.groupMembers] + const ungrouped = graph.nodeIds.filter((id) => !graph.groupsOf[id].length) + if (ungrouped.length) groups.push([UNGROUPED_BLOCK, ungrouped]) + const keys = groups.map(([g]) => g) + const radii = {} + groups.forEach(([g, m]) => { + radii[g] = localGroupRadius(m.length, localName, spacing, clusterScale) + }) + // room for the group shading (it extends beyond the outermost nodes) + const gap = spacing * 2.5 * (force / 10) + const centers = {} + + if (arrangement === 'circle') { + if (keys.length === 1) { + centers[keys[0]] = { x: 0, y: 0 } + } else { + const R = ringRadiusFor( + keys.map((g) => radii[g]), + gap, + 0 + ) + placeOnRing(keys, radii, gap, R, centers, -Math.PI / 2) + } + } else if (arrangement === 'grid') { + const cells = keys.map((g) => 2 * radii[g] + gap) + const area = cells.reduce((a, c) => a + c * c, 0) + const rowWidth = Math.max(Math.max(...cells), Math.sqrt(area) * 1.15) + let x = 0, + y = 0, + rowH = 0 + keys.forEach((g, i) => { + if (x > 0 && x + cells[i] > rowWidth) { + x = 0 + y += rowH + rowH = 0 + } + centers[g] = { x: x + cells[i] / 2, y: y + cells[i] / 2 } + x += cells[i] + rowH = Math.max(rowH, cells[i]) + }) + } else if (arrangement === 'concentric') { + // biggest group in the middle, then rings of the next biggest + const order = keys.slice().sort((x, y) => radii[y] - radii[x]) + centers[order[0]] = { x: 0, y: 0 } + let inner = radii[order[0]] + let i = 1, + ringIndex = 0 + while (i < order.length) { + const ringR = inner + gap + radii[order[i]] + const ring = [] + let usedAngle = 0 + while (i < order.length) { + const need = 2 * Math.asin(Math.min(1, (radii[order[i]] + gap / 2) / ringR)) + if (ring.length && usedAngle + need > 2 * Math.PI) break + ring.push(order[i]) + usedAngle += need + i++ + } + placeOnRing(ring, radii, gap, ringR, centers, ringIndex * 0.7) + inner = ringR + Math.max(...ring.map((g) => radii[g])) + ringIndex++ + } + } else if (arrangement === 'breadthfirst') { + // levels by distance in the group network from its best-connected group + const edges = groupSuperGraph(graph, groups) + const adj = {} + keys.forEach((g) => { + adj[g] = new Set() + }) + edges.forEach((e) => { + adj[e.source].add(e.target) + adj[e.target].add(e.source) + }) + const level = {} + const byDegree = keys.slice().sort((a, b) => adj[b].size - adj[a].size || radii[b] - radii[a]) + let depthBase = 0 + byDegree.forEach((root) => { + if (level[root] !== undefined) return + level[root] = depthBase + const queue = [root] + let maxDepth = depthBase + while (queue.length) { + const g = queue.shift() + adj[g].forEach((h) => { + if (level[h] === undefined) { + level[h] = level[g] + 1 + maxDepth = Math.max(maxDepth, level[h]) + queue.push(h) + } + }) + } + depthBase = maxDepth + 1 + }) + const rows = [] + keys.forEach((g) => { + ;(rows[level[g]] = rows[level[g]] || []).push(g) + }) + let y = 0 + rows.filter(Boolean).forEach((row) => { + const h = Math.max(...row.map((g) => 2 * radii[g])) + gap + const width = row.reduce((a, g) => a + 2 * radii[g] + gap, 0) + let x = -width / 2 + row.forEach((g) => { + centers[g] = { x: x + radii[g] + gap / 2, y: y + h / 2 } + x += 2 * radii[g] + gap + }) + y += h + }) + } else { + // force-directed arrangement of the group network, then de-overlap + const algorithm = arrangement === 'cose' ? 'cose' : 'fr' + const pos = + keys.length > 1 + ? await computeSubLayoutAsync(keys, groupSuperGraph(graph, groups), algorithm) + : { [keys[0]]: { x: 0, y: 0 } } + if (!pos) return null + const mx = keys.reduce((a, g) => a + pos[g].x, 0) / keys.length + const my = keys.reduce((a, g) => a + pos[g].y, 0) / keys.length + const spread = + keys.reduce((a, g) => a + Math.hypot(pos[g].x - mx, pos[g].y - my), 0) / keys.length || 1 + const target = keys.reduce((a, g) => a + radii[g] + gap / 2, 0) / Math.PI + keys.forEach((g) => { + centers[g] = { + x: ((pos[g].x - mx) / spread) * target, + y: ((pos[g].y - my) / spread) * target, + } + }) + resolveOverlaps(keys, centers, radii, gap, { compact: true }) + } + + const candidates = await placeGroupsLocally(graph, groups, centers, radii, localName, force) + return candidates ? averageCandidates(candidates) : null +} + +// Strategies 1 and 2 place every node themselves. With a local layout +// chosen, each group is then rearranged around where the strategy put it. +export async function applyLocalLayoutsAfter(graph, positions, localName, force, clusterScale) { + if (!positions || localName === 'keep') return positions + const spacing = targetNodeSpacing() + const groups = [...graph.groupMembers] + const keys = groups.map(([g]) => g) + const centers = {}, + radii = {} + groups.forEach(([g, members]) => { + centers[g] = { + x: members.reduce((a, id) => a + positions[id].x, 0) / members.length, + y: members.reduce((a, id) => a + positions[id].y, 0) / members.length, + } + radii[g] = localGroupRadius(members.length, localName, spacing, clusterScale) + }) + resolveOverlaps(keys, centers, radii, spacing * 1.5) + const candidates = await placeGroupsLocally(graph, groups, centers, radii, localName, force) + return candidates ? averageCandidates(candidates, positions) : null +} + +// Strategy 3 -- super nodes (Fig. 1C). Each group collapses into one +// super-node; ungrouped nodes stay as themselves. The chosen algorithm runs +// on that small super-network, the result is pushed outward by the force, +// and each group's members are then laid out locally (with light all-pairs +// edges, min weight / force) around their super-node. A node in several +// groups is placed at the average of its positions. +export async function strategySuperNodes(graph, globalName, localName, force, clusterScale) { + const spacing = targetNodeSpacing() + const superOf = (id) => + graph.groupsOf[id].length ? graph.groupsOf[id].map((g) => 'g\u0000' + g) : ['n\u0000' + id] + + const superIds = new Set() + graph.nodeIds.forEach((id) => superOf(id).forEach((s) => superIds.add(s))) + const superPairs = new Map() + graph.edges.forEach((e) => { + superOf(e.source).forEach((a) => + superOf(e.target).forEach((b) => { + if (a === b) return + const key = a < b ? a + '\t' + b : b + '\t' + a + const p = superPairs.get(key) + if (p) p.weight = Math.max(p.weight, e.weight) + else superPairs.set(key, { source: a, target: b, weight: e.weight }) + }) + ) + }) + const superList = [...superIds] + const superPos = await computeSubLayoutAsync(superList, [...superPairs.values()], globalName) + if (!superPos) return null + + const radiusOf = (s) => + s.startsWith('n\u0000') + ? spacing / 2 + : localGroupRadius( + graph.groupMembers.get(s.slice(2)).length, + localName, + spacing, + clusterScale + ) + const cx = superList.reduce((a, s) => a + superPos[s].x, 0) / superList.length + const cyc = superList.reduce((a, s) => a + superPos[s].y, 0) / superList.length + let normalize = 1 + if (superList.length > 1) { + const radii = superList.map(radiusOf) + const ring = radii.reduce((a, r) => a + r, 0) / Math.PI + const disk = (2 / 3) * Math.sqrt(2 * radii.reduce((a, r) => a + r * r, 0)) + const targetSpread = Math.min(ring, disk) + const meanSpread = + superList.reduce((a, s) => a + Math.hypot(superPos[s].x - cx, superPos[s].y - cyc), 0) / + superList.length + if (meanSpread > 1e-9) { + const spreadFactor = targetSpread / meanSpread + const groupSupers = superList.filter((s) => s.startsWith('g\u0000')) + let pairFactor = 0 + for (let i = 0; i < groupSupers.length; i++) { + for (let j = i + 1; j < groupSupers.length; j++) { + const a = superPos[groupSupers[i]], + b = superPos[groupSupers[j]] + const d = Math.hypot(a.x - b.x, a.y - b.y) + if (d > 1e-9) + pairFactor = Math.max( + pairFactor, + (radiusOf(groupSupers[i]) + radiusOf(groupSupers[j])) / d + ) + } + } + normalize = Math.min(Math.max(spreadFactor, pairFactor), spreadFactor * 2.5) + } else { + superList.forEach((s, i) => { + const angle = (2 * Math.PI * i) / superList.length + superPos[s] = { x: cx + Math.cos(angle), y: cyc + Math.sin(angle) } + }) + normalize = targetSpread + } + } + // NORMA multiplies the super-node coordinates by the force (default 10); + // here force 10 gives 1.6x the "just touching" arrangement. + const repel = normalize * force * 0.16 + const placed = {} + superList.forEach((s) => { + placed[s] = { x: (superPos[s].x - cx) * repel, y: (superPos[s].y - cyc) * repel } + }) + + const groups = [...graph.groupMembers] + const centers = {}, + radii = {} + groups.forEach(([g]) => { + centers[g] = placed['g\u0000' + g] + radii[g] = radiusOf('g\u0000' + g) + }) + const candidates = await placeGroupsLocally(graph, groups, centers, radii, localName, force) + if (!candidates) return null + const ungrouped = {} + graph.nodeIds.forEach((id) => { + if (!graph.groupsOf[id].length) ungrouped[id] = placed['n\u0000' + id] + }) + return averageCandidates(candidates, ungrouped) +} diff --git a/frontend/src/layouts/run.ts b/frontend/src/layouts/run.ts new file mode 100644 index 0000000..b288679 --- /dev/null +++ b/frontend/src/layouts/run.ts @@ -0,0 +1,260 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { S } from '../state' +import { + applyLocalLayoutsAfter, + blockGroupLayout, + collectLayoutGraph, + strategyGravity, + strategySuperNodes, + strategyVirtualNodes, +} from './input' +import { + applyPositions, + cancelFrJobs, + computeSubLayoutAsync, + layoutOptsFor, + makeLayoutProgress, + normalizeSpacing, + shownEdges, + shownNodes, + targetNodeSpacing, +} from '../metrics' +import { cy } from '../cy' +import { setStatus } from './controls' +import CoseWorker from './cose.worker?worker&inline' + +/* ---------- running layouts ---------- */ +export const STRATEGIES = new Set(['virtual', 'gravity', 'supernodes']) + +export function layoutMode() { + return document.getElementById('layoutMode').value === 'groups' ? 'groups' : 'connections' +} + +export function groupArrangement() { + return document.getElementById('groupArrangement').value +} + +export function setLayoutBusy(busy) { + document.getElementById('btnRunLayout').disabled = busy + setStatus( + 'layoutStatus', + busy ? [{ level: 'busy', text: 'Computing layout…', progress: null }] : [] + ) +} + +async function runComputedLayout(compute) { + cancelFrJobs() + cancelCoseJob() + const run = ++S.layoutRunSeq + const nodeCount = cy.nodes().length + setLayoutBusy(true) + S.layoutProgress = makeLayoutProgress((f) => { + if (run === S.layoutRunSeq) + setStatus('layoutStatus', [{ level: 'busy', text: 'Computing layout…', progress: f }]) + }) + // let the busy note paint before any work on the page starts + await new Promise((r) => setTimeout(r, 0)) + try { + const positions = await compute() + if (run !== S.layoutRunSeq || cy.nodes().length !== nodeCount) return + if (positions) applyPositions(positions) + } catch (err) { + if (run === S.layoutRunSeq) + setStatus('layoutStatus', [ + { level: 'error', text: `The layout couldn't be computed: ${err.message}` }, + ]) + return + } finally { + if (run === S.layoutRunSeq) { + document.getElementById('btnRunLayout').disabled = false + S.layoutProgress = null + } + } + if (run === S.layoutRunSeq) setStatus('layoutStatus', []) +} + +// Layout by connections: groups play no part. +export function runLayout(name) { + if (name === 'fr' || name === 'kk' || name === 'stress') { + runComputedLayout(async () => { + const graph = collectLayoutGraph() + if (!graph.nodeIds.length) return null + const pos = await computeSubLayoutAsync(graph.nodeIds, graph.edges, name) + return pos ? normalizeSpacing(pos, targetNodeSpacing()) : null + }) + return + } + // a Cytoscape layout replaces any computed one still running + cancelFrJobs() + cancelCoseJob() + S.layoutRunSeq++ + setLayoutBusy(false) + // Animating every force-directed iteration is slow on bigger graphs; + // there, compute first and animate only to the final positions. + const nodes = shownNodes() + const animate = name === 'cose' && nodes.length > 150 ? 'end' : true + const opts = { ...layoutOptsFor(name), animate, animationDuration: 500, padding: 50 } + + // Only the shown part is arranged: nodes of ticked groups and, with + // "Use checked channels only", edges of ticked channels. Hidden nodes + // keep their positions. Shown nodes without a counted edge still take + // part (repulsion only). + const channelOnly = document.getElementById('layoutOnActiveOnly').checked + if (!nodes.length) return + const eles = nodes.union(shownEdges(channelOnly)) + if (animate !== 'end') { + eles.layout(opts).run() + return + } + // Computed in one go, in a worker, then animated to the final positions. + // Once the page has painted a frame, Cytoscape's cose runs 5-7x slower on + // the main thread (a V8 effect, reproducible with plain Cytoscape); a + // worker is a fresh isolate, so it stays fast and the page stays usable. + const run = S.layoutRunSeq + document.getElementById('btnRunLayout').disabled = true + setStatus('layoutStatus', [{ level: 'busy', text: 'Computing layout…', progress: null }]) + const finish = () => { + if (run !== S.layoutRunSeq) return + document.getElementById('btnRunLayout').disabled = false + setStatus('layoutStatus', []) + } + coseInWorker(eles, opts) + .then((positions) => { + if (run !== S.layoutRunSeq) return + if (positions) { + eles + .nodes() + .layout({ + name: 'preset', + positions, + fit: true, + padding: opts.padding, + animate: true, + animationDuration: opts.animationDuration, + }) + .run() + } else eles.layout(opts).run() // no worker (very old browser): as before + }) + .finally(finish) +} + +/* ---------- cose in a worker ---------- */ +let coseWorker = null +let coseJob = null // { resolve } of the run in progress + +function coseInWorker(eles, opts) { + cancelCoseJob() + if (!coseWorker) { + try { + coseWorker = new CoseWorker() + } catch (e) { + return Promise.resolve(null) + } + coseWorker.onmessage = (e) => { + const job = coseJob + coseJob = null + if (job) job.resolve(e.data.error ? null : e.data.positions) + } + coseWorker.onerror = (e) => { + e.preventDefault() + const job = coseJob + coseJob = null + coseWorker = null + if (job) job.resolve(null) + } + } + // what cose reads: node ids, positions and outer sizes, edges, the viewport + const elements = eles.nodes().map((n) => ({ + data: { id: n.id(), w: n.outerWidth(), h: n.outerHeight() }, + position: n.position(), + })) + eles + .edges() + .forEach((e) => + elements.push({ data: { id: e.id(), source: e.source().id(), target: e.target().id() } }) + ) + const { animate, animationDuration, ...rest } = opts + const layout = { ...rest, boundingBox: { x1: 0, y1: 0, w: cy.width(), h: cy.height() } } + return new Promise((resolve) => { + coseJob = { resolve } + coseWorker.postMessage({ + elements, + style: [{ selector: 'node', style: { width: 'data(w)', height: 'data(h)' } }], + opts: layout, + }) + }) +} + +// Stops a cose computation still running in the worker; its caller sees null. +export function cancelCoseJob() { + if (!coseJob) return + const job = coseJob + coseJob = null + coseWorker.terminate() + coseWorker = null + job.resolve(null) +} + +// Layout by groups: block arrangements or a NORMA-2.0 strategy, each +// followed by the chosen layout inside every group. +export function runGroupLayout() { + const graph = collectLayoutGraph() + if (!graph.nodeIds.length) return + if (!graph.groupMembers.size) { + setStatus('layoutStatus', [ + { + level: 'warn', + text: 'This view has no active groups to arrange, so the nodes were arranged by their connections instead.', + }, + ]) + runLayout('fr') + return + } + const arrangement = groupArrangement() + const algorithm = document.getElementById('strategyAlgorithm').value + const force = parseFloat(document.getElementById('groupForce').value) || 10 + const clusterScale = parseFloat(document.getElementById('groupClusterRadius').value) || 1 + let localName = document.getElementById('localGroupLayout').value + const keepAllowed = arrangement === 'virtual' || arrangement === 'gravity' + if (localName === 'keep' && !keepAllowed) localName = 'circle' + runComputedLayout(async () => { + if (arrangement === 'virtual') { + return applyLocalLayoutsAfter( + graph, + await strategyVirtualNodes(graph, algorithm), + localName, + force, + clusterScale + ) + } + if (arrangement === 'gravity') { + return applyLocalLayoutsAfter( + graph, + await strategyGravity(graph, algorithm, force), + localName, + force, + clusterScale + ) + } + if (arrangement === 'supernodes') { + return strategySuperNodes(graph, algorithm, localName, force, clusterScale) + } + return blockGroupLayout(graph, arrangement, localName, force, clusterScale) + }) +} + +// Runs whichever layout mode is active. +export function runActiveLayout() { + if (layoutMode() === 'groups') runGroupLayout() + else runLayout(document.getElementById('layoutSelect').value) +} + +// Older names still called elsewhere (loading data, switching annotations). +export function runStrategyLayout() { + runActiveLayout() +} + +function runGroupedLayout() { + runActiveLayout() +} diff --git a/frontend/src/library.ts b/frontend/src/library.ts new file mode 100644 index 0000000..a011816 --- /dev/null +++ b/frontend/src/library.ts @@ -0,0 +1,298 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { S } from './state' +import { + UNGROUPED, + UNGROUPED_LABEL, + buildAttrSchema, + colorForGroup, + getUsedGroups, + renderAttrSchema, + setGroupOrder, +} from './network_state' +import { + annotationText, + downloadText, + fileStem, + libEntry, + libSelection, + listSample, + plural, + selectedNetworks, + selectionKey, + setStatus, + updateRefreshState, +} from './layouts/controls' +import { applyGroupVisibility, buildGroupLegend, drawGroupHulls, loadData } from './hulls' +import { applyValueColors } from './clustering/mapping' +import { bumpDataVersion, noteChange } from './demo_downloads' +import { cy } from './cy' +import { hideEdgePopup, hideInfo } from './profiler' +import { layoutMode, runGroupLayout } from './layouts/run' +import { nextLoadPositions } from './api/tester' +import { updateContextInfo } from './recording' + +/* ---------- combining library files into a view ---------- */ +// Merges the chosen networks into one node/edge set. With one network its +// edges keep their Type (or the network name) as channel; with several, +// every network is its own channel ("name" or "name: type"), so pairs +// found in more than one network become parallel edges. +function combineNetworks(netEntries) { + const multi = netEntries.length > 1 + const nodeOrder = [] + const nodeNetworks = new Map() + const edges = [] + netEntries.forEach((ne) => { + ne.parsed.nodes.forEach((id) => { + if (!nodeNetworks.has(id)) { + nodeNetworks.set(id, []) + nodeOrder.push(id) + } + nodeNetworks.get(id).push(ne.name) + }) + ne.parsed.edges.forEach((e, i) => { + const type = e.type ? (multi ? `${ne.name}: ${e.type}` : e.type) : ne.name + edges.push({ + id: `${ne.id}-${i}`, + source: e.source, + target: e.target, + type, + ...(e.weight !== undefined ? { weight: e.weight } : {}), + ...(e.directed ? { directed: true } : {}), + ...(multi ? { network: ne.name } : {}), + }) + }) + }) + return { nodeOrder, nodeNetworks, edges, multi } +} + +// Resolves an annotation and an expression file against a set of node ids. +function resolveGroupsAndColors(nodeIds, viewName, annEntry, colEntry) { + const nodeSet = new Set(nodeIds) + const notes = [] + const groupsOf = {} + let groupCount = 0 + let groupOrder = null + + if (annEntry) { + const missing = new Set() + const emptied = [] + const kept = [] + annEntry.parsed.groups.forEach(({ name, members }) => { + const present = members.filter((m) => nodeSet.has(m)) + members.forEach((m) => { + if (!nodeSet.has(m)) missing.add(m) + }) + if (!present.length) { + emptied.push(name) + return + } + kept.push({ name, members: present }) + present.forEach((m) => (groupsOf[m] = groupsOf[m] || []).push(name)) + }) + groupCount = kept.length + groupOrder = kept.map((g) => g.name) + if (!kept.length) { + notes.push({ + level: 'error', + text: `None of the names in "${annEntry.name}" are in ${viewName}, so no groups are shown. Check that the files belong together.`, + }) + } else { + if (missing.size || emptied.length) { + const parts = [] + if (missing.size) + parts.push( + `Dropped ${plural(missing.size, 'annotation name')} not found in ${viewName}: ${listSample([...missing])}.` + ) + if (emptied.length) + parts.push( + `Left out ${plural(emptied.length, 'group')} with no nodes in the view: ${listSample(emptied)}.` + ) + const text = annotationText(kept) + notes.push({ + level: 'warn', + text: parts.join(' '), + action: { + label: 'Download corrected annotation', + run: () => downloadText(`${fileStem(annEntry.name)}-corrected.txt`, text), + }, + }) + } + const ungrouped = nodeIds.filter((n) => !groupsOf[n]).length + if (ungrouped) + notes.push({ + level: 'ok', + text: `${plural(ungrouped, 'node is', 'nodes are')} in no group and listed as "${UNGROUPED_LABEL}".`, + }) + } + } + + const colorOf = new Map() + const valuesOf = new Map() + if (colEntry && colEntry.parsed.numeric) { + const unknown = [] + colEntry.parsed.values.forEach((row, node) => { + if (nodeSet.has(node)) valuesOf.set(node, row) + else unknown.push(node) + }) + if (unknown.length) + notes.push({ + level: 'warn', + text: `Ignored values for ${plural(unknown.length, 'node')} not found in ${viewName}: ${listSample(unknown)}.`, + }) + const without = nodeIds.length - valuesOf.size + if (without) + notes.push({ + level: 'ok', + text: `${plural(without, 'node has', 'nodes have')} no value and ${without === 1 ? 'is' : 'are'} drawn in the "no value" color.`, + }) + } else if (colEntry) { + const unknown = [] + colEntry.parsed.colors.forEach((color, node) => { + if (nodeSet.has(node)) colorOf.set(node, color) + else unknown.push(node) + }) + if (unknown.length) + notes.push({ + level: 'warn', + text: `Ignored colors for ${plural(unknown.length, 'node')} not found in ${viewName}: ${listSample(unknown)}.`, + }) + const uncolored = nodeIds.length - colorOf.size + if (uncolored) + notes.push({ + level: 'ok', + text: `${plural(uncolored, 'node has', 'nodes have')} no expression color and ${uncolored === 1 ? 'is' : 'are'} gray.`, + }) + } + return { groupsOf, colorOf, valuesOf, notes, groupCount, groupOrder } +} + +// NORMA's look: node fill from expression colors with groups shaded behind; +// without an expression file, fill nodes with their group colors. +function applyLibraryDisplayDefaults(annEntry, colEntry) { + document.getElementById('nodeFillSelect').value = colEntry + ? colEntry.parsed.numeric + ? 'values' + : 'data' + : 'groups' + if (colEntry && annEntry) { + document.getElementById('showGroupHulls').checked = true + document.getElementById('hullControls').style.display = 'block' + } +} + +// Changes groups and colors of the nodes already on screen, keeping their +// positions (unless grouped layout is on, which depends on the groups). +export function applyGroupsAndColorsInPlace(groupsOf, colorOf, groupOrder, meta) { + bumpDataVersion() + S.nodeColorMap = {} + S.groupAttrs = meta ? JSON.parse(JSON.stringify(meta)) : {} + setGroupOrder(groupOrder) + let anyUngrouped = false + cy.batch(() => { + cy.nodes().forEach((n) => { + const own = groupsOf[n.id()] + const groups = own && own.length ? own : [UNGROUPED] + if (!own || !own.length) anyUngrouped = true + n.data({ groups, group: groups.join(', '), nodeColor: colorOf.get(n.id()) || null }) + }) + }) + if (anyUngrouped) + S.groupAttrs[UNGROUPED] = { + label: UNGROUPED_LABEL, + description: 'Nodes that no group lists as a member', + } + const used = getUsedGroups() + used.forEach((g) => colorForGroup(g)) + S.activeGroups = new Set(used) + buildGroupLegend() + applyGroupVisibility() + buildAttrSchema() + renderAttrSchema() + hideInfo() + hideEdgePopup() + cy.elements().removeClass('dimmed highlighted') + if (layoutMode() === 'groups') runGroupLayout() + drawGroupHulls() +} + +export function refreshLibraryView() { + noteChange() + const nets = selectedNetworks() + if (!nets.length) { + setStatus('normaStatus', [ + { + level: 'error', + text: 'Tick at least one network, then show it in this view or open it in a new one.', + }, + ]) + return + } + const annEntry = libEntry('annotation', libSelection.annotation) + const colEntry = libEntry('colors', libSelection.colors) + const key = selectionKey() + const sameNetworks = + S.currentLibView && S.currentLibView.nets === key.nets && cy.nodes().length > 0 + + const combined = combineNetworks(nets) + const viewName = nets.length === 1 ? `"${nets[0].name}"` : 'the selected networks' + const { groupsOf, colorOf, valuesOf, notes, groupCount, groupOrder } = resolveGroupsAndColors( + combined.nodeOrder, + viewName, + annEntry, + colEntry + ) + applyLibraryDisplayDefaults(annEntry, colEntry) + + // extra details carried by library entries (e.g. from STRING) + const groupMeta = annEntry && annEntry.groupMeta ? annEntry.groupMeta : null + const nodeExtra = (id) => { + const out = {} + nets.forEach((e) => { + if (e.nodeAttrs && e.nodeAttrs[id]) Object.assign(out, e.nodeAttrs[id]) + }) + return out + } + const keptPositions = nets.length === 1 && nets[0].nodePositions ? nets[0].nodePositions : null + if (sameNetworks) { + cy.batch(() => cy.nodes().forEach((n) => n.data('values', valuesOf.get(n.id()) || null))) + applyGroupsAndColorsInPlace(groupsOf, colorOf, groupOrder, groupMeta) + applyValueColors() + } else { + loadData( + { + groupOrder, + ...(groupMeta ? { groupAttrs: JSON.parse(JSON.stringify(groupMeta)) } : {}), + nodes: combined.nodeOrder.map((id) => ({ + ...nodeExtra(id), + id, + groups: groupsOf[id] || [], + ...(colorOf.has(id) ? { color: colorOf.get(id) } : {}), + ...(valuesOf.has(id) ? { values: valuesOf.get(id) } : {}), + ...(combined.multi ? { networks: combined.nodeNetworks.get(id) } : {}), + })), + edges: combined.edges, + }, + nextLoadPositions || keptPositions ? { positions: nextLoadPositions || keptPositions } : {} + ) + } + S.currentLibView = key + updateRefreshState() + updateContextInfo() + + const shownNets = + nets.length === 1 + ? `"${nets[0].name}" (${nets[0].parsed.summary})` + : `${nets.length} networks overlaid as channels: ${combined.nodeOrder.length} nodes, ${combined.edges.length} edges` + const parts = [`Showing ${shownNets}`] + if (annEntry) parts.push(`${plural(groupCount, 'group')} from "${annEntry.name}"`) + if (colEntry) parts.push(`colors from "${colEntry.name}"`) + const lead = (sameNetworks ? 'Updated groups and colors in place. ' : '') + parts.join(', ') + '.' + if (groupCount > 30 && document.getElementById('showGroupHulls').checked) { + notes.push({ + level: 'ok', + text: `With ${groupCount} groups shaded at once the view gets crowded. Type in the Node groups filter and use "Deactivate matches" / "Activate matches" to focus on a few.`, + }) + } + setStatus('normaStatus', [{ level: 'ok', text: lead }, ...notes]) +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..03c6477 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,182 @@ +// @ts-nocheck +// ponytail: boot order of the former single-file script; typed later with the rest +import { NORMA_CFG } from './config' +import { NORMA_KINDS, normaLibrary, renderLibraryLists, updateStrategyUI } from './layouts/controls' +import { S } from './state' +import { TAB_VIEWS, currentTab, switchTab } from './wiring' +import { + activeView, + applyEdgeCurveStyle, + applyEdgeOpacity, + applyEdgeWidth, + renderViewBar, + updateSpreadReadout, + updateZoomReadout, + viewSettings, + views, +} from './profiler' +import { apiAnnounceReady, apiFromLocation } from './api/wiring' +import { applyEdgeDirection } from './export/dialog' +import { applyTheme } from './themes' +import { askDelete, renderSiteInfo } from './api/tester' +import { cy } from './cy' +import { net3d } from './view3d/state' +import { resizeHullCanvas } from './hulls' +import { setHistoryBaseline, updateUndoButtons } from './demo_downloads' +import { updateEmptyState } from './welcome' +import { updateStats } from './metrics' + +import './style.css' +import './state' +import './config' +import './palette' +import './themes' +import './sample_data' +import './cy' +import './network_state' +import { init as initHulls } from './hulls' +import './parallel_edges' +import './metrics' +import './layouts/input' +import './layouts/run' +import { init as initLayoutsControls } from './layouts/controls' +import './library' +import './uploads' +import './examples' +import './export_norma' +import { init as initWiring } from './wiring' +import { init as initProfiler } from './profiler' +import { init as initViewbar } from './viewbar' +import { init as initSideTabs } from './side_tabs' +import './directed_stats' +import { init as initDemoDownloads } from './demo_downloads' +import { init as initRecording } from './recording' +import { init as initLabelColors } from './label_colors' +import { init as initExportDraw } from './export/draw' +import './export/shading' +import './export/raster' +import './export/svg' +import { init as initExportDialog } from './export/dialog' +import { init as initView3dState } from './view3d/state' +import './view3d/cache' +import './view3d/camera' +import './view3d/painters' +import './view3d/draw' +import './view3d/layouts' +import './view3d/tab' +import { init as initView3dInput } from './view3d/input' +import './view3d/export' +import './string/requests' +import './string/ui_state' +import './string/import' +import './string/groupings' +import { init as initStringWiring } from './string/wiring' +import { init as initWelcome } from './welcome' +import './clustering/leiden' +import './clustering/label_propagation' +import './clustering/walktrap' +import './clustering/mcl' +import { init as initClusteringMapping } from './clustering/mapping' +import './benchmark' +import { init as initClusteringWiring } from './clustering/wiring' +import { init as initContours } from './contours' +import './arena3d' +import './group_network' +import { init as initEnrichment } from './enrichment' +import { init as initApiWiring } from './api/wiring' +import { init as initApiTester } from './api/tester' +import { init as initSidePanel } from './side_panel' + +initHulls() +initLayoutsControls() +initWiring() +initProfiler() +initViewbar() +initSideTabs() +initDemoDownloads() +initRecording() +initLabelColors() +initExportDraw() +initExportDialog() +initView3dState() +initView3dInput() +initStringWiring() +initWelcome() +initClusteringMapping() +initClusteringWiring() +initContours() +initEnrichment() +initApiWiring() +initApiTester() + +document.getElementById('btnViewDelete').addEventListener('click', () => { + const lv = S.currentLibView + const items = [] + if (lv) { + String(lv.nets || '') + .split('|') + .filter(Boolean) + .forEach((id) => items.push({ kind: 'network', id })) + if (lv.annotation) items.push({ kind: 'annotation', id: lv.annotation }) + if (lv.colors) items.push({ kind: 'colors', id: lv.colors }) + } + askDelete(items, { view: activeView() }) +}) +document.getElementById('btnGroupingDelete').addEventListener('click', () => { + const id = S.currentLibView && S.currentLibView.annotation + if (id) askDelete([{ kind: 'annotation', id }]) +}) +document.getElementById('btnDeleteAllFiles').addEventListener('click', () => { + askDelete( + NORMA_KINDS.flatMap((kind) => normaLibrary[kind].map((e) => ({ kind, id: e.id }))), + { all: true } + ) +}) +/* boot */ +applyTheme(NORMA_CFG.app.theme || 'white') +if ( + document.getElementById('themeSelect') && + [...document.getElementById('themeSelect').options].some((o) => o.value === NORMA_CFG.app.theme) +) + document.getElementById('themeSelect').value = NORMA_CFG.app.theme +applyEdgeCurveStyle() +applyEdgeWidth() +applyEdgeOpacity() +applyEdgeDirection() +updateSpreadReadout() +initSidePanel() + +resizeHullCanvas() +updateStrategyUI() +updateZoomReadout() +renderLibraryLists() +S.DEFAULT_VIEW_CONFIG = viewSettings() +// NORMA starts with an empty view and the welcome page +views.push({ + id: 'view' + ++S.viewSeq, + name: 'Untitled view', + autoName: true, + data: null, + selection: { networks: [], annotation: '', colors: '' }, + state: { config: { ...S.DEFAULT_VIEW_CONFIG } }, +}) +S.activeViewId = views[0].id +updateStats() +renderViewBar() +updateEmptyState() +switchTab(TAB_VIEWS[NORMA_CFG.app.startTab] ? NORMA_CFG.app.startTab : 'welcome') +renderSiteInfo() +// the API: data in the address, and a hello to a page that opened NORMA +apiFromLocation() +apiAnnounceReady() +setHistoryBaseline() +updateUndoButtons() +// test hook: the canvas is opaque to the DOM, so end-to-end tests read state here +window.__norma = { + cy, + net3d, + views, + get currentTab() { + return currentTab + }, +} diff --git a/frontend/src/metrics.ts b/frontend/src/metrics.ts new file mode 100644 index 0000000..9c5a7c5 --- /dev/null +++ b/frontend/src/metrics.ts @@ -0,0 +1,1331 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import cytoscape from 'cytoscape' +import { S } from './state' +import { UNGROUPED, getUsedGroups } from './network_state' +import { + anyEdgeDirected, + directionMode, + directionOf, + edgeIsDirected, + fr3dLayout, +} from './export/dialog' +import { applyBundleResult, applyLabelSizes } from './profiler' +import { cy } from './cy' +import { mulberry32 } from './sample_data' +import { scheduleLegend, scheduleSeparation, valueForSize } from './clustering/mapping' +import { updateContextInfo } from './recording' + +/* ---------- centrality metrics ---------- */ + +// Computes a raw value per node for the given metric, over either the whole +// graph or just the currently active (checked) edge channels. +// directed = true: out-neighbours only for directed edges (undirected +// edges still count both ways) +function buildAdjacency(activeEdges, nodes, directed = false) { + const adjSet = {} + nodes.forEach((n) => { + adjSet[n.id()] = new Set() + }) + activeEdges.forEach((e) => { + const s = e.data('source'), + t = e.data('target') + if (s === t || !adjSet[s] || !adjSet[t]) return + adjSet[s].add(t) + if (!directed || !edgeIsDirected(e)) adjSet[t].add(s) + }) + const adj = {} + Object.keys(adjSet).forEach((k) => { + adj[k] = [...adjSet[k]] + }) + return adj +} + +// Runs a single unweighted BFS-based pass (Brandes' algorithm) to get both +// betweenness and closeness together, since they share the same shortest-path work. +function bfsCentralities(nodeIds, adj, directed = false) { + const betweenness = {} + const closenessSum = {} + const reached = {} + nodeIds.forEach((id) => { + betweenness[id] = 0 + closenessSum[id] = 0 + reached[id] = 0 + }) + + nodeIds.forEach((s) => { + const P = {}, + sigma = {}, + d = {} + nodeIds.forEach((v) => { + P[v] = [] + sigma[v] = 0 + d[v] = -1 + }) + sigma[s] = 1 + d[s] = 0 + const S = [] + const queue = [s] + while (queue.length) { + const v = queue.shift() + S.push(v) + ;(adj[v] || []).forEach((w) => { + if (d[w] < 0) { + d[w] = d[v] + 1 + queue.push(w) + } + if (d[w] === d[v] + 1) { + sigma[w] += sigma[v] + P[w].push(v) + } + }) + } + nodeIds.forEach((v) => { + if (d[v] > 0) { + closenessSum[s] += d[v] + reached[s]++ + } + }) + const delta = {} + nodeIds.forEach((v) => (delta[v] = 0)) + while (S.length) { + const w = S.pop() + P[w].forEach((v) => { + delta[v] += (sigma[v] / sigma[w]) * (1 + delta[w]) + }) + if (w !== s) betweenness[w] += delta[w] + } + }) + // each shortest path is counted from both endpoints in an undirected graph + if (!directed) + nodeIds.forEach((v) => { + betweenness[v] /= 2 + }) + + const closeness = {} + nodeIds.forEach((v) => { + closeness[v] = closenessSum[v] > 0 ? reached[v] / closenessSum[v] : 0 + }) + + return { betweenness, closeness } +} + +// Self-contained (no reliance on cytoscape's own centrality helpers, which +// resolve neighborhoods against the full core graph rather than a given +// subset) so "compute on selected channels only" is honored exactly. +/* ============================================================ + THE SHOWN PART OF A VIEW + NORMA works on what is ticked: nodes in at least one ticked group, + and edges of ticked channels whose two ends are shown. Layouts, node + sizing, node statistics, the Profiler, Compare, search, bundling, + group shading and (by default) exports all use this part only. + ============================================================ */ +export function shownNodes() { + return cy.nodes().filter((n) => !n.hasClass('hidden-group')) +} + +export function shownEdges(channelOnly = true) { + return cy + .edges() + .filter( + (e) => + (!channelOnly || S.activeTypes.has(e.data('type'))) && + !e.source().hasClass('hidden-group') && + !e.target().hasClass('hidden-group') + ) +} + +export function exportShownOnly() { + const el = document.getElementById('exportShownOnly') + return !el || el.checked +} + +export function exportNodes() { + return exportShownOnly() ? shownNodes() : cy.nodes() +} + +export function exportEdges() { + return exportShownOnly() ? shownEdges(true) : cy.edges() +} + +export function computeMetricValues(metric, channelOnly) { + const activeEdges = shownEdges(channelOnly) + const nodes = shownNodes() + const nodeIds = nodes.map((n) => n.id()) + const values = {} + + if (metric === 'value') { + nodes.forEach((n) => { + values[n.id()] = valueForSize(n) + }) + return values + } + if (metric === 'degree' || metric === 'indegree' || metric === 'outdegree') { + nodeIds.forEach((id) => { + values[id] = 0 + }) + const add = (id) => { + if (id in values) values[id]++ + } + activeEdges.forEach((e) => { + const s = e.data('source'), + t = e.data('target') + if (metric === 'degree' || !edgeIsDirected(e)) { + add(s) + add(t) + return + } + if (metric === 'outdegree') add(s) + else add(t) + }) + return values + } + + // shortest paths follow edge direction when the shown part has any + // directed edge; clustering always ignores direction + const directed = metric !== 'clustering' && anyEdgeDirected(activeEdges) + const adj = buildAdjacency(activeEdges, nodes, directed) + + if (metric === 'clustering') { + nodeIds.forEach((id) => { + const neigh = adj[id] + const k = neigh.length + if (k < 2) { + values[id] = 0 + return + } + let links = 0 + for (let i = 0; i < neigh.length; i++) { + for (let j = i + 1; j < neigh.length; j++) { + if (adj[neigh[i]].includes(neigh[j])) links++ + } + } + values[id] = links / ((k * (k - 1)) / 2) + }) + return values + } + + if (metric === 'betweenness' || metric === 'closeness') { + const { betweenness, closeness } = bfsCentralities(nodeIds, adj, directed) + return metric === 'betweenness' ? betweenness : closeness + } + + return values +} + +// Cache of all four metrics computed on the full graph, used by the node info +// popup. Computed lazily (on first node click after a load) rather than +// eagerly on every data load: betweenness/closeness are O(V*(V+E)), and with +// networks up to several thousand nodes that eager pass would stall the UI +// even when the person never opens the info popup. +let fullMetricsCache = null + +export function invalidateFullMetrics() { + fullMetricsCache = null +} + +export function ensureFullMetrics() { + if (!fullMetricsCache) { + fullMetricsCache = { + degree: computeMetricValues('degree', true), + indegree: computeMetricValues('indegree', true), + outdegree: computeMetricValues('outdegree', true), + betweenness: computeMetricValues('betweenness', true), + closeness: computeMetricValues('closeness', true), + clustering: computeMetricValues('clustering', true), + } + } + return fullMetricsCache +} + +export function applyNodeSizing() { + const metric = document.getElementById('sizeMetric').value + const scale = parseFloat(document.getElementById('nodeScale').value) || 1 + document.getElementById('nodeScaleValue').textContent = scale.toFixed(2).replace(/0$/, '') + '×' + + if (metric === 'fixed') { + cy.batch(() => cy.nodes().forEach((n) => n.data('size', n.data('baseSize') * scale))) + applyLabelSizes() + if (typeof applyBundleResult === 'function') applyBundleResult() + return + } + + const channelOnly = document.getElementById('sizeChannelOnly').checked + const minPx = parseFloat(document.getElementById('sizeMin').value) || 22 + const maxPx = parseFloat(document.getElementById('sizeMax').value) || 74 + const values = computeMetricValues(metric, channelOnly) + const nums = Object.values(values) + if (!nums.length) return + const lo = Math.min(...nums), + hi = Math.max(...nums) + + cy.nodes().forEach((n) => { + const v = values[n.id()] || 0 + const size = hi === lo ? (minPx + maxPx) / 2 : minPx + ((v - lo) / (hi - lo)) * (maxPx - minPx) + n.data('size', size * scale) + }) + applyLabelSizes() + if (typeof applyBundleResult === 'function') applyBundleResult() +} + +export function updateStats() { + document.getElementById('statNodes').textContent = cy.nodes().length.toLocaleString() + document.getElementById('statEdges').textContent = cy.edges().length.toLocaleString() + const types = new Set(cy.edges().map((e) => e.data('type'))) + document.getElementById('statTypes').textContent = types.size + const groups = getUsedGroups().filter((g) => g !== UNGROUPED) + const active = groups.filter((g) => S.activeGroups.has(g)).length + document.getElementById('statGroups').textContent = + active === groups.length ? groups.length : `${active}/${groups.length}` + const word = (id, n, one, many) => { + document.getElementById(id).textContent = n === 1 ? one : many + } + word('statNodesWord', cy.nodes().length, 'node', 'nodes') + word('statEdgesWord', cy.edges().length, 'edge', 'edges') + word('statTypesWord', types.size, 'channel', 'channels') + word('statGroupsWord', groups.length, 'group', 'groups') + const dirEl = document.getElementById('statDirection') + dirEl.classList.remove('stat-dir-off') + if (typeof directionOf === 'function' && cy.edges().length) { + const dir = directionOf(cy.edges()) + const dataDirected = cy.edges().filter((e) => e.data('directed')).length + if (dir === 'undirected' && dataDirected && directionMode() === 'undirected') { + // the data has direction but the view ignores it (the default) + dirEl.hidden = false + dirEl.textContent = 'direction ignored' + dirEl.classList.add('stat-dir-off') + dirEl.title = `${dataDirected.toLocaleString()} edges are marked as directed in the data. Click to show them as directed (Edges → Direction: As in the data).` + } else { + dirEl.hidden = dir === 'undirected' + dirEl.textContent = dir === 'mixed' ? 'mixed direction' : 'directed' + dirEl.title = + dir === 'mixed' + ? 'Some edges are directed, some are not' + : 'Edges point from source to target' + } + } else dirEl.hidden = true + if (typeof updateContextInfo === 'function') updateContextInfo() + if (typeof scheduleLegend === 'function') { + scheduleLegend() + scheduleSeparation() + } +} + +// Per-layout-name option tweaks shared between the normal single-graph +// layout and the headless sub-layouts used by the group strategies. +export function layoutOptsFor(name) { + const opts = { name } + if (name === 'concentric') { + opts.concentric = (n) => n.degree() + opts.levelWidth = () => 1 + } + if (name === 'breadthfirst') { + opts.directed = typeof anyEdgeDirected === 'function' && anyEdgeDirected(shownEdges(true)) + opts.spacingFactor = 1.1 + } + if (name === 'cose') { + opts.idealEdgeLength = 90 + opts.nodeRepulsion = 6000 + opts.gravity = 0.4 + } + return opts +} + +// Above this size a network loads with a grid layout instead of force-directed. +export const LARGE_NETWORK_NODES = 700 + +/* ============================================================ + FORCE-DIRECTED EDGE BUNDLING + Holten D, van Wijk JJ (2009) Force-directed edge bundling for graph + visualization. Computer Graphics Forum 28(3):983-990. + Each edge becomes a polyline whose inner points are pulled toward the + matching points of "compatible" edges (similar direction, length, + position and mutual visibility) and held in shape by springs. The + number of points doubles each cycle while the step size halves. + Input: [{sx, sy, tx, ty}] in model coordinates. Output: for each edge, + a flat array [x1, y1, x2, y2, ...] of its inner points, or null. + ============================================================ */ +// Progress of long computations (layouts, edge bundling), reported from +// inside their loops as a fraction of the part in workRange. In the worker, +// workSink posts it to the page; on the page itself it is not used. +var workSink = null, + workLast = 0 + +export function workStep(f) { + if (!workSink) return + const now = Date.now() + if (now - workLast < 80) return + workLast = now + workSink(S.workRange[0] + (S.workRange[1] - S.workRange[0]) * Math.max(0, Math.min(1, f))) +} + +function fdebBundle(segs, opts) { + const E = segs.length + const threshold = opts.threshold + const K = 0.1, + EPS = 1e-6 + // 4 cycles give 8 inner points per edge; Cytoscape stops drawing + // unbundled-bezier edges with more than 8 control points (tested) + const cycles = 4 + let iterations = opts.iterations + let S = 0.1 + const COMPAT_CAP = 150 + + const len = new Float64Array(E), + mx = new Float64Array(E), + my = new Float64Array(E) + const dx = new Float64Array(E), + dy = new Float64Array(E) + for (let i = 0; i < E; i++) { + const s = segs[i] + dx[i] = s.tx - s.sx + dy[i] = s.ty - s.sy + len[i] = Math.hypot(dx[i], dy[i]) + mx[i] = (s.sx + s.tx) / 2 + my[i] = (s.sy + s.ty) / 2 + } + // how much of segment q is "visible" from segment p (projection overlap) + function visibility(p, q) { + const s = segs[p], + t = segs[q] + const L2 = len[p] * len[p] + const u0 = ((t.sx - s.sx) * dx[p] + (t.sy - s.sy) * dy[p]) / L2 + const u1 = ((t.tx - s.sx) * dx[p] + (t.ty - s.sy) * dy[p]) / L2 + const i0x = s.sx + u0 * dx[p], + i0y = s.sy + u0 * dy[p] + const i1x = s.sx + u1 * dx[p], + i1y = s.sy + u1 * dy[p] + const il = Math.hypot(i1x - i0x, i1y - i0y) + if (il < EPS) return 0 + const imx = (i0x + i1x) / 2, + imy = (i0y + i1y) / 2 + return Math.max(0, 1 - (2 * Math.hypot(mx[p] - imx, my[p] - imy)) / il) + } + + // compatible partners per edge; `flip` marks partners running the other way + const partners = Array.from({ length: E }, () => []) + for (let p = 0; p < E; p++) { + if ((p & 31) === 0) workStep(0.2 * (1 - ((E - p) * (E - p)) / (E * E))) + if (len[p] < EPS) continue + for (let q = p + 1; q < E; q++) { + if (len[q] < EPS) continue + const dot = dx[p] * dx[q] + dy[p] * dy[q] + const angle = Math.abs(dot) / (len[p] * len[q]) + if (angle < threshold) continue + const lavg = (len[p] + len[q]) / 2 + const scale = 2 / (lavg / Math.min(len[p], len[q]) + Math.max(len[p], len[q]) / lavg) + let c = angle * scale + if (c < threshold) continue + c *= lavg / (lavg + Math.hypot(mx[p] - mx[q], my[p] - my[q])) + if (c < threshold) continue + c *= Math.min(visibility(p, q), visibility(q, p)) + if (c < threshold) continue + const flip = dot < 0 + partners[p].push({ e: q, c, flip }) + partners[q].push({ e: p, c, flip }) + if (partners[p].length > COMPAT_CAP * 4) { + partners[p].sort((a, b) => b.c - a.c) + partners[p].length = COMPAT_CAP + } + if (partners[q].length > COMPAT_CAP * 4) { + partners[q].sort((a, b) => b.c - a.c) + partners[q].length = COMPAT_CAP + } + } + } + partners.forEach((list) => { + if (list.length > COMPAT_CAP) { + list.sort((a, b) => b.c - a.c) + list.length = COMPAT_CAP + } + }) + + // polylines, stored as [x0, y0, ..., xn, yn] including both endpoints + let P = 1 + let pts = segs.map((s) => + Float64Array.from([s.sx, s.sy, (s.sx + s.tx) / 2, (s.sy + s.ty) / 2, s.tx, s.ty]) + ) + + function resample(line, segments) { + const n = line.length / 2 + let total = 0 + for (let i = 1; i < n; i++) + total += Math.hypot(line[2 * i] - line[2 * i - 2], line[2 * i + 1] - line[2 * i - 1]) + const out = new Float64Array((segments + 1) * 2) + out[0] = line[0] + out[1] = line[1] + out[segments * 2] = line[(n - 1) * 2] + out[segments * 2 + 1] = line[(n - 1) * 2 + 1] + if (total < EPS) { + for (let k = 1; k < segments; k++) { + out[2 * k] = line[0] + out[2 * k + 1] = line[1] + } + return out + } + const step = total / segments + let seg = 1, + acc = 0 + for (let k = 1; k < segments; k++) { + const target = step * k + while (seg < n) { + const sl = Math.hypot( + line[2 * seg] - line[2 * seg - 2], + line[2 * seg + 1] - line[2 * seg - 1] + ) + if (acc + sl >= target || seg === n - 1) { + const f = sl > EPS ? (target - acc) / sl : 0 + out[2 * k] = line[2 * seg - 2] + (line[2 * seg] - line[2 * seg - 2]) * f + out[2 * k + 1] = line[2 * seg - 1] + (line[2 * seg + 1] - line[2 * seg - 1]) * f + break + } + acc += sl + seg++ + } + } + return out + } + + // work per iteration grows with the points per edge (2, 4, 8, 16) + let planned = 0, + spent = 0 + for (let c = 0, its = opts.iterations; c < cycles; c++) { + if (c > 0) its = Math.max(3, Math.round((its * 2) / 3)) + planned += its * 2 ** c + } + for (let cycle = 0; cycle < cycles; cycle++) { + if (cycle > 0) { + P *= 2 + S /= 2 + iterations = Math.max(3, Math.round((iterations * 2) / 3)) + pts = pts.map((line) => resample(line, P + 1)) + } + const nPts = P + 2 // including endpoints + for (let it = 0; it < iterations; it++) { + workStep(0.2 + (0.8 * spent) / planned) + spent += 2 ** cycle + const next = pts.map((line) => Float64Array.from(line)) + for (let e = 0; e < E; e++) { + const line = pts[e] + if (len[e] < EPS) continue + const kP = K / (len[e] * (P + 1)) + const plist = partners[e] + for (let i = 1; i < nPts - 1; i++) { + const x = line[2 * i], + y = line[2 * i + 1] + let fx = kP * (line[2 * i - 2] - x + line[2 * i + 2] - x) + let fy = kP * (line[2 * i - 1] - y + line[2 * i + 3] - y) + for (let j = 0; j < plist.length; j++) { + const other = pts[plist[j].e] + const oi = plist[j].flip ? nPts - 1 - i : i + const ex = other[2 * oi] - x, + ey = other[2 * oi + 1] - y + const d = Math.hypot(ex, ey) + if (d > EPS) { + fx += ex / d + fy += ey / d + } + } + next[e][2 * i] = x + S * fx + next[e][2 * i + 1] = y + S * fy + } + } + pts = next + } + } + return pts.map((line, e) => (len[e] < EPS ? null : Array.from(line.slice(2, line.length - 2)))) +} + +/* ============================================================ + WEIGHTED FRUCHTERMAN-REINGOLD + NORMA computes its layouts with igraph, where layout_with_fr + multiplies the spring attraction along an edge by that edge's weight. + The group strategies depend on this: they work by adding heavy or + light edges. Cytoscape has no equivalent, so this is a direct + implementation with igraph's defaults: 500 iterations, a start + temperature of sqrt(n)/10 cooling linearly to zero, repulsion k²/d and + attraction w·d²/k with k = 1. It is seeded (NORMA uses set.seed(123)), + so the same input always gives the same picture. + Each connected component is laid out on its own and the components + are then packed side by side; otherwise small components drift + arbitrarily far from the main one. + ============================================================ */ +export const FR_SEED = 123 + +function connectedComponents(n, edges) { + const parent = Int32Array.from({ length: n }, (_, i) => i) + const find = (i) => { + while (parent[i] !== i) { + parent[i] = parent[parent[i]] + i = parent[i] + } + return i + } + edges.forEach((e) => { + const a = find(e.s), + b = find(e.t) + if (a !== b) parent[a] = b + }) + const byRoot = new Map() + for (let i = 0; i < n; i++) { + const r = find(i) + if (!byRoot.has(r)) byRoot.set(r, []) + byRoot.get(r).push(i) + } + return [...byRoot.values()] +} + +// n nodes (0..n-1), edges [{s, t, w}] -> { x: Float64Array, y: Float64Array } +function frComponent(n, edges, rand) { + const X = new Float64Array(n), + Y = new Float64Array(n) + if (n === 1) return { x: X, y: Y } + const side = Math.sqrt(n) + for (let i = 0; i < n; i++) { + X[i] = (rand() - 0.5) * side + Y[i] = (rand() - 0.5) * side + } + const iterations = n > 3000 ? 150 : n > 1000 ? 300 : 500 + const temp0 = Math.sqrt(n) / 10 + const DX = new Float64Array(n), + DY = new Float64Array(n) + const useGrid = n > 1500 + const CELL = 2 // FR's grid variant: repulsion only within 2k + + for (let it = 0; it < iterations; it++) { + if ((it & 3) === 0) workStep(it / iterations) + DX.fill(0) + DY.fill(0) + if (!useGrid) { + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + let dx = X[i] - X[j], + dy = Y[i] - Y[j] + let d2 = dx * dx + dy * dy + if (d2 < 1e-9) { + dx = (rand() - 0.5) * 1e-3 + dy = (rand() - 0.5) * 1e-3 + d2 = dx * dx + dy * dy + } + const f = 1 / d2 // (k²/d) along the unit vector + DX[i] += dx * f + DY[i] += dy * f + DX[j] -= dx * f + DY[j] -= dy * f + } + } + } else { + const grid = new Map() + for (let i = 0; i < n; i++) { + const key = Math.floor(X[i] / CELL) + ',' + Math.floor(Y[i] / CELL) + let cell = grid.get(key) + if (!cell) grid.set(key, (cell = [])) + cell.push(i) + } + for (let i = 0; i < n; i++) { + const cx = Math.floor(X[i] / CELL), + cyy = Math.floor(Y[i] / CELL) + for (let gx = cx - 1; gx <= cx + 1; gx++) { + for (let gy = cyy - 1; gy <= cyy + 1; gy++) { + const cell = grid.get(gx + ',' + gy) + if (!cell) continue + for (const j of cell) { + if (j <= i) continue + let dx = X[i] - X[j], + dy = Y[i] - Y[j] + let d2 = dx * dx + dy * dy + if (d2 > CELL * CELL * 4) continue + if (d2 < 1e-9) { + dx = (rand() - 0.5) * 1e-3 + dy = (rand() - 0.5) * 1e-3 + d2 = dx * dx + dy * dy + } + const f = 1 / d2 + DX[i] += dx * f + DY[i] += dy * f + DX[j] -= dx * f + DY[j] -= dy * f + } + } + } + } + } + for (const e of edges) { + const dx = X[e.s] - X[e.t], + dy = Y[e.s] - Y[e.t] + const d = Math.sqrt(dx * dx + dy * dy) + const f = e.w * d // (w·d²/k) along the unit vector + DX[e.s] -= dx * f + DY[e.s] -= dy * f + DX[e.t] += dx * f + DY[e.t] += dy * f + } + const temp = temp0 * (1 - it / iterations) + for (let i = 0; i < n; i++) { + const len = Math.sqrt(DX[i] * DX[i] + DY[i] * DY[i]) + if (len > temp) { + DX[i] *= temp / len + DY[i] *= temp / len + } + X[i] += DX[i] + Y[i] += DY[i] + } + } + return { x: X, y: Y } +} + +// Shelf-packs laid-out components, largest first, into a roughly square block. +function packComponents(parts) { + const PAD = 1.5 + parts.forEach((p) => { + let x1 = Infinity, + y1 = Infinity, + x2 = -Infinity, + y2 = -Infinity + for (let i = 0; i < p.x.length; i++) { + x1 = Math.min(x1, p.x[i]) + x2 = Math.max(x2, p.x[i]) + y1 = Math.min(y1, p.y[i]) + y2 = Math.max(y2, p.y[i]) + } + p.x1 = x1 + p.y1 = y1 + p.w = x2 - x1 + PAD + p.h = y2 - y1 + PAD + }) + parts.sort((a, b) => b.w * b.h - a.w * a.h) + const area = parts.reduce((s, p) => s + p.w * p.h, 0) + const rowWidth = Math.max(parts[0].w, Math.sqrt(area) * 1.2) + let cx = 0, + cyy = 0, + rowH = 0 + parts.forEach((p) => { + if (cx > 0 && cx + p.w > rowWidth) { + cx = 0 + cyy += rowH + rowH = 0 + } + p.ox = cx - p.x1 + p.oy = cyy - p.y1 + cx += p.w + rowH = Math.max(rowH, p.h) + }) +} + +// ids: string[], edges: [{source, target, weight}] -> { id: {x, y} } in FR units +function frLayout(ids, edges) { + const index = new Map(ids.map((id, i) => [id, i])) + const all = [] + edges.forEach((e) => { + const s = index.get(e.source), + t = index.get(e.target) + if (s === undefined || t === undefined || s === t) return + const w = Number.isFinite(e.weight) && e.weight > 0 ? e.weight : 1 + all.push({ s, t, w }) + }) + const rand = mulberry32(FR_SEED) + const comps = connectedComponents(ids.length, all) + const compOf = new Int32Array(ids.length) + const localIdx = new Int32Array(ids.length) + comps.forEach((members, c) => + members.forEach((g, i) => { + compOf[g] = c + localIdx[g] = i + }) + ) + const compEdges = comps.map(() => []) + all.forEach((e) => compEdges[compOf[e.s]].push({ s: localIdx[e.s], t: localIdx[e.t], w: e.w })) + let laidOut = 0 + const parts = comps.map((members, c) => { + S.workRange = [laidOut / ids.length, (laidOut + members.length) / ids.length] + laidOut += members.length + return { members, ...frComponent(members.length, compEdges[c], rand) } + }) + S.workRange = [0, 1] + packComponents(parts) + const out = {} + parts.forEach((p) => + p.members.forEach((g, i) => { + out[ids[g]] = { x: p.x[i] + p.ox, y: p.y[i] + p.oy } + }) + ) + return out +} + +/* ============================================================ + DISTANCE-BASED LAYOUTS + kkLayout Kamada & Kawai (1989): springs between all pairs, with + lengths equal to graph distances (hops), relaxed node by + node with Newton-Raphson steps. + stressLayout Stress majorization (Gansner, Koren & North 2004): + pivot MDS start, then weighted stress minimisation + (weights 1/d^2). + Both lay out each connected component separately and pack them, like + the weighted Fruchterman-Reingold layout, and run in the worker. + Edge weights are not used: distances are shortest paths in hops. + ============================================================ */ +const DIST_LAYOUT_LIMIT = 2500 +// nodes per component + +// all-pairs hop distances of one component (local indices) +function componentDistances(n, adj) { + const D = new Float64Array(n * n) + const q = new Int32Array(n) + for (let s = 0; s < n; s++) { + const row = s * n + for (let i = 0; i < n; i++) D[row + i] = -1 + D[row + s] = 0 + let head = 0, + tail = 0 + q[tail++] = s + while (head < tail) { + const v = q[head++] + const dv = D[row + v] + const a = adj[v] + for (let k = 0; k < a.length; k++) { + const w = a[k] + if (D[row + w] < 0) { + D[row + w] = dv + 1 + q[tail++] = w + } + } + } + } + return D +} + +// Pivot MDS (Brandes & Pich 2006): a quick, good starting layout. +function pivotMDS(n, D) { + const X = new Float64Array(n), + Y = new Float64Array(n) + if (n < 3) { + for (let i = 0; i < n; i++) { + X[i] = i + } + return { X, Y } + } + const k = Math.min(50, n) + const pivots = [0] + const minD = new Float64Array(n).fill(Infinity) + for (let p = 1; p < k; p++) { + const last = pivots[p - 1] + let best = 0, + bestD = -1 + for (let i = 0; i < n; i++) { + minD[i] = Math.min(minD[i], D[last * n + i]) + if (minD[i] > bestD) { + bestD = minD[i] + best = i + } + } + pivots.push(best) + } + // C: n x k squared distances, double centered + const C = new Float64Array(n * k) + const colMean = new Float64Array(k), + rowMean = new Float64Array(n) + let total = 0 + for (let i = 0; i < n; i++) + for (let j = 0; j < k; j++) { + const v = Math.pow(D[pivots[j] * n + i], 2) + C[i * k + j] = v + colMean[j] += v + rowMean[i] += v + total += v + } + for (let j = 0; j < k; j++) colMean[j] /= n + for (let i = 0; i < n; i++) rowMean[i] /= k + total /= n * k + for (let i = 0; i < n; i++) + for (let j = 0; j < k; j++) { + C[i * k + j] = -0.5 * (C[i * k + j] - rowMean[i] - colMean[j] + total) + } + // top two eigenvectors of C^T C by power iteration + const CtC = new Float64Array(k * k) + for (let a = 0; a < k; a++) + for (let b = a; b < k; b++) { + let s = 0 + for (let i = 0; i < n; i++) s += C[i * k + a] * C[i * k + b] + CtC[a * k + b] = s + CtC[b * k + a] = s + } + const vecs = [] + for (let e = 0; e < 2; e++) { + let v = Float64Array.from({ length: k }, (_, i) => Math.sin(i * (e + 1) + 1)) + for (let it = 0; it < 100; it++) { + const w = new Float64Array(k) + for (let a = 0; a < k; a++) { + let s = 0 + for (let b = 0; b < k; b++) s += CtC[a * k + b] * v[b] + w[a] = s + } + vecs.forEach((u) => { + let d = 0 + for (let a = 0; a < k; a++) d += w[a] * u[a] + for (let a = 0; a < k; a++) w[a] -= d * u[a] + }) + let norm = 0 + for (let a = 0; a < k; a++) norm += w[a] * w[a] + norm = Math.sqrt(norm) || 1 + for (let a = 0; a < k; a++) w[a] /= norm + v = w + } + vecs.push(v) + } + for (let i = 0; i < n; i++) { + let x = 0, + y = 0 + for (let j = 0; j < k; j++) { + x += C[i * k + j] * vecs[0][j] + y += C[i * k + j] * vecs[1][j] + } + X[i] = x + Y[i] = y + } + // scale to hop units + let spread = 0, + count = 0 + for (let i = 0; i < n; i++) + for (let j = i + 1; j < Math.min(n, i + 20); j++) { + const d = Math.hypot(X[i] - X[j], Y[i] - Y[j]) + if (d > 0) { + spread += D[i * n + j] / d + count++ + } + } + const s = count ? spread / count : 1 + for (let i = 0; i < n; i++) { + X[i] *= s + Y[i] *= s + } + // break exact ties + for (let i = 0; i < n; i++) { + X[i] += 1e-3 * Math.sin(i * 12.9898) + Y[i] += 1e-3 * Math.cos(i * 78.233) + } + return { X, Y } +} + +function kkComponent(n, adj) { + const X = new Float64Array(n), + Y = new Float64Array(n) + if (n === 1) return { x: X, y: Y } + const D = componentDistances(n, adj) + // start on a circle, as igraph does + let maxD = 0 + for (let i = 0; i < D.length; i++) if (D[i] > maxD) maxD = D[i] + for (let i = 0; i < n; i++) { + X[i] = (Math.cos((2 * Math.PI * i) / n) * maxD) / 2 + Y[i] = (Math.sin((2 * Math.PI * i) / n) * maxD) / 2 + } + const gx = new Float64Array(n), + gy = new Float64Array(n) + const term = (m, i) => { + // gradient contribution of pair (m, i) to m + const d = D[m * n + i] + if (d <= 0) return [0, 0] + const dx = X[m] - X[i], + dy = Y[m] - Y[i] + const dist = Math.sqrt(dx * dx + dy * dy) || 1e-9 + const k = 1 / (d * d) + return [k * (dx - (d * dx) / dist), k * (dy - (d * dy) / dist)] + } + for (let m = 0; m < n; m++) + for (let i = 0; i < n; i++) { + if (i === m) continue + const [a, b] = term(m, i) + gx[m] += a + gy[m] += b + } + const maxIter = Math.min(50 * n, 40000) + const eps = 1e-4 + for (let it = 0; it < maxIter; it++) { + if ((it & 63) === 0) workStep(it / maxIter) + let m = -1, + best = eps + for (let i = 0; i < n; i++) { + const g = gx[i] * gx[i] + gy[i] * gy[i] + if (g > best) { + best = g + m = i + } + } + if (m < 0) break + // remove m's old contributions from everybody else + for (let i = 0; i < n; i++) { + if (i === m) continue + const [a, b] = term(i, m) + gx[i] -= a + gy[i] -= b + } + // Newton-Raphson on node m + for (let step = 0; step < 20; step++) { + let dxx = 0, + dyy = 0, + dxy = 0, + ex = 0, + ey = 0 + for (let i = 0; i < n; i++) { + if (i === m) continue + const d = D[m * n + i] + if (d <= 0) continue + const k = 1 / (d * d) + const dx = X[m] - X[i], + dy = Y[m] - Y[i] + const dist2 = dx * dx + dy * dy || 1e-12 + const dist = Math.sqrt(dist2) + const dist3 = dist2 * dist + ex += k * (dx - (d * dx) / dist) + ey += k * (dy - (d * dy) / dist) + dxx += k * (1 - (d * dy * dy) / dist3) + dyy += k * (1 - (d * dx * dx) / dist3) + dxy += k * ((d * dx * dy) / dist3) + } + const det = dxx * dyy - dxy * dxy + if (Math.abs(det) < 1e-12) break + const sx = (-ex * dyy + ey * dxy) / det + const sy = (-ey * dxx + ex * dxy) / det + X[m] += sx + Y[m] += sy + gx[m] = ex + gy[m] = ey + if (sx * sx + sy * sy < 1e-8) break + } + // recompute m's gradient and add its new contributions to everybody + gx[m] = 0 + gy[m] = 0 + for (let i = 0; i < n; i++) { + if (i === m) continue + const [a, b] = term(m, i) + gx[m] += a + gy[m] += b + const [c, e] = term(i, m) + gx[i] += c + gy[i] += e + } + } + return { x: X, y: Y } +} + +function stressComponent(n, adj) { + if (n === 1) return { x: new Float64Array(1), y: new Float64Array(1) } + const D = componentDistances(n, adj) + const { X, Y } = pivotMDS(n, D) + const iters = Math.max(20, Math.min(300, Math.floor(4e7 / (n * n)))) + let prev = Infinity + for (let it = 0; it < iters; it++) { + workStep(it / iters) + let stress = 0 + for (let i = 0; i < n; i++) { + let sx = 0, + sy = 0, + sw = 0 + for (let j = 0; j < n; j++) { + if (j === i) continue + const d = D[i * n + j] + if (d <= 0) continue + const w = 1 / (d * d) + const dx = X[i] - X[j], + dy = Y[i] - Y[j] + const dist = Math.sqrt(dx * dx + dy * dy) || 1e-9 + sx += w * (X[j] + (d * dx) / dist) + sy += w * (Y[j] + (d * dy) / dist) + sw += w + stress += w * (dist - d) * (dist - d) + } + if (sw) { + X[i] = sx / sw + Y[i] = sy / sw + } + } + if (Math.abs(prev - stress) / (stress || 1) < 1e-5) break + prev = stress + } + return { x: X, y: Y } +} + +function distanceLayout(ids, edges, kind) { + const index = new Map(ids.map((id, i) => [id, i])) + const all = [] + edges.forEach((e) => { + const s = index.get(e.source), + t = index.get(e.target) + if (s === undefined || t === undefined || s === t) return + all.push({ s, t, w: 1 }) + }) + const comps = connectedComponents(ids.length, all) + const compOf = new Int32Array(ids.length), + localIdx = new Int32Array(ids.length) + comps.forEach((members, c) => + members.forEach((g, i) => { + compOf[g] = c + localIdx[g] = i + }) + ) + const adjs = comps.map((m) => m.map(() => [])) + all.forEach((e) => { + const c = compOf[e.s] + adjs[c][localIdx[e.s]].push(localIdx[e.t]) + adjs[c][localIdx[e.t]].push(localIdx[e.s]) + }) + let laidOut = 0 + const parts = comps.map((members, c) => { + S.workRange = [laidOut / ids.length, (laidOut + members.length) / ids.length] + laidOut += members.length + if (members.length > DIST_LAYOUT_LIMIT) + throw new Error( + `${kind === 'kk' ? 'Kamada–Kawai' : 'Stress majorization'} handles connected parts of up to ${DIST_LAYOUT_LIMIT.toLocaleString('en-US')} nodes; this network has one of ${members.length.toLocaleString('en-US')}. Use a force-directed layout instead.` + ) + const lay = + kind === 'kk' + ? kkComponent(members.length, adjs[c]) + : stressComponent(members.length, adjs[c]) + // hop units -> FR-like units so packing gaps look alike + return { members, x: lay.x, y: lay.y } + }) + S.workRange = [0, 1] + packComponents(parts) + const out = {} + parts.forEach((p) => + p.members.forEach((g, i) => { + out[ids[g]] = { x: p.x[i] + p.ox, y: p.y[i] + p.oy } + }) + ) + return out +} + +function distanceLayoutAsync(ids, edges, kind, onProgress) { + const worker = ids.length > 60 ? getFrWorker() : null + if (!worker) return Promise.resolve(distanceLayout(ids, edges, kind)) + const id = ++S.frRequestSeq + return new Promise((resolve, reject) => { + frPending.set(id, { + resolve, + reject, + onProgress, + local: () => distanceLayout(ids, edges, kind), + }) + worker.postMessage({ id, kind, ids, edges }) + }) +} + +// The weighted layout runs in a background worker when the browser allows +// it, so large networks don't freeze the page. The worker is built from +// the same functions as above; if it can't be created, or fails, the +// layout runs on the page instead. +let frWorker = null + +let frWorkerFailed = false + +export const frPending = new Map() + +export function getFrWorker() { + if (frWorker || frWorkerFailed) return frWorker + try { + const src = + [ + mulberry32, + connectedComponents, + frComponent, + packComponents, + frLayout, + fdebBundle, + fr3dLayout, + componentDistances, + pivotMDS, + kkComponent, + stressComponent, + distanceLayout, + workStep, + ] + .map((f) => f.toString()) + .join('\n') + + `\nconst FR_SEED = ${FR_SEED};\nconst DIST_LAYOUT_LIMIT = ${DIST_LAYOUT_LIMIT};\nvar workRange = [0, 1], workSink = null, workLast = 0;\n` + + 'onmessage = e => { const m = e.data; let result; workRange = [0, 1]; workLast = 0; workSink = f => postMessage({ id: m.id, progress: f }); try{ result = m.kind === "fdeb" ? fdebBundle(m.segs, m.opts) : m.kind === "fr3d" ? fr3dLayout(m.ids, m.edges, m.opts) : (m.kind === "kk" || m.kind === "stress") ? distanceLayout(m.ids, m.edges, m.kind) : frLayout(m.ids, m.edges); } catch(err){ postMessage({ id: m.id, error: String(err && err.message || err) }); return; } postMessage({ id: m.id, result }); };' + const url = URL.createObjectURL(new Blob([src], { type: 'text/javascript' })) + frWorker = new Worker(url) + frWorker.onmessage = (e) => { + const job = frPending.get(e.data.id) + if (!job) return + if (e.data.progress !== undefined) { + if (job.onProgress) job.onProgress(e.data.progress) + return + } + frPending.delete(e.data.id) + if (e.data.error) { + if (job.reject) job.reject(new Error(e.data.error)) + else job.resolve(null) + } else job.resolve(e.data.result) + } + frWorker.onerror = (e) => { + e.preventDefault() + frWorkerFailed = true + frWorker = null + // finish anything still waiting on the page itself + frPending.forEach((job) => job.resolve(job.local())) + frPending.clear() + } + } catch (err) { + frWorkerFailed = true + frWorker = null + } + return frWorker +} + +// Stops any weighted layout still computing in the worker; its callers see +// null and, being superseded, discard it. +export function cancelFrJobs() { + if (!frWorker || !frPending.size) return + frWorker.terminate() + frWorker = null + frPending.forEach((job) => job.resolve(null)) + frPending.clear() +} + +export function frLayoutAsync(ids, edges, onProgress) { + const worker = ids.length > 60 ? getFrWorker() : null // small graphs: not worth the round trip + if (!worker) return Promise.resolve(frLayout(ids, edges)) + const id = ++S.frRequestSeq + return new Promise((resolve) => { + frPending.set(id, { resolve, onProgress, local: () => frLayout(ids, edges) }) + worker.postMessage({ id, kind: 'fr', ids, edges }) + }) +} + +export function bundleAsync(segs, opts, onProgress) { + const worker = segs.length > 150 ? getFrWorker() : null + if (!worker) return Promise.resolve(fdebBundle(segs, opts)) + const id = ++S.frRequestSeq + return new Promise((resolve) => { + frPending.set(id, { resolve, onProgress, local: () => fdebBundle(segs, opts) }) + worker.postMessage({ id, kind: 'fdeb', segs, opts }) + }) +} + +// Layouts that place nodes without looking at edges; strategies can skip +// adding layout-only links for these, since they change nothing. +export const EDGE_BLIND_LAYOUTS = new Set(['circle', 'grid', 'random']) + +// Runs a layout on a detached, invisible headless Cytoscape instance (or the +// weighted FR above) and returns a { nodeId: {x,y} } position map in +// arbitrary units; callers rescale. Edge weights are honored by 'fr' +// (attraction x weight) and by 'cose' (shorter, stiffer springs). +export async function computeSubLayoutAsync(nodeIds, edgeDefs, layoutName) { + const onProgress = S.layoutProgress ? (f) => S.layoutProgress && S.layoutProgress.report(f) : null + if (layoutName === 'fr' && nodeIds.length > 1) return frLayoutAsync(nodeIds, edgeDefs, onProgress) + if ((layoutName === 'kk' || layoutName === 'stress') && nodeIds.length > 1) + return distanceLayoutAsync(nodeIds, edgeDefs, layoutName, onProgress) + return computeSubLayout(nodeIds, edgeDefs, layoutName) +} + +export function makeLayoutProgress(show) { + return { + total: 1, + done: 0, + current: 1, + begin(total) { + this.total = Math.max(1e-9, total) + this.done = 0 + this.current = 0 + }, + part(weight) { + this.done += this.current + this.current = weight + this.report(0) + }, + report(f) { + show(Math.min(1, (this.done + this.current * Math.max(0, Math.min(1, f))) / this.total)) + }, + } +} + +export function computeSubLayout(nodeIds, edgeDefs, layoutName) { + if (nodeIds.length === 1) { + return { [nodeIds[0]]: { x: 0, y: 0 } } + } + if (layoutName === 'fr') return frLayout(nodeIds, edgeDefs) + if (layoutName === 'kk' || layoutName === 'stress') + return distanceLayout(nodeIds, edgeDefs, layoutName) + const size = Math.max(300, Math.sqrt(nodeIds.length) * 90) + const sub = cytoscape({ + headless: true, + styleEnabled: false, + elements: { + nodes: nodeIds.map((id) => ({ data: { id } })), + edges: edgeDefs.map((e, i) => ({ + data: { + id: 'sub' + i, + source: e.source, + target: e.target, + weight: Math.min(100, Math.max(0.02, Number.isFinite(e.weight) ? e.weight : 1)), + }, + })), + }, + }) + const opts = { + ...layoutOptsFor(layoutName), + animate: false, + fit: false, + boundingBox: { x1: 0, y1: 0, w: size, h: size }, + } + if (layoutName === 'cose') { + opts.idealEdgeLength = (e) => 90 / Math.sqrt(e.data('weight')) + opts.edgeElasticity = (e) => 32 / e.data('weight') + opts.randomize = true + } + sub.layout(opts).run() + const positions = {} + sub.nodes().forEach((n) => { + positions[n.id()] = { x: n.position('x'), y: n.position('y') } + }) + sub.destroy() + return positions +} + +// Typical spacing between neighboring nodes on screen, from current node sizes. +export function targetNodeSpacing() { + const nodes = cy.nodes(':visible') + if (!nodes.length) return 64 + const mean = nodes.reduce((s, n) => s + (n.data('size') || 42), 0) / nodes.length + return Math.max(24, mean * 1.6) +} + +// Rescales a position map so the median nearest-neighbor distance equals +// `spacing` pixels. Positions from different algorithms come in very +// different units; this puts them all on the same screen scale. +export function normalizeSpacing(positions, spacing) { + const ids = Object.keys(positions) + if (ids.length < 2) return positions + const sample = + ids.length > 1500 ? ids.filter((_, i) => i % Math.ceil(ids.length / 1500) === 0) : ids + const pts = sample.map((id) => positions[id]) + const nn = [] + for (let i = 0; i < pts.length; i++) { + let best = Infinity + for (let j = 0; j < pts.length; j++) { + if (i === j) continue + const d = Math.hypot(pts[i].x - pts[j].x, pts[i].y - pts[j].y) + if (d > 1e-9 && d < best) best = d + } + if (Number.isFinite(best)) nn.push(best) + } + if (!nn.length) return positions + nn.sort((a, b) => a - b) + const scale = spacing / nn[Math.floor(nn.length / 2)] + const out = {} + ids.forEach((id) => { + out[id] = { x: positions[id].x * scale, y: positions[id].y * scale } + }) + return out +} + +export function applyPositions(positions) { + cy.layout({ + name: 'preset', + positions: (node) => positions[node.id()] || node.position(), + animate: cy.nodes().length <= 1500, + animationDuration: 500, + fit: true, + padding: 50, + }).run() +} diff --git a/frontend/src/network_state.ts b/frontend/src/network_state.ts new file mode 100644 index 0000000..7e8a6e6 --- /dev/null +++ b/frontend/src/network_state.ts @@ -0,0 +1,402 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { EDGE_TYPES, NODE_PALETTES, colorAtIndex } from './palette' +import { NORMA_CFG } from './config' +import { S } from './state' +import { cy } from './cy' +import { nodeShapeFor, refreshEdgeLabelOptions } from './profiler' +import { parseNumber } from './clustering/mcl' + +const AUTO_EDGE_COLORS = ['#6c7793', '#d68fc0', '#7fd68a', '#e8a15f', '#8fb8e8', '#e07a7a'] + +export const PIE_MAX_SLICES = 16 + +// Nodes that belong to no group are collected under this key so they still +// have something to toggle in the legend. It is never written out as a +// real group (JSON export omits it; NORMA export skips it). +export const UNGROUPED = '__ungrouped__' + +export const UNGROUPED_LABEL = 'Not in any group' + +// Fill for nodes without their own color when coloring nodes from data, +// matching NORMA's default gray. +const NO_NODE_COLOR = '#9aa0a6' + +// Keeps only numeric (or missing) entries of a JSON values object. +export function cleanValues(obj) { + const out = {} + let any = false + Object.entries(obj).forEach(([k, v]) => { + const x = typeof v === 'number' ? v : parseNumber(v) + if (x === undefined) return + out[k] = x + any = true + }) + return any ? out : null +} + +export function nodeFillMode() { + const el = document.getElementById('nodeFillSelect') + return el ? el.value : 'groups' +} + +/* ============================================================ + ATTRIBUTE MODULE + One model for all three levels of the network: + + level | structural (reserved) keys | where custom attrs live + -------+-------------------------------------+-------------------------------- + node | id, group, groups, size, color | extra fields, or node.attrs + edge | id, source, target, type, color, | extra fields, or edge.attrs + | weight | + group | label, description, color | extra fields in groupAttrs[g], + | | or groupAttrs[g].attrs + + Reserved keys drive rendering; everything else is carried through + untouched, shown in the inspectors via one shared formatter, summarised + per level in the sidebar "Attributes" section, and written back out + on export in the same shape it came in. + ============================================================ */ +// NORMA shows networks of up to this many nodes; larger ones are cut to +// their first nodes (in file order) with a message. +export const MAX_NETWORK_NODES = Math.max( + 100, + Math.min(50000, parseInt(NORMA_CFG.app.maxNodes, 10) || 10000) +) + +export const RESERVED_NODE_KEYS = new Set([ + 'id', + 'group', + 'groups', + 'size', + 'color', + 'values', + 'attrs', +]) + +export const RESERVED_EDGE_KEYS = new Set([ + 'id', + 'source', + 'target', + 'type', + 'color', + 'weight', + 'directed', + 'attrs', +]) + +const RESERVED_GROUP_KEYS = new Set(['label', 'description', 'color', 'attrs']) + +export function collectAttrs(obj, reservedKeys) { + if (!obj || typeof obj !== 'object') return {} + const attrs = { ...(obj.attrs && typeof obj.attrs === 'object' ? obj.attrs : {}) } + Object.keys(obj).forEach((k) => { + if (!reservedKeys.has(k)) attrs[k] = obj[k] + }) + return attrs +} + +// Colors from loaded network/session data (nodeColors, groupAttrs.color, ...) +// are untrusted and end up in style/attribute values, so only a strict CSS +// color syntax is let through; anything else (e.g. an attribute-breakout +// attempt) is rejected rather than escaped, since these values are used both +// in innerHTML templates and as literal Cytoscape style colors. +export function sanitizeColor(c) { + if (typeof c !== 'string') return null + const v = c.trim() + if (/^#[0-9a-fA-F]{3,4}$|^#[0-9a-fA-F]{6}$|^#[0-9a-fA-F]{8}$/.test(v)) return v + if (/^(rgb|rgba|hsl|hsla)\(\s*[\d.]+%?(\s*,\s*[\d.]+%?){2,3}\s*\)$/.test(v)) return v + if (/^[a-zA-Z]{3,20}$/.test(v)) return v + return null +} + +export function escapeHtml(str) { + return String(str).replace( + /[&<>"']/g, + (ch) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[ch] + ) +} + +export function attrKind(v) { + if (v === null || v === undefined || v === '') return 'empty' + if (Array.isArray(v)) return 'list' + if (typeof v === 'number') return Number.isFinite(v) ? 'number' : 'text' + if (typeof v === 'boolean') return 'boolean' + if (typeof v === 'object') return 'object' + return 'text' +} + +export function formatAttrValue(v) { + switch (attrKind(v)) { + case 'empty': + return '—' + case 'number': + return Number.isInteger(v) ? String(v) : String(+v.toPrecision(4)) + case 'boolean': + return v ? 'yes' : 'no' + case 'list': + return v + .map((x) => { + const k = attrKind(x) + return k === 'list' || k === 'object' ? JSON.stringify(x) : formatAttrValue(x) + }) + .join(', ') + case 'object': + return JSON.stringify(v) + default: + return String(v) + } +} + +// Plain-text form used by search, so lists and objects are matched on +// their contents rather than on "[object Object]". +function attrSearchText(attrs) { + return Object.values(attrs || {}) + .map(formatAttrValue) + .join(' ') + .toLowerCase() +} + +export function attrRowsHtml(attrs, rowClass) { + return Object.entries(attrs || {}) + .map( + ([k, v]) => + `
    ${escapeHtml(k)}${escapeHtml(formatAttrValue(v))}
    ` + ) + .join('') +} + +export function groupLabel(g) { + return (S.groupAttrs[g] && S.groupAttrs[g].label) || g +} + +export function groupDescription(g) { + return (S.groupAttrs[g] && S.groupAttrs[g].description) || '' +} + +export function customGroupAttrs(g) { + return collectAttrs(S.groupAttrs[g], RESERVED_GROUP_KEYS) +} + +// Summary of which attribute keys the loaded network uses, per level: +// { node: { key: { count, kinds:Set, min, max, values:Set } }, edge: {...}, group: {...} } +export let attrSchema = { node: {}, edge: {}, group: {} } + +const SCHEMA_DISTINCT_CAP = 50 + +function recordAttrs(bucket, attrs) { + Object.entries(attrs || {}).forEach(([k, v]) => { + const entry = + bucket[k] || + (bucket[k] = { count: 0, kinds: new Set(), min: Infinity, max: -Infinity, values: new Set() }) + const kind = attrKind(v) + if (kind === 'empty') return + entry.count++ + entry.kinds.add(kind) + if (kind === 'number') { + entry.min = Math.min(entry.min, v) + entry.max = Math.max(entry.max, v) + } else if (kind === 'text' && entry.values.size <= SCHEMA_DISTINCT_CAP) { + entry.values.add(v) + } else if (kind === 'list' && entry.values.size <= SCHEMA_DISTINCT_CAP) { + v.forEach((x) => entry.values.add(formatAttrValue(x))) + } + }) +} + +export function buildAttrSchema() { + attrSchema = { node: {}, edge: {}, group: {} } + cy.nodes().forEach((n) => recordAttrs(attrSchema.node, n.data('attrs'))) + cy.edges().forEach((e) => recordAttrs(attrSchema.edge, e.data('attrs'))) + getUsedGroups().forEach((g) => recordAttrs(attrSchema.group, customGroupAttrs(g))) +} + +function describeSchemaEntry(entry) { + if (entry.kinds.size > 1) return 'mixed: ' + [...entry.kinds].join(', ') + const kind = [...entry.kinds][0] + if (kind === 'number') { + return entry.min === entry.max + ? `number, always ${formatAttrValue(entry.min)}` + : `number, ${formatAttrValue(entry.min)} to ${formatAttrValue(entry.max)}` + } + if (kind === 'text') { + const n = entry.values.size + if (n > SCHEMA_DISTINCT_CAP) return `text, over ${SCHEMA_DISTINCT_CAP} values` + return n <= 4 ? `text: ${[...entry.values].join(', ')}` : `text, ${n} values` + } + if (kind === 'boolean') return 'yes / no' + if (kind === 'list') { + const n = entry.values.size + return n > SCHEMA_DISTINCT_CAP + ? `list, over ${SCHEMA_DISTINCT_CAP} distinct items` + : `list, ${n} distinct ${n === 1 ? 'item' : 'items'}` + } + return kind || 'empty' +} + +export function renderAttrSchema() { + refreshEdgeLabelOptions() + const el = document.getElementById('attrSchema') + if (!el) return + const totals = { node: cy.nodes().length, edge: cy.edges().length, group: getUsedGroups().length } + if (!totals.node) { + el.innerHTML = '

    Load a network to see its attributes.

    ' + return + } + const titles = { node: 'Nodes', edge: 'Edges', group: 'Groups' } + el.innerHTML = ['node', 'edge', 'group'] + .map((level) => { + const entries = Object.entries(attrSchema[level]).filter(([, e]) => e.count > 0) + const rows = entries.length + ? entries + .map( + ([k, e]) => ` +
    + ${escapeHtml(k)} + ${e.count}/${totals[level]} + ${escapeHtml(describeSchemaEntry(e))} +
    ` + ) + .join('') + : '

    No custom attributes

    ' + return `
    +
    ${titles[level]}${totals[level]}
    + ${rows} +
    ` + }) + .join('') +} + +// "Not in any group" is always neutral gray and doesn't use up a palette color. +export function colorForGroup(g) { + if (!S.nodeColorMap[g]) { + if (g === UNGROUPED) { + S.nodeColorMap[g] = NO_NODE_COLOR + } else { + const palette = NODE_PALETTES[S.currentNodePalette] || NODE_PALETTES.vivid + const idx = Object.keys(S.nodeColorMap).filter((k) => k !== UNGROUPED).length + S.nodeColorMap[g] = colorAtIndex(palette, idx) + } + } + return S.nodeColorMap[g] +} + +export function nextAutoEdgeColor() { + const c = colorAtIndex(AUTO_EDGE_COLORS, S.autoEdgeIdx) + S.autoEdgeIdx++ + return c +} + +// Computes the color/pie-slice data fields for a node given its list of groups. +// A single-group node gets one 100% slice (looks like a plain colored circle); +// a multi-group node gets one evenly-sized slice per group, in order. +// Slices are equal-sized. A node in more groups than there are slices shows +// its first PIE_MAX_SLICES - 1 groups plus one gray slice sized for the rest. +export function computeNodeVisualFields(groupsArr) { + const all = groupsArr && groupsArr.length ? groupsArr : [UNGROUPED] + const overflow = all.length > PIE_MAX_SLICES + const shown = overflow ? all.slice(0, PIE_MAX_SLICES - 1) : all + const slices = shown.map((g) => ({ color: colorForGroup(g), size: 100 / all.length })) + if (overflow) + slices.push({ color: NO_NODE_COLOR, size: (100 * (all.length - shown.length)) / all.length }) + const fields = { color: slices[0].color } + for (let i = 0; i < PIE_MAX_SLICES; i++) { + const idx = i + 1 + fields['pieColor' + idx] = i < slices.length ? slices[i].color : '#000000' + fields['pieSize' + idx] = i < slices.length ? slices[i].size.toFixed(3) + '%' : '0%' + } + return fields +} + +// Optional display order for groups (e.g. the line order of an annotation +// file); groups not listed keep node order, and "Not in any group" is last. +let groupOrderHint = null + +export function getUsedGroups() { + const groups = [ + ...new Set( + cy + .nodes() + .map((n) => n.data('groups') || []) + .flat() + ), + ] + const rank = (g) => + g === UNGROUPED + ? Infinity + : groupOrderHint && groupOrderHint.has(g) + ? groupOrderHint.get(g) + : 1e9 + return groups + .map((g, i) => [g, i]) + .sort((a, b) => rank(a[0]) - rank(b[0]) || a[1] - b[1]) + .map((x) => x[0]) +} + +export function setGroupOrder(names) { + groupOrderHint = Array.isArray(names) ? new Map(names.map((g, i) => [g, i])) : null +} + +// A node's *effective* groups are whichever of its assigned groups are +// currently active. Deactivating one of a node's several groups doesn't just +// toggle visibility -- the node is redrawn using only its remaining active +// groups, so a 2-group node with one group turned off becomes a plain single +// color instead of staying a two-slice pie. +export function effectiveGroupsFor(node) { + const groups = node.data('groups') || [] + return groups.filter((g) => S.activeGroups.has(g)) +} + +// One solid color across the whole node, used when node fill comes from +// each node's own "color" field (e.g. a NORMA node-coloring file). +function solidVisualFields(color) { + const fields = { color, pieColor1: color, pieSize1: '100%' } + for (let i = 2; i <= PIE_MAX_SLICES; i++) { + fields['pieColor' + i] = '#000000' + fields['pieSize' + i] = '0%' + } + return fields +} + +export function refreshNodeVisual(node) { + const eff = effectiveGroupsFor(node) + if (!eff.length) { + node.addClass('hidden-group') + return + } + if (node.hasClass('hidden-group')) node.removeClass('hidden-group') + const fill = nodeFillMode() + const visual = + fill === 'data' + ? solidVisualFields(node.data('nodeColor') || NO_NODE_COLOR) + : fill === 'values' + ? solidVisualFields( + node.data('valueColor') || + document.getElementById('valueMissing').value || + NO_NODE_COLOR + ) + : computeNodeVisualFields(eff) + visual.shape = nodeShapeFor(eff) + visual.labelColor = visual.color + if (visual.shape !== 'ellipse') { + // a single-color shaped node: the fill carries the color, no pie + for (let i = 1; i <= PIE_MAX_SLICES; i++) visual['pieSize' + i] = '0%' + } + // only write fields that changed, in a single update + const changed = {} + let any = false + for (const k in visual) { + if (node.data(k) !== visual[k]) { + changed[k] = visual[k] + any = true + } + } + if (any) node.data(changed) +} diff --git a/frontend/src/palette.ts b/frontend/src/palette.ts new file mode 100644 index 0000000..eacbce9 --- /dev/null +++ b/frontend/src/palette.ts @@ -0,0 +1,106 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +/* ---------- edge type palette (STRING-style evidence channels) ---------- */ +export const EDGE_TYPES = { + neighborhood: { color: '#59c46b', label: 'Gene neighborhood' }, + fusion: { color: '#d9534f', label: 'Gene fusion' }, + cooccurrence: { color: '#3a6cd6', label: 'Co-occurrence' }, + coexpression: { color: '#111827', label: 'Co-expression' }, + experiments: { color: '#b464c9', label: 'Experiments' }, + database: { color: '#8fc7e8', label: 'Curated database' }, + textmining: { color: '#c9c14a', label: 'Text mining' }, + homology: { color: '#caa6ea', label: 'Homology' }, +} + +export const ORIGINAL_EDGE_TYPES = JSON.parse(JSON.stringify(EDGE_TYPES)) + +// Colorblind-safe palettes, shared between node and edge use. +// Okabe-Ito: Okabe,M. and Ito,K. (2008) Color Universal Design (CUD) - a widely +// used 8-color palette designed to stay distinguishable under the common forms +// of color vision deficiency (protanopia, deuteranopia, tritanopia). +const CB_OKABE_ITO = [ + '#E69F00', + '#56B4E9', + '#009E73', + '#F0E442', + '#0072B2', + '#D55E00', + '#CC79A7', + '#000000', +] + +// IBM Design Language's colorblind-safe categorical set. +const CB_IBM = ['#648FFF', '#785EF0', '#DC267F', '#FE6100', '#FFB000'] + +// Viridis-inspired perceptually-uniform sequence (also colorblind-safe by design). +const CB_VIRIDIS = [ + '#440154', + '#482878', + '#3E4A89', + '#31688E', + '#26828E', + '#1F9E89', + '#35B779', + '#FDE725', +] + +// Once a category count exceeds a curated palette's length (all palettes here +// have 5-8 hand-picked colors), further colors are generated on the fly using +// a golden-angle hue rotation. Successive hues stay well-spread around the +// color wheel however many categories are added -- this is what lets the +// legends stay usable up to ~100 distinct edge channels or node groups +// instead of the palette repeating every 6-8 entries. +function hslToHex(h, s, l) { + s /= 100 + l /= 100 + const k = (n) => (n + h / 30) % 12 + const a = s * Math.min(l, 1 - l) + const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))) + const toHex = (x) => + Math.round(255 * x) + .toString(16) + .padStart(2, '0') + return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}` +} + +function goldenAngleColor(index) { + const hue = (index * 137.508) % 360 // golden angle, avoids hue clustering + const s = 62, + l = index % 2 === 0 ? 52 : 62 // alternate lightness for extra separation + return hslToHex(hue, s, l) +} + +export function colorAtIndex(palette, index) { + return index < palette.length ? palette[index] : goldenAngleColor(index) +} + +export const NODE_PALETTES = { + vivid: ['#e8a15f', '#5fd3c4', '#e07a7a', '#8fb8e8', '#c9c14a', '#a97fd6', '#7fd68a', '#d68fc0'], + sunset: ['#f4a261', '#e76f51', '#e9c46a', '#f2a65a', '#bc6c25', '#dda15e', '#ffb703', '#fb8500'], + ocean: ['#264653', '#2a9d8f', '#8ecae6', '#219ebc', '#023047', '#48cae4', '#90e0ef', '#00b4d8'], + forest: ['#606c38', '#283618', '#a3b18a', '#588157', '#3a5a40', '#344e41', '#6a994e', '#a7c957'], + pastel: ['#ffd6ff', '#c8b6ff', '#bde0fe', '#a2d2ff', '#ffafcc', '#caffbf', '#fdffb6', '#ffc6ff'], + mono: ['#3a4a6b', '#4c5f8a', '#6478a8', '#7d91c0', '#96a9d3', '#b0bfe0', '#c9d3ea', '#e2e7f4'], + cbOkabeIto: CB_OKABE_ITO, + cbIBM: CB_IBM, + viridis: CB_VIRIDIS, +} + +export const EDGE_PALETTES = { + categorical: [ + '#e07a7a', + '#5fd3c4', + '#e8a15f', + '#8fb8e8', + '#c9c14a', + '#a97fd6', + '#7fd68a', + '#d68fc0', + ], + warm: ['#e63946', '#f4a261', '#e9c46a', '#f77f00', '#d62828', '#fb8500', '#c1121f', '#ee9b00'], + cool: ['#264653', '#2a9d8f', '#457b9d', '#1d3557', '#48cae4', '#219ebc', '#023047', '#90e0ef'], + mono: ['#3a4a6b', '#4c5f8a', '#6478a8', '#7d91c0', '#96a9d3', '#b0bfe0', '#c9d3ea', '#e2e7f4'], + cbOkabeIto: CB_OKABE_ITO, + cbIBM: CB_IBM, + viridis: CB_VIRIDIS, +} diff --git a/frontend/src/parallel_edges.ts b/frontend/src/parallel_edges.ts new file mode 100644 index 0000000..96fa9d3 --- /dev/null +++ b/frontend/src/parallel_edges.ts @@ -0,0 +1,80 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { S } from './state' +import { applyEdgeLabels, scheduleBundling } from './profiler' +import { applyNodeSizing, invalidateFullMetrics } from './metrics' +import { currentTheme } from './themes' +import { cy } from './cy' +import { edgeIsDirected } from './export/dialog' + +/* ---------- parallel edges: merge per node pair ---------- */ +// In merge mode each node pair keeps one visible edge (its first edge in an +// active channel). That edge is widened by the number of active channels +// on the pair and turns gray when they disagree; the popup lists them all. +// Uses per-edge style bypasses so it layers over whatever width and color +// rules the stylesheet currently has. +function edgeMergeOn() { + const el = document.getElementById('edgeMergeMode') + return !!el && el.value === 'merge' +} + +export function applyEdgeMerge() { + if (typeof cy === 'undefined') return + const on = edgeMergeOn() + cy.batch(() => { + cy.edges('.merged-rep').forEach((e) => { + e.removeStyle('width line-color target-arrow-color') + e.removeClass('merged-rep') + }) + cy.edges('.merged-hidden').removeClass('merged-hidden') + cy.edges().forEach((e) => { + if (e.data('mergedWith')) e.data('mergedWith', null) + }) + if (!on) return + const byPair = new Map() + cy.edges().forEach((e) => { + if (e.hasClass('hidden-type')) return + const s = e.data('source'), + t = e.data('target') + // directed edges merge only with edges running the same way + const key = edgeIsDirected(e) + ? 'D\t' + s + '\t' + t + : 'U\t' + (s < t ? s + '\t' + t : t + '\t' + s) + const list = byPair.get(key) + if (list) list.push(e) + else byPair.set(key, [e]) + }) + const baseFixed = parseFloat(document.getElementById('edgeWidthFixed').value) || 2 + const byWeight = document.getElementById('edgeWidthMode').value === 'weight' + byPair.forEach((list) => { + if (list.length < 2) return + const [rep, ...rest] = list + rest.forEach((e) => e.addClass('merged-hidden')) + rep.data( + 'mergedWith', + list.map((e) => e.id()) + ) + const colors = new Set(list.map((e) => e.data('color'))) + const base = byWeight ? rep.data('edgeWidth') || baseFixed : baseFixed + rep.addClass('merged-rep') + rep.style({ + width: Math.min(base * (1 + 0.7 * (list.length - 1)), base * 6), + 'line-color': colors.size === 1 ? rep.data('color') : currentTheme.muted, + 'target-arrow-color': colors.size === 1 ? rep.data('color') : currentTheme.muted, + }) + }) + }) + applyEdgeLabels() + if (typeof scheduleBundling === 'function') scheduleBundling() +} + +export function applyTypeVisibility() { + cy.edges().forEach((e) => { + if (S.activeTypes.has(e.data('type'))) e.removeClass('hidden-type') + else e.addClass('hidden-type') + }) + applyEdgeMerge() + invalidateFullMetrics() + // if node sizes are being driven by a channel-scoped metric, keep them live + if (document.getElementById('sizeChannelOnly').checked) applyNodeSizing() +} diff --git a/frontend/src/profiler.ts b/frontend/src/profiler.ts new file mode 100644 index 0000000..de1a316 --- /dev/null +++ b/frontend/src/profiler.ts @@ -0,0 +1,2630 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { COMMUNITY_ALGORITHMS, WALKTRAP_NODE_LIMIT } from './welcome' +import { + CONFIG_FIELDS, + applyEdgePalette, + applyGroupVisibility, + applyNodePalette, + buildGroupLegend, + buildLegend, + byName, + drawGroupHulls, + filteredGroups, + getCurrentConfig, + getUsedTypes, + legendExtra, + loadData, + refreshAllDerivedUI, + sortedByName, +} from './hulls' +import { EDGE_TYPES, NODE_PALETTES, ORIGINAL_EDGE_TYPES, colorAtIndex } from './palette' +import { PROFILE_DIRECTED_STATS, directedStatsTable, profileDirected } from './directed_stats' +import { + PROFILE_PATH_LIMIT, + PROFILE_STATS, + formatStat, + profileGraph, + simpleGraph, + switchTab, +} from './wiring' +import { S } from './state' +import { + UNGROUPED, + attrKind, + attrRowsHtml, + attrSchema, + buildAttrSchema, + customGroupAttrs, + escapeHtml, + formatAttrValue, + getUsedGroups, + groupDescription, + groupLabel, + nodeFillMode, + refreshNodeVisual, + renderAttrSchema, + sanitizeColor, +} from './network_state' +import { WEBGL_ACTIVE, cy, setStyle } from './cy' +import { + addNormaEntry, + annotationText, + downloadText, + libEntry, + libSelection, + listSample, + nextPaint, + normaLibrary, + plural, + renderLibraryLists, + selectedNetworks, + setStatus, +} from './layouts/controls' +import { applyEdgeMerge, applyTypeVisibility } from './parallel_edges' +import { applyGroupsAndColorsInPlace, refreshLibraryView } from './library' +import { applyLabelColors } from './label_colors' +import { + applyNodeSizing, + bundleAsync, + cancelFrJobs, + ensureFullMetrics, + invalidateFullMetrics, + shownEdges, + shownNodes, + updateStats, +} from './metrics' +import { applyTheme, currentTheme } from './themes' +import { applyValueColors, scheduleLegend } from './clustering/mapping' +import { arrowsShown, edgeIsDirected } from './export/dialog' +import { + bumpDataVersion, + dataVersion, + flushHistory, + historyTimer, + setHistoryBaseline, + updateUndoButtons, +} from './demo_downloads' +import { capture3d, restore3d } from './view3d/tab' +import { fit3d } from './view3d/camera' +import { net3d } from './view3d/state' +import { refreshAfterDelete } from './api/tester' +import { runCommunityAlgorithm } from './clustering/mcl' +import { setLayoutBusy } from './layouts/run' +import { updateContextInfo } from './recording' + +/* ---------- profiler UI ---------- */ +export const profilerState = { results: [] } + +export function renderProfilerNetworkList() { + const el = document.getElementById('profNetList') + if (!el) return + const previous = new Set([...el.querySelectorAll('input:checked')].map((i) => i.value)) + const firstRender = !el.dataset.rendered + el.dataset.rendered = '1' + el.innerHTML = '' + const cur = typeof activeView === 'function' && activeView() + const options = [ + { + value: 'view', + label: `Current view: ${cur ? cur.name : 'untitled'} (${shownNodes().length} shown nodes)`, + }, + ].concat( + sortedByName(normaLibrary.network, (e) => e.name).map((e) => ({ value: e.id, label: e.name })) + ) + options.forEach((o) => { + const label = document.createElement('label') + const input = document.createElement('input') + input.type = 'checkbox' + input.value = o.value + input.checked = firstRender ? o.value === 'view' : previous.has(o.value) + const span = document.createElement('span') + span.textContent = o.label + label.append(input, span) + el.appendChild(label) + }) +} + +function graphForSource(value) { + if (value === 'view') { + const ids = shownNodes().map((n) => n.id()) + const shownE = shownEdges(true) + const pairs = shownE.map((e) => [e.data('source'), e.data('target')]) + const dirEdges = shownE.map((e) => [e.data('source'), e.data('target'), edgeIsDirected(e)]) + const viewIds = S.currentLibView ? S.currentLibView.nets.split('|') : [] + const nets = normaLibrary.network.filter((e) => viewIds.includes(e.id)) + const viewLabel = nets.length ? nets.map((e) => e.name).join(' + ') : 'Current view' + const cur = typeof activeView === 'function' && activeView() + return { + name: cur ? `View: ${cur.name}` : 'Current view', + label: viewLabel, + graph: simpleGraph(ids, pairs), + isView: true, + ids, + dirEdges, + } + } + const entry = libEntry('network', value) + if (!entry) return null + return { + name: entry.name, + graph: simpleGraph( + entry.parsed.nodes, + entry.parsed.edges.map((e) => [e.source, e.target]) + ), + ids: entry.parsed.nodes, + dirEdges: entry.parsed.edges.map((e) => [e.source, e.target, !!e.directed]), + isView: false, + entry, + } +} + +function runProfiler() { + const chosen = [...document.querySelectorAll('#profNetList input:checked')].map((i) => i.value) + if (!chosen.length) { + setStatus('profStatus', [{ level: 'error', text: 'Tick at least one network to profile.' }]) + return + } + if (chosen.includes('view') && !cy.nodes().length) { + setStatus('profStatus', [ + { + level: 'error', + text: 'The current view is empty. Load a network first, or untick "Current view".', + }, + ]) + return + } + const profText = (i) => + chosen.length > 1 + ? `Computing statistics: network ${i + 1} of ${chosen.length}…` + : 'Computing statistics…' + setStatus('profStatus', [ + { level: 'busy', text: profText(0), progress: chosen.length > 1 ? 0 : null }, + ]) + document.getElementById('btnProfile').disabled = true + // one network at a time, letting the progress paint in between + ;(async () => { + await nextPaint() + try { + const started = performance.now() + const useDir = document.getElementById('profDirected').checked + const results = [] + for (const [i, value] of chosen.entries()) { + if (i > 0) { + setStatus('profStatus', [ + { level: 'busy', text: profText(i), progress: i / chosen.length }, + ]) + await nextPaint() + } + const g = graphForSource(value) + if (!g) continue + const src = { ...g, sourceValue: value } + results.push({ + ...src, + stats: profileGraph(src.graph), + directed: + useDir && src.dirEdges.some((d) => d[2]) + ? profileDirected(src.ids, src.dirEdges) + : null, + }) + } + profilerState.results = results + renderProfilerResults(results) + const secs = ((performance.now() - started) / 1000).toFixed(2) + const notes = [ + { level: 'ok', text: `Profiled ${plural(results.length, 'network')} in ${secs} s.` }, + ] + if (results.some((r) => r.stats.pathsSkipped)) + notes.push({ + level: 'warn', + text: `Path-based statistics were skipped for networks above ${PROFILE_PATH_LIMIT.toLocaleString('en-US')} nodes.`, + }) + setStatus('profStatus', notes) + document.getElementById('btnProfileTsv').disabled = false + } catch (err) { + setStatus('profStatus', [ + { level: 'error', text: `The statistics couldn't be computed: ${err.message}` }, + ]) + } finally { + document.getElementById('btnProfile').disabled = false + } + })() +} + +export function renderProfilerResults(results) { + const root = document.getElementById('profResults') + root.innerHTML = '' + + // statistics table + const sec = profSection('Statistics', '') + const wrap = document.createElement('div') + wrap.className = 'table-wrap' + const table = document.createElement('table') + table.className = 'data' + const head = document.createElement('tr') + head.innerHTML = + 'Statistic' + + results.map((r) => `${escapeHtml(r.name)}`).join('') + const thead = document.createElement('thead') + thead.appendChild(head) + const tbody = document.createElement('tbody') + PROFILE_STATS.forEach((st) => { + const tr = document.createElement('tr') + tr.innerHTML = + `${escapeHtml(st.label)}${escapeHtml(st.desc)}` + + results.map((r) => `${escapeHtml(formatStat(r.stats[st.key]))}`).join('') + tbody.appendChild(tr) + }) + table.append(thead, tbody) + wrap.appendChild(table) + sec.appendChild(wrap) + const split = results.filter((r) => r.stats.components > 1).map((r) => r.name) + if (split.length) { + sec.insertAdjacentHTML( + 'beforeend', + `

    ${escapeHtml(listSample(split, 4))} ${split.length === 1 ? 'has' : 'have'} more than one component. As in igraph, closeness is then computed within each component, so closeness centralization can exceed 1 and isn't comparable with connected networks.

    ` + ) + } + root.appendChild(sec) + + if (results.some((r) => r.directed)) { + const dsec = profSection( + 'Direction', + 'Statistics that follow edge directions, for networks with directed edges. Undirected edges can be walked both ways. A dash means the network has no directed edges.' + ) + dsec.insertAdjacentHTML('beforeend', directedStatsTable(results)) + root.appendChild(dsec) + } + + // degree distributions + const degSec = profSection('Degree distribution', 'Number of nodes with each degree.') + const controls = document.createElement('div') + controls.className = 'inline-controls' + controls.innerHTML = '' + degSec.appendChild(controls) + const degCards = document.createElement('div') + degCards.className = 'cards' + degSec.appendChild(degCards) + const drawDeg = () => { + const log = document.getElementById('degLogLog').checked + degCards.innerHTML = '' + results.forEach((r) => { + const card = document.createElement('div') + card.className = 'card chart' + card.innerHTML = + `

    ${escapeHtml(r.name)}

    ` + + degreeChartSvg(r.stats.degree, log) + degCards.appendChild(card) + }) + } + controls.querySelector('input').addEventListener('change', drawDeg) + root.appendChild(degSec) + drawDeg() + + // central nodes + const cSec = profSection('Most central nodes', 'Top ten nodes by the chosen measure.') + const cControls = document.createElement('div') + cControls.className = 'inline-controls' + cControls.innerHTML = `` + cSec.appendChild(cControls) + const cCards = document.createElement('div') + cCards.className = 'cards' + cCards.style.gridTemplateColumns = 'repeat(auto-fill, minmax(400px, 1fr))' + cSec.appendChild(cCards) + const drawCentral = () => { + const by = document.getElementById('centralBy').value + cCards.innerHTML = '' + results.forEach((r) => { + const s = r.stats + const values = + by === 'degree' + ? s.degree + : by === 'betweenness' + ? s.betweenness + : by === 'closeness' + ? s.closeness + : s.localClustering + const card = document.createElement('div') + card.className = 'card' + card.innerHTML = `

    ${escapeHtml(r.name)}

    ` + if (!values) { + card.insertAdjacentHTML( + 'beforeend', + '

    Not computed for a network this large.

    ' + ) + cCards.appendChild(card) + return + } + const idx = Array.from({ length: r.graph.n }, (_, i) => i) + .filter((i) => Number.isFinite(values[i])) + .sort((a, b) => values[b] - values[a]) + .slice(0, 10) + const rows = idx + .map((i) => { + const id = r.graph.ids[i] + const inView = !!cy.getElementById(id).length + const nameCell = inView + ? `` + : escapeHtml(id) + return `${nameCell}${escapeHtml(formatStat(s.degree[i]))}${s.betweenness ? escapeHtml(formatStat(s.betweenness[i])) : '—'}${s.closeness ? escapeHtml(formatStat(s.closeness[i])) : '—'}` + }) + .join('') + card.insertAdjacentHTML( + 'beforeend', + `
    ${rows}
    NodeDegreeBetweennessCloseness
    ` + ) + cCards.appendChild(card) + }) + cCards + .querySelectorAll('button[data-node]') + .forEach((b) => b.addEventListener('click', () => focusNodeInNetwork(b.dataset.node))) + } + cControls.querySelector('select').addEventListener('change', drawCentral) + root.appendChild(cSec) + drawCentral() + + // communities + const lSec = profSection( + 'Communities', + 'Find communities with one of five algorithms and add them to Files as an annotation to view them as groups. Modularity is computed the same way for every algorithm.' + ) + const algo = profilerState.cmAlgo || 'louvain' + lSec.insertAdjacentHTML( + 'beforeend', + `
    + + + + +
    ` + ) + const lCards = document.createElement('div') + lCards.className = 'cards' + lSec.appendChild(lCards) + const drawCommunities = () => { + lCards.innerHTML = '' + results.forEach((r) => { + const lv = r.cmResult || r.stats.louvain + const label = r.cmLabel || 'Louvain' + const card = document.createElement('div') + card.className = 'card' + if (r.cmError) { + card.innerHTML = `

    ${escapeHtml(r.name)}

    ${escapeHtml(r.cmError)}

    ` + lCards.appendChild(card) + return + } + const nonSingle = lv.sizes.filter((x) => x > 1) + const sorted = [...lv.sizes].sort((a, b) => b - a) + const strip = sorted + .slice(0, 40) + .map( + (sz, i) => + `` + ) + .join('') + card.innerHTML = ` +

    ${escapeHtml(r.name)}

    +

    ${escapeHtml(label)}: ${plural(lv.count, 'community', 'communities')} (${nonSingle.length} with more than one node), modularity ${escapeHtml(formatStat(lv.modularity))}${r.cmMs !== undefined ? `, ${Math.round(r.cmMs)} ms` : ''}. Largest: ${sorted.slice(0, 5).join(', ')} nodes.

    + ` + const btn = document.createElement('button') + btn.type = 'button' + btn.textContent = 'Add as annotation' + btn.disabled = !nonSingle.length + btn.addEventListener('click', () => addCommunitiesAsAnnotation(r)) + card.appendChild(btn) + lCards.appendChild(card) + }) + } + const paramFor = (a) => + ({ + louvain: [ + 'Resolution', + 1, + 0.05, + 10, + 0.1, + 'Higher resolution gives more, smaller communities.', + ], + leiden: [ + 'Resolution', + 1, + 0.05, + 10, + 0.1, + 'Like Louvain, but every community is guaranteed to be connected.', + ], + lpa: [ + null, + null, + 0, + 0, + 0, + 'Each node takes the most common label among its neighbours until nothing changes.', + ], + walktrap: [ + 'Walk length', + 4, + 2, + 10, + 1, + `Short random walks tend to stay inside communities. Up to ${WALKTRAP_NODE_LIMIT.toLocaleString('en-US')} nodes.`, + ], + mcl: ['Inflation', 2, 1.1, 6, 0.1, 'Higher inflation gives more, smaller clusters.'], + })[a] + const syncParam = () => { + const a = lSec.querySelector('#cmAlgo').value + const [name, def, min, max, step, hint] = paramFor(a) + const input = lSec.querySelector('#cmParam') + lSec.querySelector('#cmParamLabel').hidden = !name + if (name) { + lSec.querySelector('#cmParamName').textContent = name + Object.assign(input, { min, max, step }) + const saved = profilerState.cmParams && profilerState.cmParams[a] + input.value = saved ?? def + } + lSec.querySelector('#cmHint').textContent = hint + } + lSec.querySelector('#cmAlgo').addEventListener('change', syncParam) + lSec.querySelector('#btnCommunities').addEventListener('click', () => { + const a = lSec.querySelector('#cmAlgo').value + const value = parseFloat(lSec.querySelector('#cmParam').value) + const [name, def, min, max] = paramFor(a) + const v = name ? Math.min(max, Math.max(min, Number.isFinite(value) ? value : def)) : null + profilerState.cmAlgo = a + profilerState.cmParams = { ...(profilerState.cmParams || {}), [a]: v } + const params = + a === 'walktrap' + ? { steps: Math.round(v) } + : a === 'mcl' + ? { inflation: v } + : { resolution: v } + const cmText = `Finding communities with ${COMMUNITY_ALGORITHMS[a].label}…` + setStatus('profStatus', [ + { level: 'busy', text: cmText, progress: results.length > 1 ? 0 : null }, + ]) + ;(async () => { + await nextPaint() + for (const [i, r] of results.entries()) { + if (i > 0) { + setStatus('profStatus', [{ level: 'busy', text: cmText, progress: i / results.length }]) + await nextPaint() + } + const t0 = performance.now() + try { + r.cmResult = runCommunityAlgorithm(r.graph, a, params) + r.cmError = null + } catch (err) { + r.cmResult = null + r.cmError = err.message + } + r.cmMs = performance.now() - t0 + r.cmAlgo = a + r.cmLabel = `${COMMUNITY_ALGORITHMS[a].label}${name ? ` (${name.toLowerCase()} ${a === 'walktrap' ? Math.round(v) : v})` : ''}` + } + drawCommunities() + setStatus('profStatus', [ + { level: 'ok', text: `Found communities with ${COMMUNITY_ALGORITHMS[a].label}.` }, + ]) + })() + }) + syncParam() + drawCommunities() + root.appendChild(lSec) + + // channels of the current view + const viewResult = results.find((r) => r.isView) + const channels = viewResult ? channelProfile() : null + if (channels) root.appendChild(renderChannelSection(channels)) +} + +export function profSection(title, sub) { + const sec = document.createElement('section') + sec.className = 'prof-section' + sec.innerHTML = + `

    ${escapeHtml(title)}

    ` + (sub ? `

    ${escapeHtml(sub)}

    ` : '') + return sec +} + +function degreeChartSvg(deg, logScale) { + const W = 340, + H = 170, + L = 40, + R = 10, + T = 10, + B = 28 + const counts = new Map() + let maxDeg = 0 + deg.forEach((d) => { + counts.set(d, (counts.get(d) || 0) + 1) + if (d > maxDeg) maxDeg = d + }) + const pw = W - L - R, + ph = H - T - B + let body = '' + let xLabels = '', + yLabels = '' + if (logScale) { + const pts = [...counts].filter(([d]) => d > 0) + if (!pts.length) return '

    No connected nodes to plot.

    ' + const maxC = Math.max(...pts.map((p) => p[1])) + const lx = Math.log10(Math.max(maxDeg, 10)), + ly = Math.log10(Math.max(maxC, 10)) + pts.forEach(([d, c]) => { + const x = L + (Math.log10(d) / lx) * pw + const y = T + ph - (Math.log10(c) / ly) * ph + body += `degree ${d}: ${c} nodes` + }) + xLabels = `1${Math.round(Math.pow(10, lx))}` + yLabels = `1${Math.round(Math.pow(10, ly))}` + } else { + const binWidth = Math.max(1, Math.ceil((maxDeg + 1) / 60)) + const bins = new Array(Math.floor(maxDeg / binWidth) + 1).fill(0) + counts.forEach((c, d) => { + bins[Math.floor(d / binWidth)] += c + }) + const maxC = Math.max(...bins, 1) + const bw = pw / bins.length + bins.forEach((c, i) => { + if (!c) return + const h = (c / maxC) * ph + const lo = i * binWidth, + hi = lo + binWidth - 1 + body += `degree ${binWidth > 1 ? `${lo}–${hi}` : lo}: ${c} nodes` + }) + xLabels = `0${maxDeg}` + yLabels = `0${maxC}` + } + return ` + + + ${body}${xLabels}${yLabels} + degree + ` +} + +function focusNodeInNetwork(id) { + const node = cy.getElementById(id) + if (!node.length) return + switchTab('network') + if (node.hasClass('hidden-group')) { + ;(node.data('groups') || []).forEach((g) => S.activeGroups.add(g)) + buildGroupLegend() + applyGroupVisibility() + } + cy.animate( + { center: { eles: node }, zoom: Math.max(cy.zoom(), 1.2) }, + { duration: 300, complete: clampViewport } + ) + node.emit('tap') +} + +function addCommunitiesAsAnnotation(result) { + const lv = result.cmResult || result.stats.louvain + const algoName = COMMUNITY_ALGORITHMS[result.cmAlgo || 'louvain'].short + const members = new Map() + result.graph.ids.forEach((id, i) => { + const c = lv.membership[i] + if (!members.has(c)) members.set(c, []) + members.get(c).push(id) + }) + const groups = [...members.values()] + .filter((list) => list.length > 1) + .sort((a, b) => b.length - a.length) + .map((list, i) => ({ name: `Community-${i + 1}`, members: list })) + const skippedNodes = result.graph.n - groups.reduce((s, g) => s + g.members.length, 0) + const entry = addNormaEntry( + 'annotation', + `${result.label || result.name} ${algoName} communities`, + annotationText(groups), + '' + ) + libSelection.annotation = entry.id + renderLibraryLists() + const notes = [ + { + level: 'ok', + text: `Added the annotation "${entry.name}" with ${plural(groups.length, 'community', 'communities')} to Files and selected it.`, + }, + ] + if (skippedNodes) + notes.push({ + level: 'ok', + text: `${plural(skippedNodes, 'node')} that form a community on their own ${skippedNodes === 1 ? 'was' : 'were'} left out.`, + }) + if (result.isView && S.currentLibView) { + notes.push({ + level: 'ok', + text: 'Show them in this view to see the communities as groups.', + action: { + label: 'Show in this view', + run: () => { + refreshLibraryView() + switchTab('network') + }, + }, + }) + } else if (result.isView) { + notes.push({ + level: 'ok', + text: 'The current view came from an example or JSON rather than Files, so the communities can be applied to it here.', + action: { + label: 'Show as groups now', + run: () => { + applyCommunitiesToView(groups) + switchTab('network') + }, + }, + }) + } else { + notes.push({ + level: 'ok', + text: `Tick "${result.name}" under Files and choose Show in this view to see them.`, + }) + } + setStatus('profStatus', notes) +} + +// For views that didn't come from Files: set groups directly. +function applyCommunitiesToView(groups) { + const groupsOf = {} + groups.forEach((g) => g.members.forEach((id) => (groupsOf[id] = groupsOf[id] || []).push(g.name))) + document.getElementById('nodeFillSelect').value = 'groups' + applyGroupsAndColorsInPlace( + groupsOf, + new Map(), + groups.map((g) => g.name) + ) +} + +function channelProfile() { + const types = getUsedTypes() + if (types.length < 2) return null + const pairsByType = new Map(types.map((t) => [t, new Set()])) + const typesByPair = new Map() + cy.edges().forEach((e) => { + const s = e.data('source'), + t = e.data('target') + if (s === t) return + const key = s < t ? s + '\t' + t : t + '\t' + s + const type = e.data('type') + pairsByType.get(type).add(key) + if (!typesByPair.has(key)) typesByPair.set(key, new Set()) + typesByPair.get(key).add(type) + }) + const perPair = new Map() + typesByPair.forEach((set) => { + perPair.set(set.size, (perPair.get(set.size) || 0) + 1) + }) + return { + types, + pairsByType, + perPair, + totalPairs: typesByPair.size, + totalEdges: cy.edges().length, + } +} + +function renderChannelSection(ch) { + const sec = profSection( + 'Edge channels in the current view', + `${plural(ch.totalEdges, 'edge')} over ${plural(ch.totalPairs, 'connected node pair')}.` + ) + const byCount = [...ch.perPair] + .sort((a, b) => a[0] - b[0]) + .map( + ([k, v]) => + `${plural(k, 'channel')}${v.toLocaleString('en-US')}${((100 * v) / ch.totalPairs).toFixed(1)}%` + ) + .join('') + const shown = ch.types.slice(0, 12) + const matrixHead = + 'ChannelPairs' + + shown.map((t, i) => `${i + 1}`).join('') + + '' + const matrixRows = shown + .map((a, i) => { + const A = ch.pairsByType.get(a) + const cells = shown + .map((b, j) => { + if (i === j) return '—' + const B = ch.pairsByType.get(b) + let shared = 0 + const [small, big] = A.size < B.size ? [A, B] : [B, A] + small.forEach((k) => { + if (big.has(k)) shared++ + }) + const union = A.size + B.size - shared + const jac = union ? shared / union : 0 + return `${jac.toFixed(2)}` + }) + .join('') + return `${i + 1}. ${escapeHtml(a)}${A.size.toLocaleString('en-US')}${cells}` + }) + .join('') + sec.insertAdjacentHTML( + 'beforeend', + ` +
    +
    +

    Node pairs by number of channels

    +
    ${byCount}
    Carried byPairsShare
    +
    +
    +

    Channel overlap (Jaccard index of node pairs)

    +

    1 means two channels connect exactly the same pairs; 0 means they share none.${ch.types.length > shown.length ? ` Showing the first ${shown.length} of ${ch.types.length} channels.` : ''}

    +
    ${matrixHead}${matrixRows}
    +
    +
    ` + ) + return sec +} + +function profilerTsv() { + const results = profilerState.results + if (!results.length) return + const lines = [['Statistic', ...results.map((r) => r.name)].join('\t')] + PROFILE_STATS.forEach((st) => + lines.push([st.label, ...results.map((r) => formatStat(r.stats[st.key]))].join('\t')) + ) + if (results.some((r) => r.directed)) { + PROFILE_DIRECTED_STATS.forEach((st) => + lines.push( + [st.label, ...results.map((r) => (r.directed ? formatStat(r.directed[st.key]) : ''))].join( + '\t' + ) + ) + ) + } + downloadText('network-profile.tsv', lines.join('\n') + '\n') +} + +/* labels */ +// Node label size per node: fixed, or grown in proportion to node size. +export function applyLabelSizes() { + const base = parseFloat(document.getElementById('nodeLabelSize').value) || 12 + const grow = document.getElementById('labelScaleWithNode').checked + cy.batch(() => { + cy.nodes().forEach((n) => { + const px = grow ? Math.max(3, (base * (n.data('size') || 42)) / 42) : base + if (n.data('labelPx') !== px) n.data('labelPx', px) + }) + }) +} + +// Edge label text. A merged edge speaks for all the parallel edges it stands for. +function edgeLabelText(e, mode) { + const merged = e.data('mergedWith') + const group = merged && merged.length > 1 ? merged.map((id) => cy.getElementById(id)) : [e] + const channel = + group.length > 1 + ? `${group.length} channels` + : (EDGE_TYPES[e.data('type')] || {}).label || e.data('type') + const weights = group.map((x) => x.data('weight')).filter((w) => typeof w === 'number') + const weight = weights.length ? formatAttrValue(Math.max(...weights)) : '' + if (mode === 'weight') return weight + if (mode === 'type+weight') return weight ? `${channel} (${weight})` : channel + if (mode.startsWith('attr:')) { + const key = mode.slice(5) + const values = group + .map((x) => (x.data('attrs') || {})[key]) + .filter((v) => attrKind(v) !== 'empty') + return [...new Set(values.map(formatAttrValue))].join(', ') + } + return channel +} + +export function applyEdgeLabels() { + if (!document.getElementById('showEdgeLabels').checked) return + const mode = document.getElementById('edgeLabelContent').value || 'type' + cy.batch(() => { + cy.edges().forEach((e) => { + const text = edgeLabelText(e, mode) + if (e.data('elabel') !== text) e.data('elabel', text) + }) + }) +} + +// Keeps the "attribute" choices in the edge label list in step with the +// edge attributes of the loaded network. +export function refreshEdgeLabelOptions() { + const sel = document.getElementById('edgeLabelContent') + const current = sel.value + ;[...sel.querySelectorAll('option[data-attr]')].forEach((o) => o.remove()) + Object.keys(attrSchema.edge || {}).forEach((key) => { + const opt = new Option(`Text: attribute "${key}"`, 'attr:' + key) + opt.dataset.attr = '1' + sel.add(opt) + }) + sel.value = [...sel.options].some((o) => o.value === current) ? current : 'type' +} + +export function updateLabelStyle() { + const showNode = document.getElementById('showNodeLabels').checked + const showEdge = document.getElementById('showEdgeLabels').checked + const pos = document.getElementById('labelPosition').value + const nodePx = parseFloat(document.getElementById('nodeLabelSize').value) || 12 + const edgePx = parseFloat(document.getElementById('edgeLabelSize').value) || 9 + const minPx = parseFloat(document.getElementById('labelMinScreenSize').value) || 0 + document.getElementById('nodeLabelSizeValue').textContent = `${nodePx} px` + document.getElementById('edgeLabelSizeValue').textContent = `${edgePx} px` + document.getElementById('edgeLabelControls').hidden = !showEdge + + let valign = 'center', + halign = 'center', + marginY = 0, + marginX = 0 + if (pos === 'top') { + valign = 'top' + marginY = -4 + } else if (pos === 'bottom') { + valign = 'bottom' + marginY = 4 + } else if (pos === 'left') { + halign = 'left' + marginX = -6 + } else if (pos === 'right') { + halign = 'right' + marginX = 6 + } + + applyLabelSizes() + applyEdgeLabels() + setStyle('node', { + label: showNode ? 'data(id)' : '', + 'text-valign': valign, + 'text-halign': halign, + 'text-margin-y': marginY, + 'text-margin-x': marginX, + 'min-zoomed-font-size': minPx, + }) + setStyle('edge', { + label: showEdge ? 'data(elabel)' : '', + 'font-size': edgePx, + 'font-family': 'Inter, sans-serif', + color: currentTheme.text, + 'text-background-color': currentTheme.bg, + 'text-background-opacity': 0.85, + 'text-background-padding': 2, + 'text-background-shape': 'roundrectangle', + 'text-rotation': document.getElementById('edgeLabelOrientation').value, + 'min-zoomed-font-size': minPx, + }) + applyEdgeCurveStyle() + applyLabelColors() +} + +/* edge opacity */ +export function applyEdgeOpacity() { + const o = parseFloat(document.getElementById('edgeOpacity').value) || 0.85 + document.getElementById('edgeOpacityValue').textContent = o.toFixed(2) + setStyle('edge', { opacity: o }) + setStyle('edge.dimmed', { opacity: Math.min(0.04, o) }) +} + +/* spread: nodes repel (right) or attract (left) each other. + The slider sets the strength of repulsion (which acts between all nearby + nodes) against the pull of the links, from a quarter to four times the + current arrangement (the "base"). Each pair of nearby nodes, plus a few + distant anchors, gets a target distance from the base: pushed apart, + unlinked neighbours separate most and links resist, so linked groups + stay together and move apart; drawn together, links shorten most and + nodes stop before they overlap. Stress majorization moves the nodes + toward those distances, keeping the arrangement. At 1x the targets are + the base distances, and returning to 1x restores the base exactly. + Dragging nodes, undo and layouts start a new base. */ +let spreadApplied = 0 + +export let spreadBase = null +// { v0, ids, pos, out } +let spreadFrame = 0 + +function spreadFactor(v) { + return Math.pow(2, v) +} + +export function updateSpreadReadout() { + const v = parseFloat(document.getElementById('spreadSlider').value) || 0 + document.getElementById('spreadValue').textContent = spreadFactor(v).toFixed(2) + '×' +} + +// The positions the slider last produced, if the nodes are still there. +function spreadBaseValid(nodes) { + const b = spreadBase + if (!b || b.ids.length !== nodes.length) return false + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i] + if (n.id() !== b.ids[i]) return false + const p = n.position() + if (Math.abs(p.x - b.out[2 * i]) > 1e-6 || Math.abs(p.y - b.out[2 * i + 1]) > 1e-6) return false + } + return true +} + +// Nodes may come as close as they were in the base, but not overlap more. +function spreadSeparate(X, B, n, radii, passes) { + const gap = 4 + const maxR = radii.reduce((a, r) => Math.max(a, r), 0) + if (!(maxR > 0)) return + const size = 2 * maxR + gap + for (let pass = 0; pass < passes; pass++) { + const grid = new Map() + for (let i = 0; i < n; i++) { + const key = + (Math.floor(X[2 * i] / size) + 50000) * 100003 + Math.floor(X[2 * i + 1] / size) + 50000 + let c = grid.get(key) + if (!c) grid.set(key, (c = [])) + c.push(i) + } + let moved = false + grid.forEach((list, key) => { + for (let ox = -1; ox <= 1; ox++) + for (let oy = -1; oy <= 1; oy++) { + const other = grid.get(key + ox * 100003 + oy) + if (!other) continue + for (const i of list) + for (const j of other) { + if (i >= j) continue + let dx = X[2 * i] - X[2 * j], + dy = X[2 * i + 1] - X[2 * j + 1] + const d = Math.hypot(dx, dy) + const want = Math.min( + radii[i] + radii[j] + gap, + Math.hypot(B[2 * i] - B[2 * j], B[2 * i + 1] - B[2 * j + 1]) + ) + if (d >= want - 1e-6) continue + let len = d + if (d < 1e-9) { + dx = (i + j) % 2 ? 1 : -1 + dy = 0 + len = 1 + } + const push = (want - d) / 2 / len + X[2 * i] += dx * push + X[2 * i + 1] += dy * push + X[2 * j] -= dx * push + X[2 * j + 1] -= dy * push + moved = true + } + } + }) + if (!moved) break + } +} + +// Target distances from the base: repulsion acts on every pair of nearby +// nodes and attraction only along links, so pushing apart stretches unlinked +// pairs most (links resist) and drawing together shortens links most +// (unlinked pairs follow less). Crowded pairs change most, distant pairs +// least. Stress majorization then moves the nodes toward those distances. +function spreadRelax(base, n, edges, radii, rel) { + const B = base.pos + if (n < 2) return B.slice() + const dist = (i, j) => Math.hypot(B[2 * i] - B[2 * j], B[2 * i + 1] - B[2 * j + 1]) + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity + for (let i = 0; i < n; i++) { + minX = Math.min(minX, B[2 * i]) + maxX = Math.max(maxX, B[2 * i]) + minY = Math.min(minY, B[2 * i + 1]) + maxY = Math.max(maxY, B[2 * i + 1]) + } + const cell = Math.max(Math.sqrt(Math.max((maxX - minX) * (maxY - minY), 1) / n) * 2, 1e-6) + const cellOf = (i) => [ + Math.floor((B[2 * i] - minX) / cell), + Math.floor((B[2 * i + 1] - minY) / cell), + ] + const cells = new Map() + for (let i = 0; i < n; i++) { + const [gx, gy] = cellOf(i), + key = gx * 100003 + gy + let c = cells.get(key) + if (!c) cells.set(key, (c = [])) + c.push(i) + } + const NN = 12, + FAR = 12 + const pairs = new Map() + const addPair = (i, j, linked) => { + if (i === j) return + const a = Math.min(i, j), + b = Math.max(i, j), + key = a * n + b + const had = pairs.get(key) + if (had) { + if (linked) had[2] = true + return + } + pairs.set(key, [a, b, linked]) + } + for (let e = 0; e < edges.length; e += 2) addPair(edges[e], edges[e + 1], true) + const nnDist = [] + for (let i = 0; i < n; i++) { + const [gx, gy] = cellOf(i) + let cand = [] + for (let r = 1; r <= 4 && cand.length < NN; r++) { + cand = [] + for (let ox = -r; ox <= r; ox++) + for (let oy = -r; oy <= r; oy++) { + const c = cells.get((gx + ox) * 100003 + gy + oy) + if (c) for (const j of c) if (j !== i) cand.push(j) + } + } + cand.sort((a, b) => dist(i, a) - dist(i, b)) + cand.slice(0, NN).forEach((j) => addPair(i, j, false)) + if (cand.length && dist(i, cand[0]) > 1e-9) nnDist.push(dist(i, cand[0])) + let seed = (i * 2654435761) >>> 0 + for (let f = 0; f < FAR; f++) { + seed = (seed * 1664525 + 1013904223) >>> 0 + addPair(i, seed % n, false) + } + } + nnDist.sort((a, b) => a - b) + const k = nnDist.length ? nnDist[nnDist.length >> 1] * 2 : 50 + + const lr = Math.log(rel) + const P = [] + pairs.forEach(([i, j, linked]) => { + const d0 = dist(i, j) + if (d0 < 1e-9) return + const ratio = d0 / k + const near = ratio <= 1 ? 1 : ratio >= 12 ? 0.08 : 1 - (0.92 * Math.log(ratio)) / Math.log(12) + const follows = rel >= 1 ? (linked ? 0.6 : 1) : linked ? 1 : 0.6 + let t = d0 * Math.exp(lr * near * follows) + if (rel < 1) t = Math.max(t, Math.min(d0, radii[i] + radii[j] + 4)) + P.push(i, j, t) + }) + const X = B.slice() + const iters = n > 2500 ? 30 : n > 800 ? 45 : 70 + const nx = new Float64Array(2 * n), + ws = new Float64Array(n) + let cx = 0, + cyy = 0 + for (let i = 0; i < n; i++) { + cx += B[2 * i] + cyy += B[2 * i + 1] + } + cx /= n + cyy /= n + for (let it = 0; it < iters; it++) { + nx.fill(0) + ws.fill(0) + for (let p = 0; p < P.length; p += 3) { + const i = P[p], + j = P[p + 1], + t = P[p + 2] + let dx = X[2 * i] - X[2 * j], + dy = X[2 * i + 1] - X[2 * j + 1] + let d = Math.hypot(dx, dy) + if (d < 1e-9) { + dx = 1e-3 + dy = 0 + d = 1e-3 + } + const w = 1 / t + const ux = (dx / d) * t, + uy = (dy / d) * t + nx[2 * i] += w * (X[2 * j] + ux) + nx[2 * i + 1] += w * (X[2 * j + 1] + uy) + ws[i] += w + nx[2 * j] += w * (X[2 * i] - ux) + nx[2 * j + 1] += w * (X[2 * i + 1] - uy) + ws[j] += w + } + for (let i = 0; i < n; i++) { + if (ws[i] > 0) { + X[2 * i] = nx[2 * i] / ws[i] + X[2 * i + 1] = nx[2 * i + 1] / ws[i] + } + } + spreadSeparate(X, B, n, radii, 6) + } + spreadSeparate(X, B, n, radii, 150) + let mx = 0, + my = 0 + for (let i = 0; i < n; i++) { + mx += X[2 * i] + my += X[2 * i + 1] + } + mx = mx / n - cx + my = my / n - cyy + for (let i = 0; i < n; i++) { + X[2 * i] -= mx + X[2 * i + 1] -= my + } + return X +} + +function applySpread() { + const v = parseFloat(document.getElementById('spreadSlider').value) || 0 + updateSpreadReadout() + const nodes = cy.nodes() + if (!nodes.length) { + spreadApplied = v + return + } + if (!spreadBaseValid(nodes)) { + const ids = nodes.map((nd) => nd.id()) + const pos = new Float64Array(2 * nodes.length) + nodes.forEach((nd, i) => { + const p = nd.position() + pos[2 * i] = p.x + pos[2 * i + 1] = p.y + }) + spreadBase = { v0: spreadApplied, ids, pos, out: pos } + } + spreadApplied = v + const b = spreadBase + const rel = spreadFactor(v - b.v0) + let out + if (Math.abs(v - b.v0) < 1e-9) out = b.pos + else { + const index = new Map(b.ids.map((id, i) => [id, i])) + const seen = new Set(), + edges = [] + cy.edges().forEach((e) => { + const i = index.get(e.source().id()), + j = index.get(e.target().id()) + if (i === undefined || j === undefined || i === j) return + const key = i < j ? i * nodes.length + j : j * nodes.length + i + if (seen.has(key)) return + seen.add(key) + edges.push(i, j) + }) + const radii = nodes.map((nd) => (nd.width() || 0) / 2) + out = spreadRelax(b, nodes.length, edges, radii, rel) + } + b.out = out + cy.batch(() => { + nodes.forEach((nd, i) => nd.position({ x: out[2 * i], y: out[2 * i + 1] })) + }) + drawGroupHulls() +} + +// Large networks take a moment per step, so they follow the slider when it +// is released rather than while it is dragged. +const SPREAD_LIVE_MAX = 1500 + +function queueSpread() { + updateSpreadReadout() + if (spreadFrame || cy.nodes().length > SPREAD_LIVE_MAX) return + spreadFrame = requestAnimationFrame(() => { + spreadFrame = 0 + applySpread() + }) +} + +function resetSpread() { + spreadApplied = 0 + spreadBase = null + document.getElementById('spreadSlider').value = 0 + updateSpreadReadout() +} + +/* edge curve style (straight / curved / bundled) */ +export const BUNDLE_MAX_EDGES = 10000 + +const BUNDLED_STYLE_PROPS = + 'curve-style control-point-distances control-point-weights edge-distances' + +let bundleSeq = 0 + +let bundleTimer = null + +let bundleSignature = '' + +function bundlingOn() { + return document.getElementById('edgeCurveStyle').value === 'bundled' +} + +function clearBundles() { + bundleSignature = '' + lastBundle = null + cy.batch(() => + cy.edges('.bundled').forEach((e) => { + e.removeStyle(BUNDLED_STYLE_PROPS) + e.removeClass('bundled') + }) + ) +} + +export function applyEdgeCurveStyle() { + const curveStyle = document.getElementById('edgeCurveStyle').value + const curvature = parseInt(document.getElementById('edgeCurvature').value, 10) || 40 + document.getElementById('curvatureValue').textContent = curvature + document.getElementById('curvatureRow').style.display = curveStyle === 'bezier' ? 'block' : 'none' + document.getElementById('bundleRow').hidden = curveStyle !== 'bundled' + document.getElementById('bundleStrengthValue').textContent = + document.getElementById('bundleStrength').value + '%' + + // Cytoscape can't draw labels on haystack edges; with edge labels on, + // straight edges use the (equally straight) 'straight' style instead. + // Bundled edges start straight and are bent individually once computed. + const labelsOn = document.getElementById('showEdgeLabels').checked + let effective = curveStyle + if (curveStyle === 'bundled') effective = 'straight' + else if ( + curveStyle === 'haystack' && + (labelsOn || (typeof arrowsShown === 'function' && arrowsShown())) + ) + effective = 'straight' + setStyle('edge', { + 'curve-style': effective, + 'control-point-step-size': curvature, + 'haystack-radius': 0.4, + }) + + if (curveStyle === 'bundled') scheduleBundling(true) + else { + bundleSeq++ + clearBundles() + setStatus('bundleStatus', []) + } +} + +// Recomputes bundles shortly after the drawing changes. Repeated triggers +// within the delay collapse into one run. +export function scheduleBundling(force) { + if (!bundlingOn()) return + if (force) bundleSignature = '' + clearTimeout(bundleTimer) + bundleTimer = setTimeout(runBundling, 250) +} + +// The last computed bundles, kept so they can be re-applied cheaply when +// node sizes change (which changes which bend points fall inside nodes). +var lastBundle = null + +// Bends each edge through its bundled points. Points that fall inside the +// source or target node are left out: Cytoscape can't find where such an +// edge leaves its node and silently skips drawing it. +export function applyBundleResult() { + if (!lastBundle || !bundlingOn()) return + const { list, segs, result } = lastBundle + const keep = new Set(list.map((e) => e.id())) + cy.batch(() => { + cy.edges('.bundled').forEach((e) => { + if (!keep.has(e.id())) { + e.removeStyle(BUNDLED_STYLE_PROPS) + e.removeClass('bundled') + } + }) + list.forEach((e, k) => { + const pts = result[k] + if (!pts || e.removed()) return + const s = segs[k] + const vx = s.tx - s.sx, + vy = s.ty - s.sy + const L2 = vx * vx + vy * vy + if (L2 < 1e-9) return + const L = Math.sqrt(L2) + const srcR = e.source().outerWidth() / 2 + 3 + const tgtR = e.target().outerWidth() / 2 + 3 + const weights = [], + distances = [] + for (let i = 0; i < pts.length; i += 2) { + if (Math.hypot(pts[i] - s.sx, pts[i + 1] - s.sy) <= srcR) continue + if (Math.hypot(pts[i] - s.tx, pts[i + 1] - s.ty) <= tgtR) continue + const px = pts[i] - s.sx, + py = pts[i + 1] - s.sy + // rounded: Cytoscape's style parser rejects exponent notation (1e-15) + weights.push( + Math.round(Math.min(0.999, Math.max(0.001, (px * vx + py * vy) / L2)) * 1000) / 1000 + ) + // Cytoscape measures positive distances to the left of source->target + distances.push(Math.round(((py * vx - px * vy) / L) * 100) / 100) + } + if (!weights.length || distances.every((d) => d === 0)) { + if (e.hasClass('bundled')) { + e.removeStyle(BUNDLED_STYLE_PROPS) + e.removeClass('bundled') + } + return + } + e.style({ + 'curve-style': 'unbundled-bezier', + 'edge-distances': 'node-position', + 'control-point-weights': weights, + 'control-point-distances': distances, + }) + e.addClass('bundled') + }) + }) +} + +async function runBundling() { + if (!bundlingOn()) return + const edges = cy.edges(':visible').filter((e) => e.data('source') !== e.data('target')) + const strength = (parseFloat(document.getElementById('bundleStrength').value) || 50) / 100 + const signature = + strength + + '|' + + edges + .map((e) => { + const a = e.source().position(), + b = e.target().position() + return ( + e.id() + + ':' + + Math.round(a.x) + + ',' + + Math.round(a.y) + + ',' + + Math.round(b.x) + + ',' + + Math.round(b.y) + ) + }) + .join(';') + if (signature === bundleSignature) return + if (edges.length > BUNDLE_MAX_EDGES) { + clearBundles() + setStatus('bundleStatus', [ + { + level: 'warn', + text: `Bundling handles up to ${BUNDLE_MAX_EDGES.toLocaleString()} visible edges; this view shows ${edges.length.toLocaleString()}. Untick some channels or groups, or merge parallel edges.`, + }, + ]) + return + } + const run = ++bundleSeq + setStatus( + 'bundleStatus', + edges.length > 300 + ? [{ level: 'busy', text: `Bundling ${edges.length.toLocaleString()} edges…`, progress: 0 }] + : [] + ) + const list = edges.toArray() + const segs = list.map((e) => { + const a = e.source().position(), + b = e.target().position() + return { sx: a.x, sy: a.y, tx: b.x, ty: b.y } + }) + const result = await bundleAsync( + segs, + { + threshold: 0.85 - 0.5 * strength, + iterations: Math.round(30 + 60 * strength), + }, + (f) => { + if (run === bundleSeq && edges.length > 300) + setStatus('bundleStatus', [ + { level: 'busy', text: `Bundling ${edges.length.toLocaleString()} edges…`, progress: f }, + ]) + } + ) + if (run !== bundleSeq || !result || !bundlingOn()) return + bundleSignature = signature + lastBundle = { list, segs, result } + applyBundleResult() + setStatus('bundleStatus', []) +} + +/* edge thickness */ +export function applyEdgeWidth() { + const mode = document.getElementById('edgeWidthMode').value + document.getElementById('edgeWidthFixedRow').style.display = mode === 'fixed' ? 'block' : 'none' + document.getElementById('edgeWidthWeightRow').style.display = mode === 'weight' ? 'block' : 'none' + + if (mode === 'fixed') { + const w = parseFloat(document.getElementById('edgeWidthFixed').value) || 2 + document.getElementById('edgeWidthFixedValue').textContent = w + setStyle('edge', { width: w }) + setStyle('edge.highlighted', { width: w * 1.6 }) + } else { + const minW = parseFloat(document.getElementById('edgeWidthMin').value) || 1 + const maxW = parseFloat(document.getElementById('edgeWidthMax').value) || 8 + const knownWeights = cy + .edges() + .map((e) => e.data('weight')) + .filter((w) => typeof w === 'number' && !isNaN(w)) + const lo = knownWeights.length ? Math.min(...knownWeights) : 0 + const hi = knownWeights.length ? Math.max(...knownWeights) : 1 + cy.edges().forEach((e) => { + const w = e.data('weight') + const v = typeof w === 'number' && !isNaN(w) ? w : (lo + hi) / 2 + const width = hi === lo ? (minW + maxW) / 2 : minW + ((v - lo) / (hi - lo)) * (maxW - minW) + e.data('edgeWidth', width) + }) + setStyle('edge', { width: 'data(edgeWidth)' }) + setStyle('edge.highlighted', { width: 'data(edgeWidth)' }) + } + applyEdgeMerge() +} + +/* ============================================================ + VIEWPORT: unbounded zoom, but the network never leaves the frame + Zoom is effectively unlimited (see minZoom/maxZoom). After every pan + or zoom the view is nudged so that: + - a network smaller than the canvas stays entirely inside it, and + - a network larger than the canvas always covers it, so there is + no panning off into empty space. + The model-space bounding box of the visible nodes is cached and only + recomputed when nodes move, appear or disappear, so the check itself + is cheap enough to run on every viewport change. + ============================================================ */ +const VIEW_MARGIN = 40 + +var viewBoxCache = null + +var clampingViewport = false + +export function invalidateViewBox() { + viewBoxCache = null +} + +function visibleModelBox() { + if (!viewBoxCache) { + const nodes = cy.nodes(':visible') + viewBoxCache = nodes.length + ? nodes.boundingBox({ includeLabels: false, includeOverlays: false }) + : 'empty' + } + return viewBoxCache === 'empty' ? null : viewBoxCache +} + +function axisCorrection(lo, hi, size) { + const m = Math.min(VIEW_MARGIN, size / 4) + if (hi - lo <= size - 2 * m) { + if (lo < m) return m - lo + if (hi > size - m) return size - m - hi + return 0 + } + if (lo > m) return m - lo + if (hi < size - m) return size - m - hi + return 0 +} + +function clampViewport() { + if (clampingViewport || cy.animated()) return + const W = cy.width(), + H = cy.height() + if (!W || !H) return + const bb = visibleModelBox() + if (!bb) return + const z = cy.zoom(), + pan = cy.pan() + const dx = axisCorrection(bb.x1 * z + pan.x, bb.x2 * z + pan.x, W) + const dy = axisCorrection(bb.y1 * z + pan.y, bb.y2 * z + pan.y, H) + if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) { + clampingViewport = true + cy.panBy({ x: dx, y: dy }) + clampingViewport = false + } +} + +export function formatZoom(z) { + const pct = z * 100 + if (pct >= 1e5 || pct < 0.01) return pct.toExponential(0).replace('e+', 'e') + '%' + if (pct >= 100) return Math.round(pct).toLocaleString() + '%' + if (pct >= 1) return pct.toFixed(pct < 10 ? 1 : 0) + '%' + return pct.toPrecision(2) + '%' +} + +export function updateZoomReadout() { + document.getElementById('zoomLevel').textContent = formatZoom(cy.zoom()) +} + +function zoomAroundCenter(factor) { + const level = Math.min(cy.maxZoom(), Math.max(cy.minZoom(), cy.zoom() * factor)) + cy.zoom({ level, renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } }) +} + +export function fitView(eles, padding = 40) { + if (typeof net3d !== 'undefined' && net3d.active) { + fit3d(eles) + return + } + const target = eles && eles.length ? eles : cy.elements(':visible') + if (!target.length) return + cy.animate( + { fit: { eles: target, padding } }, + { duration: 400, easing: 'ease-out', complete: clampViewport } + ) +} + +/* ============================================================ + NODE SEARCH + Matches node names (and, optionally, attribute values) by substring, + exact name, prefix, suffix or regular expression. A regular expression + may be written plainly (^Rp[LS]\d+$) or with slashes and flags + (/^rp[ls]/i). Enter zooms to the matches; Escape clears. + ============================================================ */ +var searchMatches = null + +var searchTimer = null + +function attrValueStrings(attrs) { + const out = [] + Object.values(attrs || {}).forEach((v) => { + if (Array.isArray(v)) v.forEach((x) => out.push(formatAttrValue(x))) + else if (attrKind(v) !== 'empty') out.push(formatAttrValue(v)) + }) + return out +} + +// Returns { test(str) -> bool } or throws with a readable message. +function buildSearchMatcher(query, mode, matchCase) { + if (mode === 'regex') { + let pattern = query, + flags = '' + const slashed = query.match(/^\/(.*)\/([a-z]*)$/s) + if (slashed) { + pattern = slashed[1] + flags = slashed[2] + } + flags = flags.replace(/[gy]/g, '') // stateful flags would skip matches + if (!matchCase && !flags.includes('i')) flags += 'i' + let re + try { + re = new RegExp(pattern, flags) + } catch (err) { + // browsers phrase this as "Invalid regular expression: /pattern/flags: reason" + const reason = err.message.split(': ').pop() + throw new Error( + `Not a valid regular expression (${reason.charAt(0).toLowerCase() + reason.slice(1)}). Check brackets and escape special characters with \\.` + ) + } + return { test: (str) => re.test(str) } + } + const q = matchCase ? query : query.toLowerCase() + const norm = (str) => (matchCase ? str : str.toLowerCase()) + if (mode === 'exact') return { test: (str) => norm(str) === q } + if (mode === 'prefix') return { test: (str) => norm(str).startsWith(q) } + if (mode === 'suffix') return { test: (str) => norm(str).endsWith(q) } + return { test: (str) => norm(str).includes(q) } +} + +function setSearchStatus(text, isError) { + const el = document.getElementById('searchStatus') + el.textContent = text + el.classList.toggle('error', !!isError) + const badge = document.getElementById('searchBadge') + if (badge) badge.textContent = isError ? '!' : text + document.getElementById('search').setAttribute('aria-invalid', isError ? 'true' : 'false') +} + +function clearSearchHighlight() { + searchMatches = null + cy.elements().removeClass('dimmed highlighted') +} + +function runSearch() { + const input = document.getElementById('search') + const mode = document.getElementById('searchMode').value + // keep surrounding spaces in regex and exact modes; they may be intended + const query = mode === 'regex' || mode === 'exact' ? input.value : input.value.trim() + if (!query) { + clearSearchHighlight() + setSearchStatus('', false) + return + } + let matcher + try { + matcher = buildSearchMatcher(query, mode, document.getElementById('searchCase').checked) + } catch (err) { + clearSearchHighlight() + setSearchStatus(err.message, true) + return + } + const withAttrs = document.getElementById('searchAttrs').checked + const candidates = cy.nodes().filter((n) => !n.hasClass('hidden-group')) + const matched = candidates.filter( + (n) => + matcher.test(n.id()) || + (withAttrs && attrValueStrings(n.data('attrs')).some((v) => matcher.test(v))) + ) + searchMatches = matched + cy.batch(() => { + cy.elements().removeClass('highlighted').addClass('dimmed') + matched.removeClass('dimmed').addClass('highlighted') + matched.connectedEdges().removeClass('dimmed') + }) + setSearchStatus( + matched.length + ? `${matched.length.toLocaleString()} of ${candidates.length.toLocaleString()}` + : 'No matches', + false + ) +} + +function scheduleSearch() { + clearTimeout(searchTimer) + searchTimer = setTimeout(runSearch, cy.nodes().length > 2000 ? 200 : 60) +} + +/* node click -> info panel */ +export function hideInfo() { + document.getElementById('infopanel').classList.remove('show') +} + +function setInfoDesc(text) { + const el = document.getElementById('infoDesc') + el.textContent = text || '' + el.style.display = text ? 'block' : 'none' +} + +/* group inspector: reuses the node info panel for a whole group */ +export function showGroupInfo(g) { + hideEdgePopup() + const members = cy.nodes().filter((n) => (n.data('groups') || []).includes(g)) + const memberIds = new Set(members.map((n) => n.id())) + + let internalEdges = 0, + boundaryEdges = 0 + const internalPairs = new Set() + const internal = cy.collection() + cy.edges().forEach((e) => { + const s = e.data('source'), + t = e.data('target') + const inS = memberIds.has(s), + inT = memberIds.has(t) + if (inS && inT) { + internalEdges++ + internal.merge(e) + if (s !== t) internalPairs.add(s < t ? s + '|' + t : t + '|' + s) + } else if (inS || inT) { + boundaryEdges++ + } + }) + const k = members.length + const density = k > 1 ? internalPairs.size / ((k * (k - 1)) / 2) : 0 + + const overlaps = {} + members.forEach((n) => + (n.data('groups') || []).forEach((o) => { + if (o !== g) overlaps[o] = (overlaps[o] || 0) + 1 + }) + ) + const sharedCount = members.filter((n) => (n.data('groups') || []).length > 1).length + + cy.elements().removeClass('highlighted').addClass('dimmed') + members.removeClass('dimmed').addClass('highlighted') + internal.removeClass('dimmed') + + document.getElementById('infoDots').innerHTML = + `` + document.getElementById('infoName').textContent = groupLabel(g) + document.getElementById('infoExplore').hidden = true + document.getElementById('infoSub').textContent = + groupLabel(g) === g ? `group · ${k} members` : `group "${g}" · ${k} members` + setInfoDesc(groupDescription(g)) + + const stat = (label, value) => + `
    ${label}${value}
    ` + document.getElementById('infoMetrics').innerHTML = + stat('Shared', sharedCount) + + stat('Density', density.toFixed(3)) + + stat('Inside', internalEdges) + + stat('Outgoing', boundaryEdges) + + const custom = customGroupAttrs(g) + const currentShape = S.groupShapes[g] || 'ellipse' + document.getElementById('infoAttrs').innerHTML = + '
    Node shape for this group
    ' + + NODE_SHAPES.map( + ([key, name]) => + `` + ).join('') + + '
    ' + + (Object.keys(custom).length + ? '
    Group attributes
    ' + attrRowsHtml(custom, 'kvrow') + : '') + document.querySelectorAll('#infoAttrs .shape-opt').forEach((btn) => { + btn.addEventListener('click', () => { + setGroupShape(g, btn.dataset.shape) + document + .querySelectorAll('#infoAttrs .shape-opt') + .forEach((b) => b.setAttribute('aria-selected', b === btn ? 'true' : 'false')) + }) + }) + + const overlapEntries = Object.entries(overlaps).sort((a, b) => + byName(groupLabel(a[0]), groupLabel(b[0])) + ) + const connEl = document.getElementById('infoConns') + connEl.innerHTML = overlapEntries.length + ? '
    Shares members with
    ' + + overlapEntries + .map( + ([o, c]) => ` +
    + ${escapeHtml(groupLabel(o))}${c} ${c === 1 ? 'node' : 'nodes'} +
    ` + ) + .join('') + : '
    No members shared with other groups
    ' + connEl.querySelectorAll('.conn.jump').forEach((row) => { + const go = () => showGroupInfo(row.dataset.group) + row.addEventListener('click', go) + row.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault() + go() + } + }) + }) + + document.getElementById('infopanel').classList.add('show') +} + +/* edge click -> popup */ +export function hideEdgePopup() { + document.getElementById('edgePopup').classList.remove('show') +} + +/* ============================================================ + GROUP SHAPES + Each group can have a node shape. A node takes its group's shape only + when exactly one of its groups is currently ticked; nodes showing two + or more active groups stay circular so their pie slices remain + readable. Unticking groups therefore changes which shape a node shows. + ============================================================ */ +function polyPoints(n, r, rot = -Math.PI / 2, cx = 8, cy = 8) { + return Array.from({ length: n }, (_, i) => { + const a = rot + (2 * Math.PI * i) / n + return `${(cx + r * Math.cos(a)).toFixed(2)},${(cy + r * Math.sin(a)).toFixed(2)}` + }).join(' ') +} + +function starPoints() { + return Array.from({ length: 10 }, (_, i) => { + const a = -Math.PI / 2 + (Math.PI * i) / 5 + const r = i % 2 ? 3 : 7 + return `${(8 + r * Math.cos(a)).toFixed(2)},${(8.6 + r * Math.sin(a)).toFixed(2)}` + }).join(' ') +} + +// [cytoscape shape, name, svg markup inside a 16x16 box] +var NODE_SHAPES = [ + ['ellipse', 'Circle', ''], + ['rectangle', 'Square', ''], + ['round-rectangle', 'Rounded square', ''], + ['diamond', 'Diamond', ''], + ['triangle', 'Triangle', ''], + ['vee', 'Vee', ''], + ['pentagon', 'Pentagon', ``], + ['hexagon', 'Hexagon', ``], + ['octagon', 'Octagon', ``], + ['star', 'Star', ``], + ['rhomboid', 'Rhomboid', ''], + ['tag', 'Tag', ''], + ['concave-hexagon', 'Concave hexagon', ''], + ['barrel', 'Barrel', ''], +] + +export var SHAPE_BY_KEY = Object.fromEntries(NODE_SHAPES.map((s) => [s[0], s])) + +export function shapeSvg(key) { + const s = SHAPE_BY_KEY[key] || SHAPE_BY_KEY.ellipse + return `` +} + +// The shape a node should show given its currently active groups. +export function nodeShapeFor(effectiveGroups) { + return effectiveGroups.length === 1 ? S.groupShapes[effectiveGroups[0]] || 'ellipse' : 'ellipse' +} + +function setGroupShape(g, shape) { + if (!shape || shape === 'ellipse') delete S.groupShapes[g] + else S.groupShapes[g] = shape + cy.batch(() => cy.nodes().forEach((n) => refreshNodeVisual(n))) + buildGroupLegend() +} + +function assignDistinctShapes() { + const choices = NODE_SHAPES.map((s) => s[0]).filter((k) => k !== 'ellipse') + const groups = typeof filteredGroups === 'function' ? filteredGroups() : getUsedGroups() + groups.forEach((g, i) => { + S.groupShapes[g] = choices[i % choices.length] + }) + cy.batch(() => cy.nodes().forEach((n) => refreshNodeVisual(n))) + buildGroupLegend() +} + +function resetShapes() { + const groups = typeof filteredGroups === 'function' ? filteredGroups() : getUsedGroups() + groups.forEach((g) => { + delete S.groupShapes[g] + }) + cy.batch(() => cy.nodes().forEach((n) => refreshNodeVisual(n))) + buildGroupLegend() +} + +/* shape chooser popover, shared by all group rows */ +function positionShapePopover() { + const pop = document.getElementById('shapePopover') + if (pop.hidden || !shapePopoverAnchor) return + const r = shapePopoverAnchor.getBoundingClientRect() + const side = document.getElementById('sidebar').getBoundingClientRect() + if (!document.body.contains(shapePopoverAnchor) || r.bottom < side.top || r.top > side.bottom) { + closeShapePopover(false) + return + } + const w = pop.offsetWidth, + h = pop.offsetHeight + pop.style.left = Math.min(window.innerWidth - w - 8, Math.max(8, r.left)) + 'px' + pop.style.top = + (r.bottom + 4 + h > window.innerHeight ? Math.max(8, r.top - h - 4) : r.bottom + 4) + 'px' +} + +export let shapePopoverGroup = null + +let shapePopoverAnchor = null + +export function closeShapePopover(returnFocus) { + const pop = document.getElementById('shapePopover') + if (pop.hidden) return + pop.hidden = true + if (returnFocus && shapePopoverAnchor && document.body.contains(shapePopoverAnchor)) + shapePopoverAnchor.focus() + shapePopoverGroup = null + shapePopoverAnchor = null +} + +export function openShapePopover(g, anchor) { + const pop = document.getElementById('shapePopover') + shapePopoverGroup = g + shapePopoverAnchor = anchor + const current = S.groupShapes[g] || 'ellipse' + pop.innerHTML = + `
    Shape for ${escapeHtml(groupLabel(g))}
    ` + + NODE_SHAPES.map( + ([key, name]) => + `` + ).join('') + + '
    ' + pop.hidden = false + positionShapePopover() + const opts = [...pop.querySelectorAll('.shape-opt')] + opts.forEach((btn, i) => { + btn.addEventListener('click', () => { + setGroupShape(g, btn.dataset.shape) + closeShapePopover(false) + }) + btn.addEventListener('keydown', (e) => { + const cols = 7 + let j = i + if (e.key === 'ArrowRight') j = i + 1 + else if (e.key === 'ArrowLeft') j = i - 1 + else if (e.key === 'ArrowDown') j = i + cols + else if (e.key === 'ArrowUp') j = i - cols + else if (e.key === 'Escape') { + e.preventDefault() + closeShapePopover(true) + return + } else return + e.preventDefault() + opts[(j + opts.length) % opts.length].focus() + }) + }) + ;(pop.querySelector('[aria-selected="true"]') || opts[0]).focus() +} + +/* ============================================================ + VIEWS + A view is one network visualization with everything that belongs to + it: the data on the canvas, which library files it was built from, + every Display setting, node positions, zoom and pan, which groups and + channels are ticked, and group and channel colors. The theme and the + search options are app-wide and are not part of a view. + Only the active view lives on the canvas; switching saves it into its + record and rebuilds the other one from its record. + ============================================================ */ +const APP_WIDE_SETTINGS = new Set(['themeSelect', 'searchMode', 'searchCase', 'searchAttrs']) + +export const views = [] + +export function viewSettings() { + const cfg = getCurrentConfig() + APP_WIDE_SETTINGS.forEach((k) => delete cfg[k]) + return cfg +} + +// Writes settings into the controls without side effects; callers refresh. +export function setControls(cfg) { + CONFIG_FIELDS.forEach(({ id, prop }) => { + if (!cfg || !(id in cfg) || APP_WIDE_SETTINGS.has(id)) return + const el = document.getElementById(id) + if (!el) return + if (prop === 'checked') el.checked = !!cfg[id] + else el.value = cfg[id] + }) +} + +// The canvas contents in loadData's JSON shape (the same shape Save JSON writes). +export function snapshotData() { + if (!cy.nodes().length) return null + const nodeColors = {} + Object.entries(S.nodeColorMap).forEach(([g, c]) => { + if (g !== UNGROUPED) nodeColors[g] = c + }) + const edgeColors = {} + cy.edges().forEach((e) => { + edgeColors[e.data('type')] = e.data('color') + }) + return { + nodes: cy.nodes().map((n) => ({ + id: n.id(), + groups: (n.data('groups') || []).filter((g) => g !== UNGROUPED), + size: n.data('baseSize'), + ...(n.data('nodeColor') ? { color: n.data('nodeColor') } : {}), + ...(n.data('values') ? { values: n.data('values') } : {}), + ...(n.data('attrs') || {}), + })), + edges: cy.edges().map((e) => { + const out = { + id: e.id(), + source: e.data('source'), + target: e.data('target'), + type: e.data('type'), + } + if (typeof e.data('weight') === 'number') out.weight = e.data('weight') + if (e.data('directed')) out.directed = true + return { ...out, ...(e.data('attrs') || {}) } + }), + nodeColors, + edgeColors, + groupOrder: getUsedGroups().filter((g) => g !== UNGROUPED), + ...(Object.keys(S.groupShapes).length ? { groupShapes: { ...S.groupShapes } } : {}), + ...(Object.keys(S.groupAttrs).length ? { groupAttrs: { ...S.groupAttrs } } : {}), + ...(legendExtra ? { legendExtra } : {}), + } +} + +export function selectionFromKey(key) { + return { + networks: key && key.nets ? key.nets.split('|').filter(Boolean) : [], + annotation: key ? key.annotation || '' : '', + colors: key ? key.colors || '' : '', + } +} + +export function activeView() { + return views.find((v) => v.id === S.activeViewId) || null +} + +export function captureActiveView() { + const v = activeView() + if (!v) return + flushHistory() + cancelFrJobs() + S.layoutRunSeq++ + const positions = {} + cy.nodes().forEach((n) => { + const p = n.position() + positions[n.id()] = { x: p.x, y: p.y } + }) + const typeColors = {} + getUsedTypes().forEach((t) => { + if (EDGE_TYPES[t]) typeColors[t] = EDGE_TYPES[t].color + }) + v.data = snapshotData() + v.selection = { + networks: [...libSelection.networks], + annotation: libSelection.annotation, + colors: libSelection.colors, + } + v.state = { + config: viewSettings(), + positions, + zoom: cy.zoom(), + pan: { ...cy.pan() }, + activeGroups: [...S.activeGroups], + activeTypes: [...S.activeTypes], + groupColors: { ...S.nodeColorMap }, + typeColors, + libView: S.currentLibView ? { ...S.currentLibView } : null, + spread: parseFloat(document.getElementById('spreadSlider').value) || 0, + groupFilter: document.getElementById('groupFilter').value, + status: document.getElementById('normaStatus').innerHTML, + view3d: capture3d(), + } +} + +export function clearCanvas() { + bumpDataVersion() + cancelFrJobs() + S.layoutRunSeq++ + cy.elements().remove() + document.getElementById('edgeLegend').innerHTML = '' + document.getElementById('nodeGroupLegend').innerHTML = '' + Object.keys(EDGE_TYPES).forEach((k) => { + if (ORIGINAL_EDGE_TYPES[k]) EDGE_TYPES[k] = { ...ORIGINAL_EDGE_TYPES[k] } + else delete EDGE_TYPES[k] + }) + S.autoEdgeIdx = 0 + S.nodeColorMap = {} + S.groupShapes = {} + S.groupAttrs = {} + S.activeGroups = new Set() + invalidateFullMetrics() + buildAttrSchema() + renderAttrSchema() + buildGroupLegend() + updateStats() + hideInfo() + hideEdgePopup() + S.currentLibView = null + drawGroupHulls() +} + +// With reuseData, the network already on the canvas is kept and only the +// view's look and positions are put back (used by undo/redo). +export function restoreView(v, { reuseData = false } = {}) { + const st = v.state || {} + setControls(st.config || S.DEFAULT_VIEW_CONFIG) + document.getElementById('groupFilter').value = st.groupFilter || '' + if (reuseData) { + cancelFrJobs() + S.layoutRunSeq++ + setLayoutBusy(false) + } else if (v.data) { + loadData(v.data, { positions: st.positions || null }) + } else { + clearCanvas() + } + // some choices (e.g. an edge attribute as label text) only exist once + // the view's data is loaded, so apply the settings again + if (v.data) setControls(st.config || S.DEFAULT_VIEW_CONFIG) + refreshAllDerivedUI() + if (v.data) { + if (st.groupColors) { + S.nodeColorMap = {} + Object.entries(st.groupColors).forEach(([g, c]) => { + const sc = sanitizeColor(c) + if (sc) S.nodeColorMap[g] = sc + }) + } + if (st.typeColors) { + Object.entries(st.typeColors).forEach(([t, c]) => { + const sc = sanitizeColor(c) + if (!sc) return + if (EDGE_TYPES[t]) EDGE_TYPES[t].color = sc + else EDGE_TYPES[t] = { color: sc, label: t } + }) + cy.edges().forEach((e) => { + const m = EDGE_TYPES[e.data('type')] + if (m) e.data('color', m.color) + }) + } + const used = new Set(getUsedGroups()) + if (st.activeGroups) S.activeGroups = new Set(st.activeGroups.filter((g) => used.has(g))) + const usedTypes = getUsedTypes() + if (st.activeTypes) S.activeTypes = new Set(st.activeTypes.filter((t) => usedTypes.includes(t))) + buildGroupLegend() + buildLegend(usedTypes) + applyGroupVisibility() + applyTypeVisibility() + if (reuseData && st.positions) { + cy.batch(() => + cy.nodes().forEach((n) => { + const p = st.positions[n.id()] + if (p) n.position(p) + }) + ) + invalidateViewBox() + } + if (!reuseData && st.positions && typeof st.zoom === 'number') { + cy.viewport({ zoom: st.zoom, pan: st.pan }) + } + } + restore3d(st.view3d) + spreadApplied = st.spread || 0 + spreadBase = st.spreadBase ? { ...st.spreadBase } : null + document.getElementById('spreadSlider').value = spreadApplied + updateSpreadReadout() + + const sel = v.selection || { networks: [], annotation: '', colors: '' } + libSelection.networks = new Set(sel.networks.filter((id) => libEntry('network', id))) + libSelection.annotation = libEntry('annotation', sel.annotation) ? sel.annotation : '' + libSelection.colors = libEntry('colors', sel.colors) ? sel.colors : '' + S.currentLibView = st.libView || null + renderLibraryLists() + if (!reuseData) document.getElementById('normaStatus').innerHTML = st.status || '' + updateStats() + invalidateViewBox() + drawGroupHulls() + cy.elements().removeClass('dimmed highlighted') + if (document.getElementById('search').value) runSearch() + if (v.needsRefresh && !reuseData) refreshAfterDelete(v) +} + +export function uniqueViewName(base) { + const taken = new Set(views.map((v) => v.name)) + if (!taken.has(base)) return base + let n = 2 + while (taken.has(`${base} ${n}`)) n++ + return `${base} ${n}` +} + +export function renderViewBar() { + const sel = document.getElementById('viewSelect') + sel.innerHTML = '' + views.forEach((v) => sel.add(new Option(v.name, v.id))) + sel.value = S.activeViewId + document.getElementById('viewCount').textContent = + views.length > 1 ? `${views.length} views open` : '' + if (typeof updateContextInfo === 'function') updateContextInfo() +} + +// Creates a view and makes it active. With `copy`, it starts as a copy of +// the current view; otherwise it starts empty with default settings. +export function createView(name, { copy = false } = {}) { + captureActiveView() + const from = activeView() + const v = { + id: 'view' + ++S.viewSeq, + name: uniqueViewName(name || 'Untitled view'), + autoName: !name || name === 'Untitled view', + data: copy && from ? from.data : null, + selection: + copy && from + ? { ...from.selection, networks: [...from.selection.networks] } + : { networks: [], annotation: '', colors: '' }, + state: + copy && from + ? JSON.parse(JSON.stringify(from.state)) + : { config: { ...S.DEFAULT_VIEW_CONFIG } }, + } + views.push(v) + S.activeViewId = v.id + S.historySuspended++ + try { + restoreView(v) + } finally { + S.historySuspended-- + } + setHistoryBaseline() + renderViewBar() + return v +} + +export function switchView(id) { + if (id === S.activeViewId || !views.some((v) => v.id === id)) return + captureActiveView() + S.activeViewId = id + S.historySuspended++ + try { + restoreView(activeView()) + } finally { + S.historySuspended-- + } + S.dataCache = { version: dataVersion, data: activeView().data } + setHistoryBaseline() + renderViewBar() + updateUndoButtons() +} + +export function closeActiveView() { + const idx = views.findIndex((v) => v.id === S.activeViewId) + if (idx < 0) return + views.splice(idx, 1) + if (!views.length) { + S.activeViewId = null + createView('Untitled view') + return + } + S.activeViewId = views[Math.max(0, idx - 1)].id + S.historyGestureOpen = false + clearTimeout(historyTimer) + S.historySuspended++ + try { + restoreView(activeView()) + } finally { + S.historySuspended-- + } + S.dataCache = { version: dataVersion, data: activeView().data } + setHistoryBaseline() + renderViewBar() + updateUndoButtons() +} + +// Runs `load` in a view of its own. An untouched empty view is reused +// rather than leaving it behind. +export function openInNewView(name, load) { + const cur = activeView() + if (cur && !cy.nodes().length && cur.autoName !== false && !cur.data) { + cur.name = uniqueViewName(name) + cur.autoName = false + setControls(S.DEFAULT_VIEW_CONFIG) + refreshAllDerivedUI() + } else { + createView(name) + activeView().autoName = false + } + S.historySuspended++ + try { + load() + } finally { + S.historySuspended-- + } + setHistoryBaseline() + renderViewBar() + updateUndoButtons() +} + +export function nameForSelection() { + const nets = selectedNetworks() + const ann = libEntry('annotation', libSelection.annotation) + const base = + nets.length === 1 ? nets[0].name : nets.length ? `${nets.length} networks` : 'Untitled view' + return ann ? `${base}, ${ann.name}` : base +} + +// page wiring, run by main.ts in the original order +export function init() { + document.getElementById('btnProfile').addEventListener('click', runProfiler) + + document.getElementById('btnProfileTsv').addEventListener('click', profilerTsv) + + // statistic definitions in the Help tab come from the same table + document.getElementById('helpStatsRows').innerHTML = PROFILE_STATS.map( + (st) => `${escapeHtml(st.label)}${escapeHtml(st.desc)}` + ).join('') + + ;[ + 'showNodeLabels', + 'showEdgeLabels', + 'labelPosition', + 'labelScaleWithNode', + 'edgeLabelContent', + 'edgeLabelOrientation', + 'labelMinScreenSize', + ].forEach((id) => { + document.getElementById(id).addEventListener('change', updateLabelStyle) + }) + + ;['nodeLabelSize', 'edgeLabelSize'].forEach((id) => { + document.getElementById(id).addEventListener('input', updateLabelStyle) + }) + + document.getElementById('edgeOpacity').addEventListener('input', applyEdgeOpacity) + + document.getElementById('spreadSlider').addEventListener('input', queueSpread) + + document.getElementById('spreadSlider').addEventListener('change', () => { + const v = parseFloat(document.getElementById('spreadSlider').value) || 0 + if (spreadFrame) { + cancelAnimationFrame(spreadFrame) + spreadFrame = 0 + } + if (v !== spreadApplied || cy.nodes().length > SPREAD_LIVE_MAX) applySpread() + scheduleBundling() + }) + + document.getElementById('spreadSlider').addEventListener('dblclick', () => { + document.getElementById('spreadSlider').value = 0 + applySpread() + }) + + cy.on('layoutstart', resetSpread) + + document.getElementById('edgeCurveStyle').addEventListener('change', applyEdgeCurveStyle) + + document.getElementById('edgeCurvature').addEventListener('input', applyEdgeCurveStyle) + + document.getElementById('bundleStrength').addEventListener('input', () => { + document.getElementById('bundleStrengthValue').textContent = + document.getElementById('bundleStrength').value + '%' + }) + + document.getElementById('bundleStrength').addEventListener('change', () => scheduleBundling(true)) + + cy.on('layoutstop dragfree', () => scheduleBundling()) + + document.getElementById('edgeMergeMode').addEventListener('change', applyEdgeMerge) + + document.getElementById('edgeWidthMode').addEventListener('change', applyEdgeWidth) + + document.getElementById('edgeWidthFixed').addEventListener('input', applyEdgeWidth) + + document.getElementById('edgeWidthMin').addEventListener('change', applyEdgeWidth) + + document.getElementById('edgeWidthMax').addEventListener('change', applyEdgeWidth) + + /* node fill mode */ + document.getElementById('nodeFillSelect').addEventListener('change', () => { + if (nodeFillMode() === 'values') applyValueColors() + cy.batch(() => cy.nodes().forEach((n) => refreshNodeVisual(n))) + scheduleLegend() + updateContextInfo() + }) + + /* color palettes */ + document + .getElementById('nodePaletteSelect') + .addEventListener('change', (e) => applyNodePalette(e.target.value)) + + document + .getElementById('edgePaletteSelect') + .addEventListener('change', (e) => applyEdgePalette(e.target.value)) + + /* node size by centrality */ + ;['sizeMetric', 'sizeChannelOnly'].forEach((id) => { + document.getElementById(id).addEventListener('change', applyNodeSizing) + }) + + document.getElementById('sizeMin').addEventListener('change', applyNodeSizing) + + document.getElementById('sizeMax').addEventListener('change', applyNodeSizing) + + document.getElementById('nodeScale').addEventListener('input', applyNodeSizing) + + cy.on('viewport', () => { + clampViewport() + updateZoomReadout() + }) + + cy.on('position add remove', invalidateViewBox) + + cy.on('style data', 'node', invalidateViewBox) + + cy.on('dragfree layoutstop', () => { + invalidateViewBox() + clampViewport() + }) + + window.addEventListener('resize', () => { + invalidateViewBox() + clampViewport() + }) + + document.getElementById('zoomIn').addEventListener('click', () => zoomAroundCenter(1.25)) + + document.getElementById('zoomOut').addEventListener('click', () => zoomAroundCenter(0.8)) + + document.getElementById('zoomFit').addEventListener('click', () => fitView()) + + document.getElementById('search').addEventListener('input', scheduleSearch) + + ;['searchMode', 'searchCase', 'searchAttrs'].forEach((id) => { + document.getElementById(id).addEventListener('change', runSearch) + }) + + document.getElementById('search').addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault() + clearTimeout(searchTimer) + runSearch() + if (searchMatches && searchMatches.length) fitView(searchMatches, 80) + } else if (e.key === 'Escape') { + e.target.value = '' + runSearch() + } + }) + + document.getElementById('searchMode').addEventListener('change', (e) => { + document.getElementById('search').placeholder = + { + contains: 'Find nodes', + exact: 'Exact node name', + prefix: 'Name starts with…', + suffix: 'Name ends with…', + regex: 'e.g. ^Rp[LS]\\d+ or /kinase$/i', + }[e.target.value] || 'Find nodes' + }) + + cy.on('tap', 'node', (evt) => { + const node = evt.target + hideEdgePopup() + cy.elements().removeClass('highlighted') + cy.elements().addClass('dimmed') + node.removeClass('dimmed').addClass('highlighted') + const neigh = node.closedNeighborhood() + neigh.removeClass('dimmed') + node.connectedEdges().addClass('highlighted') + + const panel = document.getElementById('infopanel') + const groupsArr = node.data('groups') || [UNGROUPED] + document.getElementById('infoDots').innerHTML = groupsArr + .map((g) => ``) + .join('') + document.getElementById('infoName').textContent = node.data('id') + const groupLinks = groupsArr + .map( + (g) => + `` + ) + .join(' + ') + const subEl = document.getElementById('infoSub') + subEl.innerHTML = `${groupLinks} · ${node.connectedEdges().length} edges · ${node.neighborhood('node').length} neighbors` + subEl + .querySelectorAll('.glink') + .forEach((b) => b.addEventListener('click', () => showGroupInfo(b.dataset.group))) + setInfoDesc('') + + const nid = node.id() + const m = ensureFullMetrics() + const nodeDirected = node.connectedEdges().some((e) => edgeIsDirected(e)) + document.getElementById('infoMetrics').innerHTML = ` +
    Degree${m.degree[nid] || 0}
    + ${nodeDirected ? `
    In / out${m.indegree[nid] || 0} / ${m.outdegree[nid] || 0}
    ` : ''} +
    Betweenness${(m.betweenness[nid] || 0).toFixed(3)}
    +
    Closeness${(m.closeness[nid] || 0).toFixed(3)}
    +
    Clustering${(m.clustering[nid] || 0).toFixed(3)}
    + ` + + const attrs = { + ...(node.data('nodeColor') ? { color: node.data('nodeColor') } : {}), + ...(node.data('attrs') || {}), + } + document.getElementById('infoAttrs').innerHTML = Object.keys(attrs).length + ? '
    Node attributes
    ' + attrRowsHtml(attrs, 'kvrow') + : '' + + const connsByNeighbor = {} + node.connectedEdges().forEach((e) => { + const outgoing = e.source().id() === node.id() + const other = outgoing ? e.target().id() : e.source().id() + connsByNeighbor[other] = connsByNeighbor[other] || [] + const meta = EDGE_TYPES[e.data('type')] || { label: e.data('type') } + const arrow = edgeIsDirected(e) ? (outgoing ? '→ ' : '← ') : '' + connsByNeighbor[other].push(arrow + meta.label) + }) + const connEl = document.getElementById('infoConns') + connEl.innerHTML = + (nodeDirected + ? '
    → outgoing · ← incoming
    ' + : '') + + Object.entries(connsByNeighbor) + .sort((a, b) => byName(a[0], b[0])) + .map( + ([other, types]) => ` +
    ${escapeHtml(other)}${escapeHtml(types.join(', '))}
    + ` + ) + .join('') + + S.infoNodeId = node.id() + document.getElementById('infoExplore').hidden = false + panel.classList.add('show') + }) + + cy.on('tap', 'edge', (evt) => { + const edge = evt.target + hideInfo() + cy.elements().removeClass('highlighted') + cy.elements().addClass('dimmed') + edge.removeClass('dimmed').addClass('highlighted') + edge.connectedNodes().removeClass('dimmed') + + const meta = EDGE_TYPES[edge.data('type')] || { + color: edge.data('color'), + label: edge.data('type'), + } + document.getElementById('epDot').style.background = meta.color + document.getElementById('epType').textContent = meta.label + document.getElementById('epSource').textContent = edge.data('source') + document.getElementById('epTarget').textContent = edge.data('target') + document.getElementById('epId').textContent = edge.data('id') + document.getElementById('epDirection').textContent = edgeIsDirected(edge) + ? `${edge.data('source')} → ${edge.data('target')}` + : 'undirected' + + const weight = edge.data('weight') + const attrs = edge.data('attrs') || {} + const rows = [] + if (typeof weight === 'number') + rows.push(`
    weight${weight}
    `) + rows.push(attrRowsHtml(attrs, 'eprow')) + const merged = edge.data('mergedWith') + if (merged && merged.length > 1) { + rows.push( + `
    Merged channels${merged.length}
    ` + ) + merged.forEach((id) => { + const other = cy.getElementById(id) + if (!other.length) return + const m = EDGE_TYPES[other.data('type')] || { label: other.data('type') } + const w = other.data('weight') + rows.push( + `
    ${escapeHtml(m.label)}${typeof w === 'number' ? escapeHtml(formatAttrValue(w)) : ''}
    ` + ) + }) + } + document.getElementById('epExtra').innerHTML = rows.join('') + + const canvasRect = document.getElementById('canvas').getBoundingClientRect() + const pos = evt.renderedPosition || { x: canvasRect.width / 2, y: canvasRect.height / 2 } + const popup = document.getElementById('edgePopup') + const left = Math.min(pos.x + 14, canvasRect.width - 266) + const top = Math.min(pos.y + 14, canvasRect.height - 180) + popup.style.left = Math.max(10, left) + 'px' + popup.style.top = Math.max(10, top) + 'px' + popup.classList.add('show') + }) + + document.getElementById('edgePopupClose').addEventListener('click', () => { + cy.elements().removeClass('dimmed highlighted') + hideEdgePopup() + }) + + cy.on('tap', (evt) => { + if (evt.target === cy) { + cy.elements().removeClass('dimmed highlighted') + hideInfo() + hideEdgePopup() + } + }) + + /* double-click to recenter */ + cy.on('dbltap', (evt) => { + if (evt.target === cy) { + fitView() + } + }) + + cy.on('dbltap', 'node', (evt) => { + cy.animate( + { center: { eles: evt.target } }, + { duration: 300, easing: 'ease-out', complete: clampViewport } + ) + }) + + document.getElementById('infoClose').addEventListener('click', () => { + cy.elements().removeClass('dimmed highlighted') + hideInfo() + }) + + /* theme */ + document + .getElementById('themeSelect') + .addEventListener('change', (e) => applyTheme(e.target.value)) + + /* group highlighting (hulls / fog clouds) */ + cy.on('render', drawGroupHulls) + + // The WebGL renderer does not emit 'render', so the shading follows the + // changes that move or restyle nodes instead (once per frame). + if (WEBGL_ACTIVE) { + let hullQueued = false + const queueHulls = () => { + if (hullQueued) return + hullQueued = true + requestAnimationFrame(() => { + hullQueued = false + drawGroupHulls() + }) + } + cy.on('viewport position add remove data style class resize layoutstop', queueHulls) + } + + document.getElementById('showGroupHulls').addEventListener('change', (e) => { + document.getElementById('hullControls').style.display = e.target.checked ? 'block' : 'none' + drawGroupHulls() + }) + + document.getElementById('hullStyle').addEventListener('change', drawGroupHulls) + + document.getElementById('hullOpacity').addEventListener('input', (e) => { + document.getElementById('hullOpacityValue').textContent = e.target.value + drawGroupHulls() + }) + + document.addEventListener('mousedown', (e) => { + const pop = document.getElementById('shapePopover') + if (!pop.hidden && !pop.contains(e.target) && !e.target.closest('.gshape')) + closeShapePopover(false) + }) + + document.getElementById('sidebar').addEventListener('scroll', positionShapePopover) + + window.addEventListener('resize', positionShapePopover) + + document.getElementById('btnShapesAssign').addEventListener('click', assignDistinctShapes) + + document.getElementById('btnShapesReset').addEventListener('click', resetShapes) +} diff --git a/frontend/src/recording.ts b/frontend/src/recording.ts new file mode 100644 index 0000000..79dbb8c --- /dev/null +++ b/frontend/src/recording.ts @@ -0,0 +1,291 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { S } from './state' +import { UNGROUPED, escapeHtml, getUsedGroups, nodeFillMode } from './network_state' +import { activeView, fitView } from './profiler' +import { currentTab } from './wiring' +import { cy } from './cy' +import { + historyTimer, + noteChange, + redo, + scheduleSettle, + setHistoryBaseline, + undo, +} from './demo_downloads' +import { libEntry, libSelection, normaLibrary, renderLibraryLists } from './layouts/controls' +import { refreshLibraryView } from './library' +import { sortedByName } from './hulls' +import { updateStats } from './metrics' +import { updateStringUI } from './string/ui_state' + +/* ---------- what gets recorded ---------- */ +const panelDisplayEl = document.getElementById('panelDisplay') + +function typingInField(target) { + if (!target) return false + if (target.isContentEditable) return true + if (target.tagName === 'TEXTAREA') return true + if (target.tagName === 'INPUT') { + const t = (target.type || '').toLowerCase() + return !['checkbox', 'radio', 'range', 'button', 'color', 'file'].includes(t) + } + return false +} + +/* ============================================================ + MULTIPLE SELECTION + Shift/Ctrl/Cmd-click adds or removes nodes, Shift-drag on the + background draws a selection box, Ctrl/Cmd+A selects every visible + node, Escape clears. Selected nodes get a thick border and a halo; + dragging one moves them all. + ============================================================ */ +let selectionUpdateQueued = false + +function updateSelectionBar() { + selectionUpdateQueued = false + const nodes = cy.nodes(':selected').length + const edges = cy.edges(':selected').length + const bar = document.getElementById('selectionBar') + bar.hidden = nodes + edges < 2 && !(nodes === 1 && edges > 0) + if (bar.hidden) return + const parts = [] + if (nodes) parts.push(`${nodes.toLocaleString()} node${nodes === 1 ? '' : 's'}`) + if (edges) parts.push(`${edges.toLocaleString()} edge${edges === 1 ? '' : 's'}`) + document.getElementById('selectionText').textContent = `${parts.join(' and ')} selected` + document.getElementById('btnSelPaths').hidden = nodes !== 2 + document.getElementById('btnSelNeighbours').hidden = !nodes +} + +/* ============================================================ + CONTEXT LINE: what the current view shows + Network, grouping and node colors of the active view, shown next to + the page tabs so they stay visible on every page. + ============================================================ */ +function contextChip(tint, label, value, title) { + return `${escapeHtml(label)}${escapeHtml(value)}` +} + +// Grouping choices for the current view: every annotation in the file +// library when the view was built from files, otherwise the groups that +// came with the data. +let groupingSelectKey = '' + +export function renderGroupingSelect() { + const sel = document.getElementById('viewGrouping') + if (!sel) return + const lib = S.currentLibView + const groupCount = getUsedGroups().filter((g) => g !== UNGROUPED).length + let options, value, disabled, title + if (lib) { + options = [['', 'None']].concat( + sortedByName(normaLibrary.annotation, (e) => e.name).map((e) => [ + e.id, + `${e.name} (${e.parsed.summary})`, + ]) + ) + value = lib.annotation || '' + disabled = false + title = 'Group the nodes of this view by another annotation. Node positions are kept.' + } else if (cy.nodes().length) { + options = [ + ['data', groupCount ? `From the data (${groupCount} groups)` : 'No groups in the data'], + ] + value = 'data' + disabled = true + title = + 'This view was opened from a built-in demo or a JSON file, so its groups come with the data. Views opened from network files can switch annotations here.' + } else { + options = [['', 'No network shown']] + value = '' + disabled = true + title = 'Open an example or files first.' + } + const key = JSON.stringify([options, value, disabled]) + if (key === groupingSelectKey) { + updateGroupingDelete() + return + } + groupingSelectKey = key + sel.innerHTML = '' + options.forEach(([v, t]) => sel.add(new Option(t, v))) + sel.value = value + sel.disabled = disabled + sel.title = title + updateGroupingDelete() +} + +function updateGroupingDelete() { + const btn = document.getElementById('btnGroupingDelete') + if (!btn) return + const id = S.currentLibView && S.currentLibView.annotation + const e = id && libEntry('annotation', id) + btn.disabled = !e + btn.title = e ? `Completely remove the grouping "${e.name}"` : 'No grouping file to remove' +} + +export function updateContextInfo() { + renderGroupingSelect() + if (typeof updateStringUI === 'function') updateStringUI() + const el = document.getElementById('contextInfo') + if (!el) return + const v = typeof activeView === 'function' ? activeView() : null + if (!cy.nodes().length) { + el.innerHTML = + 'Empty view. Open an example or files in the Upload Data tab, or fetch a network under Database importers.' + return + } + const lib = S.currentLibView + const groups = getUsedGroups().filter((g) => g !== UNGROUPED) + let network, grouping, colors + if (lib) { + const nets = lib.nets + .split('|') + .map((id) => libEntry('network', id)) + .filter(Boolean) + .map((e) => e.name) + network = nets.length ? nets.join(' + ') : 'network files' + const ann = lib.annotation ? libEntry('annotation', lib.annotation) : null + grouping = ann ? ann.name : 'none' + const col = lib.colors ? libEntry('colors', lib.colors) : null + const fill = nodeFillMode() + colors = + fill === 'groups' + ? groups.length + ? 'group colors' + : 'default' + : fill === 'values' + ? `${col ? col.name : 'values'}: ${document.getElementById('valueColumn').value}` + : col + ? col.name + : 'from the data' + } else { + network = v ? v.name : 'current data' + grouping = groups.length ? 'from the data' : 'none' + const hasOwn = cy.nodes().some((n) => n.data('nodeColor')) + const fill = nodeFillMode() + colors = + fill === 'values' + ? `values: ${document.getElementById('valueColumn').value}` + : fill === 'data' && hasOwn + ? 'from the data' + : groups.length + ? 'group colors' + : 'default' + } + const groupText = groups.length + ? `${grouping} (${groups.length.toLocaleString()} groups)` + : grouping + el.innerHTML = + contextChip('data', 'Network', network) + + contextChip('groups', 'Grouping', groupText) + + contextChip('colors', 'Colors', colors) +} + +// page wiring, run by main.ts in the original order +export function init() { + ;['input', 'change'].forEach((type) => { + panelDisplayEl.addEventListener( + type, + (e) => { + if (e.target.id === 'groupFilter') return + noteChange() + }, + true + ) + }) + + panelDisplayEl.addEventListener( + 'click', + (e) => { + const btn = e.target.closest('button') + if ( + !btn || + btn.disabled || + btn.classList.contains('helplink') || + btn.classList.contains('ginfo') || + btn.id === 'btnFit' + ) + return + if (btn.getAttribute('role') === 'radio') return // switching layout mode alone moves nothing + noteChange() + }, + true + ) + + ;['btnRefreshView', 'btnClear'].forEach((id) => { + document.getElementById(id).addEventListener('click', noteChange, true) + }) + + document.getElementById('configFileInput').addEventListener('change', noteChange, true) + + cy.on('dragfree', 'node', noteChange) + + cy.on('layoutstop', () => { + S.cyLayoutsRunning = Math.max(0, S.cyLayoutsRunning - 1) + if (S.historySuspended) return + // a layout the user started belongs to the open entry; one that ran by + // itself (e.g. when data loads) just moves the starting point + if (historyTimer || S.historyGestureOpen) scheduleSettle() + else setHistoryBaseline() + }) + + document.getElementById('btnUndo').addEventListener('click', undo) + + document.getElementById('btnRedo').addEventListener('click', redo) + + document.addEventListener('keydown', (e) => { + if (typingInField(e.target)) return + const mod = e.ctrlKey || e.metaKey + const key = e.key.toLowerCase() + if (mod && key === 'z') { + e.preventDefault() + if (e.shiftKey) redo() + else undo() + } else if (mod && key === 'y') { + e.preventDefault() + redo() + } else if ( + (currentTab === 'network' || currentTab === 'network3d') && + mod && + key === 'a' && + !['SELECT'].includes(e.target.tagName) + ) { + e.preventDefault() + cy.nodes(':visible').select() + } else if ( + (currentTab === 'network' || currentTab === 'network3d') && + key === 'escape' && + cy.$(':selected').length + ) { + cy.$(':selected').unselect() + } + }) + + cy.on('select unselect remove', () => { + if (selectionUpdateQueued) return + selectionUpdateQueued = true + requestAnimationFrame(updateSelectionBar) + }) + + document + .getElementById('btnSelClear') + .addEventListener('click', () => cy.$(':selected').unselect()) + + document.getElementById('btnSelFit').addEventListener('click', () => { + const sel = cy.$(':selected') + if (sel.length) + fitView(sel.union(sel.connectedNodes ? sel.connectedNodes() : cy.collection()), 60) + }) + + document.getElementById('viewGrouping').addEventListener('change', (e) => { + if (!S.currentLibView) return + libSelection.networks = new Set(S.currentLibView.nets.split('|').filter(Boolean)) + libSelection.annotation = e.target.value + if (S.currentLibView.colors !== undefined) libSelection.colors = S.currentLibView.colors + renderLibraryLists() + refreshLibraryView() + groupingSelectKey = '' + updateStats() + }) +} diff --git a/frontend/src/sample_data.ts b/frontend/src/sample_data.ts new file mode 100644 index 0000000..c9736e5 --- /dev/null +++ b/frontend/src/sample_data.ts @@ -0,0 +1,546 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +/* ---------- sample data, modeled on the STRING trp-operon screenshot ---------- */ +function buildSampleData() { + // seeded, so the demo (and its download in Help) is the same every time + const rand = mulberry32(3) + const hub = ['trpA', 'trpB', 'trpC', 'trpD', 'trpE', 'pabB', 'tyrA'] + const secondGroup = { + trpA: 'indole-pathway', + pabB: 'folate-pathway', + tyrA: 'tyrosine-pathway', + } + // node attributes: arbitrary metadata about an individual node, shown in + // its info popup (distinct from group attributes below, which describe + // the *group* rather than any one member). + const geneFunction = { + trpA: 'Tryptophan synthase, alpha subunit', + trpB: 'Tryptophan synthase, beta subunit', + trpC: 'Indole-3-glycerol-phosphate synthase', + trpD: 'Anthranilate phosphoribosyltransferase', + trpE: 'Anthranilate synthase component I', + pabB: 'Aminodeoxychorismate synthase component I', + tyrA: 'Chorismate mutase / prephenate dehydrogenase', + lgt: 'Prolipoprotein diacylglyceryl transferase', + putP: 'Sodium/proline symporter', + tnaA: 'Tryptophanase', + glyA: 'Serine hydroxymethyltransferase', + } + const ecNumber = { + trpA: '4.2.1.20', + trpB: '4.2.1.20', + trpC: '4.1.1.48', + trpD: '2.4.2.18', + trpE: '4.1.3.27', + tnaA: '4.1.99.1', + glyA: '2.1.2.1', + } + const nodes = [ + ...hub.map((id) => ({ + id, + groups: secondGroup[id] ? ['core', secondGroup[id]] : ['core'], + size: id === 'trpA' ? 54 : 46, + function: geneFunction[id], + ...(ecNumber[id] ? { ecNumber: ecNumber[id] } : {}), + })), + { id: 'lgt', groups: ['peripheral'], size: 40, function: geneFunction.lgt }, + { id: 'putP', groups: ['peripheral', 'transport'], size: 40, function: geneFunction.putP }, + { + id: 'tnaA', + groups: ['peripheral', 'tryptophan-catabolism'], + size: 40, + function: geneFunction.tnaA, + ecNumber: ecNumber.tnaA, + }, + { + id: 'glyA', + groups: ['peripheral'], + size: 40, + function: geneFunction.glyA, + ecNumber: ecNumber.glyA, + }, + ] + + // group attributes: metadata about each group as a whole -- a nicer + // display label and a short description -- shown in the Node groups + // legend (as the row's label and hover tooltip) and in a node's info + // popup subtitle. + const groupAttrs = { + core: { label: 'Core operon', description: 'Tryptophan biosynthesis operon genes (trpA-E)' }, + 'indole-pathway': { + label: 'Indole pathway', + description: 'Shared branch point with indole/tryptophan interconversion', + }, + 'folate-pathway': { + label: 'Folate pathway', + description: 'para-aminobenzoate / folate biosynthesis branch', + }, + 'tyrosine-pathway': { + label: 'Tyrosine pathway', + description: 'Aromatic amino acid biosynthesis branch', + }, + peripheral: { + label: 'Peripheral genes', + description: 'Functionally related genes outside the core operon', + }, + transport: { label: 'Transport', description: 'Membrane transport function' }, + 'tryptophan-catabolism': { + label: 'Trp catabolism', + description: 'Tryptophan breakdown pathway', + }, + } + + const edges = [] + let eid = 0 + const pairTypes = [ + 'neighborhood', + 'cooccurrence', + 'coexpression', + 'experiments', + 'textmining', + 'database', + ] + + // dense, richly multi-edged core cluster + for (let i = 0; i < hub.length; i++) { + for (let j = i + 1; j < hub.length; j++) { + const a = hub[i], + b = hub[j] + const n = 3 + Math.floor(rand() * 3) // 3-5 parallel edges + const shuffled = [...pairTypes].sort(() => rand() - 0.5).slice(0, n) + shuffled.forEach((t) => { + // edge attributes: arbitrary metadata about an individual edge + // (evidenceCount here), shown in its popup alongside weight/type. + edges.push({ + id: 'e' + eid++, + source: a, + target: b, + type: t, + weight: +(0.4 + rand() * 0.59).toFixed(2), + evidenceCount: 1 + Math.floor(rand() * 14), + }) + }) + } + } + + // peripheral, sparser connections + edges.push({ id: 'e' + eid++, source: 'lgt', target: 'trpA', type: 'coexpression' }) + edges.push({ id: 'e' + eid++, source: 'lgt', target: 'trpA', type: 'textmining' }) + + edges.push({ id: 'e' + eid++, source: 'putP', target: 'trpA', type: 'database' }) + edges.push({ id: 'e' + eid++, source: 'putP', target: 'pabB', type: 'database' }) + edges.push({ id: 'e' + eid++, source: 'putP', target: 'tyrA', type: 'database' }) + + edges.push({ id: 'e' + eid++, source: 'tnaA', target: 'trpB', type: 'database' }) + edges.push({ id: 'e' + eid++, source: 'tnaA', target: 'trpB', type: 'cooccurrence' }) + edges.push({ id: 'e' + eid++, source: 'tnaA', target: 'trpA', type: 'database' }) + + edges.push({ id: 'e' + eid++, source: 'glyA', target: 'trpB', type: 'database' }) + edges.push({ id: 'e' + eid++, source: 'glyA', target: 'trpB', type: 'cooccurrence' }) + + return { nodes, edges, groupAttrs } +} + +// Procedurally generates a plausible multi-group, multi-channel network of a +// given size, for demonstrating the tool across a range of scales (a handful +// of nodes up to several thousand). Group count grows with network size; +// edges are a random spanning backbone (guarantees connectivity) plus extra +// random links for density, each carrying a random channel and weight. +export function generateRandomNetwork(n, opts = {}) { + // seeded, so each demo (and its download in Help) is the same every time + const rand = mulberry32(opts.seed ?? n * 7919 + 17) + const INTERACTIONS = ['activates', 'inhibits', 'binds', 'phosphorylates', 'regulates'] + const groupCount = opts.groupCount || Math.max(2, Math.min(16, Math.round(Math.sqrt(n)))) + const groupNames = Array.from({ length: groupCount }, (_, i) => 'group-' + (i + 1)) + const edgeTypePool = [ + 'neighborhood', + 'cooccurrence', + 'coexpression', + 'experiments', + 'database', + 'textmining', + ] + + const nodes = [] + for (let i = 0; i < n; i++) { + const primary = groupNames[i % groupNames.length] + const groups = [primary] + if (rand() < 0.12) { + const second = groupNames[Math.floor(rand() * groupNames.length)] + if (second !== primary) groups.push(second) + } + nodes.push({ id: 'n' + i, groups, size: 30 + rand() * 20 }) + } + + const edges = [] + let eid = 0 + const makeEdge = (i, j) => ({ + id: 'ge' + eid++, + source: 'n' + i, + target: 'n' + j, + type: edgeTypePool[Math.floor(rand() * edgeTypePool.length)], + weight: +(0.3 + rand() * 0.69).toFixed(2), + ...(opts.edgeLabels + ? { interaction: INTERACTIONS[Math.floor(rand() * INTERACTIONS.length)] } + : {}), + }) + + // random spanning backbone -- every node (after the first) attaches to + // some earlier node, so the whole network stays connected + for (let i = 1; i < n; i++) { + edges.push(makeEdge(i, Math.floor(rand() * i))) + } + // extra random edges for density + const avgExtra = opts.avgExtraEdges ?? (n <= 100 ? 2 : n <= 1000 ? 1.2 : 0.6) + const extraCount = Math.round(n * avgExtra) + for (let k = 0; k < extraCount; k++) { + const a = Math.floor(rand() * n) + const b = Math.floor(rand() * n) + if (a === b) continue + edges.push(makeEdge(a, b)) + } + + const data = { nodes, edges } + if (opts.edgeLabels) { + // open with each edge labelled by its interaction, on curved edges so + // parallel labels don't sit on top of each other + data.config = { + showEdgeLabels: true, + edgeLabelContent: 'attr:interaction', + edgeLabelSize: '10', + edgeCurveStyle: 'bezier', + } + } + return data +} + +// A directed multi-edge network: each connected pair gets one to three +// channels (regulation, phosphorylation, binding, ...), each drawn as an +// arrow from source to target; some pairs also have edges in the reverse +// direction, so reciprocal and parallel arrows can be explored. +export function generateDirectedMultiNetwork(n, opts = {}) { + const rand = mulberry32(opts.seed ?? 4242) + const channels = ['activation', 'inhibition', 'phosphorylation', 'binding', 'expression'] + const groupNames = ['receptors', 'kinases', 'transcription factors', 'targets'] + const groupAttrs = { + receptors: { description: 'Upstream sensors that start the signal' }, + kinases: { description: 'Relay the signal by phosphorylation' }, + 'transcription factors': { description: 'Turn target genes on or off' }, + targets: { description: 'Downstream genes and effectors' }, + } + const nodes = [] + const layerOf = [] + for (let i = 0; i < n; i++) { + // nodes are spread over four layers, roughly a signalling cascade + const layer = Math.min(3, Math.floor((i / n) * 4)) + layerOf.push(layer) + const groups = [groupNames[layer]] + if (rand() < 0.1 && layer < 3) groups.push(groupNames[layer + 1]) + nodes.push({ id: ['R', 'K', 'TF', 'G'][layer] + (i + 1), groups, size: 34 + rand() * 16 }) + } + const edges = [] + let eid = 0 + const addPair = (a, b) => { + const count = 1 + Math.floor(rand() * 3) // 1-3 channels + const picked = [...channels].sort(() => rand() - 0.5).slice(0, count) + picked.forEach((type) => + edges.push({ + id: 'de' + eid++, + source: nodes[a].id, + target: nodes[b].id, + type, + weight: +(0.3 + rand() * 0.7).toFixed(2), + directed: true, + }) + ) + if (rand() < 0.25) { + // feedback in the other direction + edges.push({ + id: 'de' + eid++, + source: nodes[b].id, + target: nodes[a].id, + type: rand() < 0.5 ? 'inhibition' : 'binding', + weight: +(0.3 + rand() * 0.7).toFixed(2), + directed: true, + }) + } + } + const used = new Set() + const pairOnce = (a, b) => { + const key = a < b ? a + ',' + b : b + ',' + a + if (a === b || used.has(key)) return + used.add(key) + addPair(a, b) + } + // every node below the top layer is reached from an earlier layer + for (let i = 0; i < n; i++) { + if (layerOf[i] === 0) continue + const upstream = [] + for (let j = 0; j < n; j++) if (layerOf[j] === layerOf[i] - 1) upstream.push(j) + pairOnce(upstream[Math.floor(rand() * upstream.length)], i) + } + // extra links within and across neighbouring layers + for (let k = 0; k < n * 0.9; k++) { + const a = Math.floor(rand() * n) + const candidates = [] + for (let j = 0; j < n; j++) + if (layerOf[j] === layerOf[a] || layerOf[j] === layerOf[a] + 1) candidates.push(j) + pairOnce(a, candidates[Math.floor(rand() * candidates.length)]) + } + return { + nodes, + edges, + groupAttrs, + groupOrder: groupNames, + directed: true, + config: { + edgeDirection: 'data', + edgeCurveStyle: 'bezier', + edgeCurvature: '28', + arrowShape: 'triangle', + arrowScale: '1', + layoutSelect: 'breadthfirst', + }, + } +} + +// Small seeded PRNG so the module examples come out identical on every load. +export function mulberry32(seed) { + return function () { + seed |= 0 + seed = (seed + 0x6d2b79f5) | 0 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +// Four synthetic functional modules (not real genes). Each module biases +// what its members look like (localization, domains) and which evidence +// channels its internal links tend to come from. +const MODULE_DEFS = [ + { + key: 'signaling', + prefix: 'SIG', + label: 'Signaling', + category: 'regulatory', + description: 'Receptors and kinases that relay outside cues into the cell', + localization: ['membrane', 'cytoplasm', 'cytoplasm'], + domains: ['kinase', 'SH2', 'PH', 'GTPase'], + channels: ['experiments', 'experiments', 'coexpression', 'textmining'], + }, + { + key: 'metabolism', + prefix: 'MET', + label: 'Central metabolism', + category: 'enzymatic', + description: 'Enzymes that turn nutrients into energy and building blocks', + localization: ['cytoplasm', 'mitochondrion', 'mitochondrion'], + domains: ['Rossmann fold', 'TIM barrel', 'aldolase', 'NAD-binding'], + channels: ['database', 'neighborhood', 'cooccurrence', 'coexpression'], + }, + { + key: 'transport', + prefix: 'TRN', + label: 'Membrane transport', + category: 'transport', + description: 'Carriers and channels that move solutes across membranes', + localization: ['membrane', 'membrane', 'vesicle'], + domains: ['MFS', 'ABC', 'ion channel', 'porin'], + channels: ['cooccurrence', 'database', 'neighborhood', 'experiments'], + }, + { + key: 'transcription', + prefix: 'TSC', + label: 'Transcription control', + category: 'regulatory', + description: 'Factors that switch target genes on and off', + localization: ['nucleus', 'nucleus', 'cytoplasm'], + domains: ['zinc finger', 'HTH', 'bZIP', 'bromodomain'], + channels: ['textmining', 'coexpression', 'experiments', 'database'], + }, +] + +const CROSS_MODULE_CHANNELS = ['textmining', 'database', 'cooccurrence'] + +// Builds a 4-module network that exercises all three attribute levels: +// node attrs: localization (text), expression (number), essential (yes/no), +// length (integer), domains (list) +// edge attrs: evidenceCount (integer), firstReported (year) +// group attrs: label/description (built-in) plus category, enrichmentFDR, +// curated, keyMembers (custom) +// sizes -- members per module (before overlaps), sums to the node count +// overlaps -- one entry per shared node: [homeModule, ...extraModules] +// pIn/pOut -- link probability for a node pair inside / across modules +export function buildModuleNetwork({ sizes, overlaps, pIn, pOut, seed }) { + const rand = mulberry32(seed) + const pick = (arr) => arr[Math.floor(rand() * arr.length)] + const shuffle = (arr) => { + const a = arr.slice() + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)) + ;[a[i], a[j]] = [a[j], a[i]] + } + return a + } + const pickDistinct = (pool, n) => { + const out = [] + const distinct = new Set(pool).size + while (out.length < Math.min(n, distinct)) { + const v = pick(pool) + if (!out.includes(v)) out.push(v) + } + return out + } + + // 1. nodes, each with a single home module + const nodes = [] + const members = MODULE_DEFS.map(() => []) + MODULE_DEFS.forEach((m, gi) => { + for (let i = 0; i < sizes[gi]; i++) { + const node = { + id: `${m.prefix}-${String(i + 1).padStart(2, '0')}`, + groups: [m.key], + size: 30 + Math.round(rand() * 16), + localization: pick(m.localization), + expression: +((rand() * 2 - 1) * 3).toFixed(2), + essential: rand() < 0.2, + length: 120 + Math.round(rand() * 1280), + domains: pickDistinct(m.domains, rand() < 0.35 ? 2 : 1), + } + nodes.push(node) + members[gi].push(node) + } + }) + + // 2. small overlaps: a few nodes join one or two extra modules and pick + // up a domain typical of each module they join + overlaps.forEach(([home, ...extra]) => { + const candidates = members[home].filter((n) => n.groups.length === 1) + if (!candidates.length) return + const node = pick(candidates) + extra.forEach((gj) => { + const m = MODULE_DEFS[gj] + node.groups.push(m.key) + members[gj].push(node) + const d = pick(m.domains) + if (!node.domains.includes(d)) node.domains.push(d) + }) + }) + + // 3. edges: 1-3 parallel channels per linked pair + const edges = [] + const seenPairs = new Set() + const degree = {} + let eid = 0 + const link = (a, b, channelPool) => { + if (a === b) return + const key = a.id < b.id ? a.id + '|' + b.id : b.id + '|' + a.id + if (seenPairs.has(key)) return + seenPairs.add(key) + const channelCount = 1 + (rand() < 0.45 ? 1 : 0) + (rand() < 0.15 ? 1 : 0) + pickDistinct(channelPool, channelCount).forEach((type) => { + edges.push({ + id: 'm' + eid++, + source: a.id, + target: b.id, + type, + weight: +(0.4 + rand() * 0.59).toFixed(2), + evidenceCount: 1 + Math.floor(rand() * 12), + firstReported: 1995 + Math.floor(rand() * 30), + }) + degree[a.id] = (degree[a.id] || 0) + 1 + degree[b.id] = (degree[b.id] || 0) + 1 + }) + } + + // inside each module: a random tree keeps the module connected, then + // extra links at density pIn + members.forEach((list, gi) => { + const pool = MODULE_DEFS[gi].channels + const order = shuffle(list) + for (let i = 1; i < order.length; i++) link(order[i], order[Math.floor(rand() * i)], pool) + for (let i = 0; i < list.length; i++) { + for (let j = i + 1; j < list.length; j++) { + if (rand() < pIn) link(list[i], list[j], pool) + } + } + }) + + // across modules: sparse links between nodes that share no module + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const a = nodes[i], + b = nodes[j] + if (a.groups.some((g) => b.groups.includes(g))) continue + if (rand() < pOut) link(a, b, CROSS_MODULE_CHANNELS) + } + } + + // 4. group attributes + const groupAttrs = {} + MODULE_DEFS.forEach((m, gi) => { + const keyMembers = members[gi] + .slice() + .sort((a, b) => (degree[b.id] || 0) - (degree[a.id] || 0)) + .slice(0, 3) + .map((n) => n.id) + groupAttrs[m.key] = { + label: m.label, + description: m.description, + category: m.category, + enrichmentFDR: +Math.pow(10, -(2 + rand() * 6)).toPrecision(2), + curated: gi % 2 === 0, + keyMembers, + } + }) + + return { nodes, edges, groupAttrs } +} + +export const SAMPLE_GENERATORS = { + trp: () => buildSampleData(), + // 50 nodes; 5 shared nodes (10%): one bridge per neighbouring module pair + // around the ring, plus one node sitting in three modules + modules50: () => + buildModuleNetwork({ + sizes: [14, 12, 13, 11], + overlaps: [ + [0, 1], + [1, 2], + [2, 3], + [3, 0], + [0, 1, 2], + ], + pIn: 0.26, + pOut: 0.012, + seed: 50, + }), + // 100 nodes; 10 shared nodes (10%): two bridges per neighbouring pair, + // one across the ring and one node in three modules + modules100: () => + buildModuleNetwork({ + sizes: [28, 24, 26, 22], + overlaps: [ + [0, 1], + [1, 0], + [1, 2], + [2, 1], + [2, 3], + [3, 2], + [3, 0], + [0, 3], + [0, 2], + [1, 2, 3], + ], + pIn: 0.16, + pOut: 0.006, + seed: 100, + }), + tiny: () => generateRandomNetwork(20, { edgeLabels: true, avgExtraEdges: 1 }), + directed: () => generateDirectedMultiNetwork(40), + small: () => generateRandomNetwork(60), + medium: () => generateRandomNetwork(200), + large: () => generateRandomNetwork(800), + massive: () => generateRandomNetwork(5000, { avgExtraEdges: 0.6 }), +} diff --git a/frontend/src/side_panel.ts b/frontend/src/side_panel.ts new file mode 100644 index 0000000..ac5df23 --- /dev/null +++ b/frontend/src/side_panel.ts @@ -0,0 +1,104 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) + +/* ---------- side panel: drag to resize, arrow to collapse ---------- */ +// page wiring, run by main.ts in the original order +export function init() { + const app = document.getElementById('app') + const handle = document.getElementById('sideResizer') + const toggle = document.getElementById('sideToggle') + const DEFAULT_W = 300, + MIN_W = 260, + COLLAPSE_BELOW = 150 + const maxW = () => Math.max(MIN_W, Math.min(720, Math.round(window.innerWidth * 0.6))) + let width = DEFAULT_W, + collapsed = false + try { + const saved = JSON.parse(localStorage.getItem('norma3-sidebar') || 'null') + if (saved) { + width = Number(saved.width) || DEFAULT_W + collapsed = !!saved.collapsed + } + } catch (e) {} + const save = () => { + try { + localStorage.setItem('norma3-sidebar', JSON.stringify({ width, collapsed })) + } catch (e) {} + } + function apply() { + width = Math.max(MIN_W, Math.min(maxW(), Math.round(width))) + app.style.setProperty('--side-w', collapsed ? '0px' : width + 'px') + app.classList.toggle('side-collapsed', collapsed) + const label = collapsed ? 'Expand the side panel' : 'Collapse the side panel' + toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true') + toggle.setAttribute('aria-label', label) + toggle.title = label + handle.setAttribute('aria-valuemin', MIN_W) + handle.setAttribute('aria-valuemax', maxW()) + handle.setAttribute('aria-valuenow', width) + } + function setCollapsed(c) { + collapsed = c + apply() + save() + } + toggle.addEventListener('click', () => setCollapsed(!collapsed)) + + handle.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return + e.preventDefault() + handle.setPointerCapture(e.pointerId) + app.classList.add('side-resizing') + const left = app.getBoundingClientRect().left + const beforeW = width + const move = (ev) => { + const w = ev.clientX - left + if (w < COLLAPSE_BELOW) { + if (!collapsed) { + collapsed = true + apply() + } + return + } + collapsed = false + width = w + apply() + } + const up = () => { + app.classList.remove('side-resizing') + // dragged shut: reopen later at the width it had before the drag + if (collapsed) width = beforeW + handle.removeEventListener('pointermove', move) + handle.removeEventListener('pointerup', up) + handle.removeEventListener('pointercancel', up) + save() + } + handle.addEventListener('pointermove', move) + handle.addEventListener('pointerup', up) + handle.addEventListener('pointercancel', up) + }) + handle.addEventListener('dblclick', () => { + width = DEFAULT_W + collapsed = false + apply() + save() + }) + handle.addEventListener('keydown', (e) => { + const step = e.shiftKey ? 50 : 10 + if (e.key === 'ArrowLeft') width -= step + else if (e.key === 'ArrowRight') width += step + else if (e.key === 'Home') width = MIN_W + else if (e.key === 'End') width = maxW() + else if (e.key === 'Enter') { + setCollapsed(true) + toggle.focus() + e.preventDefault() + return + } else return + e.preventDefault() + apply() + save() + }) + window.addEventListener('resize', apply) + apply() +} diff --git a/frontend/src/side_tabs.ts b/frontend/src/side_tabs.ts new file mode 100644 index 0000000..3cd2473 --- /dev/null +++ b/frontend/src/side_tabs.ts @@ -0,0 +1,273 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { NORMA_EXAMPLE_SETS } from './wiring' +import { downloadText } from './layouts/controls' + +/* ---------- Data / Display sub-tabs ---------- */ +const SIDE_TABS = [ + ['data', 'sideTabData', 'panelData'], + ['db', 'sideTabDb', 'panelDb'], + ['display', 'sideTabDisplay', 'panelDisplay'], + ['export', 'sideTabExport', 'panelExport'], +] + +export function switchSideTab(name) { + SIDE_TABS.forEach(([k, tabId, panelId]) => { + const on = k === name + const tab = document.getElementById(tabId) + tab.setAttribute('aria-selected', on ? 'true' : 'false') + tab.tabIndex = on ? 0 : -1 + document.getElementById(panelId).hidden = !on + }) + document.getElementById('sidebar').scrollTop = 0 +} + +/* ---------- example file links (Help > Examples) ---------- + Download links for NORMA's example files as published by the NORMA + server, each paired with the copy bundled in this page (by example set + and file name), which keeps working offline. */ +const NORMA_DOWNLOAD_BASE = + 'https://pavlopoulos-lab-services.org/shiny/app_proxy/a64cd611-0420-4151-a75c-e1e2066f6bc5/session/ef352e36fc9ed86bf6e69f45a83d6216/download/' + +const EXAMPLE_FILE_LINKS = [ + { + title: 'STRING example 1 (TP53 interactors)', + set: 'string-tp53', + files: [ + ['STRING Network file', 'string_net_tp53', 'string_interactions.txt'], + ['STRING Annotation file', 'string_annot', 'string_interactions_groups_comma_duplicate.txt'], + ['STRING Expression file', 'string_expr', 'string_expression_colors.txt'], + ], + }, + { + title: 'STRING example 2 (BCAR3 interactors)', + set: 'string-bcar3', + files: [ + ['STRING Network file', 'string_net_bcar3', 'BCAR3.txt'], + ['GO Annotation - Biological Process', 'string_bp', 'BCAR3_GO_BP.txt'], + ['GO Annotation - Molecular Function', 'string_mf', 'BCAR3_GO_MF.txt'], + ['KEGG pathways', 'string_kegg', 'BCAR3_KEGG.txt'], + ], + }, + { + title: 'Drosophila (Tau) Network', + set: 'tau', + refs: [ + ['PMID:31488613', 'https://pubmed.ncbi.nlm.nih.gov/31488613/'], + ['PMCID:PMC6794924', 'https://www.ncbi.nlm.nih.gov/pmc/articles/PMC6794924/'], + ['DOI:10.1523/JNEUROSCI.0391-19.2019', 'https://doi.org/10.1523/JNEUROSCI.0391-19.2019'], + ], + files: [ + ['Drosophila Network file', 'dros_net', 'TAU_network_DEGs_NORMA.txt'], + ['Drosophila Kegg pathways', 'dros_annot', 'TAU_KEGG_Annotation_NORMA.txt'], + ['Drosophila Louvain automated annotation file', 'dros_louvain', 'TAU_Louvain.txt'], + ['Drosophila Expression file', 'dros_express', 'TAU_expressions.txt'], + ], + }, + { + title: 'Human Gene Co-expression Network', + set: 'coexpr', + refs: [ + ['PMID:19081792', 'https://pubmed.ncbi.nlm.nih.gov/19081792/'], + ['PMCID:PMC2597745', 'https://www.ncbi.nlm.nih.gov/pmc/articles/PMC2597745/'], + ['DOI:10.1371/journal.pone.0003911', 'https://doi.org/10.1371/journal.pone.0003911'], + ['bioinfow.dep.usal.es/coexpression', 'http://bioinfow.dep.usal.es/coexpression/'], + ], + files: [ + ['Gene Co-expression Network', 'co_express', 'NORMA_Human_coexpression_NETWORK.txt'], + [ + 'GO Annotation - Biological Process', + 'co_express_bp', + 'NORMA_Human_coexpression_Annotation_GO_BP.txt', + ], + [ + 'GO Annotation - Molecular Function', + 'co_express_mf', + 'NORMA_Human_coexpression_Annotation_GO_MF.txt', + ], + [ + 'GO Annotation - Cellular Components', + 'co_express_cc', + 'NORMA_Human_coexpression_Annotation_GO_CC.txt', + ], + ['KEGG pathways', 'co_express_kegg', 'NORMA_Human_coexpression_Annotation_KEGG.txt'], + ['MCODE Node coloring', 'co_express_mcode', 'NORMA_Human_coexpression_Expression_MCODE.txt'], + ], + }, + { + title: 'COVID-19: IntAct Database', + set: 'covid', + files: [ + ['COVID-19 Network', 'covid_19_net', 'Intact-data_COVID19_no_self_loops.txt'], + [ + 'HomoSapiens Protein Domains - INTERPRO', + 'covid_19_interpro', + 'HomoSapiens_Protein_Domains_INTERPRO_FILTERED.txt', + ], + [ + 'HomoSapiens GO Annotation - Biological Process', + 'covid_19_bp', + 'HomoSapiens_Gene_Ontology_GOTERM_BP_DIRECT_FILTERED.txt', + ], + [ + 'HomoSapiens GO Annotation - Molecular Function', + 'covid_19_mf', + 'HomoSapiens_Gene_Ontology_GOTERM_MF_DIRECT_FILTERED.txt', + ], + [ + 'HomoSapiens Protein Domains GO Annotation - Cellular Components', + 'covid_19_cc', + 'HomoSapiens_Gene_Ontology_GOTERM_CC_DIRECT_FILTERED.txt', + ], + [ + 'HomoSapiens Protein Domains KEGG pathways', + 'covid_19_kegg', + 'HomoSapiens_Pathways_KEGG_PATHWAY_FILTERED.txt', + ], + [ + 'HomoSapiens Protein Domains SMART', + 'covid_19_smart', + 'HomoSapiens_Protein_Domains_SMART_FILTERED.txt', + ], + ], + }, + { + title: 'Gallus gallus: BioGrid Database', + set: 'gallus', + files: [ + ['Gallus gallus Network', 'Gallus_gallus_net', 'Biogrid_no_self_loops.txt'], + [ + 'BioGrid Gallus gallus KEGG pathways', + 'Gallus_gallus_kegg', + 'BioGrid_Chicken_Gallus_Pathways_KEGG_PATHWAY_FILTERED.txt', + ], + ], + }, +] + +function bundledExampleFile(setKey, fileName) { + const set = NORMA_EXAMPLE_SETS[setKey] + return set ? set.files.find((f) => f.fileName === fileName) || null : null +} + +function renderLocalExampleSets(root) { + Object.values(NORMA_EXAMPLE_SETS) + .filter((set) => set.local) + .forEach((set) => { + const block = document.createElement('div') + block.className = 'dl-group' + const h = document.createElement('h3') + h.textContent = set.title + block.appendChild(h) + const list = document.createElement('ul') + list.className = 'dl-list' + set.files.forEach((f) => { + const li = document.createElement('li') + li.append(`${f.name} `) + const btn = document.createElement('button') + btn.type = 'button' + btn.className = 'dl-copy' + btn.textContent = 'Download' + btn.title = `Download ${f.fileName}` + btn.addEventListener('click', () => downloadText(f.fileName, f.text)) + li.appendChild(btn) + list.appendChild(li) + }) + block.appendChild(list) + root.appendChild(block) + }) +} + +function renderExampleLinks() { + const root = document.getElementById('helpExampleFiles') + if (!root) return + root.innerHTML = '' + renderLocalExampleSets(root) + EXAMPLE_FILE_LINKS.forEach((group) => { + const block = document.createElement('div') + block.className = 'dl-group' + const h = document.createElement('h3') + h.textContent = group.title + block.appendChild(h) + if (group.refs) { + const refs = document.createElement('p') + refs.className = 'dl-refs' + group.refs.forEach(([label, url], i) => { + if (i) refs.append(', ') + const a = document.createElement('a') + a.href = url + a.target = '_blank' + a.rel = 'noopener' + a.textContent = label + refs.appendChild(a) + }) + block.appendChild(refs) + } + const list = document.createElement('ul') + list.className = 'dl-list' + group.files.forEach(([label, slug, fileName]) => { + const li = document.createElement('li') + const a = document.createElement('a') + const local = bundledExampleFile(group.set, fileName) + if (local) { + // the sample file itself, from this page, so it never goes missing + a.href = '#' + a.title = `Download ${fileName}` + a.addEventListener('click', (e) => { + e.preventDefault() + downloadText(fileName, local.text) + }) + } else { + a.href = `${NORMA_DOWNLOAD_BASE}${slug}?w=` + a.target = '_blank' + a.rel = 'noopener' + } + a.textContent = label + li.appendChild(a) + const fn = document.createElement('span') + fn.className = 'dl-name' + fn.textContent = fileName + li.appendChild(fn) + list.appendChild(li) + }) + block.appendChild(list) + root.appendChild(block) + }) +} + +/* ---------- search panel: collapse / expand ---------- */ +function setSearchCollapsed(collapsed) { + const panel = document.getElementById('searchPanel') + const toggle = document.getElementById('searchToggle') + panel.classList.toggle('collapsed', collapsed) + toggle.setAttribute('aria-expanded', String(!collapsed)) + toggle.title = collapsed ? 'Open the search panel' : 'Minimize the search panel' + toggle.setAttribute('aria-label', collapsed ? 'Open node search' : 'Minimize node search') + if (!collapsed) document.getElementById('search').focus() +} + +// page wiring, run by main.ts in the original order +export function init() { + document.querySelectorAll('#sideTabs [role="tab"]').forEach((tab) => { + tab.addEventListener('click', () => switchSideTab(tab.dataset.side)) + tab.addEventListener('keydown', (e) => { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return + const keys = SIDE_TABS.map((t) => t[0]) + const i = keys.indexOf(tab.dataset.side) + const next = keys[(i + (e.key === 'ArrowRight' ? 1 : keys.length - 1)) % keys.length] + switchSideTab(next) + document.querySelector(`#sideTabs [data-side="${next}"]`).focus() + }) + }) + + renderExampleLinks() + + document.getElementById('searchToggle').addEventListener('click', () => { + setSearchCollapsed(!document.getElementById('searchPanel').classList.contains('collapsed')) + }) + + document.getElementById('searchMinimize').addEventListener('click', () => { + setSearchCollapsed(true) + document.getElementById('searchToggle').focus() + }) +} diff --git a/frontend/src/state.ts b/frontend/src/state.ts new file mode 100644 index 0000000..a39fe88 --- /dev/null +++ b/frontend/src/state.ts @@ -0,0 +1,26 @@ +import { EDGE_TYPES } from './palette' + +// ponytail: mutable page state that several modules assign; ES module bindings are read-only across files +export const S = { + currentLibView: null, + nodeColorMap: {}, + groupShapes: {}, + groupAttrs: {}, + activeTypes: new Set(Object.keys(EDGE_TYPES)), + activeGroups: new Set(), + layoutRunSeq: 0, + currentNodePalette: 'vivid', + layoutProgress: null, + infoNodeId: null, + autoEdgeIdx: 0, + historySuspended: 0, + dataCache: { version: -1, data: null }, + historyGestureOpen: false, + cyLayoutsRunning: 0, + workRange: [0, 1], + frRequestSeq: 0, + normaEntrySeq: 0, + viewSeq: 0, + activeViewId: null, + DEFAULT_VIEW_CONFIG: null, +} diff --git a/frontend/src/string/groupings.ts b/frontend/src/string/groupings.ts new file mode 100644 index 0000000..63ed838 --- /dev/null +++ b/frontend/src/string/groupings.ts @@ -0,0 +1,185 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { STRING_CATEGORIES, stringCategoryKey, stringCategoryName } from '../view3d/export' +import { + addNormaEntry, + libSelection, + plural, + renderLibraryLists, + setStatus, + startProgress, +} from '../layouts/controls' +import { refreshLibraryView } from '../library' +import { stringCall, stringState } from './requests' +import { stringNetworkInView, stringSettings, updateStringUI } from './ui_state' + +/* ---------- groupings ---------- */ +// task/step: the import's progress; on its own, it shows a progress of its own. +export async function fetchStringGroupings(netEntry, st, notes, task = null, step = 0) { + const meta = netEntry.stringMeta + const nameOf = new Map(Object.entries(meta.nameOf)) + const byPreferred = new Map([...nameOf.values()].map((n) => [n.toLowerCase(), n])) + const ids = meta.stringIds + const enrichment = st.annotMode === 'enrichment' + const own = !task + if (own) { + task = startProgress('stringStatus', [1]) + step = 0 + } + task.step( + step, + enrichment + ? `Running functional enrichment for ${plural(ids.length, 'protein')}…` + : `Fetching functional annotations for ${plural(ids.length, 'protein')}…` + ) + let rows + try { + rows = await stringCall( + enrichment ? 'enrichment' : 'functional_annotation', + { identifiers: ids.join('\r'), species: meta.taxon }, + { onBytes: (got, total) => task.bytes(got, total) } + ) + } catch (err) { + notes.push({ level: 'warn', text: `Groupings couldn't be fetched: ${err.message}` }) + if (own) task.stop() + return [] + } + if (own) task.stop() + const wanted = new Set(st.categories) + const byCat = new Map() + const seenCats = new Map() + ;(rows || []).forEach((r) => { + const cat = stringCategoryKey(r.category) + seenCats.set(cat, (seenCats.get(cat) || 0) + 1) + const known = STRING_CATEGORIES.some((c) => c[0] === cat) + if (!(wanted.has(cat) || (!known && st.otherCategories))) return + if (enrichment && !(Number(r.fdr) <= st.fdr)) return + // members: map STRING ids (inputGenes) or names back to node names + const inputs = Array.isArray(r.inputGenes) + ? r.inputGenes + : String(r.inputGenes || '').split(',') + const names = Array.isArray(r.preferredNames) + ? r.preferredNames + : String(r.preferredNames || '').split(',') + const members = new Set() + inputs.forEach((g, i) => { + const n = + nameOf.get(g) || + byPreferred.get(String(names[i] || g).toLowerCase()) || + byPreferred.get(String(g).toLowerCase()) + if (n) members.add(n) + }) + if (members.size < st.minGroup) return + if (!byCat.has(cat)) byCat.set(cat, []) + byCat.get(cat).push({ + term: r.term, + description: r.description || r.term, + members: [...members], + fdr: Number(r.fdr), + p: Number(r.p_value), + background: Number(r.number_of_genes_in_background), + ratio: Number(r.ratio_in_set), + }) + }) + const entries = [] + const order = [ + ...STRING_CATEGORIES.map((c) => c[0]), + ...[...byCat.keys()].filter((k) => !STRING_CATEGORIES.some((c) => c[0] === k)), + ] + order.forEach((cat) => { + const list = byCat.get(cat) + if (!list || !list.length) return + list.sort((a, b) => + enrichment + ? a.fdr - b.fdr || b.members.length - a.members.length + : b.members.length - a.members.length + ) + const top = list.slice(0, st.maxTerms) + const usedNames = new Set() + const groupMeta = {} + const lines = top.map((t) => { + let name = String(t.description) + .replace(/[\t\r\n]+/g, ' ') + .trim() + if (usedNames.has(name)) name = `${name} (${t.term})` + usedNames.add(name) + const info = { + description: `${stringCategoryName(cat)}${t.term && t.term !== name ? ', ' + t.term : ''}`, + term: t.term, + category: stringCategoryName(cat), + 'proteins in network': t.members.length, + } + if (enrichment) { + info.FDR = t.fdr + if (Number.isFinite(t.p)) info['p-value'] = t.p + if (Number.isFinite(t.background)) info['proteins in genome'] = t.background + } + groupMeta[name] = info + return `${name}\t${t.members.join(',')}` + }) + const label = `${netEntry.name}: ${stringCategoryName(cat)}${enrichment ? ` (FDR ≤ ${st.fdr})` : ''}` + const entry = addNormaEntry('annotation', label, lines.join('\n') + '\n', '', null, {}) + entry.groupMeta = groupMeta + entry.stringCategory = cat + entry.stringCategoryName = stringCategoryName(cat) + entry.forNetwork = netEntry.id + entries.push(entry) + if (list.length > top.length) + notes.push({ + level: 'ok', + text: `${stringCategoryName(cat)}: kept the ${top.length} ${enrichment ? 'most significant' : 'largest'} of ${list.length} terms.`, + }) + }) + const offered = [...seenCats.keys()].map((k) => `${stringCategoryName(k)} (${seenCats.get(k)})`) + if (offered.length) + notes.push({ + level: 'ok', + text: `STRING returned ${enrichment ? 'enriched terms' : 'annotations'} in: ${offered.join(', ')}.`, + }) + const emptyWanted = st.categories.filter((k) => !byCat.has(k) && !(k === 'KEGG' && !enrichment)) + if (emptyWanted.length) + notes.push({ + level: 'warn', + text: `No ${enrichment ? `terms with FDR ≤ ${st.fdr}` : 'annotations'} and at least ${st.minGroup} proteins for: ${emptyWanted.map(stringCategoryName).join(', ')}.`, + }) + if (!enrichment && wanted.has('KEGG')) + notes.push({ + level: 'ok', + text: 'STRING does not share full KEGG annotations for licensing reasons; choose Terms: "Enriched in the network" to get KEGG pathways.', + }) + return entries +} + +export async function runStringGroupings() { + const net = stringNetworkInView() + if (!net) return + const st = stringSettings() + if (!st.categories.length && !st.otherCategories) { + setStatus('stringStatus', [{ level: 'error', text: 'Tick at least one term collection.' }]) + return + } + stringState.busy = true + stringState.cancelled = false + updateStringUI() + const notes = [] + try { + const entries = await fetchStringGroupings(net, st, notes) + if (entries.length) { + libSelection.annotation = entries[0].id + renderLibraryLists() + refreshLibraryView() + } + setStatus('stringStatus', [ + { + level: entries.length ? 'ok' : 'warn', + text: entries.length + ? `Added ${plural(entries.length, 'grouping')} for "${net.name}": ${entries.map((e) => e.stringCategoryName).join(', ')}.` + : `No new groupings for "${net.name}".`, + }, + ...notes, + ]) + } finally { + stringState.busy = false + updateStringUI() + } +} diff --git a/frontend/src/string/import.ts b/frontend/src/string/import.ts new file mode 100644 index 0000000..01796cf --- /dev/null +++ b/frontend/src/string/import.ts @@ -0,0 +1,292 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { STRING_CHANNELS, STRING_PREFERRED_GROUPING } from '../view3d/export' +import { + addNormaEntry, + libSelection, + listSample, + plural, + renderLibraryLists, + setStatus, + startProgress, +} from '../layouts/controls' +import { + combineStringScores, + scoreValue, + stringBase, + stringCall, + stringState, + stringVersion, +} from './requests' +import { fetchStringGroupings } from './groupings' +import { openInNewView } from '../profiler' +import { refreshLibraryView } from '../library' +import { stringSettings, updateStringUI } from './ui_state' + +/* ---------- import ---------- */ + +export async function runStringImport() { + const st = stringSettings() + if (!st.names.length) { + setStatus('stringStatus', [ + { level: 'error', text: 'Type at least one protein or gene name, for example TP53.' }, + ]) + return + } + if (!/^(\d+|STRG\w+)$/.test(st.taxon)) { + setStatus('stringStatus', [ + { + level: 'error', + text: 'Enter the organism as an NCBI taxon number, for example 9606 for human.', + }, + ]) + return + } + if (!st.channels.length) { + setStatus('stringStatus', [{ level: 'error', text: 'Tick at least one evidence channel.' }]) + return + } + if (st.names.length > 2000) { + setStatus('stringStatus', [ + { level: 'error', text: 'STRING accepts at most 2,000 names per query.' }, + ]) + return + } + stringState.busy = true + stringState.cancelled = false + updateStringUI() + const notes = [] + const withGroupings = !!(st.categories.length || st.otherCategories) + // steps: version, names, network, descriptions, groupings, opening + const task = startProgress('stringStatus', [ + 1, + 2, + 6, + st.descriptions ? 2 : 0, + withGroupings ? 4 : 0, + 1, + ]) + const onBytes = (got, total) => task.bytes(got, total) + try { + task.step(0, 'Checking the STRING version…') + const ver = await stringVersion() + + task.step(1, `Looking up ${plural(st.names.length, 'name')} in ${st.speciesLabel}…`) + const mapped = await stringCall( + 'get_string_ids', + { identifiers: st.names.join('\r'), species: st.taxon, echo_query: 1 }, + { onBytes } + ) + const byQuery = new Map() + ;(mapped || []).forEach((r) => { + const q = r.queryItem ?? st.names[r.queryIndex] + if (q !== undefined && !byQuery.has(q)) byQuery.set(q, r) + }) + const found = st.names.filter((n) => byQuery.has(n)) + const missing = st.names.filter((n) => !byQuery.has(n)) + if (!found.length) + throw new Error( + `STRING found none of the names in ${st.speciesLabel}. Check the spelling and the organism.` + ) + if (missing.length) + notes.push({ + level: 'warn', + text: `Not found in ${st.speciesLabel}: ${listSample(missing)}.`, + }) + const renamed = found.filter( + (n) => + byQuery.get(n).preferredName && + byQuery.get(n).preferredName.toLowerCase() !== n.toLowerCase() + ) + if (renamed.length) + notes.push({ + level: 'ok', + text: `Matched ${listSample(renamed.map((n) => `${n} as ${byQuery.get(n).preferredName}`))}.`, + }) + const queryIds = [...new Set(found.map((n) => byQuery.get(n).stringId))] + const taxonName = byQuery.get(found[0]).taxonName || st.speciesLabel + + task.step( + 2, + st.addNodes + ? `Fetching the network with up to ${st.addNodes} interactors…` + : 'Fetching the network…' + ) + const params = { + identifiers: queryIds.join('\r'), + species: st.taxon, + required_score: Math.round(st.threshold * 1000), + network_type: st.networkType, + } + if (st.addNodes > 0) params.add_nodes = st.addNodes + const rows = await stringCall('network', params, { onBytes }) + const built = buildStringNetwork(rows || [], queryIds, byQuery, st) + if (!built.edges.length) + throw new Error( + `No ${st.networkType} connections pass a confidence of ${st.threshold.toFixed(2)} with the ticked channels. Lower the confidence, tick more channels or add interactors.` + ) + notes.push(...built.notes) + + // descriptions for every protein + if (st.descriptions) { + task.step(3, `Fetching descriptions for ${plural(built.nodeIds.length, 'protein')}…`) + try { + const info = await stringCall( + 'get_string_ids', + { identifiers: built.stringIds.join('\r'), species: st.taxon }, + { onBytes } + ) + ;(info || []).forEach((r) => { + const name = built.nameOf.get(r.stringId) + if (name && r.annotation) built.nodeAttrs[name].description = r.annotation + }) + } catch (err) { + notes.push({ + level: 'warn', + text: `Protein descriptions couldn't be fetched: ${err.message}`, + }) + } + } + + const netName = `STRING ${found + .slice(0, 3) + .map((n) => byQuery.get(n).preferredName || n) + .join(', ')}${found.length > 3 ? ` +${found.length - 3}` : ''} (${shortSpecies(taxonName)})` + const netEntry = addNormaEntry('network', netName, built.text, '', null, {}) + netEntry.nodeAttrs = built.nodeAttrs + netEntry.stringMeta = { + taxon: st.taxon, + taxonName, + version: ver.version, + address: ver.address || stringBase(), + query: found, + stringIds: built.stringIds, + nameOf: Object.fromEntries(built.nameOf), + threshold: st.threshold, + networkType: st.networkType, + channels: st.channels.map((c) => c[1]), + } + + // groupings + let annEntries = [] + if (withGroupings) { + annEntries = await fetchStringGroupings(netEntry, st, notes, task, 4) + } + task.step(5, 'Opening the network…') + + // show it + const preferred = + STRING_PREFERRED_GROUPING.map((k) => annEntries.find((e) => e.stringCategory === k)).find( + Boolean + ) || annEntries[0] + openInNewView(netName, () => { + // a new view starts with its own (empty) file selection + libSelection.networks = new Set([netEntry.id]) + libSelection.annotation = preferred ? preferred.id : '' + libSelection.colors = '' + renderLibraryLists() + refreshLibraryView() + }) + const summary = `Imported ${netEntry.parsed.summary} from STRING${ver.version ? ' ' + ver.version : ''} (${taxonName}, ${st.networkType}, confidence ≥ ${st.threshold.toFixed(2)}).` + const groupText = annEntries.length + ? `Added ${plural(annEntries.length, 'grouping')}: ${annEntries.map((e) => e.stringCategoryName).join(', ')}. Switch between them with the Grouping list at the top.` + : 'No groupings were added.' + setStatus('stringStatus', [ + { level: 'ok', text: summary }, + { level: 'ok', text: groupText }, + ...notes, + ]) + } catch (err) { + setStatus('stringStatus', [{ level: 'error', text: err.message }, ...notes]) + } finally { + task.stop() + stringState.busy = false + stringState.abort = null + updateStringUI() + } +} + +function shortSpecies(name) { + const parts = String(name || '').split(/\s+/) + return parts.length >= 2 ? `${parts[0][0]}. ${parts[1]}` : name +} + +function buildStringNetwork(rows, queryIds, byQuery, st) { + const notes = [] + const nameOf = new Map() // stringId -> node name + const used = new Set() + const nodeAttrs = {} + const querySet = new Set(queryIds) + const addNode = (sid, preferred) => { + if (nameOf.has(sid)) return nameOf.get(sid) + // node names must be unique and can't contain commas (annotation files) + let name = + String(preferred || sid) + .replace(/,/g, ';') + .trim() || sid + if (used.has(name)) name = `${name} (${sid})` + used.add(name) + nameOf.set(sid, name) + nodeAttrs[name] = { stringId: sid, query: querySet.has(sid) } + return name + } + queryIds.forEach((sid) => { + const q = [...byQuery.values()].find((r) => r.stringId === sid) + addNode(sid, q && q.preferredName) + }) + const seen = new Set() + const lines = [] + let dropped = 0, + pairs = 0 + rows.forEach((r) => { + const a = r.stringId_A, + b = r.stringId_B + if (!a || !b || a === b) return + const key = a < b ? a + '\t' + b : b + '\t' + a + if (seen.has(key)) return // STRING may list a pair in both directions + seen.add(key) + const scores = st.channels.map((c) => scoreValue(r[c[0]])) + const combined = + st.channels.length === STRING_CHANNELS.length + ? scoreValue(r.score) + : combineStringScores(scores) + if (combined < st.threshold - 1e-9) { + dropped++ + return + } + const na = addNode(a, r.preferredName_A), + nb = addNode(b, r.preferredName_B) + pairs++ + if (st.edgeMode === 'combined') { + lines.push([na, nb, combined.toFixed(3), 'combined']) + } else { + st.channels.forEach((c, i) => { + if (scores[i] > 0) lines.push([na, nb, scores[i].toFixed(3), c[1]]) + }) + } + }) + if (dropped) + notes.push({ + level: 'ok', + text: `Left out ${plural(dropped, 'connection')} whose score from the ticked channels alone is below ${st.threshold.toFixed(2)}.`, + }) + const connected = new Set(lines.flatMap((l) => [l[0], l[1]])) + const lonely = [...nameOf.values()].filter((n) => !connected.has(n)) + if (lonely.length) + notes.push({ + level: 'warn', + text: `${plural(lonely.length, 'protein has', 'proteins have')} no connection that passes the filters and ${lonely.length === 1 ? 'is' : 'are'} not shown: ${listSample(lonely)}.`, + }) + const text = ['Source\tTarget\tWeight\tType', ...lines.map((l) => l.join('\t'))].join('\n') + '\n' + const kept = [...nameOf.entries()].filter(([, n]) => connected.has(n)) + return { + text, + edges: lines, + pairs, + nodeIds: kept.map(([, n]) => n), + stringIds: kept.map(([sid]) => sid), + nameOf: new Map(kept), + nodeAttrs: Object.fromEntries(kept.map(([, n]) => [n, nodeAttrs[n]])), + notes, + } +} diff --git a/frontend/src/string/requests.ts b/frontend/src/string/requests.ts new file mode 100644 index 0000000..720fa6a --- /dev/null +++ b/frontend/src/string/requests.ts @@ -0,0 +1,160 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { STRING_CALLER, STRING_PRIOR } from '../view3d/export' +import { readTextWithProgress } from '../layouts/controls' + +/* ---------- requests ---------- */ +export const stringState = { busy: false, abort: null, lastCall: 0, versions: new Map() } + +// How requests reach STRING: through server.py's relay (same origin, so no +// cross-site restrictions) or directly from the browser. +export const stringRoute = { mode: null, checked: false } + +export async function resolveStringRoute() { + const choice = document.getElementById('stringConnect').value + if (choice !== 'auto') { + stringRoute.mode = choice + return choice + } + if (stringRoute.checked) return stringRoute.mode + stringRoute.checked = true + stringRoute.mode = 'direct' + if (/^https?:$/.test(location.protocol)) { + try { + const ctl = new AbortController() + const t = setTimeout(() => ctl.abort(), 2500) + const r = await fetch('string-api/ping', { signal: ctl.signal, cache: 'no-store' }) + clearTimeout(t) + const j = r.ok ? await r.json() : null + if (j && j.norma_proxy) stringRoute.mode = 'proxy' + } catch (e) { + /* no relay: go direct */ + } + } + updateStringConnectState() + return stringRoute.mode +} + +export function updateStringConnectState() { + const el = document.getElementById('stringConnectState') + if (!el) return + const choice = document.getElementById('stringConnect').value + const mode = choice === 'auto' ? stringRoute.mode : choice + el.textContent = !mode + ? 'Decided at the first request.' + : mode === 'proxy' + ? 'Requests go through this server to STRING.' + : 'Requests go from the browser straight to STRING.' +} + +export function stringBase() { + const raw = (document.getElementById('stringAddress').value || 'https://string-db.org') + .trim() + .replace(/\/+$/, '') + return /^https?:\/\//i.test(raw) ? raw : 'https://' + raw +} + +export async function stringCall(method, params, { onBytes = null } = {}) { + // one second between calls, as STRING asks + const wait = stringState.lastCall + 1050 - Date.now() + if (wait > 0) await new Promise((r) => setTimeout(r, wait)) + stringState.lastCall = Date.now() + // The body is sent as a plain string: some environments pass fetch + // requests on with postMessage, which can't copy URLSearchParams objects. + const body = new URLSearchParams({ ...params, caller_identity: STRING_CALLER }).toString() + const route = await resolveStringRoute() + const url = + route === 'proxy' + ? `string-api/json/${method}?upstream=${encodeURIComponent(stringBase())}` + : `${stringBase()}/api/json/${method}` + const controller = new AbortController() + stringState.abort = controller + const timer = setTimeout(() => controller.abort(), 90000) + let response + try { + // a form-encoded POST with a plain-text body is a "simple" request: no CORS preflight + response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, + body, + signal: controller.signal, + }) + } catch (err) { + if (err.name === 'AbortError') + throw new Error( + stringState.cancelled + ? 'Cancelled.' + : 'STRING did not answer within 90 seconds. Try again, or ask for fewer interactors.' + ) + throw new Error( + route === 'proxy' + ? `The server relay could not be reached (${err.message}). Check that server.py is still running, or choose Connect: Directly.` + : `STRING could not be reached (${err.message}). Check the internet connection. The most reliable setup is to run NORMA with server.py, which passes requests on to STRING; see the README.` + ) + } finally { + clearTimeout(timer) + } + const text = await readTextWithProgress(response, onBytes) + if (!response.ok) { + if (route === 'proxy') { + if (response.status === 502 && /could not be reached from this server/.test(text)) + throw new Error(`${text.slice(0, 200)} Check the server's internet connection.`) + // a plain web server answers relay requests with its own (non-JSON) error page + if (!/^\s*[\[{]/.test(text)) + throw new Error( + 'This server has no STRING relay. Start NORMA with server.py, or choose Connect: Directly.' + ) + } + if (response.status === 404) + throw new Error('STRING found none of the given names for this organism.') + let detail = '' + try { + const j = JSON.parse(text) + detail = (Array.isArray(j) ? j[0] : j)?.message || '' + } catch (e) { + detail = text.slice(0, 160) + } + throw new Error( + `STRING answered with an error (${response.status})${detail ? ': ' + detail : ''}.` + ) + } + try { + return JSON.parse(text) + } catch (e) { + throw new Error('STRING sent an answer that is not JSON. The STRING address may be wrong.') + } +} + +export async function stringVersion() { + const base = stringBase() + if (stringState.versions.has(base)) return stringState.versions.get(base) + try { + const res = await stringCall('version', {}) + const v = Array.isArray(res) ? res[0] : res + const info = { version: v && v.string_version, address: v && v.string_stable_address } + stringState.versions.set(base, info) + return info + } catch (err) { + return { version: null, address: null } + } +} + +/* ---------- scores ---------- */ +// STRING's rule for combining channel scores: remove the prior from each +// channel, combine as independent evidence, then add the prior back. +export function combineStringScores(scores) { + let miss = 1 + scores.forEach((s) => { + if (!(s > 0)) return + const noPrior = Math.max(0, (s - STRING_PRIOR) / (1 - STRING_PRIOR)) + miss *= 1 - noPrior + }) + const combined = 1 - miss + return combined + STRING_PRIOR * (1 - combined) +} + +export function scoreValue(v) { + const x = Number(v) + if (!Number.isFinite(x)) return 0 + return x > 1 ? x / 1000 : x // accept 0-1 or 0-1000 scales +} diff --git a/frontend/src/string/ui_state.ts b/frontend/src/string/ui_state.ts new file mode 100644 index 0000000..0b8d8c8 --- /dev/null +++ b/frontend/src/string/ui_state.ts @@ -0,0 +1,80 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { S } from '../state' +import { STRING_CATEGORIES, STRING_CHANNELS, STRING_SPECIES } from '../view3d/export' +import { libEntry } from '../layouts/controls' +import { stringState } from './requests' + +/* ---------- UI state ---------- */ +export function stringSettings() { + const speciesSel = document.getElementById('stringSpecies').value + const taxon = + speciesSel === 'other' ? document.getElementById('stringTaxon').value.trim() : speciesSel + const preset = document.getElementById('stringScorePreset').value + const threshold = + preset === 'custom' + ? parseFloat(document.getElementById('stringScore').value) + : parseFloat(preset) + return { + names: document + .getElementById('stringQuery') + .value.split(/[\s,;]+/) + .map((s) => s.trim()) + .filter(Boolean), + taxon, + speciesLabel: + speciesSel === 'other' + ? `taxon ${taxon}` + : (STRING_SPECIES.find((s) => String(s[0]) === speciesSel) || [0, speciesSel])[1], + networkType: document.getElementById('stringNetType').value, + addNodes: parseInt(document.getElementById('stringAddNodes').value, 10) || 0, + threshold: Number.isFinite(threshold) ? threshold : 0.4, + channels: STRING_CHANNELS.filter((c) => document.getElementById('stringCh_' + c[1]).checked), + edgeMode: document.getElementById('stringEdgeMode').value, + annotMode: document.getElementById('stringAnnotMode').value, + fdr: parseFloat(document.getElementById('stringFdr').value) || 0.05, + categories: STRING_CATEGORIES.map((c) => c[0]).filter( + (k) => document.getElementById('stringCat_' + k).checked + ), + otherCategories: document.getElementById('stringCatOther').checked, + maxTerms: Math.max(1, parseInt(document.getElementById('stringMaxTerms').value, 10) || 30), + minGroup: Math.max(1, parseInt(document.getElementById('stringMinGroup').value, 10) || 2), + descriptions: document.getElementById('stringDescriptions').checked, + } +} + +export function updateStringUI() { + const st = stringSettings() + document.getElementById('stringTaxonRow').hidden = + document.getElementById('stringSpecies').value !== 'other' + document.getElementById('stringAddNodesValue').textContent = st.addNodes + const preset = document.getElementById('stringScorePreset').value + document.getElementById('stringScoreRow').hidden = preset !== 'custom' + document.getElementById('stringScoreValue').textContent = parseFloat( + document.getElementById('stringScore').value + ).toFixed(2) + document.getElementById('stringFdrRow').hidden = st.annotMode !== 'enrichment' + document.getElementById('stringKeggNote').hidden = st.annotMode !== 'annotation' + const busy = stringState.busy + document.getElementById('btnStringFetch').disabled = busy + document.getElementById('btnStringCancel').hidden = !busy + const view = stringNetworkInView() + document.getElementById('btnStringGroupings').disabled = busy || !view + document.getElementById('btnStringGroupings').title = view + ? `Fetch groupings for "${view.name}" with the settings above` + : 'Show a network imported from STRING in the current view first' + let hint = '' + if (st.names.length === 1 && st.addNodes === 0) + hint = 'With a single protein STRING always adds its 10 best interactors.' + else if (st.names.length > 1 && st.addNodes === 0) + hint = 'Only connections among the given proteins are fetched.' + document.getElementById('stringNodesHint').textContent = hint +} + +// The STRING network shown in the current view, if any. +export function stringNetworkInView() { + if (!S.currentLibView) return null + const ids = S.currentLibView.nets.split('|').filter(Boolean) + const nets = ids.map((id) => libEntry('network', id)).filter((e) => e && e.stringMeta) + return nets.length === 1 ? nets[0] : null +} diff --git a/frontend/src/string/wiring.ts b/frontend/src/string/wiring.ts new file mode 100644 index 0000000..ad3937e --- /dev/null +++ b/frontend/src/string/wiring.ts @@ -0,0 +1,191 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { EDGE_TYPES } from '../palette' +import { STRING_CATEGORIES, STRING_CHANNELS, STRING_SPECIES } from '../view3d/export' +import { escapeHtml } from '../network_state' +import { runStringGroupings } from './groupings' +import { runStringImport } from './import' +import { stringRoute, stringState, updateStringConnectState } from './requests' +import { updateStringUI } from './ui_state' + +/* ============================================================ + ICONS AND WELCOME CARD + Small line icons (16 x 16, drawn in the current text color) for the + page tabs, sidebar tabs and sidebar sections, and the welcome card + shown while the current view is empty. + ============================================================ */ +const ICON_PATHS = { + code: '', + legend: + '', + gauge: + '', + info: '', + home: '', + graph: + '', + cube: '', + chart: + '', + venn: '', + help: '', + database: + '', + sliders: + '', + sparkle: '', + cloud: + '', + folder: + '', + swap: '', + layout: + '', + groups: + '', + fog: '', + palette: + '', + node: '', + tag: '', + edge: '', + layers: '', + list: '', + play: '', + upload: '', +} + +function iconSvg(name) { + const body = ICON_PATHS[name] + if (!body) return '' + return `` +} + +function iconSpan(name) { + const span = document.createElement('span') + span.className = 'ico' + span.innerHTML = iconSvg(name) + return span +} + +// page wiring, run by main.ts in the original order +export function init() { + /* ---------- wiring ---------- */ + ;(function setupStringImporter() { + const sel = document.getElementById('stringSpecies') + STRING_SPECIES.forEach(([id, name]) => sel.add(new Option(name, String(id)))) + sel.add(new Option('Other organism (enter the taxon ID)…', 'other')) + const chBox = document.getElementById('stringChannels') + STRING_CHANNELS.forEach(([, key, label]) => { + const row = document.createElement('label') + row.className = 'string-check' + row.innerHTML = `${escapeHtml(label)}` + chBox.appendChild(row) + }) + const catBox = document.getElementById('stringCategories') + STRING_CATEGORIES.forEach(([key, label, on]) => { + const row = document.createElement('label') + row.className = 'string-check' + row.innerHTML = `${escapeHtml(label)}` + catBox.appendChild(row) + }) + const other = document.createElement('label') + other.className = 'string-check' + other.innerHTML = + 'Any other collection STRING offers' + catBox.appendChild(other) + document.getElementById('stringSection').addEventListener('input', updateStringUI) + document.getElementById('stringSection').addEventListener('change', updateStringUI) + document.getElementById('btnStringFetch').addEventListener('click', runStringImport) + document.getElementById('btnStringGroupings').addEventListener('click', runStringGroupings) + document.getElementById('btnStringCancel').addEventListener('click', () => { + stringState.cancelled = true + if (stringState.abort) stringState.abort.abort() + }) + document.getElementById('stringQuery').addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) { + ev.preventDefault() + runStringImport() + } + }) + document.getElementById('stringConnect').addEventListener('change', () => { + stringRoute.checked = false + stringRoute.mode = null + updateStringConnectState() + }) + updateStringConnectState() + document.getElementById('btnStringCatAll').addEventListener('click', () => { + STRING_CATEGORIES.forEach((c) => { + document.getElementById('stringCat_' + c[0]).checked = true + }) + updateStringUI() + }) + document.getElementById('btnStringCatNone').addEventListener('click', () => { + STRING_CATEGORIES.forEach((c) => { + document.getElementById('stringCat_' + c[0]).checked = false + }) + document.getElementById('stringCatOther').checked = false + updateStringUI() + }) + updateStringUI() + })() + + ;(function addIcons() { + const tabIcons = { + welcome: 'home', + about: 'info', + api: 'code', + network: 'graph', + network3d: 'cube', + profiler: 'chart', + compare: 'venn', + help: 'help', + } + document.querySelectorAll('#tabs .tab').forEach((t) => { + if (tabIcons[t.dataset.tab]) t.prepend(iconSpan(tabIcons[t.dataset.tab])) + }) + const sideIcons = { data: 'upload', db: 'database', display: 'sliders', export: 'swap' } + document + .querySelectorAll('#sideTabs [data-side]') + .forEach((t) => t.prepend(iconSpan(sideIcons[t.dataset.side]))) + const sectionIcons = { + Examples: 'sparkle', + STRING: 'cloud', + Files: 'folder', + 'Open saved work': 'upload', + Reactome: 'cloud', + OmniPath: 'cloud', + NDEx: 'cloud', + IntAct: 'cloud', + 'Gene Ontology': 'cloud', + 'NORMA files': 'folder', + Image: 'palette', + Arena3D: 'cube', + 'Other tools': 'swap', + 'Save your work': 'upload', + '3D layout': 'cube', + Layout: 'layout', + 'Node groups': 'groups', + 'Group highlighting': 'fog', + Colors: 'palette', + Nodes: 'node', + Labels: 'tag', + Edges: 'edge', + 'Edge channels': 'layers', + Attributes: 'list', + Legend: 'legend', + Performance: 'gauge', + } + document.querySelectorAll('.section > h3').forEach((h) => { + const title = [...h.childNodes] + .filter((n) => n.nodeType === 3) + .map((n) => n.textContent) + .join('') + .trim() + if (sectionIcons[title]) h.prepend(iconSpan(sectionIcons[title])) + }) + document.querySelectorAll('.ico[data-icon]').forEach((s) => { + s.innerHTML = iconSvg(s.dataset.icon) + }) + })() +} diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..4bcf0dc --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,3644 @@ +:root { + --bg: #0f1420; + --panel: #161d2e; + --panel-2: #1d2740; + --panel-2-hover: #223050; + --line: #2a3554; + --text: #e7ebf5; + --muted: #8993ad; + --accent: #5fd3c4; + --accent-dim: #3a7d74; + --accent-hover: #7fe0d3; + --accent-text: #08221e; + --warn: #e7a15f; + --canvas-dot: #1c2540; + --mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace; + --sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} +* { + box-sizing: border-box; +} +[hidden] { + display: none !important; +} +#shapePopover { + position: fixed; + z-index: 100; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); +} +.shape-pop-title { + font-size: 11.5px; + color: var(--muted); + margin: 0 2px 6px; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.shape-grid { + display: grid; + grid-template-columns: repeat(7, 28px); + gap: 3px; +} +.shape-opt { + width: 28px; + height: 28px; + padding: 5px; + display: flex; + align-items: center; + justify-content: center; +} +.shape-opt svg, +.gshape svg { + width: 100%; + height: 100%; + fill: var(--muted); +} +.shape-opt[aria-selected='true'] { + border-color: var(--accent); + background: var(--panel-2-hover); +} +.shape-opt[aria-selected='true'] svg { + fill: var(--accent); +} +.toggle-row .gshape { + width: 20px; + height: 20px; + padding: 3px; + flex-shrink: 0; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; +} +html, +body { + margin: 0; + padding: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--sans); + overflow: hidden; +} +#app { + display: grid; + --side-w: 300px; + position: relative; + grid-template-columns: var(--side-w) minmax(0, 1fr); + grid-template-rows: 52px 42px 36px minmax(0, 1fr); + grid-template-areas: + 'brand viewhead' + 'sidetabs topbar' + 'sidebar ctxbar' + 'sidebar canvas'; + height: 100vh; + width: 100vw; +} +#brand { + grid-area: brand; + display: flex; + align-items: center; + gap: 10px; + padding: 0 18px; + border-bottom: 1px solid var(--line); + border-right: 1px solid var(--line); + background: var(--panel); +} +#brand .dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 10px var(--accent); +} +#brand .title { + font-weight: 600; + font-size: 14.5px; + letter-spacing: 0.2px; +} +#brand .title small { + display: block; + font-size: 10.5px; + color: var(--muted); + font-weight: 400; + margin-top: 1px; +} +#topbar { + grid-area: topbar; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 18px; + border-bottom: 1px solid var(--line); + background: var(--panel); + gap: 14px; +} +#topbar .group { + display: flex; + align-items: center; + gap: 8px; +} +#search { + background: var(--bg); + border: 1px solid var(--line); + color: var(--text); + padding: 7px 10px; + border-radius: 6px; + font-size: 13px; + width: 220px; + outline: none; +} +#search:focus { + border-color: var(--accent-dim); +} +#stats { + font-size: 12px; + color: var(--muted); + font-family: var(--mono); + white-space: nowrap; + flex-shrink: 0; +} +@media (max-width: 1180px) { + #stats { + font-size: 11px; + } +} +#stats b { + color: var(--text); + font-weight: 600; +} +button { + font-family: var(--sans); + font-size: 12.5px; + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--line); + padding: 7px 12px; + border-radius: 6px; + cursor: pointer; + transition: + border-color 0.15s, + background 0.15s; +} +button:hover { + border-color: var(--accent-dim); + background: var(--panel-2-hover); +} +button.primary { + background: var(--accent); + color: var(--accent-text); + border-color: var(--accent); + font-weight: 600; +} +button.primary:hover { + background: var(--accent-hover); +} +#sidebar { + grid-area: sidebar; + background: var(--panel); + border-right: 1px solid var(--line); + overflow-y: auto; + padding: 16px 16px 40px; +} +#sidebar { + padding-top: 0; +} +.dl-group { + margin: 14px 0 4px; +} +.help-callout { + border: 1px solid color-mix(in srgb, #10b981 45%, var(--line)); + border-left: 4px solid #10b981; + background: color-mix(in srgb, #10b981 8%, var(--panel)); + border-radius: 8px; + padding: 12px 16px; + margin: 8px 0 14px; +} +.help-callout h3 { + margin: 0 0 6px; + font-size: 15px; + color: color-mix(in srgb, #10b981 55%, var(--text)); +} +.help-callout p { + margin: 6px 0; +} +.help-callout-alt { + border-color: color-mix(in srgb, #3b82f6 45%, var(--line)); + border-left-color: #3b82f6; + background: color-mix(in srgb, #3b82f6 8%, var(--panel)); +} +.help-callout-dir { + border-color: color-mix(in srgb, #8b5cf6 45%, var(--line)); + border-left-color: #8b5cf6; + background: color-mix(in srgb, #8b5cf6 8%, var(--panel)); +} +.help-callout-dir h3 { + color: color-mix(in srgb, #8b5cf6 60%, var(--text)); +} +.help-callout-3d { + border-color: color-mix(in srgb, #0ea5e9 45%, var(--line)); + border-left-color: #0ea5e9; + background: color-mix(in srgb, #0ea5e9 8%, var(--panel)); +} +.help-callout-3d h3 { + color: color-mix(in srgb, #0ea5e9 60%, var(--text)); +} +.help-callout-string { + border-color: color-mix(in srgb, #ef4444 40%, var(--line)); + border-left-color: #ef4444; + background: color-mix(in srgb, #ef4444 6%, var(--panel)); +} +.help-callout-string h3 { + color: color-mix(in srgb, #ef4444 60%, var(--text)); +} +.help-callout-alt h3 { + color: color-mix(in srgb, #3b82f6 60%, var(--text)); +} +.help-callout ul { + margin: 6px 0; +} +.help-note { + padding: 8px 12px; + border-left: 3px solid #3b82f6; + background: color-mix(in srgb, #3b82f6 7%, var(--panel)); + border-radius: 6px; +} +.help-body p.cite { + padding-left: 12px; + border-left: 2px solid var(--accent); +} +.dl-group h3 { + margin: 0 0 4px; +} +.dl-refs { + margin: 0 0 6px; + font-size: 12.5px; +} +.dl-list { + margin: 0; + padding-left: 18px; +} +.dl-list li { + margin: 3px 0; +} +.dl-buttons { + white-space: nowrap; +} +.dl-buttons .dl-copy:first-child { + margin-left: 0; +} +.dl-copy { + margin-left: 8px; + padding: 2px 7px; + font-size: 11px; + vertical-align: 1px; +} +#viewBar { + position: sticky; + top: 0; + z-index: 5; + background: var(--panel); + margin: 0 -16px 14px; + padding: 12px 16px 0; + border-bottom: 1px solid var(--line); +} +.viewbar-head { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 4px; +} +.viewbar-head label { + font-size: 12px; + font-weight: 600; + color: var(--text); +} +#viewSelect { + font-weight: 600; +} +.view-actions { + margin-top: 6px; +} +.view-actions button { + padding: 5px 8px; + font-size: 11.5px; +} +#sideTabs { + display: flex; + margin-top: 10px; +} +#sideTabs button { + flex: 1; + background: none; + border: none; + border-radius: 0; + border-bottom: 2px solid transparent; + padding: 8px 4px 7px; + font-size: 12.5px; + font-weight: 600; + color: var(--muted); +} +#sideTabs button:hover { + color: var(--text); + background: none; +} +#sideTabs button[aria-selected='true'] { + color: var(--text); + border-bottom-color: var(--accent); +} +.row.stack { + flex-direction: column; + gap: 6px; +} +.seg { + display: flex; + border: 1px solid var(--line); + border-radius: 7px; + padding: 2px; + background: var(--bg); + margin-bottom: 8px; +} +.seg button { + flex: 1; + border: none; + background: none; + border-radius: 5px; + padding: 6px 4px; + font-size: 12px; + font-weight: 600; + color: var(--muted); +} +.seg button:hover { + background: none; + color: var(--text); +} +.seg button[aria-checked='true'] { + background: var(--accent); + color: var(--accent-text); +} +.layout-set { + border: 1px solid var(--line); + border-radius: 7px; + margin: 0 0 8px; + padding: 8px 10px 10px; + min-width: 0; + transition: opacity 0.15s; +} +.layout-set legend { + font-size: 11px; + font-weight: 600; + color: var(--muted); + padding: 0 4px; +} +.layout-set:disabled { + opacity: 0.45; +} +.layout-set:disabled .strategy-fig { + display: none; +} +.layout-set:disabled legend::after { + content: ' (frozen)'; + font-weight: 400; +} +.hint-line { + font-size: 11px; + color: var(--muted); + line-height: 1.4; + margin-top: 4px; +} +#sidebar::-webkit-scrollbar { + width: 8px; +} +#sidebar::-webkit-scrollbar-thumb { + background: var(--line); + border-radius: 4px; +} +.section { + margin-bottom: 22px; +} +.section h3 { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--muted); + margin: 0 0 10px; + font-weight: 600; +} +select { + width: 100%; + background: var(--bg); + color: var(--text); + border: 1px solid var(--line); + padding: 8px 10px; + border-radius: 6px; + font-size: 13px; + outline: none; +} +.row { + display: flex; + gap: 8px; + margin-top: 8px; +} +/* buttons in a row share the width equally and may shrink below their + text width, so a row never sticks out of its (padded) section */ +.row > button { + flex: 1 1 0; + min-width: 0; + padding-left: 6px; + padding-right: 6px; +} +.row.three > button { + font-size: 12px; + padding-left: 4px; + padding-right: 4px; +} +input[type='range'] { + width: 100%; + margin-left: 0; + margin-right: 0; + box-sizing: border-box; +} +.toggle-row { + display: flex; + align-items: center; + gap: 9px; + padding: 6px 0; + font-size: 12.5px; + cursor: pointer; + user-select: none; +} +.toggle-row input { + accent-color: var(--accent); + width: 14px; + height: 14px; + cursor: pointer; +} +.swatch { + width: 22px; + height: 3px; + border-radius: 2px; + flex-shrink: 0; +} +input[type='color'].swatch-picker { + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 14px; + flex-shrink: 0; + border: 1px solid var(--line); + padding: 0; + border-radius: 3px; + background: none; + cursor: pointer; +} +input[type='color'].swatch-picker::-webkit-color-swatch-wrapper { + padding: 0; +} +input[type='color'].swatch-picker::-webkit-color-swatch { + border: none; + border-radius: 2px; +} +input[type='color'].swatch-picker::-moz-color-swatch { + border: none; + border-radius: 2px; +} +input[type='color'].swatch-picker.round { + width: 14px; + height: 14px; + border-radius: 50%; +} +input[type='color'].swatch-picker.round::-webkit-color-swatch { + border-radius: 50%; +} +input[type='color'].swatch-picker.round::-moz-color-swatch { + border-radius: 50%; +} +.toggle-row .label { + flex: 1; + color: var(--text); +} +.toggle-row .count { + color: var(--muted); + font-family: var(--mono); + font-size: 11px; +} +#edgeLegend, +#nodeGroupLegend { + max-height: 260px; + overflow-y: auto; + padding-right: 4px; +} +#edgeLegend::-webkit-scrollbar, +#nodeGroupLegend::-webkit-scrollbar { + width: 6px; +} +#edgeLegend::-webkit-scrollbar-thumb, +#nodeGroupLegend::-webkit-scrollbar-thumb { + background: var(--line); + border-radius: 3px; +} +textarea { + width: 100%; + height: 110px; + background: var(--bg); + color: var(--text); + border: 1px solid var(--line); + border-radius: 6px; + font-family: var(--mono); + font-size: 11.5px; + padding: 8px; + resize: vertical; + outline: none; +} +textarea:focus { + border-color: var(--accent-dim); +} +#canvas { + grid-area: canvas; + position: relative; + background: + radial-gradient(circle at 1px 1px, var(--canvas-dot) 1.5px, transparent 0) 0 0/22px 22px, + var(--bg); +} +#groupHullCanvas { + position: absolute; + inset: 0; + pointer-events: none; +} +#cy { + position: absolute; + inset: 0; +} +#cy3d { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: block; + cursor: grab; + outline: none; + touch-action: none; +} +#cy3d[hidden] { + display: none !important; +} +#canvas.mode3d #cy, +#canvas.mode3d #groupHullCanvas { + visibility: hidden; +} +#canvas.mode3d { + background: var(--bg); +} +#selectBox3d { + position: absolute; + border: 1px solid var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); + pointer-events: none; +} +#nav3d { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 4px; + padding-top: 8px; + border-top: 1px solid var(--line); +} +.nav3d-pad { + display: grid; + grid-template-columns: repeat(3, 32px); + gap: 3px; +} +#zoomctl #nav3d button { + width: 32px; + height: 32px; +} +#zoomctl #nav3d #btnAuto3d { + padding: 0 8px; + font-size: 11px; + width: 102px; +} +#zoomctl, +#hint, +#nav3d { + user-select: none; + -webkit-user-select: none; +} +#canvas.mode3d #hint { + left: 132px; +} +#btnAuto3d[aria-pressed='true'] { + background: var(--accent); + color: var(--accent-text); + border-color: var(--accent); +} +#infopanel { + position: absolute; + bottom: 16px; + right: 16px; + width: 280px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + padding: 14px 16px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); + display: none; + max-height: 60%; + overflow-y: auto; +} +#infopanel.show { + display: block; +} +#infopanel h4 { + margin: 0 0 2px; + font-size: 15px; + display: flex; + align-items: center; + gap: 8px; +} +#infopanel .nodedot { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} +#infopanel .sub { + font-size: 11px; + color: var(--muted); + margin-bottom: 10px; + font-family: var(--mono); +} +#infopanel .connlist { + font-size: 12px; +} +#infopanel .conn { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 5px 0; + border-top: 1px solid var(--line); +} +#infopanel .conn .ename { + color: var(--muted); +} +#infopanel .close { + position: absolute; + top: 10px; + right: 10px; + background: none; + border: none; + color: var(--muted); + cursor: pointer; + font-size: 16px; + padding: 2px 6px; +} +#edgePopup { + position: absolute; + width: 250px; + max-height: 60%; + overflow-y: auto; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + padding: 13px 15px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45); + display: none; + z-index: 30; +} +#edgePopup.show { + display: block; +} +#edgePopup .eptitle { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 13.5px; + margin: 0 0 8px; +} +#edgePopup .epdot { + width: 11px; + height: 11px; + border-radius: 50%; + flex-shrink: 0; +} +#edgePopup .eprow { + display: flex; + justify-content: space-between; + gap: 10px; + padding: 4px 0; + border-top: 1px solid var(--line); + font-size: 12px; + color: var(--muted); +} +#edgePopup .eprow span:last-child { + color: var(--text); + font-family: var(--mono); + font-size: 11px; + text-align: right; +} +#edgePopup .close { + position: absolute; + top: 8px; + right: 8px; + background: none; + border: none; + color: var(--muted); + cursor: pointer; + font-size: 15px; + padding: 2px 6px; +} +#selectionBar { + position: absolute; + top: 12px; + right: 12px; + z-index: 20; + display: flex; + align-items: center; + gap: 6px; + background: var(--panel); + border: 1px solid var(--accent); + border-radius: 8px; + padding: 5px 6px 5px 10px; + font-size: 12px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); +} +#selectionBar button { + padding: 4px 8px; + font-size: 11.5px; +} +#selectionText { + font-weight: 600; + margin-right: 2px; +} +.undo-group { + display: flex; + gap: 2px; +} +.undo-group button { + width: 32px; + height: 32px; + padding: 0; + display: grid; + place-items: center; +} +.undo-group svg { + width: 16px; + height: 16px; +} +#searchPanel { + position: absolute; + top: 12px; + left: 12px; + z-index: 20; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 8px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); + width: min(400px, calc(100% - 24px)); +} +#searchPanel .search-row { + display: flex; + gap: 6px; + align-items: center; +} +#search { + flex: 1; + min-width: 0; + width: auto; +} +#search[aria-invalid='true'] { + border-color: var(--warn); +} +#searchMode { + width: auto; + flex-shrink: 0; + padding: 7px 8px; + font-size: 12.5px; +} +#searchPanel .search-opts { + margin-top: 6px; + gap: 12px; + font-size: 11.5px; + color: var(--muted); + flex-wrap: wrap; +} +#searchPanel .search-opts label { + display: flex; + align-items: center; + gap: 5px; + cursor: pointer; + user-select: none; +} +#searchPanel .search-opts input { + accent-color: var(--accent); + margin: 0; +} +#searchStatus { + margin-left: auto; + font-family: var(--mono); + font-size: 11px; +} +#searchStatus.error { + color: var(--warn); + font-family: var(--sans); + margin-left: 0; + flex-basis: 100%; +} +#zoomLevel { + font-family: var(--mono); + font-size: 10px; + color: var(--muted); + text-align: center; + padding-top: 2px; +} +#zoomctl button.fit { + font-size: 11px; +} +#hint { + position: absolute; + bottom: 16px; + left: 64px; + font-size: 11.5px; + color: var(--muted); + background: var(--panel); + border: 1px solid var(--line); + padding: 6px 10px; + border-radius: 6px; + pointer-events: none; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); +} +/* NORMA-2.0 strategy schematic (after Fig. 1 of Karatzas et al. 2022) */ +.strategy-fig { + margin: 8px 0 0; + padding: 8px 8px 6px; + background: var(--bg); + border: 1px solid var(--line); + border-radius: 6px; +} +.strategy-fig svg { + display: none; + width: 100%; + height: auto; +} +.strategy-fig svg.on { + display: block; +} +.strategy-fig .e { + stroke: var(--muted); + stroke-width: 1.3; +} +.strategy-fig .e.add { + stroke-dasharray: 3 3; +} +.strategy-fig .e.heavy { + stroke-width: 3; +} +.strategy-fig .e.faint { + opacity: 0.35; +} +.strategy-fig .na { + fill: #e8a15f; +} +.strategy-fig .nb { + fill: #5fd3c4; +} +.strategy-fig .nab { + fill: url(#figSplit); +} +.strategy-fig .n0 { + fill: #9aa0a6; +} +.strategy-fig .vn { + fill: var(--bg); + stroke: var(--text); + stroke-width: 1.3; + stroke-dasharray: 2 2; +} +.strategy-fig .ring { + fill: none; + stroke: var(--line); + stroke-dasharray: 2 3; +} +.strategy-fig .arrow { + fill: none; + stroke: var(--muted); + stroke-width: 1.5; +} +.strategy-fig text { + fill: var(--muted); + font-size: 9.5px; + font-family: var(--sans); +} +.strategy-fig figcaption { + font-size: 11px; + color: var(--muted); + line-height: 1.45; + margin-top: 6px; +} +/* shared key/value row for node, group and edge attributes */ +.kvrow { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 2px 0; +} +.kvrow span:first-child { + color: var(--muted); + flex-shrink: 0; +} +.kvrow span:last-child { + color: var(--text); + text-align: right; + word-break: break-word; +} +.kvhead { + color: var(--muted); + margin-bottom: 3px; +} +#infopanel .desc { + font-size: 12px; + color: var(--text); + line-height: 1.45; + margin: -4px 0 10px; +} +#infopanel .conn.jump { + cursor: pointer; +} +#infopanel .conn.jump:hover span:first-child { + color: var(--accent); +} +.glink { + background: none; + border: none; + padding: 0; + margin: 0; + font: inherit; + color: inherit; + text-decoration: underline dotted; + text-underline-offset: 2px; + cursor: pointer; +} +.glink:hover { + color: var(--accent); + background: none; +} +.toggle-row .ginfo { + width: 18px; + height: 18px; + padding: 0; + flex-shrink: 0; + border-radius: 50%; + font-size: 10.5px; + font-weight: 600; + line-height: 1; + font-family: var(--mono); + color: var(--muted); +} +.toggle-row .ginfo:hover { + color: var(--text); +} +button:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} +/* NORMA files */ +.field-label { + font-size: 11px; + color: var(--muted); + display: block; + margin: 10px 0 3px; +} +.select-row { + display: flex; + gap: 6px; +} +.select-row select { + flex: 1; + min-width: 0; +} +.select-row button { + flex-shrink: 0; + padding: 7px 9px; +} +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} +button:disabled:hover { + border-color: var(--line); + background: var(--panel-2); +} +.text-input { + width: 100%; + background: var(--bg); + color: var(--text); + border: 1px solid var(--line); + border-radius: 6px; + padding: 7px 10px; + font-size: 12.5px; + outline: none; +} +.text-input:focus { + border-color: var(--accent-dim); +} +.status { + margin-top: 10px; +} +.status:empty { + display: none; +} +.status:empty { + display: none; +} +.status .note { + font-size: 11.5px; + line-height: 1.45; + padding: 5px 0 5px 10px; + border-left: 2px solid var(--line); + margin-bottom: 4px; + overflow-wrap: anywhere; +} +.status .note.ok { + border-left-color: var(--accent); +} +.status .note.warn { + border-left-color: var(--warn); + color: var(--text); +} +.status .note.error { + border-left-color: var(--warn); + color: var(--warn); + font-weight: 600; +} +.status .note button { + display: block; + margin-top: 6px; +} +.status .note.busy { + border-left-color: var(--muted); + color: var(--muted); +} +.status .note .progress-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 6px; +} +.status .note .progress { + position: relative; + flex: 1; + height: 6px; + border-radius: 3px; + overflow: hidden; + background: color-mix(in srgb, var(--muted) 22%, transparent); +} +.status .note .progress > span { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 0; + border-radius: 3px; + background: var(--accent); + transition: width 0.25s ease; +} +.status .note .progress.indeterminate > span { + width: 35%; + animation: progress-slide 1.3s ease-in-out infinite; +} +.status .note .progress-pct { + font-family: var(--mono); + font-size: 11px; + min-width: 3.2em; + text-align: right; + color: var(--text); +} +@keyframes progress-slide { + from { + transform: translateX(-100%); + } + to { + transform: translateX(290%); + } +} +@media (prefers-reduced-motion: reduce) { + .status .note .progress.indeterminate > span { + animation: none; + width: 100%; + opacity: 0.45; + } + .status .note .progress > span { + transition: none; + } +} + +/* sidebar structure */ +.section h3 { + display: flex; + align-items: center; + justify-content: space-between; +} +.helplink { + width: 18px; + height: 18px; + padding: 0; + border-radius: 50%; + font-size: 10.5px; + line-height: 1; + font-weight: 600; + color: var(--muted); + background: transparent; + letter-spacing: 0; + text-transform: none; +} +.helplink:hover { + color: var(--text); +} +.slider-label { + font-size: 11px; + color: var(--muted); + display: flex; + justify-content: space-between; + margin-top: 8px; +} +input[type='range'] { + width: 100%; + accent-color: var(--accent); +} +.num-input { + width: 100%; + background: var(--bg); + color: var(--text); + border: 1px solid var(--line); + border-radius: 6px; + padding: 6px 8px; + font-size: 12.5px; +} +.upload-box { + border: 1px dashed var(--line); + border-radius: 8px; + padding: 8px; + transition: + border-color 0.15s, + background 0.15s; +} +.upload-box.dragging { + border-color: var(--accent); + background: var(--panel-2); +} +.drop-hint { + font-size: 11px; + color: var(--muted); + text-align: center; + margin-top: 6px; +} +.lib-head { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: 12px; + font-weight: 600; + margin: 12px 0 4px; +} +.lib-hint { + font-size: 11px; + color: var(--muted); + font-weight: 400; +} +.lib-list { + max-height: 176px; + overflow-y: auto; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--bg); +} +.lib-row { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 4px 5px 8px; + font-size: 12px; + cursor: pointer; + border-top: 1px solid var(--line); +} +.lib-row:first-child { + border-top: none; +} +.lib-row:hover { + background: var(--panel-2); +} +.lib-row input { + accent-color: var(--accent); + margin: 0; + flex-shrink: 0; + cursor: pointer; +} +.lib-text { + flex: 1; + min-width: 0; +} +.lib-name { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.lib-meta { + display: block; + font-size: 10.5px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.lib-remove { + flex-shrink: 0; + width: 22px; + height: 22px; + padding: 0; + background: transparent; + border-color: transparent; + color: var(--muted); + font-size: 12px; +} +.lib-remove:hover { + color: var(--warn); + border-color: var(--line); +} +.lib-empty { + font-size: 11.5px; + color: var(--muted); + padding: 7px 8px; +} +.refresh-hint { + font-size: 11px; + color: var(--warn); + margin-top: 5px; +} +#btnRefreshView.pending { + box-shadow: + 0 0 0 2px var(--panel), + 0 0 0 3px var(--warn); +} + +/* tabs */ +#tabs { + display: flex; + gap: 2px; + height: 100%; + align-items: stretch; +} +.tab { + background: none; + border: none; + border-radius: 0; + border-bottom: 2px solid transparent; + padding: 0 12px; + font-size: 13px; + color: var(--muted); +} +.tab:hover { + background: none; + color: var(--text); + border-color: transparent; + border-bottom-color: var(--line); +} +.tab[aria-selected='true'] { + color: var(--text); + border-bottom-color: var(--accent); + font-weight: 600; +} +#topTools { + margin-right: auto; + margin-left: 10px; +} +#topbar { + padding: 0 18px 0 8px; +} + +/* full-page views */ +.page { + grid-area: canvas; + overflow-y: auto; + background: var(--bg); + display: none; +} +.page.active { + display: block; +} +#canvas.inactive { + display: none; +} +.page-inner { + max-width: 1180px; + padding: 26px 32px 64px; +} +.page-head h2 { + margin: 0 0 6px; + font-size: 20px; + font-weight: 600; +} +.page-head p { + margin: 0 0 18px; + color: var(--muted); + font-size: 13px; + line-height: 1.5; + max-width: 72ch; +} + +/* profiler */ +.prof-controls { + display: flex; + gap: 18px; + align-items: flex-end; + flex-wrap: wrap; +} +.prof-netlist { + display: flex; + flex-wrap: wrap; + gap: 6px; + max-width: 760px; +} +.prof-netlist label { + display: flex; + align-items: center; + gap: 6px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + padding: 5px 9px; + font-size: 12px; + cursor: pointer; +} +.prof-netlist input { + accent-color: var(--accent); + margin: 0; +} +.prof-actions .toggle-row { + margin: 0 6px 0 0; +} +.prof-actions { + display: flex; + gap: 8px; +} +.prof-section { + margin-top: 28px; +} +.prof-section h3 { + font-size: 14px; + font-weight: 600; + margin: 0 0 10px; +} +.prof-section .sub { + font-size: 12px; + color: var(--muted); + margin: -4px 0 10px; +} +.table-wrap { + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); +} +table.data { + border-collapse: collapse; + width: 100%; + font-size: 12.5px; +} +table.data th, +table.data td { + padding: 6px 12px; + border-bottom: 1px solid var(--line); + text-align: right; + white-space: nowrap; +} +table.data th:first-child, +table.data td:first-child { + text-align: left; +} +table.data thead th { + font-weight: 600; + background: var(--panel-2); + position: sticky; + top: 0; +} +table.data tbody tr:last-child td { + border-bottom: none; +} +table.data td.num { + font-family: var(--mono); + font-size: 12px; +} +table.data td .stat-desc { + display: block; + font-size: 11px; + color: var(--muted); + line-height: 1.35; + max-width: 34ch; + white-space: normal; + margin-top: 1px; +} +.cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 14px; +} +.card { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 12px 14px; + min-width: 0; +} +.card h4 { + margin: 0 0 8px; + font-size: 13px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.card .meta { + font-size: 11.5px; + color: var(--muted); + margin: 0 0 8px; + line-height: 1.45; +} +.chart svg { + display: block; + width: 100%; + height: auto; +} +.chart .bar { + fill: var(--accent); +} +.chart .dot { + fill: var(--accent); +} +.chart .axis { + stroke: var(--line); +} +.chart text { + fill: var(--muted); + font-size: 10px; + font-family: var(--sans); +} +.inline-controls { + display: flex; + gap: 14px; + align-items: center; + font-size: 12px; + margin-bottom: 10px; + flex-wrap: wrap; +} +.inline-controls label { + display: flex; + gap: 6px; + align-items: center; + cursor: pointer; +} +.inline-controls select { + width: auto; +} +.linkish { + background: none; + border: none; + padding: 0; + color: var(--text); + font: inherit; + text-decoration: underline dotted; + text-underline-offset: 2px; + cursor: pointer; +} +.linkish:hover { + background: none; + color: var(--accent); +} +.size-strip { + display: flex; + height: 10px; + border-radius: 3px; + overflow: hidden; + margin: 6px 0 10px; + background: var(--panel-2); +} +.size-strip span { + display: block; + height: 100%; + border-right: 1px solid var(--panel); +} + +/* help */ +.help-layout { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 28px; + padding: 26px 32px 80px; + max-width: 1100px; +} +.help-nav { + position: sticky; + top: 20px; + align-self: start; + display: flex; + flex-direction: column; + gap: 1px; + max-height: calc(100vh - 170px); + overflow-y: auto; + overscroll-behavior: contain; + padding-right: 6px; + scrollbar-width: thin; +} +.help-nav a { + flex-shrink: 0; +} +.help-nav-group { + margin: 14px 0 4px; + padding: 0 10px; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); + flex-shrink: 0; +} +.help-nav-group:first-child { + margin-top: 0; +} +.help-nav .help-nav-about { + color: var(--accent); +} +.cite-box { + margin: 0 0 18px; + padding: 14px 18px; + border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--line)); + border-left: 5px solid var(--accent); + background: color-mix(in srgb, var(--accent) 8%, var(--panel)); +} +.cite-box > b { + display: block; + font-size: 15px; + margin-bottom: 4px; +} +.cite-list { + margin: 6px 0 8px; + padding-left: 20px; +} +.cite-list li { + margin: 6px 0; + font-size: 13px; + line-height: 1.5; + padding-left: 2px; +} +.cite-list li::marker { + color: var(--accent); +} +.cite-note { + color: var(--muted); + font-size: 12px; +} +.cite-box p { + margin: 6px 0; + font-size: 13px; + line-height: 1.5; +} +.cite-box .cite-more { + color: var(--muted); + font-size: 12.5px; +} +.about-cite-box h2 { + margin-top: 0 !important; +} +.help-nav .help-nav-cite { + font-weight: 600; + color: var(--accent); + margin-bottom: 4px; +} +.api-title { + margin: 0 0 8px; + font-size: 26px; +} +.api-ways { + margin: 12px 0 8px; +} +.api-base { + color: var(--muted); + font-size: 13px; +} +.api-code-wrap { + position: relative; + margin: 8px 0 14px; +} +.api-code { + margin: 0; + padding: 12px 14px; + border-radius: 8px; + background: var(--panel-2, var(--panel)); + border: 1px solid var(--line); + overflow-x: auto; + font-size: 12.5px; + line-height: 1.5; +} +.api-copy { + position: absolute; + top: 6px; + right: 6px; + padding: 2px 10px; + font-size: 11.5px; + z-index: 1; +} +.api-payload { + width: 100%; + min-height: 300px; + font-family: var(--mono); + font-size: 12.5px; + line-height: 1.45; +} +.api-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 10px 0; +} +.api-result { + display: flex; + gap: 8px; + align-items: center; + margin: 6px 0; +} +.api-result input { + flex: 1; + font-family: var(--mono); + font-size: 12px; +} +.help-part { + margin: 34px 0 -6px; + padding-top: 14px; + border-top: 2px solid var(--line); + font-size: 11.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); +} +.help-intro { + font-size: 14px; + color: var(--muted); +} +.brand-home { + display: inline-flex; + border-radius: 6px; +} +.brand-home:hover .brand-logo { + filter: brightness(1.06); +} +.brand-home:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.about-layout { + max-width: 960px; + margin: 0 auto; + padding: 28px 32px 60px; + line-height: 1.6; + font-size: 14px; +} +.about-layout h2 { + margin: 34px 0 10px; + font-size: 20px; + scroll-margin-top: 12px; +} +.about-layout h3 { + margin: 18px 0 6px; + font-size: 15px; +} +.about-hero { + display: flex; + align-items: center; + gap: 24px; + margin-bottom: 14px; +} +.about-hero h1 { + margin: 0 0 6px; + font-size: 26px; +} +.about-hero h1 span { + font-weight: 500; + color: var(--muted); + font-size: 18px; + margin-left: 6px; +} +.about-hero p { + margin: 0; + color: var(--muted); + max-width: 70ch; +} +.about-logo { + flex-shrink: 0; +} +.about-logo-svg { + height: 84px !important; + width: auto; +} +.about-toc { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 10px 0 16px; +} +.about-toc a { + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--panel); + font-size: 12px; + text-decoration: none; + color: var(--text); +} +.about-toc a:hover { + border-color: var(--accent); +} +.about-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + gap: 10px; +} +.about-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 14px; + border-radius: 10px; + border: 1px solid var(--line); + background: var(--panel); + border-top: 3px solid var(--accent); +} +.about-card span { + color: var(--muted); + font-size: 12.5px; + line-height: 1.45; +} +.about-layout h2.flash { + animation: flash 1.2s ease-out; +} +@media (max-width: 760px) { + .about-hero { + flex-direction: column; + align-items: flex-start; + } +} +.help-nav a { + color: var(--muted); + text-decoration: none; + font-size: 12.5px; + padding: 4px 10px; + border-left: 2px solid var(--line); +} +.help-nav a:hover, +.help-nav a.current { + color: var(--text); + border-left-color: var(--accent); +} +.help-body { + max-width: 74ch; + font-size: 14px; + line-height: 1.6; +} +.help-body h2 { + font-size: 18px; + font-weight: 600; + margin: 34px 0 8px; + scroll-margin-top: 16px; +} +.help-body h2:first-child { + margin-top: 0; +} +.help-body h3 { + font-size: 14px; + font-weight: 600; + margin: 18px 0 4px; +} +.help-body p, +.help-body li { + color: var(--text); +} +.help-body a, +.about-layout a { + color: var(--accent); +} +.help-body code { + font-family: var(--mono); + font-size: 12.5px; + background: var(--panel-2); + padding: 1px 4px; + border-radius: 3px; +} +.help-body pre { + font-family: var(--mono); + font-size: 12px; + line-height: 1.5; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 6px; + padding: 10px 12px; + overflow-x: auto; + tab-size: 12; +} +.help-body h2.flash { + animation: flash 1.2s ease-out; +} +@keyframes flash { + from { + color: var(--accent); + } + to { + color: var(--text); + } +} +.help-table { + border-collapse: collapse; + font-size: 12.5px; + line-height: 1.45; + margin: 8px 0 12px; + width: 100%; +} +.help-table th, +.help-table td { + text-align: left; + vertical-align: top; + padding: 6px 10px 6px 0; + border-bottom: 1px solid var(--line); +} +.help-table th { + font-weight: 600; +} +.help-table code { + font-size: 11px; +} +@media (prefers-reduced-motion: reduce) { + .help-body h2.flash { + animation: none; + } +} +@media (max-width: 900px) { + .help-layout { + grid-template-columns: 1fr; + padding: 18px; + } + .help-nav { + position: static; + flex-direction: row; + flex-wrap: wrap; + } +} +#attrSchema .schema-block { + margin-bottom: 10px; +} +#attrSchema .schema-head { + display: flex; + justify-content: space-between; + font-size: 12px; + font-weight: 600; + padding-bottom: 3px; + border-bottom: 1px solid var(--line); + margin-bottom: 3px; +} +#attrSchema .schema-head span { + color: var(--muted); + font-weight: 400; + font-family: var(--mono); + font-size: 11px; +} +#attrSchema .schema-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0 8px; + padding: 3px 0; + font-size: 12px; +} +#attrSchema .schema-row .k { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +#attrSchema .schema-row .cov { + color: var(--muted); + font-family: var(--mono); + font-size: 11px; + text-align: right; +} +#attrSchema .schema-row .kind { + grid-column: 1 / -1; + color: var(--muted); + font-size: 11px; +} +#attrSchema .schema-empty { + font-size: 11.5px; + color: var(--muted); + margin: 0 0 4px; +} +#zoomctl { + position: absolute; + bottom: 16px; + left: 16px; + display: flex; + flex-direction: column; + gap: 6px; +} +#zoomctl button { + width: 32px; + height: 32px; + padding: 0; + font-size: 16px; + line-height: 1; + border-radius: 8px; +} + +/* ---------- comparison page ---------- */ +.cmp-controls { + display: flex; + gap: 18px; + align-items: flex-end; + flex-wrap: wrap; +} +.cmp-pick { + flex: 1; + min-width: 320px; +} +.cmp-count { + color: var(--muted); + font-weight: 400; + margin-left: 6px; +} +.cmp-netlist { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 4px 12px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + max-height: 240px; + overflow-y: auto; +} +.cmp-group-head { + grid-column: 1 / -1; + font-size: 11px; + font-weight: 600; + color: var(--muted); + margin-top: 4px; +} +.cmp-option { + display: flex; + gap: 7px; + align-items: flex-start; + font-size: 12.5px; + cursor: pointer; + padding: 3px 0; + min-width: 0; +} +.cmp-option input { + margin-top: 2px; + accent-color: var(--accent); +} +.cmp-option input:disabled + span { + opacity: 0.45; +} +.cmp-name { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.cmp-meta { + display: block; + font-size: 11px; + color: var(--muted); +} +.cmp-overview { + display: flex; + gap: 10px; + flex-wrap: wrap; +} +.cmp-card { + display: flex; + gap: 10px; + align-items: center; + min-width: 200px; + max-width: 280px; + flex: 1; + padding: 8px 12px; + border-radius: 8px; + background: color-mix(in srgb, var(--c) 9%, var(--panel)); + border: 1px solid color-mix(in srgb, var(--c) 35%, var(--line)); +} +.cmp-letter { + width: 26px; + height: 26px; + border-radius: 50%; + background: var(--c); + color: #fff; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.cmp-card-body { + min-width: 0; +} +.cmp-card-name { + font-weight: 600; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.cmp-card-meta { + font-size: 11.5px; + color: var(--muted); +} +.cmp-legend { + display: flex; + gap: 14px; + flex-wrap: wrap; + font-size: 12px; + margin: 0 0 8px; +} +.cmp-legend i, +.cmp-matrix i, +.cmp-topo i { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 5px; + vertical-align: -1px; +} +.cmp-row { + display: flex; + gap: 14px; + flex-wrap: wrap; + align-items: flex-start; +} +.cmp-venn { + flex: 0 0 340px; +} +.cmp-upset { + flex: 1 1 420px; + overflow-x: auto; +} +.cmp-ccdf { + max-width: 620px; +} +.chart .venn-count { + fill: var(--text); + font-size: 13px; + font-weight: 600; +} +.chart .venn-set { + font-size: 12px; + font-weight: 600; +} +.chart .upset-num { + fill: var(--text); + font-size: 9.5px; +} +.chart .upset-set { + fill: var(--text); + font-size: 11px; +} +.chart .upset-on { + fill: var(--text); +} +.chart .upset-off { + fill: var(--line); +} +.chart .upset-link { + stroke: var(--text); + stroke-width: 2; +} +.chart .upset-stripe { + fill: color-mix(in srgb, var(--text) 4%, transparent); +} +.cmp-subhead { + margin: 16px 0 2px; + font-size: 13px; +} +.cmp-matrix { + border-collapse: collapse; + font-size: 12.5px; + margin-top: 6px; +} +.cmp-matrix th, +.cmp-matrix td { + border: 1px solid var(--line); + padding: 6px 10px; + text-align: center; + min-width: 52px; +} +.cmp-matrix th { + background: var(--panel); + font-weight: 600; +} +.cmp-matrix td { + font-family: var(--mono); +} +.cmp-matrix td.na { + color: var(--muted); +} +.cmp-topo th.cmp-rowhead { + text-align: left; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--muted); + background: var(--panel); +} +.cmp-topo td.cmp-max { + font-weight: 700; + color: color-mix(in srgb, #2563eb 70%, var(--text)); +} + +/* ---------- search panel collapse ---------- */ +#searchPanel .search-toggle { + flex-shrink: 0; + min-width: 28px; + height: 28px; + padding: 0 7px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} +#searchPanel .search-toggle svg, +#searchPanel .search-min svg { + width: 14px; + height: 14px; + flex-shrink: 0; +} +#searchPanel .search-min { + flex-shrink: 0; + width: 28px; + height: 28px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} +#searchPanel .search-toggle-text { + display: none; + font-size: 12.5px; + font-weight: 600; +} +#searchPanel.collapsed { + width: auto; + padding: 5px; +} +#searchPanel.collapsed .search-row > :not(.search-toggle), +#searchPanel.collapsed .search-opts { + display: none; +} +#searchPanel.collapsed .search-toggle-text { + display: inline; +} +#searchPanel .search-badge { + display: none; + font-size: 11px; + font-family: var(--mono); + color: var(--muted); +} +#searchPanel.collapsed .search-badge:not(:empty) { + display: inline; +} + +/* ---------- label color controls ---------- */ +.label-color-row { + display: flex; + gap: 6px; + align-items: center; +} +.label-color-row select { + flex: 1; + min-width: 0; +} +.label-color-row input[type='color'] { + width: 34px; + height: 32px; + padding: 2px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--bg); + flex-shrink: 0; +} +.info-shapes { + display: flex; + flex-wrap: wrap; + gap: 3px; + margin: 4px 0 10px; +} +.info-shapes .shape-opt { + width: 24px; + height: 24px; + padding: 4px; +} + +/* ---------- brand, banner, empty state, icons ---------- */ +#brand .dot { + display: none; +} +.brand-logo { + height: 38px; + width: auto; + flex-shrink: 0; + display: block; + margin-left: -4px; +} +.brand-logo .lg-edge { + stroke: var(--text); + opacity: 0.88; +} +.brand-logo .lg-ring { + stroke: var(--panel); +} +#brand .title { + display: flex; + flex-direction: column; + line-height: 1.1; +} +.brand-version { + font-size: 12px; + font-weight: 700; + color: var(--accent); +} +.norma-banner { + width: 100%; + height: auto; + display: block; + --bn-card: var(--panel); + --bn-line: var(--line); + --bn-edge: var(--muted); + --bn-stroke: var(--bg); + --bn-accent: var(--accent); + font-family: var(--sans); +} +.norma-banner .bn-cap { + font-size: 15px; + font-weight: 700; + fill: var(--text); +} +.norma-banner .bn-sub { + font-size: 12px; + fill: var(--muted); +} +.norma-banner .bn-tick { + font-size: 10px; + fill: var(--muted); +} +.help-banner { + margin: 0 0 18px; + width: min(920px, calc(100vw - 560px)); + max-width: none; +} +#emptyState { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + z-index: 3; + pointer-events: none; +} +#emptyState[hidden] { + display: none !important; +} +.welcome-page { + min-height: 100%; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 28px 24px 40px; +} +.welcome-page .welcome { + max-height: none; + overflow: visible; +} +.empty-card.compact { + max-width: 560px; + padding: 20px 24px; +} +.empty-card.compact h2 { + margin-top: 0; +} +.welcome { + pointer-events: auto; + width: min(1080px, 100%); + max-height: 100%; + overflow: auto; + background: color-mix(in srgb, var(--panel) 95%, transparent); + border: 1px solid var(--line); + border-radius: 18px; + padding: 22px 28px 18px; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.12); +} +.welcome-head { + display: flex; + align-items: center; + gap: 22px; + margin-bottom: 12px; +} +.welcome-logo-svg { + height: 72px !important; + width: auto; +} +.welcome-title h1 { + margin: 0 0 4px; + font-size: 22px; + letter-spacing: 0.2px; +} +.welcome-title p { + margin: 0; + color: var(--muted); + font-size: 13.5px; + line-height: 1.5; + max-width: 70ch; +} +.welcome .empty-banner { + margin: 6px 0 12px; +} +.welcome-steps { + list-style: none; + counter-reset: step; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + padding: 0; + margin: 0 0 14px; +} +.welcome-steps li { + counter-increment: step; + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px 10px 44px; + position: relative; + border-radius: 10px; + background: var(--bg); + border: 1px solid var(--line); + font-size: 13px; +} +.welcome-steps li::before { + content: counter(step); + position: absolute; + left: 12px; + top: 10px; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--accent); + color: #fff; + font-weight: 700; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; +} +.welcome-steps span { + color: var(--muted); + font-size: 12px; +} +button.danger { + background: #dc2626; + border-color: #dc2626; + color: #fff; + font-weight: 600; +} +button.danger:hover { + background: #b91c1c; + border-color: #b91c1c; +} +.danger-link { + background: none; + border: none; + color: var(--muted); + font-size: 12px; + padding: 2px 0; + margin-left: auto; + text-decoration: underline; +} +.danger-link:hover { + color: #dc2626; + background: none; +} +.danger-link:disabled { + opacity: 0.5; + text-decoration: none; +} +.lib-danger-row { + margin-top: 4px; +} +.delete-text p { + margin: 0 0 8px; + font-size: 13px; + line-height: 1.5; +} +.delete-choices { + margin: 4px 0 12px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--bg); +} +.delete-choices label { + display: flex; + gap: 8px; + align-items: flex-start; + padding: 4px 0; + font-size: 13px; + cursor: pointer; +} +.delete-choices label small { + display: block; + color: var(--muted); + font-size: 11.5px; +} +.icon-del { + padding: 2px 7px; + min-width: 0; + line-height: 1.2; + color: var(--muted); +} +.icon-del:not(:disabled):hover { + color: #dc2626; + border-color: #dc2626; +} +.del-btn:hover { + color: #dc2626; + border-color: #dc2626; +} +#deleteDialog { + width: min(520px, calc(100vw - 32px)); +} +.list-empty { + margin: 0; + padding: 6px 2px; + font-size: 12.5px; + line-height: 1.45; + color: var(--muted); +} +.group-tools { + display: flex; + gap: 8px; + align-items: center; + margin-top: 6px; +} +.group-tools label { + font-size: 12px; + color: var(--muted); + flex-shrink: 0; +} +.group-tools select { + flex: 1; + min-width: 0; +} +.site-footer { + margin: 14px 0 0; + text-align: center; + font-size: 12px; + line-height: 1.7; + color: var(--text); +} +.site-footer .dot { + margin: 0 7px; + color: var(--muted); +} +.site-footer a { + color: var(--accent); +} +#siteNotice { + position: fixed; + left: 50%; + top: 8px; + transform: translateX(-50%); + z-index: 50; + max-width: min(760px, 92vw); + display: flex; + gap: 12px; + align-items: center; + padding: 8px 12px 8px 16px; + border-radius: 10px; + background: #fef3c7; + color: #78350f; + border: 1px solid #f59e0b; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); + font-size: 13px; +} +#siteNotice[hidden] { + display: none; +} +#siteNotice button { + padding: 2px 8px; + background: transparent; + border-color: #d97706; + color: #78350f; +} +.api-template-box { + margin-top: 6px; +} +.api-template-usage { + margin-left: 8px; + font-size: 12px; + color: var(--muted); +} +.api-caption { + caption-side: top; + text-align: left; + font-weight: 600; + padding: 0 0 6px; +} +.dl-name { + margin-left: 8px; + font-family: var(--mono); + font-size: 11px; + color: var(--muted); +} +.welcome-foot { + margin: 12px 0 0; + text-align: center; + color: var(--muted); + font-size: 11.5px; +} +#canvas.is-empty #searchPanel, +#canvas.is-empty #zoomctl, +#canvas.is-empty #hint, +#canvas.is-empty #nav3d, +#canvas.is-empty #frameBadge, +#canvas.is-empty #legendPanel { + display: none !important; +} +@media (max-width: 900px) { + .welcome-steps { + grid-template-columns: 1fr; + } + .welcome-head { + flex-direction: column; + align-items: flex-start; + } +} +.empty-card { + pointer-events: auto; + max-width: 980px; + width: 100%; + background: color-mix(in srgb, var(--panel) 92%, transparent); + border: 1px solid var(--line); + border-radius: 16px; + padding: 22px 26px; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.12); + text-align: center; +} +.empty-card h2 { + margin: 14px 0 4px; + font-size: 20px; +} +.empty-card p { + margin: 0 auto 14px; + color: var(--muted); + max-width: 62ch; + font-size: 13.5px; + line-height: 1.5; +} +.empty-actions { + display: flex; + gap: 8px; + justify-content: center; + flex-wrap: wrap; +} +.empty-actions button { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 14px; +} +.ico { + display: inline-flex; + width: 15px; + height: 15px; + flex-shrink: 0; +} +.ico svg { + width: 100%; + height: 100%; +} +.section > h3 .ico { + margin-right: 7px; + width: 14px; + height: 14px; + color: color-mix(in srgb, var(--tint) 75%, var(--text)); +} +.section > h3 { + justify-content: flex-start; +} +.section > h3 .helplink { + margin-left: auto; +} +.tab .ico, +#sideTabs .ico { + margin-right: 6px; + vertical-align: -2px; + opacity: 0.85; +} +#sideTabs button { + display: inline-flex; + align-items: center; + justify-content: center; +} +.string-brand { + display: flex; + align-items: center; + gap: 8px; + margin: -2px 0 10px; + text-decoration: none; + color: var(--muted); +} +.string-brand img { + height: 28px; + width: auto; + display: block; +} +.string-wordmark { + font-weight: 800; + letter-spacing: 1.5px; + font-size: 15px; + color: var(--text); + padding: 2px 8px; + border: 1.5px solid var(--line); + border-radius: 6px; +} +.string-tag { + font-size: 10.5px; + line-height: 1.25; +} +/* ---------- numeric values, legend, separation, benchmark ---------- */ +.value-box { + margin-top: 8px; + padding: 8px 10px; + border-radius: 6px; + border: 1px dashed color-mix(in srgb, var(--tint) 35%, var(--line)); +} +.value-scale-preview { + height: 10px; + border-radius: 3px; + margin-top: 6px; + border: 1px solid var(--line); +} +.inline-num { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--muted); + white-space: nowrap; +} +.inline-num .num-input { + width: 80px; +} +.inline-color { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--muted); + margin-top: 8px; +} +.inline-color input { + width: 34px; + height: 24px; + padding: 1px; + border: 1px solid var(--line); + border-radius: 4px; + background: var(--bg); +} +#legendPanel { + position: absolute; + right: 16px; + bottom: 16px; + z-index: 4; + background: color-mix(in srgb, var(--panel) 94%, transparent); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.14); + max-height: calc(100% - 140px); + overflow: auto; +} +#legendPanel[hidden] { + display: none; +} +#canvas:has(#infopanel.show) #legendPanel, +#canvas:has(#edgePopup.show) #legendPanel { + display: none; +} +#legendPanel svg { + display: block; +} +.sep-box { + margin-top: 12px; + padding: 8px 10px; + border-radius: 6px; + background: var(--panel); + border: 1px solid color-mix(in srgb, var(--tint) 25%, var(--line)); +} +.sep-head { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + font-weight: 600; + margin-bottom: 4px; +} +.sep-row { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 2px 8px; + font-size: 12px; + color: var(--muted); + padding: 1px 0; +} +.sep-row b { + color: var(--text); + font-family: var(--mono); + font-weight: 600; +} +.sep-bar { + grid-column: 1 / -1; + position: relative; + height: 5px; + border-radius: 3px; + background: linear-gradient(90deg, #ef4444, #f59e0b 50%, #10b981); + opacity: 0.75; +} +.sep-bar i { + position: absolute; + top: -3px; + width: 3px; + height: 11px; + margin-left: -1.5px; + background: var(--text); + border-radius: 1px; +} +.sep-empty { + font-size: 12px; + color: var(--muted); +} +.cm-controls, +.bench-controls { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin: 4px 0 10px; +} +.bench-table td .sd { + color: var(--muted); + font-size: 11px; +} +.bench-table .bench-use { + padding: 2px 8px; + font-size: 11.5px; +} +#benchSection { + margin-top: 18px; +} +/* ---------- analysis, frames, exports ---------- */ +.frames { + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid var(--line); +} +.frame-row { + display: flex; + gap: 6px; + align-items: center; + margin-bottom: 6px; +} +.frame-row button { + padding: 4px 9px; +} +.frame-row select { + width: auto; + flex: 1; +} +#frameBadge { + position: absolute; + top: 16px; + left: 50%; + transform: translateX(-50%); + z-index: 4; + padding: 4px 12px; + border-radius: 999px; + font-size: 13px; + font-weight: 600; + background: color-mix(in srgb, var(--panel) 92%, transparent); + border: 1px solid var(--line); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + pointer-events: none; +} +#frameBadge[hidden] { + display: none; +} +.export-grid { + display: grid !important; + grid-template-columns: 1fr 1fr; + gap: 6px; +} +.info-explore { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--muted); + margin: 0 0 10px; +} +.info-explore[hidden] { + display: none; +} +.info-explore button { + padding: 2px 7px; + font-size: 11px; +} +#btnSelNeighbours[hidden], +#btnSelPaths[hidden] { + display: none; +} +.cmp-arena { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + margin-top: 10px; + padding: 8px 10px; + border-radius: 8px; + border: 1px dashed var(--line); +} +.cmp-arena[hidden] { + display: none; +} +.cmp-arena-title { + font-weight: 600; + font-size: 12.5px; + display: inline-flex; + align-items: center; + gap: 6px; +} +.cmp-arena .status { + flex-basis: 100%; +} +.toast { + position: absolute; + left: 50%; + bottom: 56px; + transform: translateX(-50%); + z-index: 6; + max-width: 70%; + padding: 8px 14px; + border-radius: 8px; + font-size: 12.5px; + background: var(--panel); + border: 1px solid var(--line); + border-left: 4px solid var(--accent); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.16); +} +.toast.warn { + border-left-color: #f59e0b; +} +.toast[hidden] { + display: none; +} +.ga-sub { + margin: 18px 0 2px; + font-size: 14px; +} +.ga-grid { + display: grid; + grid-template-columns: auto minmax(200px, 360px); + justify-content: start; + gap: 6px 12px; + align-items: center; + margin: 6px 0; + font-size: 12.5px; +} +.ga-grid .num-input { + width: 100px; +} +#gaSetsFromRow[hidden] { + display: none; +} +.nodedot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + vertical-align: -1px; +} +#gaSection, +#runtimeSection { + margin-top: 18px; +} + +/* ---------- STRING import ---------- */ +.string-query { + resize: vertical; + min-height: 44px; + font-family: var(--mono); + font-size: 12px; + width: 100%; +} +.string-more { + margin-top: 10px; + border-top: 1px solid color-mix(in srgb, var(--tint) 20%, var(--line)); + padding-top: 6px; +} +.string-more > summary { + cursor: pointer; + font-size: 12px; + font-weight: 600; + color: var(--text); + padding: 3px 0; +} +.string-checks { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 6px; + max-height: 230px; + overflow-y: auto; +} +.string-check { + display: flex; + align-items: center; + gap: 7px; + font-size: 12px; + cursor: pointer; + padding: 1px 0; +} +.string-check input { + margin: 0; + accent-color: var(--accent); +} +.row button .ico { + margin-right: 6px; + vertical-align: -2px; +} +.arena-more { + border-top-color: var(--line); +} +.string-check .swatch { + width: 14px; + height: 4px; + border-radius: 2px; + flex-shrink: 0; +} +/* ---------- image export ---------- */ +.export-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + font-size: 12px; +} +.export-btn svg { + width: 15px; + height: 15px; +} +@media (max-width: 1500px) { + .export-btn span { + display: none; + } + .export-btn { + padding: 5px 8px; + } + #viewHeader .vh-count { + display: none; + } +} +#exportDialog, +#deleteDialog { + border: 1px solid var(--line); + border-radius: 12px; + padding: 0; + background: var(--panel); + color: var(--text); + width: min(560px, calc(100vw - 32px)); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} +#exportDialog::backdrop, +#deleteDialog::backdrop { + background: rgba(15, 20, 32, 0.45); +} +.dlg { + padding: 18px 20px 16px; + margin: 0; +} +.dlg h2 { + margin: 0 0 2px; + font-size: 17px; +} +.dlg-sub { + margin: 0 0 12px; + font-size: 12.5px; + color: var(--muted); +} +.fmt-grid { + border: none; + padding: 0; + margin: 0 0 12px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 6px; +} +.fmt-grid legend { + font-size: 12px; + font-weight: 600; + margin-bottom: 6px; + padding: 0; +} +.fmt-grid label { + display: flex; + gap: 8px; + align-items: flex-start; + cursor: pointer; + border: 1px solid var(--line); + border-radius: 8px; + padding: 8px 10px; + background: var(--bg); +} +.fmt-grid label:has(input:checked) { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, var(--bg)); +} +.fmt-grid input { + margin-top: 3px; + accent-color: var(--accent); +} +.fmt-grid b { + display: block; + font-size: 13px; +} +.fmt-grid small { + display: block; + font-size: 11px; + color: var(--muted); + line-height: 1.35; +} +.dlg-grid { + display: grid; + grid-template-columns: 100px 1fr; + gap: 8px 12px; + align-items: center; + font-size: 12.5px; +} +.dlg-grid > label { + color: var(--muted); +} +.dlg-size { + margin: -2px 0 2px; + font-size: 11.5px; + color: var(--muted); + font-family: var(--mono); +} +.dlg-file { + display: flex; + align-items: center; + gap: 4px; +} +.dlg-file span { + font-family: var(--mono); + font-size: 12px; + color: var(--muted); +} +.dlg-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; +} +.dlg-actions button { + min-width: 110px; +} +/* ---------- header: view switcher ---------- */ +#viewHeader { + grid-area: viewhead; + display: flex; + align-items: center; + gap: 10px; + padding: 0 18px 0 14px; + background: color-mix(in srgb, #6366f1 9%, var(--panel)); + border-bottom: 1px solid color-mix(in srgb, #6366f1 25%, var(--line)); + min-width: 0; +} +.vh-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.2px; + color: color-mix(in srgb, #6366f1 70%, var(--text)); + flex-shrink: 0; +} +.vh-label svg { + width: 16px; + height: 16px; +} +.vh-select { + display: flex; + gap: 6px; + align-items: center; + min-width: 0; +} +.vh-sub { + margin-left: 6px; + color: color-mix(in srgb, #f59e0b 70%, var(--text)); +} +#viewGrouping { + width: 270px; + min-width: 120px; + flex: 0 1 270px; + padding: 6px 10px; + font-weight: 600; + background: color-mix(in srgb, #f59e0b 8%, var(--bg)); + border-color: color-mix(in srgb, #f59e0b 40%, var(--line)); +} +#viewGrouping:disabled { + opacity: 0.75; + font-weight: 400; +} + +#viewSelect { + width: 230px; + min-width: 140px; + font-weight: 600; + padding: 6px 10px; + border-color: color-mix(in srgb, #6366f1 35%, var(--line)); +} +.vh-select { + flex: 0 1 auto; +} +#viewNameInput { + width: 240px; +} +.vh-count { + font-size: 11.5px; + color: var(--muted); + white-space: nowrap; +} +#viewHeader .view-actions { + display: flex; + gap: 4px; + margin-top: 0; +} +#viewHeader .view-actions button { + padding: 5px 9px; + font-size: 12px; +} +#viewHeader #topTools { + margin-left: auto; + margin-right: 0; + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} +#viewHeader { + overflow: hidden; +} +#viewHeader .view-actions { + flex-shrink: 0; +} +#brand { + border-bottom: 1px solid color-mix(in srgb, #6366f1 25%, var(--line)); +} + +/* ---------- sidebar tabs sit in the grid, aligned with the page tabs ---------- */ +#sideTabs { + grid-area: sidetabs; + display: flex; + margin: 0; + background: var(--panel); + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); + padding: 0 10px; +} +#sideTabs { + padding: 0 4px; + gap: 2px; +} +#sideTabs button { + padding: 0 2px; + font-size: 11px; + line-height: 1.15; + white-space: normal; + text-align: center; + flex-direction: row; + gap: 3px; +} +#sideTabs button .ico { + margin-right: 2px; + width: 13px; + height: 13px; + flex-shrink: 0; +} +#sideTabs button[data-side='data'][aria-selected='true'] { + border-bottom-color: #2563eb; + color: color-mix(in srgb, #2563eb 60%, var(--text)); +} +#sideTabs button[data-side='db'][aria-selected='true'] { + border-bottom-color: #dc2626; + color: color-mix(in srgb, #dc2626 60%, var(--text)); +} +#sideTabs button[data-side='display'][aria-selected='true'] { + border-bottom-color: #16a34a; + color: color-mix(in srgb, #16a34a 55%, var(--text)); +} +#sideTabs button[data-side='export'][aria-selected='true'] { + border-bottom-color: #7c3aed; + color: color-mix(in srgb, #7c3aed 60%, var(--text)); +} +#sidebar { + padding-top: 14px; +} + +/* ---------- resizable / collapsible sidebar ---------- */ +#brand, +#sideTabs, +#sidebar { + min-width: 0; +} +#brand { + overflow: hidden; +} +#sideResizer { + position: absolute; + top: 0; + bottom: 0; + left: calc(var(--side-w) - 4px); + width: 8px; + cursor: col-resize; + z-index: 30; + touch-action: none; + outline: none; +} +#sideResizer::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 3px; + width: 2px; + background: transparent; + transition: background 0.15s; +} +#sideResizer:hover::after, +#sideResizer:focus-visible::after, +#app.side-resizing #sideResizer::after { + background: var(--accent); +} +#sideToggle { + position: absolute; + top: 50%; + left: calc(var(--side-w) - 1px); + z-index: 31; + transform: translateY(-50%); + width: 16px; + height: 44px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--line); + border-left: none; + border-radius: 0 6px 6px 0; + background: var(--panel); + color: var(--muted); +} +#sideToggle:hover { + color: var(--text); + border-color: var(--accent-dim); + background: var(--panel-2-hover); +} +#sideToggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} +#sideToggle svg { + width: 10px; + height: 10px; + transition: transform 0.15s; +} +#app.side-collapsed #brand, +#app.side-collapsed #sideTabs, +#app.side-collapsed #sidebar { + display: none; +} +#app.side-collapsed #sideResizer { + display: none; +} +#app.side-collapsed #sideToggle { + left: 0; +} +#app.side-collapsed #sideToggle svg { + transform: rotate(180deg); +} +#app.side-resizing, +#app.side-resizing * { + cursor: col-resize !important; + user-select: none !important; +} + +/* ---------- what the current view shows ---------- */ +#topbar { + gap: 12px; +} +#ctxbar { + grid-area: ctxbar; + display: flex; + align-items: center; + gap: 14px; + padding: 0 18px; + background: color-mix(in srgb, var(--text) 2.5%, var(--panel)); + border-bottom: 1px solid var(--line); + min-width: 0; +} +#contextInfo { + flex: 1 1 0; + min-width: 0; + display: flex; + gap: 6px; + align-items: center; + overflow: hidden; +} +.ctx-chip { + --tint: #64748b; + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + max-width: 40%; + padding: 3px 9px; + border-radius: 999px; + font-size: 12px; + white-space: nowrap; + background: color-mix(in srgb, var(--tint) 12%, var(--panel)); + border: 1px solid color-mix(in srgb, var(--tint) 30%, var(--line)); +} +.ctx-chip b { + font-weight: 600; + color: color-mix(in srgb, var(--tint) 65%, var(--text)); + flex-shrink: 0; +} +.ctx-chip span { + overflow: hidden; + text-overflow: ellipsis; +} +.ctx-chip[data-tint='data'] { + --tint: #2563eb; +} +.ctx-chip[data-tint='groups'] { + --tint: #d97706; +} +.ctx-chip[data-tint='colors'] { + --tint: #db2777; +} +.ctx-empty { + font-size: 12px; + color: var(--muted); +} +#stats { + display: flex; + gap: 12px; + flex-shrink: 0; + align-items: center; +} +.stat-dir-off { + cursor: pointer; + opacity: 0.85; + border-style: dashed !important; +} +.stat-dir { + font-family: var(--sans); + font-size: 11px; + padding: 1px 7px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, #06b6d4 45%, var(--line)); + background: color-mix(in srgb, #06b6d4 12%, var(--panel)); + color: color-mix(in srgb, #06b6d4 55%, var(--text)); +} +@media (max-width: 1380px) { + #stats { + gap: 8px; + font-size: 11px; + } + .vh-count { + display: none; + } +} + +/* ---------- tinted sidebar sections ---------- */ +.section { + --tint: #64748b; + background: color-mix(in srgb, var(--tint) 6%, var(--panel)); + border: 1px solid color-mix(in srgb, var(--tint) 20%, var(--line)); + border-left: 3px solid color-mix(in srgb, var(--tint) 70%, var(--panel)); + border-radius: 8px; + padding: 10px 12px 12px; + margin-bottom: 12px; +} +.section > h3 { + color: color-mix(in srgb, var(--tint) 60%, var(--text)); +} +/* Data tab */ +.section[data-tint='examples'] { + --tint: #059669; +} +.section[data-tint='string'] { + --tint: #dc2626; +} +.section[data-tint='files'] { + --tint: #2563eb; +} +.section[data-tint='saved'] { + --tint: #7c3aed; +} +/* Database importers tab */ +.section[data-tint='reactome'] { + --tint: #2563eb; +} +.section[data-tint='omnipath'] { + --tint: #0d9488; +} +.section[data-tint='ndex'] { + --tint: #ea580c; +} +.section[data-tint='intact'] { + --tint: #16a34a; +} +.section[data-tint='go'] { + --tint: #7c3aed; +} +/* Export tab */ +.section[data-tint='exfiles'] { + --tint: #2563eb; +} +.section[data-tint='eximage'] { + --tint: #db2777; +} +.section[data-tint='exarena'] { + --tint: #0d9488; +} +.section[data-tint='exother'] { + --tint: #ea580c; +} +.section[data-tint='exsave'] { + --tint: #475569; +} +.section.collapsible > h3 { + cursor: pointer; + user-select: none; +} +.section.collapsible > h3::after { + content: '▾'; + margin-left: 6px; + font-size: 11px; + color: var(--muted); + order: 3; +} +.section.collapsible.collapsed > h3::after { + content: '▸'; +} +.section.collapsible.collapsed > h3 { + margin-bottom: 0; +} +.section.collapsible > h3:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 4px; +} +.section.collapsible.collapsed > *:not(h3) { + display: none !important; +} +.section.collapsible > h3 .helplink { + order: 2; +} +.db-tag { + margin: -2px 0 8px; + font-size: 11px; + color: var(--muted); + line-height: 1.35; +} +.db-tag a { + color: inherit; +} +.db-pick { + width: 100%; + margin-top: 6px; + font-size: 12px; +} +.menu-sep { + display: flex; + align-items: center; + gap: 8px; + margin: 14px 0 8px; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: color-mix(in srgb, var(--tint) 60%, var(--muted)); +} +.menu-sep::after { + content: ''; + flex: 1; + height: 1px; + background: color-mix(in srgb, var(--tint) 28%, var(--line)); +} +.menu-sep > span { + display: inline-flex; + align-items: baseline; + gap: 6px; + flex-wrap: wrap; +} +.menu-sep .lib-hint { + text-transform: none; + letter-spacing: 0; + font-weight: 400; + font-size: 11px; + color: var(--muted); +} +.section > h3 + .menu-sep, +.db-tag + .menu-sep, +.db-body > .menu-sep:first-child, +details > summary + .menu-sep { + margin-top: 4px; +} +.panel-intro { + margin: 0 2px 12px; + font-size: 12px; + color: var(--muted); + line-height: 1.45; +} +/* Display tab, in order; neighbours always differ clearly */ +.section[data-tint='groups'] { + --tint: #d97706; +} +.section[data-tint='hulls'] { + --tint: #a21caf; +} +.section[data-tint='layout3d'] { + --tint: #0d9488; +} +.section[data-tint='layout'] { + --tint: #16a34a; +} +.section[data-tint='colors'] { + --tint: #db2777; +} +.section[data-tint='nodes'] { + --tint: #0284c7; +} +.section[data-tint='labels'] { + --tint: #ea580c; +} +.section[data-tint='edges'] { + --tint: #475569; +} +.section[data-tint='channels'] { + --tint: #65a30d; +} +.section[data-tint='legend'] { + --tint: #be123c; +} +.section[data-tint='attrs'] { + --tint: #0891b2; +} +.section[data-tint='perf'] { + --tint: #92400e; +} +.section .upload-box, +.section .lib-list, +.section .strategy-fig { + background: var(--panel); +} +::selection { + background: #fde68a; + color: #111827; +} diff --git a/frontend/src/themes.ts b/frontend/src/themes.ts new file mode 100644 index 0000000..5770be1 --- /dev/null +++ b/frontend/src/themes.ts @@ -0,0 +1,142 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { SELECTION_YELLOW, setStyle } from './cy' +import { applyEdgeMerge } from './parallel_edges' +import { updateLabelStyle } from './profiler' + +/* ---------- UI themes (chrome + canvas; independent of node/edge data palettes) ---------- */ +const THEMES = { + dark: { + bg: '#0f1420', + panel: '#161d2e', + panel2: '#1d2740', + panel2Hover: '#223050', + line: '#2a3554', + text: '#e7ebf5', + muted: '#8993ad', + accent: '#5fd3c4', + accentDim: '#3a7d74', + accentHover: '#7fe0d3', + accentText: '#08221e', + warn: '#e7a15f', + canvasDot: '#1c2540', + nodeBorder: 'rgba(255,255,255,0.35)', + }, + midnight: { + bg: '#050608', + panel: '#0c0e12', + panel2: '#131620', + panel2Hover: '#1a1e2b', + line: '#1f2430', + text: '#dfe3ee', + muted: '#767c8c', + accent: '#7c5cff', + accentDim: '#5540b3', + accentHover: '#9b82ff', + accentText: '#0a0612', + warn: '#e7a15f', + canvasDot: '#111420', + nodeBorder: 'rgba(255,255,255,0.3)', + }, + light: { + bg: '#f4f5f8', + panel: '#ffffff', + panel2: '#eef1f6', + panel2Hover: '#e2e7ef', + line: '#d7dce6', + text: '#1b2333', + muted: '#6b7387', + accent: '#2a9d8f', + accentDim: '#1f7a6f', + accentHover: '#39b7a7', + accentText: '#ffffff', + warn: '#c76b1f', + canvasDot: '#e4e8f0', + nodeBorder: 'rgba(20,25,40,0.25)', + }, + white: { + bg: '#ffffff', + panel: '#ffffff', + panel2: '#f5f5f7', + panel2Hover: '#e9e9ec', + line: '#e0e0e5', + text: '#111114', + muted: '#6e6e76', + accent: '#0f766e', + accentDim: '#0b5c56', + accentHover: '#14958a', + accentText: '#ffffff', + warn: '#b45309', + canvasDot: '#eeeeee', + nodeBorder: 'rgba(0,0,0,0.2)', + }, + gray: { + bg: '#2b2e33', + panel: '#34383f', + panel2: '#3d424a', + panel2Hover: '#464b54', + line: '#4a4f58', + text: '#e7e8ea', + muted: '#9a9fa8', + accent: '#8fb8e8', + accentDim: '#5f83ad', + accentHover: '#a9cdf5', + accentText: '#12161c', + warn: '#e0a458', + canvasDot: '#3a3f47', + nodeBorder: 'rgba(255,255,255,0.2)', + }, + paper: { + bg: '#f3ecdd', + panel: '#fbf6ea', + panel2: '#efe6d2', + panel2Hover: '#e6dac0', + line: '#ddd0b0', + text: '#3a2f1f', + muted: '#8a7a5c', + accent: '#b3541e', + accentDim: '#8a4017', + accentHover: '#d06a2c', + accentText: '#fff7ec', + warn: '#7a5a1e', + canvasDot: '#e9ddc2', + nodeBorder: 'rgba(58,47,31,0.3)', + }, +} + +export let currentTheme = THEMES.dark + +export function applyTheme(name) { + const t = THEMES[name] || THEMES.dark + currentTheme = t + + const root = document.documentElement.style + root.setProperty('--bg', t.bg) + root.setProperty('--panel', t.panel) + root.setProperty('--panel-2', t.panel2) + root.setProperty('--panel-2-hover', t.panel2Hover) + root.setProperty('--line', t.line) + root.setProperty('--text', t.text) + root.setProperty('--muted', t.muted) + root.setProperty('--accent', t.accent) + root.setProperty('--accent-dim', t.accentDim) + root.setProperty('--accent-hover', t.accentHover) + root.setProperty('--accent-text', t.accentText) + root.setProperty('--warn', t.warn) + root.setProperty('--canvas-dot', t.canvasDot) + + // cytoscape doesn't read CSS variables, so the canvas-drawn parts + // (node label color/halo, node border, highlight color) are set directly + setStyle('node', { color: t.text, 'text-outline-color': t.bg, 'border-color': t.nodeBorder }) + setStyle('node.highlighted', { 'border-color': t.accent }) + // selected nodes and edges are marked in yellow on every theme + setStyle('node:selected', { + 'border-color': SELECTION_YELLOW, + 'underlay-color': SELECTION_YELLOW, + }) + setStyle('edge:selected', { 'underlay-color': SELECTION_YELLOW }) + setStyle('core', { 'selection-box-color': t.accent, 'selection-box-border-color': t.accent }) + + updateLabelStyle() // edge label color/halo also depend on the theme + applyEdgeMerge() // merged edges use the theme's muted color +} diff --git a/frontend/src/uploads.ts b/frontend/src/uploads.ts new file mode 100644 index 0000000..cf762ce --- /dev/null +++ b/frontend/src/uploads.ts @@ -0,0 +1,192 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { + NORMA_KIND_NAMES, + addNormaEntry, + annotationText, + articleFor, + detectNormaKind, + downloadText, + fileStem, + libSelection, + listSample, + normaLibrary, + plural, + renderLibraryLists, + selectedNetworks, + setStatus, +} from './layouts/controls' +import { convertArena3dNetwork, isArena3dNetworkText } from './arena3d' + +/* ---------- uploads ---------- */ +export function readFileText(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = (ev) => resolve(ev.target.result) + reader.onerror = () => + reject(new Error('The file could not be read. Check that it is a plain text file.')) + reader.readAsText(file) + }) +} + +// Splits an annotation's members into those present in `known` and those not. +function checkAnnotationAgainst(entry, known) { + const missing = new Set() + const emptied = [] + const kept = [] + entry.parsed.groups.forEach(({ name, members }) => { + const present = members.filter((m) => known.has(m)) + members.forEach((m) => { + if (!known.has(m)) missing.add(m) + }) + if (present.length) kept.push({ name, members: present }) + else emptied.push(name) + }) + return { missing: [...missing], emptied, kept } +} + +export async function handleNormaUploads(fileList) { + const files = [...fileList] + if (!files.length) return + const chosenKind = document.getElementById('normaKind').value + const nameInput = document.getElementById('normaName') + const typedName = files.length === 1 ? nameInput.value.trim() : '' + const notes = [] + const added = [] + const addedNotes = new Map() + for (const file of files) { + try { + const text = await readFileText(file) + if ((chosenKind === 'auto' || chosenKind === 'network') && isArena3dNetworkText(text)) { + // an Arena3D network file: a network plus its layers as groups + const conv = convertArena3dNetwork(text) + const base = typedName || file.name.replace(/\.[^.]+$/, '') || file.name + const netEntry = addNormaEntry('network', base, conv.network, file.name, null, { + directed: document.getElementById('normaDirected').value === 'directed', + }) + const annEntry = addNormaEntry( + 'annotation', + `${base} layers`, + conv.annotation, + file.name, + null, + {} + ) + ;[netEntry, annEntry].forEach((entry) => { + added.push(entry) + const note = { + level: 'ok', + text: `Added ${articleFor(NORMA_KIND_NAMES[entry.kind])} ${NORMA_KIND_NAMES[entry.kind]} "${entry.name}" from an Arena3D network file: ${entry.parsed.summary}.`, + } + addedNotes.set(entry, note) + notes.push(note) + entry.parsed.notes.forEach((t) => + notes.push({ level: 'warn', text: `${entry.name}: ${t}` }) + ) + }) + continue + } + const kind = chosenKind === 'auto' ? detectNormaKind(text) : chosenKind + if (!kind) throw new Error('The file is empty.') + const name = typedName || file.name.replace(/\.[^.]+$/, '') || file.name + const entry = addNormaEntry(kind, name, text, file.name, null, { + directed: document.getElementById('normaDirected').value === 'directed', + }) + added.push(entry) + const addedNote = { + level: 'ok', + text: `Added ${articleFor(NORMA_KIND_NAMES[kind])} ${NORMA_KIND_NAMES[kind]} "${entry.name}": ${entry.parsed.summary}.`, + } + addedNotes.set(entry, addedNote) + notes.push(addedNote) + entry.parsed.notes.forEach((t) => notes.push({ level: 'warn', text: `${entry.name}: ${t}` })) + } catch (err) { + const kindText = + chosenKind === 'auto' + ? 'a file' + : `${articleFor(NORMA_KIND_NAMES[chosenKind])} ${NORMA_KIND_NAMES[chosenKind]}` + notes.push({ + level: 'error', + text: `"${file.name}" wasn't added as ${kindText}. ${err.message}`, + }) + } + } + // Check new annotations against the networks they will most likely be + // used with: networks uploaded together with them, otherwise the ticked + // networks, otherwise every network in the list. Names missing from all + // of those are discarded now, with a download of the corrected file. + const newAnnotations = added.filter((e) => e.kind === 'annotation') + if (newAnnotations.length) { + const batchNets = added.filter((e) => e.kind === 'network') + const tickedNets = selectedNetworks() + const refNets = batchNets.length + ? batchNets + : tickedNets.length + ? tickedNets + : normaLibrary.network + const refNames = + refNets.length === 1 + ? `"${refNets[0].name}"` + : `the ${refNets.length} ${batchNets.length ? 'uploaded' : tickedNets.length ? 'ticked' : 'listed'} networks` + if (!refNets.length) { + notes.push({ + level: 'ok', + text: 'No network is loaded yet, so annotation names will be checked when you show them with a network.', + }) + } + const known = new Set(refNets.flatMap((e) => e.parsed.nodes)) + newAnnotations.forEach((entry) => { + if (!refNets.length) return + const check = checkAnnotationAgainst(entry, known) + if (!check.missing.length) return + if (!check.kept.length) { + normaLibrary.annotation = normaLibrary.annotation.filter((x) => x !== entry) + added.splice(added.indexOf(entry), 1) + notes.splice(notes.indexOf(addedNotes.get(entry)), 1) + notes.push({ + level: 'error', + text: `"${entry.name}" was not kept: none of its ${plural(check.missing.length, 'node name')} are in ${refNames}. Check that the annotation belongs to this network.`, + }) + return + } + entry.parsed.groups = check.kept + entry.parsed.summary = plural(check.kept.length, 'group') + addedNotes.get(entry).text = + `Added the annotation "${entry.name}": ${entry.parsed.summary} after the check below.` + entry.correctedText = annotationText(check.kept) + const parts = [ + `Discarded ${plural(check.missing.length, 'node name')} from "${entry.name}" that ${check.missing.length === 1 ? 'is' : 'are'} not in ${refNames}: ${listSample(check.missing)}.`, + ] + if (check.emptied.length) + parts.push( + `${plural(check.emptied.length, 'group')} left with no nodes ${check.emptied.length === 1 ? 'was' : 'were'} removed: ${listSample(check.emptied)}.` + ) + const fileName = `${fileStem(entry.name)}-corrected.txt` + notes.push({ + level: 'warn', + text: parts.join(' '), + action: { + label: 'Download corrected annotation', + run: () => downloadText(fileName, entry.correctedText), + }, + }) + }) + } + if (added.length) { + nameInput.value = '' + // Select what was just added: new networks replace the network + // selection; the last new annotation / expression becomes the choice. + const newNets = added.filter((e) => e.kind === 'network') + if (newNets.length) libSelection.networks = new Set(newNets.map((e) => e.id)) + const lastAnn = added.filter((e) => e.kind === 'annotation').pop() + if (lastAnn) libSelection.annotation = lastAnn.id + const lastCol = added.filter((e) => e.kind === 'colors').pop() + if (lastCol) libSelection.colors = lastCol.id + notes.push({ + level: 'ok', + text: 'The new files are ticked. Choose Show in this view or Open in new view to see them.', + }) + } + renderLibraryLists() + setStatus('normaStatus', notes) +} diff --git a/frontend/src/view3d/cache.ts b/frontend/src/view3d/cache.ts new file mode 100644 index 0000000..f89b367 --- /dev/null +++ b/frontend/src/view3d/cache.ts @@ -0,0 +1,102 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { PIE_MAX_SLICES, effectiveGroupsFor } from '../network_state' +import { cy } from '../cy' +import { net3d } from './state' + +/* ---------- element cache: everything read from the 2D elements ---------- */ +export function buildCache3d() { + const nodes = [] + const byId = new Map() + cy.nodes().forEach((n) => { + if (!n.visible()) return + const pies = [] + if (n.style('shape') === 'ellipse') { + for (let i = 1; i <= PIE_MAX_SLICES; i++) { + const size = parseFloat(n.data('pieSize' + i)) || 0 + if (size > 0) pies.push([n.data('pieColor' + i), size / 100]) + } + } + const rec = { + id: n.id(), + el: n, + size: n.width(), + color: n.style('background-color'), + pies, + shape: n.style('shape'), + border: n.style('border-color'), + borderW: n.pstyle('border-width').pfValue, + opacity: n.effectiveOpacity(), + underlay: + n.pstyle('underlay-opacity').value > 0 + ? { + color: n.style('underlay-color'), + alpha: n.pstyle('underlay-opacity').value, + pad: n.pstyle('underlay-padding').pfValue, + } + : null, + label: n.style('label'), + labelColor: n.style('color'), + halo: n.style('text-outline-color'), + haloW: n.pstyle('text-outline-width').pfValue, + fontPx: n.pstyle('font-size').pfValue, + valign: n.style('text-valign'), + halign: n.style('text-halign'), + marginX: n.pstyle('text-margin-x').pfValue || 0, + marginY: n.pstyle('text-margin-y').pfValue || 0, + groups: effectiveGroupsFor(n), + highlighted: n.hasClass('highlighted'), + } + nodes.push(rec) + byId.set(rec.id, rec) + }) + const edges = [] + const pairCount = new Map() + cy.edges().forEach((e) => { + if (!e.visible()) return + const s = e.data('source'), + t = e.data('target') + if (!byId.has(s) || !byId.has(t) || s === t) return + const pair = s < t ? s + '\t' + t : t + '\t' + s + const k = pairCount.get(pair) || 0 + pairCount.set(pair, k + 1) + const arrow = e.style('target-arrow-shape') + edges.push({ + id: e.id(), + el: e, + s, + t, + pair, + index: k, + color: e.style('line-color'), + width: e.pstyle('width').pfValue, + opacity: e.effectiveOpacity(), + arrow: arrow && arrow !== 'none' ? arrow : null, + arrowColor: e.style('target-arrow-color'), + arrowScale: e.pstyle('arrow-scale').value || 1, + label: e.style('label'), + labelColor: e.style('color'), + labelBg: e.style('text-background-color'), + fontPx: e.pstyle('font-size').pfValue, + rotate: e.style('text-rotation') === 'autorotate', + underlay: e.pstyle('underlay-opacity').value > 0 ? e.style('underlay-color') : null, + }) + }) + edges.forEach((e) => { + e.siblings = pairCount.get(e.pair) + }) + net3d.cache = { nodes, edges, byId } + net3d.cacheDirty = false +} + +/* ---------- positions ---------- */ +export function pos3dOf(id) { + let p = net3d.pos.get(id) + if (!p) { + const n = cy.getElementById(id) + const q = n.length ? n.position() : { x: 0, y: 0 } + p = [q.x, q.y, 0] + net3d.pos.set(id, p) + } + return p +} diff --git a/frontend/src/view3d/camera.ts b/frontend/src/view3d/camera.ts new file mode 100644 index 0000000..b948474 --- /dev/null +++ b/frontend/src/view3d/camera.ts @@ -0,0 +1,182 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { buildCache3d, pos3dOf } from './cache' +import { canvas3d, el3, net3d, requestRender3d } from './state' +import { cy } from '../cy' +import { formatZoom } from '../profiler' +import { render3d } from './draw' + +/* ---------- camera and projection ---------- */ +function perspectiveMode() { + return el3('persp3d').value +} + +export function focalLength(H) { + const fov = perspectiveMode() === 'strong' ? 65 : 30 + return H / 2 / Math.tan((fov * Math.PI) / 360) +} + +// Returns a projector for a given viewport size and camera. +export function makeProjector(W, H, cam) { + const cyaw = Math.cos(cam.yaw), + syaw = Math.sin(cam.yaw) + const cp = Math.cos(cam.pitch), + sp = Math.sin(cam.pitch) + const f = focalLength(H) + const ortho = perspectiveMode() === 'none' + const orthoScale = f / cam.dist + const near = cam.dist * 0.02 + return (p) => { + const qx = p[0] - cam.tx, + qy = p[1] - cam.ty, + qz = p[2] - cam.tz + const x1 = cyaw * qx - syaw * qz + const z1 = syaw * qx + cyaw * qz + const y2 = cp * qy - sp * z1 + const z2 = sp * qy + cp * z1 + const depth = cam.dist + z2 + if (!ortho && depth < near) return null + const scale = ortho ? orthoScale : f / depth + return { x: W / 2 + x1 * scale, y: H / 2 + y2 * scale, depth, scale } + } +} + +// Inverse of the rotation: a screen-plane vector (dx, dy) to world units at a given scale. +export function screenToWorldDelta(dx, dy, scale, cam) { + const cyaw = Math.cos(cam.yaw), + syaw = Math.sin(cam.yaw) + const cp = Math.cos(cam.pitch), + sp = Math.sin(cam.pitch) + const x1 = dx / scale, + y2 = dy / scale + // camera-space vector (x1, y2, 0) back to world space + const qy = cp * y2 + const z1 = -sp * y2 + const qx = cyaw * x1 + syaw * z1 + const qz = -syaw * x1 + cyaw * z1 + return [qx, qy, qz] +} + +/* ---------- camera helpers ---------- */ +export function shownIds3d(eles) { + if (eles && eles.length) + return eles.filter((e) => (e.isNode ? e.isNode() : true)).map((n) => n.id()) + if (net3d.cacheDirty || !net3d.cache) buildCache3d() + return net3d.cache.nodes.map((n) => n.id) +} + +export function fit3d(eles, animate = true) { + const ids = shownIds3d( + eles && eles.nodes + ? eles.nodes().union(eles.connectedNodes ? eles.connectedNodes() : cy.collection()) + : eles + ) + if (!ids.length) return + const pts = ids.map((id) => ({ p: pos3dOf(id), r: (cy.getElementById(id).width() || 40) / 2 })) + // aim at the middle of the bounding box + const lo = [Infinity, Infinity, Infinity], + hi = [-Infinity, -Infinity, -Infinity] + pts.forEach(({ p }) => { + for (let d = 0; d < 3; d++) { + lo[d] = Math.min(lo[d], p[d]) + hi[d] = Math.max(hi[d], p[d]) + } + }) + const cam = { + ...net3d.cam, + tx: (lo[0] + hi[0]) / 2, + ty: (lo[1] + hi[1]) / 2, + tz: (lo[2] + hi[2]) / 2, + } + const cv = canvas3d() + const W = cv.clientWidth || 800, + H = cv.clientHeight || 600 + const margin = 40 + cam.dist = Math.max(60, Math.hypot(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]) * 1.2) + // refine: project, measure how far the picture reaches, rescale the distance + for (let it = 0; it < 6; it++) { + const project = makeProjector(W, H, cam) + let need = 0 + for (const { p, r } of pts) { + const q = project(p) + if (!q) { + need = Infinity + break + } + const ex = Math.abs(q.x - W / 2) + r * q.scale, + ey = Math.abs(q.y - H / 2) + r * q.scale + need = Math.max(need, ex / (W / 2 - margin), ey / (H / 2 - margin)) + } + if (!Number.isFinite(need)) { + cam.dist *= 2 + continue + } + if (Math.abs(need - 1) < 0.02) break + cam.dist *= perspectiveMode() === 'none' ? need : Math.max(0.3, need) + } + moveCamera3d({ tx: cam.tx, ty: cam.ty, tz: cam.tz, dist: cam.dist }, animate) +} + +export function moveCamera3d(target, animate = true) { + const from = { ...net3d.cam } + const to = { ...from, ...target } + if (!animate) { + Object.assign(net3d.cam, to) + requestRender3d() + return + } + // turn the short way round (both angles are unlimited) + ;['yaw', 'pitch'].forEach((k) => { + if (target[k] === undefined) return + to[k] = from[k] + wrapAngle(to[k] - from[k]) + }) + const start = performance.now() + const step = (now) => { + const t = Math.min(1, (now - start) / 450) + const e = 1 - Math.pow(1 - t, 3) + Object.keys(to).forEach((key) => { + net3d.cam[key] = from[key] + (to[key] - from[key]) * e + }) + if (t >= 1) { + net3d.cam.yaw = wrapAngle(net3d.cam.yaw) + net3d.cam.pitch = wrapAngle(net3d.cam.pitch) + } + render3d() + if (t < 1) requestAnimationFrame(step) + } + requestAnimationFrame(step) +} + +export function zoom3d(factor) { + net3d.cam.dist = Math.min(1e7, Math.max(5, net3d.cam.dist / factor)) + requestRender3d() +} + +// keeps an angle in (-pi, pi] +export function wrapAngle(a) { + a = a % (Math.PI * 2) + if (a > Math.PI) a -= Math.PI * 2 + if (a <= -Math.PI) a += Math.PI * 2 + return a +} + +export function rotate3d(dyaw, dpitch) { + // turning and tilting have no limits: the view can go over the poles + // as often as wanted + net3d.cam.yaw = wrapAngle(net3d.cam.yaw + dyaw) + net3d.cam.pitch = wrapAngle(net3d.cam.pitch + dpitch) + requestRender3d() +} + +export const VIEW_PRESETS_3D = { + front: { yaw: 0, pitch: 0 }, + top: { yaw: 0, pitch: Math.PI / 2 - 0.02 }, + side: { yaw: Math.PI / 2, pitch: 0 }, + tilted: { yaw: 0.6, pitch: 0.45 }, +} + +export function updateZoomReadout3d() { + const H = canvas3d().clientHeight || 600 + const scale = focalLength(H) / net3d.cam.dist + document.getElementById('zoomLevel').textContent = formatZoom(scale) +} diff --git a/frontend/src/view3d/draw.ts b/frontend/src/view3d/draw.ts new file mode 100644 index 0000000..aec2d6d --- /dev/null +++ b/frontend/src/view3d/draw.ts @@ -0,0 +1,395 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { S } from '../state' +import { UNGROUPED, getUsedGroups } from '../network_state' +import { blobPath, capsulePath } from '../export/shading' +import { bubbleContour, bubblePathD } from '../clustering/wiring' +import { buildCache3d, pos3dOf } from './cache' +import { canvas3d, el3, mixColor, net3d } from './state' +import { canvasPainter } from './painters' +import { convexHull, inflateHull } from '../hulls' +import { currentTheme } from '../themes' +import { cy } from '../cy' +import { makeProjector, updateZoomReadout3d } from './camera' + +/* ---------- drawing ---------- */ +function nodeShapePoints(shape, x, y, r) { + const def = cy.renderer().nodeShapes[shape] + if (!def || !def.points) return null + const pts = [] + for (let i = 0; i < def.points.length; i += 2) + pts.push([x + def.points[i] * r, y + def.points[i + 1] * r]) + return pts +} + +export function drawScene3d(painter, W, H, opts) { + if (net3d.cacheDirty || !net3d.cache) buildCache3d() + const { nodes, edges, byId } = net3d.cache + const cam = opts.cam || net3d.cam + const project = makeProjector(W, H, cam) + const fast = !!opts.fast + const bg = opts.bg + painter.clear(W, H, bg) + const fogColor = opts.fogColor || currentTheme.bg + const fogAmount = parseFloat(el3('fog3d').value) || 0 + const shaded = el3('style3d').value === 'shaded' + const k = opts.pixelScale || 1 // extra scaling for high-resolution export + + const T = { t: performance.now() } + const mark = (k) => { + const now = performance.now() + T[k] = Math.round(now - T.t) + T.t = now + } + // project nodes + const P = new Map() + let dMin = Infinity, + dMax = -Infinity + nodes.forEach((nd) => { + const pr = project(pos3dOf(nd.id)) + if (!pr) return + pr.x *= k + pr.y *= k + pr.scale *= k + P.set(nd.id, pr) + if (pr.depth < dMin) dMin = pr.depth + if (pr.depth > dMax) dMax = pr.depth + }) + const span = Math.max(1e-6, dMax - dMin) + const fogOf = (depth) => (fogAmount * 0.75 * (depth - dMin)) / span + + mark('project') + const floorPx = labelFloor3d() * k + const nodeLabelMin = labelCutoff3d( + nodes.filter((nd) => nd.label && P.has(nd.id)).map((nd) => nd.fontPx * P.get(nd.id).scale), + floorPx, + 500 + ) + const edgeLabelMin = labelCutoff3d( + edges + .filter((e) => e.label && P.has(e.s) && P.has(e.t)) + .map((e) => (e.fontPx * (P.get(e.s).scale + P.get(e.t).scale)) / 2), + floorPx, + 300 + ) + // floor grid and axes + if (el3('grid3d').checked && nodes.length) drawFloor3d(painter, project, k) + + // group shading, from the projected positions + if (document.getElementById('showGroupHulls').checked) { + const style = document.getElementById('hullStyle').value + const opacity = parseFloat(document.getElementById('hullOpacity').value) || 0.25 + const groups = getUsedGroups().filter((g) => S.activeGroups.has(g) && g !== UNGROUPED) + const bubbles = style === 'bubble' && !fast + const projected = bubbles + ? nodes + .filter((nd) => P.has(nd.id)) + .map((nd) => { + const pr = P.get(nd.id) + return { x: pr.x, y: pr.y, r: (nd.size / 2) * pr.scale, groups: nd.groups } + }) + : null + groups.forEach((g) => { + const pts = [] + nodes.forEach((nd) => { + if (!nd.groups.includes(g)) return + const pr = P.get(nd.id) + if (pr) pts.push({ x: pr.x, y: pr.y, r: (nd.size / 2) * pr.scale }) + }) + if (!pts.length) return + const color = S.nodeColorMap[g] || '#888888' + if (bubbles) { + const loops = bubbleContour( + pts, + projected.filter((p) => !p.groups.includes(g)), + { margin: 30 * k, edgeRadius: 21 * k, avoid: 12 * k, cell: 3 * k, maxCells: 120000 } + ) + const d = bubblePathD(loops) + if (d) + painter.pathD(d, { + fill: color, + fillAlpha: opacity, + stroke: color, + strokeAlpha: Math.min(1, opacity + 0.45), + width: 1.6 * k, + evenodd: true, + }) + return + } + const pad = 18 * k + let d + if (pts.length === 1) + d = `M${pts[0].x - pts[0].r - pad},${pts[0].y}a${pts[0].r + pad},${pts[0].r + pad} 0 1 0 ${2 * (pts[0].r + pad)},0a${pts[0].r + pad},${pts[0].r + pad} 0 1 0 ${-2 * (pts[0].r + pad)},0Z` + else if (pts.length === 2) d = capsulePath(pts[0], pts[1], Math.max(pts[0].r, pts[1].r) + pad) + else d = blobPath(inflateHull(convexHull(pts), pad + Math.max(...pts.map((p) => p.r)))) + painter.pathD( + d, + style === 'fog' + ? { fill: color, fillAlpha: opacity, blur: 14 * k } + : { + fill: color, + fillAlpha: opacity, + stroke: color, + strokeAlpha: Math.min(1, opacity + 0.35), + width: 1.5 * k, + } + ) + }) + } + + mark('hulls') + // edges, far to near + const curveStyle = document.getElementById('edgeCurveStyle').value + const fan = curveStyle === 'bezier' || curveStyle === 'bundled' + const fanStep = (parseInt(document.getElementById('edgeCurvature').value, 10) || 40) * 0.6 * k + const edgeList = [] + edges.forEach((e) => { + const a = P.get(e.s), + b = P.get(e.t) + if (!a || !b) return + edgeList.push({ e, a, b, depth: (a.depth + b.depth) / 2 }) + }) + edgeList.sort((p, q) => q.depth - p.depth) + const projEdges = [] + const labelsLater = [] + edgeList.forEach(({ e, a, b, depth }) => { + const fog = fogOf(depth) + const color = mixColor(e.color, fogColor, fog) + const avgScale = (a.scale + b.scale) / 2 + const width = Math.max(0.6 * k, Math.min(e.width * avgScale, 12 * k)) + let cx, cy0 + if (fan && e.siblings > 1) { + const off = (e.index - (e.siblings - 1) / 2) * fanStep * Math.min(1.5, avgScale) + // perpendicular in a direction that doesn't depend on edge orientation + const [p, q] = e.s < e.t ? [a, b] : [b, a] + const dx = q.x - p.x, + dy = q.y - p.y, + len = Math.hypot(dx, dy) || 1 + cx = (a.x + b.x) / 2 - (dy / len) * off + cy0 = (a.y + b.y) / 2 + (dx / len) * off + } + // shorten the line at the target so an arrow sits on the node's rim + let x2 = b.x, + y2 = b.y + const rb = (byId.get(e.t).size / 2) * b.scale + if (e.arrow) { + const fx = cx !== undefined ? cx : a.x, + fy = cx !== undefined ? cy0 : a.y + const dx = b.x - fx, + dy = b.y - fy, + len = Math.hypot(dx, dy) || 1 + const ux = dx / len, + uy = dy / len + const tipX = b.x - ux * rb, + tipY = b.y - uy * rb + const size = Math.max(Math.pow(e.width * 13.37, 0.9), 29) * e.arrowScale * avgScale + x2 = tipX - ux * size * 0.25 + y2 = tipY - uy * size * 0.25 + if (e.underlay) painter.line(a.x, a.y, x2, y2, e.underlay, width + 6 * k, 0.4, cx, cy0) + painter.line(a.x, a.y, x2, y2, color, width, e.opacity, cx, cy0) + const def = cy.renderer().arrowShapes[e.arrow] + if (def && def.points && !fast) { + const pts = [] + for (let i = 0; i < def.points.length; i += 2) { + const px = def.points[i] * size, + py = def.points[i + 1] * size + pts.push([tipX - uy * px + ux * py, tipY + ux * px + uy * py]) + } + painter.poly(pts, mixColor(e.arrowColor, fogColor, fog), e.opacity) + } + } else { + if (e.underlay) painter.line(a.x, a.y, x2, y2, e.underlay, width + 6 * k, 0.4, cx, cy0) + painter.line(a.x, a.y, x2, y2, color, width, e.opacity, cx, cy0) + } + const mx = cx !== undefined ? 0.25 * a.x + 0.5 * cx + 0.25 * b.x : (a.x + b.x) / 2 + const my = cx !== undefined ? 0.25 * a.y + 0.5 * cy0 + 0.25 * b.y : (a.y + b.y) / 2 + projEdges.push({ id: e.id, ax: a.x, ay: a.y, bx: b.x, by: b.y, cx, cy: cy0, mx, my, width }) + if (e.label && !fast) { + const size = e.fontPx * avgScale + if (size >= edgeLabelMin) { + let angle = 0 + if (e.rotate) { + angle = Math.atan2(b.y - a.y, b.x - a.x) + if (angle > Math.PI / 2 || angle < -Math.PI / 2) angle += Math.PI + } + labelsLater.push({ + str: e.label, + x: mx, + y: my, + o: { + size, + color: e.labelColor, + alpha: e.opacity, + align: 'center', + baseline: 'middle', + rotate: angle, + bg: e.labelBg, + }, + }) + } + } + }) + + mark('edges') + // nodes, far to near + const order = nodes + .filter((nd) => P.has(nd.id)) + .sort((p, q) => P.get(q.id).depth - P.get(p.id).depth) + const proj = [] + order.forEach((nd) => { + const pr = P.get(nd.id) + const r = Math.max(1.2 * k, (nd.size / 2) * pr.scale) + const fog = fogOf(pr.depth) + const alpha = nd.opacity + if (nd.underlay) + painter.circle( + pr.x, + pr.y, + r + nd.underlay.pad * pr.scale, + nd.underlay.color, + nd.underlay.alpha * alpha + ) + const bw = nd.borderW > 0 ? Math.max(0.5 * k, Math.min(nd.borderW * pr.scale, 8 * k)) : 0 + const border = mixColor(nd.border, fogColor, fog) + if (nd.shape === 'ellipse' || fast || r < 2.5 * k) { + if (nd.pies.length > 1 && !fast && r > 3 * k) { + painter.circle(pr.x, pr.y, r, mixColor(nd.color, fogColor, fog), alpha) + let a0 = -Math.PI / 2 + nd.pies.forEach(([c, share]) => { + const a1 = a0 + share * Math.PI * 2 + painter.wedge(pr.x, pr.y, r, a0, a1, mixColor(c, fogColor, fog), alpha) + a0 = a1 + }) + if (bw) painter.circle(pr.x, pr.y, r, null, alpha, border, bw) + } else { + const fill = nd.pies.length === 1 ? nd.pies[0][0] : nd.color + painter.circle(pr.x, pr.y, r, mixColor(fill, fogColor, fog), alpha, bw ? border : null, bw) + } + if (shaded && !fast && r > 3 * k) painter.shade(pr.x, pr.y, r, alpha) + } else { + const pts = + nodeShapePoints(nd.shape, pr.x, pr.y, r) || nodeShapePoints('rectangle', pr.x, pr.y, r) + painter.poly(pts, mixColor(nd.color, fogColor, fog), alpha, bw ? border : null, bw) + } + proj.push({ id: nd.id, x: pr.x, y: pr.y, r, depth: pr.depth, scale: pr.scale }) + if (nd.label && !fast) { + const size = nd.fontPx * pr.scale + if (size >= nodeLabelMin) { + let x = pr.x + nd.marginX * pr.scale, + y = pr.y + nd.marginY * pr.scale + let align = 'center', + baseline = 'middle' + if (nd.valign === 'top') { + y = pr.y - r + nd.marginY * pr.scale + baseline = 'bottom' + } else if (nd.valign === 'bottom') { + y = pr.y + r + nd.marginY * pr.scale + baseline = 'top' + } + if (nd.halign === 'left') { + x = pr.x - r + nd.marginX * pr.scale + align = 'right' + } else if (nd.halign === 'right') { + x = pr.x + r + nd.marginX * pr.scale + align = 'left' + } + painter.text(nd.label, x, y, { + size, + color: mixColor(nd.labelColor, fogColor, fog * 0.6), + alpha, + align, + baseline, + halo: nd.halo, + haloWidth: nd.haloW * Math.min(1.5, pr.scale), + }) + } + } + }) + mark('nodes') + labelsLater.forEach((l) => painter.text(l.str, l.x, l.y, l.o)) + painter.done() + mark('labels') + net3d.timing = T + if (!opts.offscreen) { + net3d.proj = proj.reverse() // nearest first, for picking + net3d.projEdges = projEdges + } +} + +// Smallest label size worth drawing: the user's floor, raised so that at +// most `budget` labels are drawn (the largest, i.e. nearest, ones win). +function labelCutoff3d(sizes, floor, budget) { + if (sizes.length <= budget) return floor + const sorted = sizes.filter((v) => v >= floor).sort((a, b) => b - a) + return sorted.length > budget ? Math.max(floor, sorted[budget]) + 1e-9 : floor +} + +function labelFloor3d() { + const min = parseFloat(document.getElementById('labelMinScreenSize').value) || 0 + return Math.max(3, min) +} + +function drawFloor3d(painter, project, k) { + // a square grid under the network, plus short x/y/z axes at the centre + let minY = Infinity, + maxY = -Infinity, + R = 0 + net3d.cache.nodes.forEach((nd) => { + const p = pos3dOf(nd.id) + if (p[1] < minY) minY = p[1] + if (p[1] > maxY) maxY = p[1] + R = Math.max(R, Math.hypot(p[0] - net3d.cam.tx, p[2] - net3d.cam.tz)) + }) + const floorY = maxY + 40 + const half = Math.ceil((R + 60) / 100) * 100 + const step = half / 5 + const color = currentTheme.muted + for (let i = -5; i <= 5; i++) { + const v = i * step + const a = project([net3d.cam.tx + v, floorY, net3d.cam.tz - half]), + b = project([net3d.cam.tx + v, floorY, net3d.cam.tz + half]) + const c = project([net3d.cam.tx - half, floorY, net3d.cam.tz + v]), + d = project([net3d.cam.tx + half, floorY, net3d.cam.tz + v]) + if (a && b) painter.line(a.x * k, a.y * k, b.x * k, b.y * k, color, k, 0.18) + if (c && d) painter.line(c.x * k, c.y * k, d.x * k, d.y * k, color, k, 0.18) + } + const o = [net3d.cam.tx, net3d.cam.ty, net3d.cam.tz] + const len = step + ;[ + [[len, 0, 0], '#e05252', 'x'], + [[0, -len, 0], '#3fae5a', 'y'], + [[0, 0, len], '#3b7ddd', 'z'], + ].forEach(([v, col, name]) => { + const a = project(o), + b = project([o[0] + v[0], o[1] + v[1], o[2] + v[2]]) + if (!a || !b) return + painter.line(a.x * k, a.y * k, b.x * k, b.y * k, col, 2 * k, 0.8) + painter.text(name, b.x * k, b.y * k, { + size: 11 * k, + color: col, + alpha: 0.9, + align: 'center', + baseline: 'middle', + }) + }) +} + +export function render3d() { + if (!net3d.active) return + const cv = canvas3d() + const rect = cv.getBoundingClientRect() + const dpr = window.devicePixelRatio || 1 + const W = Math.max(1, Math.round(rect.width)), + H = Math.max(1, Math.round(rect.height)) + if (cv.width !== Math.round(W * dpr) || cv.height !== Math.round(H * dpr)) { + cv.width = Math.round(W * dpr) + cv.height = Math.round(H * dpr) + } + const ctx = cv.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + const big = + (net3d.cache ? net3d.cache.nodes.length + net3d.cache.edges.length : cy.elements().length) > + 3000 + drawScene3d(canvasPainter(ctx), W, H, { fast: net3d.interacting && big }) + updateZoomReadout3d() +} diff --git a/frontend/src/view3d/export.ts b/frontend/src/view3d/export.ts new file mode 100644 index 0000000..b8dc6b6 --- /dev/null +++ b/frontend/src/view3d/export.ts @@ -0,0 +1,189 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { EXPORT_MAX_PIXELS, EXPORT_MAX_SIDE } from '../export/draw' +import { canvas3d, net3d } from './state' +import { canvasPainter, svgPainter } from './painters' +import { currentTheme } from '../themes' +import { drawScene3d } from './draw' +import { fit3d } from './camera' + +/* ---------- image export of the 3D view ---------- */ +export function export3dSize(scale) { + const cv = canvas3d() + const W = cv.clientWidth || 800, + H = cv.clientHeight || 600 + let k = scale + const shrink = Math.min( + 1, + EXPORT_MAX_SIDE / (W * k), + EXPORT_MAX_SIDE / (H * k), + Math.sqrt(EXPORT_MAX_PIXELS / (W * H * k * k)) + ) + k *= shrink + return { W, H, k, outW: Math.round(W * k), outH: Math.round(H * k), limited: shrink < 1 } +} + +function export3dCamera(area) { + if (area !== 'full') return { ...net3d.cam } + const saved = { ...net3d.cam } + fit3d(null, false) + const cam = { ...net3d.cam } + Object.assign(net3d.cam, saved) + return cam +} + +export function renderExportCanvas3d(o) { + const { W, H, k, outW, outH } = export3dSize(o.scale) + const canvas = document.createElement('canvas') + canvas.width = outW + canvas.height = outH + const ctx = canvas.getContext('2d') + const savedHulls = document.getElementById('showGroupHulls').checked + if (!o.hulls) document.getElementById('showGroupHulls').checked = false + try { + drawScene3d(canvasPainter(ctx), W, H, { + cam: export3dCamera(o.area), + bg: o.bg, + pixelScale: k, + offscreen: true, + fogColor: o.bg || currentTheme.bg, + }) + } finally { + document.getElementById('showGroupHulls').checked = savedHulls + } + return canvas +} + +export function buildSvg3d(o) { + const { W, H } = export3dSize(1) + const s = Math.min(o.scale, 8) + const painter = svgPainter(Math.round(W * s), Math.round(H * s)) + const savedHulls = document.getElementById('showGroupHulls').checked + if (!o.hulls) document.getElementById('showGroupHulls').checked = false + try { + drawScene3d(painter, W, H, { + cam: export3dCamera(o.area), + bg: o.bg, + pixelScale: s, + offscreen: true, + fogColor: o.bg || currentTheme.bg, + }) + } finally { + document.getElementById('showGroupHulls').checked = savedHulls + } + return new Blob([painter.toString(o.title)], { type: 'image/svg+xml' }) +} + +/* ============================================================ + STRING IMPORT + Queries the STRING database (https://string-db.org, Szklarczyk et al.) + through its public API: + 1. get_string_ids resolve the typed names for the chosen organism + 2. network the query proteins plus their best interactors + 3. get_string_ids descriptions for every protein in the network + 4. enrichment or functional_annotation term collections for the + network's proteins (GO, KEGG, Reactome, keywords, domains, + diseases, tissues, ...), turned into NORMA groupings + The network and each term collection become ordinary files in the + library, so everything else in NORMA works on them unchanged. + STRING asks callers to wait a second between requests and to identify + themselves (caller_identity); both are respected here. + ============================================================ */ +export const STRING_CALLER = 'NORMA3' + +export const STRING_SPECIES = [ + [9606, 'Homo sapiens (human)'], + [10090, 'Mus musculus (mouse)'], + [10116, 'Rattus norvegicus (rat)'], + [7955, 'Danio rerio (zebrafish)'], + [7227, 'Drosophila melanogaster (fruit fly)'], + [6239, 'Caenorhabditis elegans (nematode)'], + [4932, 'Saccharomyces cerevisiae (baker\u2019s yeast)'], + [4896, 'Schizosaccharomyces pombe (fission yeast)'], + [3702, 'Arabidopsis thaliana (thale cress)'], + [39947, 'Oryza sativa japonica (rice)'], + [511145, 'Escherichia coli K-12 MG1655'], + [224308, 'Bacillus subtilis 168'], + [83332, 'Mycobacterium tuberculosis H37Rv'], + [9031, 'Gallus gallus (chicken)'], + [9913, 'Bos taurus (cattle)'], + [9823, 'Sus scrofa (pig)'], + [9615, 'Canis lupus familiaris (dog)'], + [8364, 'Xenopus tropicalis (western clawed frog)'], + [36329, 'Plasmodium falciparum 3D7 (malaria parasite)'], +] + +// score field -> NORMA channel (the classic palette uses STRING's colors) +export const STRING_CHANNELS = [ + ['nscore', 'neighborhood', 'Gene neighborhood'], + ['fscore', 'fusion', 'Gene fusion'], + ['pscore', 'cooccurrence', 'Co-occurrence'], + ['ascore', 'coexpression', 'Co-expression'], + ['escore', 'experiments', 'Experiments'], + ['dscore', 'database', 'Curated databases'], + ['tscore', 'textmining', 'Text mining'], +] + +// STRING category key -> readable name, in the order they are offered +export const STRING_CATEGORIES = [ + ['Process', 'GO Biological Process', true], + ['Function', 'GO Molecular Function', true], + ['Component', 'GO Cellular Component', true], + ['KEGG', 'KEGG pathways', true], + ['RCTM', 'Reactome pathways', true], + ['WikiPathways', 'WikiPathways', false], + ['Keyword', 'UniProt keywords', true], + ['Pfam', 'Pfam domains', false], + ['InterPro', 'InterPro domains and features', true], + ['SMART', 'SMART domains', false], + ['DISEASES', 'Diseases (DISEASES)', true], + ['TISSUES', 'Tissues (TISSUES)', false], + ['COMPARTMENTS', 'Subcellular localization (COMPARTMENTS)', false], + ['HPO', 'Human phenotypes (Monarch)', false], + ['MPO', 'Mammalian phenotypes (Monarch)', false], + ['DPO', 'Drosophila phenotypes (Monarch)', false], + ['WPO', 'C. elegans phenotypes (Monarch)', false], + ['ZPO', 'Zebrafish phenotypes (Monarch)', false], + ['FYPO', 'Fission yeast phenotypes (Monarch)', false], + ['NetworkNeighborAL', 'Local network clusters (STRING)', false], + ['PMID', 'Reference publications (PubMed)', false], +] + +const STRING_CATEGORY_ALIASES = { + 'go process': 'Process', + 'biological process': 'Process', + 'go function': 'Function', + 'molecular function': 'Function', + 'go component': 'Component', + 'cellular component': 'Component', + 'kegg pathways': 'KEGG', + reactome: 'RCTM', + 'reactome pathways': 'RCTM', + 'uniprot keywords': 'Keyword', + keywords: 'Keyword', +} + +export const STRING_PRIOR = 0.041 + +export const STRING_PREFERRED_GROUPING = [ + 'KEGG', + 'RCTM', + 'Process', + 'WikiPathways', + 'Function', + 'Component', + 'Keyword', + 'InterPro', + 'DISEASES', +] + +export function stringCategoryKey(raw) { + if (!raw) return 'Other' + if (STRING_CATEGORIES.some((c) => c[0] === raw)) return raw + return STRING_CATEGORY_ALIASES[String(raw).toLowerCase()] || raw +} + +export function stringCategoryName(key) { + const c = STRING_CATEGORIES.find((x) => x[0] === key) + return c ? c[1] : key +} diff --git a/frontend/src/view3d/input.ts b/frontend/src/view3d/input.ts new file mode 100644 index 0000000..76fc7f3 --- /dev/null +++ b/frontend/src/view3d/input.ts @@ -0,0 +1,367 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { + VIEW_PRESETS_3D, + fit3d, + focalLength, + moveCamera3d, + rotate3d, + screenToWorldDelta, + zoom3d, +} from './camera' +import { applySpread3d, runLayout3d, updateSpread3dReadout } from './layouts' +import { canvas3d, el3, markDirty3d, net3d, requestRender3d } from './state' +import { cy, onCommitStyle } from '../cy' +import { hideEdgePopup, hideInfo } from '../profiler' +import { pos3dOf } from './cache' +import { toggleAutoRotate3d } from './tab' + +/* ---------- picking ---------- */ +function pickNode3d(x, y) { + for (const p of net3d.proj) { + if (Math.hypot(p.x - x, p.y - y) <= Math.max(p.r, 4)) return p + } + return null +} + +function pickEdge3d(x, y) { + let best = null, + bestD = 6 + for (const e of net3d.projEdges) { + let d + if (e.cx !== undefined) { + d = Infinity + let px = e.ax, + py = e.ay + for (let i = 1; i <= 12; i++) { + const t = i / 12 + const qx = (1 - t) * (1 - t) * e.ax + 2 * (1 - t) * t * e.cx + t * t * e.bx + const qy = (1 - t) * (1 - t) * e.ay + 2 * (1 - t) * t * e.cy + t * t * e.by + d = Math.min(d, segDist(x, y, px, py, qx, qy)) + px = qx + py = qy + } + } else d = segDist(x, y, e.ax, e.ay, e.bx, e.by) + if (d < bestD + e.width / 2) { + bestD = d + best = e + } + } + return best +} + +function segDist(x, y, x1, y1, x2, y2) { + const dx = x2 - x1, + dy = y2 - y1 + const l2 = dx * dx + dy * dy + const t = l2 ? Math.max(0, Math.min(1, ((x - x1) * dx + (y - y1) * dy) / l2)) : 0 + return Math.hypot(x - (x1 + t * dx), y - (y1 + t * dy)) +} + +function updateLook3dReadouts() { + el3('fog3dValue').textContent = Math.round((parseFloat(el3('fog3d').value) || 0) * 100) + '%' + el3('layer3dRow').hidden = !['layers', 'hierarchy', 'degree'].includes(el3('layout3d').value) + el3('layer3dSpacingValue').textContent = el3('layer3dSpacing').value +} + +// page wiring, run by main.ts in the original order +export function init() { + /* ---------- mouse, touch and keyboard ---------- */ + ;(function setupInput3d() { + const cv = canvas3d() + const box = el3('selectBox3d') + let drag = null + const local = (ev) => { + const r = cv.getBoundingClientRect() + return { x: ev.clientX - r.left, y: ev.clientY - r.top } + } + const startInteracting = () => { + net3d.interacting = true + clearTimeout(net3d.interactTimer) + } + const stopInteracting = () => { + clearTimeout(net3d.interactTimer) + net3d.interactTimer = setTimeout(() => { + net3d.interacting = false + requestRender3d() + }, 160) + } + + cv.addEventListener('contextmenu', (ev) => ev.preventDefault()) + cv.addEventListener('pointerdown', (ev) => { + cv.focus() + const p = local(ev) + const node = pickNode3d(p.x, p.y) + let mode + if (ev.button === 2 || ev.button === 1 || ev.altKey) mode = 'pan' + else if (ev.shiftKey && !node) mode = 'box' + else if (node && !ev.shiftKey) mode = 'node' + else mode = 'rotate' + drag = { + mode, + x0: p.x, + y0: p.y, + x: p.x, + y: p.y, + moved: false, + node, + shift: ev.shiftKey, + meta: ev.metaKey || ev.ctrlKey, + cam: { ...net3d.cam }, + } + if (mode === 'node') { + const sel = cy.getElementById(node.id) + drag.group = sel.selected() ? cy.nodes(':selected').map((n) => n.id()) : [node.id] + } + cv.setPointerCapture(ev.pointerId) + ev.preventDefault() + }) + cv.addEventListener('pointermove', (ev) => { + if (!drag) { + const p = local(ev) + cv.style.cursor = pickNode3d(p.x, p.y) ? 'pointer' : 'grab' + return + } + const p = local(ev) + const dx = p.x - drag.x, + dy = p.y - drag.y + if (!drag.moved && Math.hypot(p.x - drag.x0, p.y - drag.y0) < 3) return + if (!drag.moved) { + drag.moved = true + startInteracting() + } + drag.x = p.x + drag.y = p.y + if (drag.mode === 'rotate') { + cv.style.cursor = 'grabbing' + rotate3d(dx * 0.008, dy * 0.008) + } else if (drag.mode === 'pan') { + const H = cv.clientHeight + const scale = focalLength(H) / net3d.cam.dist + const d = screenToWorldDelta(-dx, -dy, scale, net3d.cam) + net3d.cam.tx += d[0] + net3d.cam.ty += d[1] + net3d.cam.tz += d[2] + requestRender3d() + } else if (drag.mode === 'node') { + const d = screenToWorldDelta(dx, dy, drag.node.scale, net3d.cam) + drag.group.forEach((id) => { + const q = pos3dOf(id) + net3d.pos.set(id, [q[0] + d[0], q[1] + d[1], q[2] + d[2]]) + }) + requestRender3d() + } else if (drag.mode === 'box') { + box.hidden = false + box.style.left = Math.min(drag.x0, p.x) + 'px' + box.style.top = Math.min(drag.y0, p.y) + 'px' + box.style.width = Math.abs(p.x - drag.x0) + 'px' + box.style.height = Math.abs(p.y - drag.y0) + 'px' + } + }) + const finish = (ev) => { + if (!drag) return + const p = local(ev) + const d = drag + drag = null + cv.style.cursor = 'grab' + box.hidden = true + if (d.moved) { + stopInteracting() + if (d.mode === 'box') { + const x1 = Math.min(d.x0, p.x), + x2 = Math.max(d.x0, p.x), + y1 = Math.min(d.y0, p.y), + y2 = Math.max(d.y0, p.y) + const ids = net3d.proj + .filter((q) => q.x >= x1 && q.x <= x2 && q.y >= y1 && q.y <= y2) + .map((q) => q.id) + cy.batch(() => ids.forEach((id) => cy.getElementById(id).select())) + } + return + } + // a click + if (d.node) { + const n = cy.getElementById(d.node.id) + if (d.shift || d.meta) { + n.selected() ? n.unselect() : n.select() + return + } + n.emit('tap') + return + } + const edge = pickEdge3d(p.x, p.y) + if (edge) { + cy.getElementById(edge.id).emit('tap') + // place the edge details next to the click + const popup = document.getElementById('edgePopup') + const rect = document.getElementById('canvas').getBoundingClientRect() + popup.style.left = Math.max(10, Math.min(p.x + 14, rect.width - 266)) + 'px' + popup.style.top = Math.max(10, Math.min(p.y + 14, rect.height - 180)) + 'px' + return + } + cy.elements().removeClass('dimmed highlighted') + hideInfo() + hideEdgePopup() + if (!d.shift) cy.$(':selected').unselect() + } + cv.addEventListener('pointerup', finish) + cv.addEventListener('pointercancel', finish) + cv.addEventListener('dblclick', (ev) => { + const p = local(ev) + const node = pickNode3d(p.x, p.y) + if (node) { + const q = pos3dOf(node.id) + moveCamera3d({ tx: q[0], ty: q[1], tz: q[2] }) + } else fit3d() + }) + cv.addEventListener( + 'wheel', + (ev) => { + ev.preventDefault() + startInteracting() + zoom3d(Math.exp(-ev.deltaY * 0.0015)) + stopInteracting() + }, + { passive: false } + ) + cv.addEventListener('keydown', (ev) => { + const k = ev.key + const step = ev.shiftKey ? 0.25 : 0.08 + if (k === 'ArrowLeft') rotate3d(-step, 0) + else if (k === 'ArrowRight') rotate3d(step, 0) + else if (k === 'ArrowUp') rotate3d(0, -step) + else if (k === 'ArrowDown') rotate3d(0, step) + else if (k === '+' || k === '=') zoom3d(1.25) + else if (k === '-' || k === '_') zoom3d(0.8) + else if (k === '0' || k === 'f') fit3d() + else return + ev.preventDefault() + }) + new ResizeObserver(() => requestRender3d()).observe(cv) + })() + + /* ---------- controls ---------- */ + document.getElementById('zoomIn').addEventListener( + 'click', + (ev) => { + if (net3d.active) { + ev.stopImmediatePropagation() + zoom3d(1.25) + } + }, + true + ) + + document.getElementById('zoomOut').addEventListener( + 'click', + (ev) => { + if (net3d.active) { + ev.stopImmediatePropagation() + zoom3d(0.8) + } + }, + true + ) + + document.getElementById('zoomFit').addEventListener( + 'click', + (ev) => { + if (net3d.active) { + ev.stopImmediatePropagation() + fit3d() + } + }, + true + ) + + el3('btnRotL3d').addEventListener('click', () => + moveCamera3d({ yaw: net3d.cam.yaw - Math.PI / 8 }) + ) + + el3('btnRotR3d').addEventListener('click', () => + moveCamera3d({ yaw: net3d.cam.yaw + Math.PI / 8 }) + ) + + el3('btnRotU3d').addEventListener('click', () => + moveCamera3d({ pitch: net3d.cam.pitch - Math.PI / 8 }) + ) + + el3('btnRotD3d').addEventListener('click', () => + moveCamera3d({ pitch: net3d.cam.pitch + Math.PI / 8 }) + ) + + el3('btnReset3d').addEventListener('click', () => { + moveCamera3d({ ...VIEW_PRESETS_3D.tilted }, false) + fit3d() + }) + + el3('btnAuto3d').addEventListener('click', () => { + const on = el3('btnAuto3d').getAttribute('aria-pressed') !== 'true' + el3('autoRotate3d').checked = on + toggleAutoRotate3d(on) + }) + + el3('autoRotate3d').addEventListener('change', () => + toggleAutoRotate3d(el3('autoRotate3d').checked) + ) + + document.querySelectorAll('[data-view3d]').forEach((btn) => { + btn.addEventListener('click', () => { + moveCamera3d({ ...VIEW_PRESETS_3D[btn.dataset.view3d] }, false) + fit3d() + }) + }) + + el3('btnRun3d').addEventListener('click', () => runLayout3d()) + + el3('btnFit3d').addEventListener('click', () => fit3d()) + + el3('layout3d').addEventListener('change', () => { + const algo = el3('layout3d').value + el3('layer3dRow').hidden = !['layers', 'hierarchy', 'degree'].includes(algo) + }) + + el3('layer3dSpacing').addEventListener('input', () => { + el3('layer3dSpacingValue').textContent = el3('layer3dSpacing').value + }) + + el3('spread3d').addEventListener('input', applySpread3d) + + el3('spread3d').addEventListener('dblclick', () => { + el3('spread3d').value = 0 + applySpread3d() + }) + + ;['persp3d', 'fog3d', 'style3d', 'grid3d'].forEach((id) => { + el3(id).addEventListener('input', () => { + updateLook3dReadouts() + requestRender3d() + }) + el3(id).addEventListener('change', () => { + updateLook3dReadouts() + requestRender3d() + }) + }) + + updateLook3dReadouts() + + updateSpread3dReadout() + + // settings that change what the 2D view draws also change the 3D picture + onCommitStyle(() => { + if (net3d.active) markDirty3d() + else net3d.cacheDirty = true + }) + + ;[ + 'showGroupHulls', + 'hullStyle', + 'hullOpacity', + 'edgeCurveStyle', + 'edgeCurvature', + 'labelMinScreenSize', + ].forEach((id) => { + document.getElementById(id).addEventListener('input', requestRender3d) + document.getElementById(id).addEventListener('change', requestRender3d) + }) +} diff --git a/frontend/src/view3d/layouts.ts b/frontend/src/view3d/layouts.ts new file mode 100644 index 0000000..f8c024e --- /dev/null +++ b/frontend/src/view3d/layouts.ts @@ -0,0 +1,411 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { S } from '../state' +import { UNGROUPED, effectiveGroupsFor, getUsedGroups } from '../network_state' +import { VIEW_PRESETS_3D, fit3d, moveCamera3d, shownIds3d } from './camera' +import { + anyEdgeDirected, + edgeIsDirected, + fibonacciBall, + fibonacciSphere, + fr3dAsync, + fr3dLayout, +} from '../export/dialog' +import { computeMetricValues, shownEdges, shownNodes, targetNodeSpacing } from '../metrics' +import { cy } from '../cy' +import { el3, net3d, requestRender3d } from './state' +import { mulberry32 } from '../sample_data' +import { pos3dOf } from './cache' +import { render3d } from './draw' +import { setStatus } from '../layouts/controls' + +/* ---------- layouts ---------- */ +function setLayoutStatus3d(items) { + setStatus('layout3dStatus', items) +} + +function positions2dOf(ids) { + const out = {} + ids.forEach((id) => { + const p = cy.getElementById(id).position() + out[id] = [p.x, p.y] + }) + return out +} + +function centerPositions(map) { + const ids = Object.keys(map) + if (!ids.length) return map + const c = [0, 0, 0] + ids.forEach((id) => { + c[0] += map[id][0] + c[1] += map[id][1] + c[2] += map[id][2] + }) + c.forEach((v, i) => { + c[i] = v / ids.length + }) + ids.forEach((id) => { + map[id] = [map[id][0] - c[0], map[id][1] - c[1], map[id][2] - c[2]] + }) + return map +} + +// Layer index (0, 1, ...) for each node: its ticked groups in legend order; +// nodes in several groups sit between their layers. +function groupLayerOf(nodes) { + const groups = getUsedGroups().filter((g) => S.activeGroups.has(g)) + const rank = new Map(groups.map((g, i) => [g, i])) + const out = {} + nodes.forEach((n) => { + const gs = effectiveGroupsFor(n).filter((g) => rank.has(g)) + out[n.id()] = gs.length ? gs.reduce((s, g) => s + rank.get(g), 0) / gs.length : groups.length + }) + return out +} + +function hierarchyLevels(nodes, edges) { + const ids = nodes.map((n) => n.id()) + const out = new Map(ids.map((id) => [id, []])) + const indeg = new Map(ids.map((id) => [id, 0])) + edges.forEach((e) => { + const s = e.data('source'), + t = e.data('target') + if (!out.has(s) || !out.has(t) || s === t) return + out.get(s).push(t) + indeg.set(t, indeg.get(t) + 1) + if (!edgeIsDirected(e)) { + out.get(t).push(s) + indeg.set(s, indeg.get(s) + 1) + } + }) + const level = new Map() + const directed = anyEdgeDirected(edges) + // roots: nodes nothing points to (directed) or the best-connected node of each component + const byDegree = [...ids].sort((a, b) => out.get(b).length - out.get(a).length) + const roots = directed ? ids.filter((id) => indeg.get(id) === 0 && out.get(id).length) : [] + const bfs = (starts) => { + const queue = [] + starts.forEach((s) => { + if (!level.has(s)) { + level.set(s, 0) + queue.push(s) + } + }) + for (let q = 0; q < queue.length; q++) { + const v = queue[q] + out.get(v).forEach((w) => { + if (!level.has(w)) { + level.set(w, level.get(v) + 1) + queue.push(w) + } + }) + } + } + bfs(roots) + byDegree.forEach((id) => { + if (!level.has(id)) bfs([id]) + }) + return level +} + +async function computeLayout3d(algo) { + const nodes = shownNodes() + const channelOnly = el3('layout3dChannels').checked + const edges = shownEdges(channelOnly) + const ids = nodes.map((n) => n.id()) + const spacing = targetNodeSpacing() + const layerGap = parseFloat(el3('layer3dSpacing').value) || 150 + const pos2 = positions2dOf(ids) + const result = {} + if (!ids.length) return result + + if (algo === 'fr3d') { + const init = {} + const r = await fr3dAsync( + ids, + edges.map((e) => ({ s: e.data('source'), t: e.data('target'), w: e.data('weight') })), + { spacing, seed: 123 }, + net3d.onLayoutProgress + ) + return r + } + if (algo === 'flat') { + ids.forEach((id) => { + result[id] = [pos2[id][0], pos2[id][1], 0] + }) + return centerPositions(result) + } + if (algo === 'layers' || algo === 'hierarchy' || algo === 'degree') { + // the 2D layout lies flat; height (y) comes from groups, levels or degree + let height + if (algo === 'layers') { + const layer = groupLayerOf(nodes) + height = (id) => layer[id] * layerGap + } else if (algo === 'hierarchy') { + const level = hierarchyLevels(nodes, edges) + height = (id) => (level.get(id) || 0) * layerGap + } else { + const deg = computeMetricValues('degree', channelOnly) + const max = Math.max(1, ...Object.values(deg)) + height = (id) => (-(deg[id] || 0) / max) * layerGap * 3 + } + ids.forEach((id) => { + result[id] = [pos2[id][0], height(id), pos2[id][1]] + }) + return centerPositions(result) + } + if (algo === 'sphere') { + const R = spacing * Math.sqrt(ids.length) * 0.5 + spacing + const order = [...nodes].sort((a, b) => b.degree() - a.degree()) + fibonacciSphere(order.length).forEach((p, i) => { + result[order[i].id()] = [p[0] * R, p[1] * R, p[2] * R] + }) + return result + } + if (algo === 'cube') { + const side = Math.ceil(Math.cbrt(ids.length)) + const order = [...nodes].sort((a, b) => b.degree() - a.degree()) + order.forEach((n, i) => { + const x = i % side, + y = Math.floor(i / side) % side, + z = Math.floor(i / (side * side)) + const step = spacing * 1.8 + result[n.id()] = [ + (x - (side - 1) / 2) * step, + (y - (side - 1) / 2) * step, + (z - (side - 1) / 2) * step, + ] + }) + return result + } + if (algo === 'random') { + const rand = mulberry32(7) + const R = spacing * Math.cbrt(ids.length) * 0.9 + ids.forEach((id) => { + result[id] = [(rand() * 2 - 1) * R, (rand() * 2 - 1) * R, (rand() * 2 - 1) * R] + }) + return result + } + if (algo === 'groups3d') { + // each ticked group becomes a ball of nodes (hubs in the middle); the + // balls are placed by a 3D force layout of the group network and then + // pushed apart until they no longer overlap + const groups = getUsedGroups().filter((g) => S.activeGroups.has(g)) + const members = new Map(groups.map((g) => [g, []])) + const memberOf = {} + nodes.forEach((n) => { + let gs = effectiveGroupsFor(n).filter((g) => members.has(g)) + if (!gs.length) { + if (!members.has(UNGROUPED)) members.set(UNGROUPED, []) + gs = [UNGROUPED] + } + memberOf[n.id()] = gs + gs.forEach((g) => members.get(g).push(n)) + }) + const keys = [...members.keys()].filter((g) => members.get(g).length) + const radius = new Map( + keys.map((g) => [g, spacing * 0.55 * Math.cbrt(members.get(g).length) + spacing * 0.3]) + ) + const weight = new Map() + edges.forEach((e) => { + const ga = memberOf[e.data('source')], + gb = memberOf[e.data('target')] + if (!ga || !gb) return + ga.forEach((a) => + gb.forEach((b) => { + if (a === b) return + const key = a < b ? a + '\u0000' + b : b + '\u0000' + a + weight.set(key, (weight.get(key) || 0) + 1) + }) + ) + }) + const metaEdges = [...weight].map(([key, w]) => { + const [s, t] = key.split('\u0000') + return { s, t, w } + }) + const maxR = Math.max(...radius.values()) + const centers = fr3dLayout(keys, metaEdges, { spacing: maxR * 2.4, seed: 11, iterations: 300 }) + const gap = spacing * 0.6 + const needOf = (a, b) => radius.get(a) + radius.get(b) + gap + if (keys.length > 1) { + // scale so that each group's nearest neighbour is about touching, + // then pull far-away groups (often unconnected ones) back in + const ratios = keys + .map((a) => { + let best = Infinity, + need = 1 + keys.forEach((b) => { + if (a === b) return + const d = + Math.hypot( + centers[a][0] - centers[b][0], + centers[a][1] - centers[b][1], + centers[a][2] - centers[b][2] + ) || 0.01 + if (d / needOf(a, b) < best) { + best = d / needOf(a, b) + need = needOf(a, b) + } + }) + return best + }) + .sort((x, y) => x - y) + const factor = 1.15 / ratios[Math.floor(ratios.length / 2)] + keys.forEach((g) => { + centers[g] = centers[g].map((v) => v * factor) + }) + keys.forEach((a) => { + let nearest = null, + best = Infinity + keys.forEach((b) => { + if (a === b) return + const d = Math.hypot( + centers[a][0] - centers[b][0], + centers[a][1] - centers[b][1], + centers[a][2] - centers[b][2] + ) + if (d < best) { + best = d + nearest = b + } + }) + const need = needOf(a, nearest) + if (best > 2.5 * need) { + const t = 1 - (1.6 * need) / best + centers[a] = centers[a].map((v, d) => v + (centers[nearest][d] - v) * t) + } + }) + } + // separate overlapping balls + for (let it = 0; it < 80; it++) { + let moved = false + for (let i = 0; i < keys.length; i++) + for (let j = i + 1; j < keys.length; j++) { + const a = centers[keys[i]], + b = centers[keys[j]] + const dx = b[0] - a[0], + dy = b[1] - a[1], + dz = b[2] - a[2] + const d = Math.hypot(dx, dy, dz) || 0.01 + const need = radius.get(keys[i]) + radius.get(keys[j]) + gap + if (d < need) { + const push = (need - d) / 2 / d + a[0] -= dx * push + a[1] -= dy * push + a[2] -= dz * push + b[0] += dx * push + b[1] += dy * push + b[2] += dz * push + moved = true + } + } + if (!moved) break + } + const acc = {} + keys.forEach((g) => { + const list = [...members.get(g)].sort((a, b) => b.degree() - a.degree()) + const ball = fibonacciBall(list.length, radius.get(g)) + const c = centers[g] + list.forEach((n, i) => { + const id = n.id() + ;(acc[id] = acc[id] || []).push([c[0] + ball[i][0], c[1] + ball[i][1], c[2] + ball[i][2]]) + }) + }) + Object.entries(acc).forEach(([id, list]) => { + result[id] = [0, 1, 2].map((d) => list.reduce((s, p) => s + p[d], 0) / list.length) + }) + return centerPositions(result) + } + return result +} + +export async function runLayout3d(algo) { + algo = algo || el3('layout3d').value + const seq = ++net3d.layoutSeq + el3('btnRun3d').disabled = true + const big3d = shownNodes().length > 300 + setLayoutStatus3d( + big3d ? [{ level: 'busy', text: 'Computing the 3D layout…', progress: null }] : [] + ) + net3d.onLayoutProgress = (f) => { + if (big3d && seq === net3d.layoutSeq) + setLayoutStatus3d([{ level: 'busy', text: 'Computing the 3D layout…', progress: f }]) + } + await new Promise((r) => setTimeout(r, 20)) + try { + const result = await computeLayout3d(algo) + if (seq !== net3d.layoutSeq || !result) return + net3d.spreadApplied = 0 + el3('spread3d').value = 0 + updateSpread3dReadout() + animatePositions3d(result) + net3d.hasLayout = true + if (algo === 'layers' || algo === 'hierarchy' || algo === 'degree') { + moveCamera3d({ ...VIEW_PRESETS_3D.tilted, pitch: 0.55 }, false) + } + setTimeout(() => fit3d(null, true), 520) + setLayoutStatus3d([]) + } catch (err) { + setLayoutStatus3d([ + { level: 'error', text: `The 3D layout couldn't be computed: ${err.message}` }, + ]) + } finally { + if (seq === net3d.layoutSeq) el3('btnRun3d').disabled = false + } +} + +function animatePositions3d(target) { + const ids = Object.keys(target) + const from = new Map(ids.map((id) => [id, pos3dOf(id).slice()])) + const start = performance.now() + const step = (now) => { + const t = Math.min(1, (now - start) / 500) + const e = 1 - Math.pow(1 - t, 3) + ids.forEach((id) => { + const a = from.get(id), + b = target[id] + net3d.pos.set(id, [ + a[0] + (b[0] - a[0]) * e, + a[1] + (b[1] - a[1]) * e, + a[2] + (b[2] - a[2]) * e, + ]) + }) + render3d() + if (t < 1) requestAnimationFrame(step) + } + requestAnimationFrame(step) +} + +export function updateSpread3dReadout() { + const v = parseFloat(el3('spread3d').value) || 0 + el3('spread3dValue').textContent = Math.pow(2, v).toFixed(2) + '×' +} + +export function applySpread3d() { + const v = parseFloat(el3('spread3d').value) || 0 + const ratio = Math.pow(2, v - net3d.spreadApplied) + net3d.spreadApplied = v + updateSpread3dReadout() + const ids = shownIds3d() + if (!ids.length) return + const c = [0, 0, 0] + ids.forEach((id) => { + const p = pos3dOf(id) + c[0] += p[0] + c[1] += p[1] + c[2] += p[2] + }) + c.forEach((x, i) => { + c[i] = x / ids.length + }) + ids.forEach((id) => { + const p = pos3dOf(id) + net3d.pos.set(id, [ + c[0] + (p[0] - c[0]) * ratio, + c[1] + (p[1] - c[1]) * ratio, + c[2] + (p[2] - c[2]) * ratio, + ]) + }) + requestRender3d() +} diff --git a/frontend/src/view3d/painters.ts b/frontend/src/view3d/painters.ts new file mode 100644 index 0000000..dd98753 --- /dev/null +++ b/frontend/src/view3d/painters.ts @@ -0,0 +1,226 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +/* ---------- painters: the same drawing code writes to a canvas or to SVG ---------- */ +export function canvasPainter(ctx) { + return { + kind: 'canvas', + clear(W, H, bg) { + ctx.clearRect(0, 0, W, H) + if (bg) { + ctx.fillStyle = bg + ctx.fillRect(0, 0, W, H) + } + }, + pathD(d, st) { + const path = new Path2D(d) + ctx.save() + if (st.blur) ctx.filter = `blur(${st.blur}px)` + if (st.fill) { + ctx.globalAlpha = st.fillAlpha ?? 1 + ctx.fillStyle = st.fill + ctx.fill(path, st.evenodd ? 'evenodd' : 'nonzero') + } + if (st.stroke) { + ctx.globalAlpha = st.strokeAlpha ?? 1 + ctx.strokeStyle = st.stroke + ctx.lineWidth = st.width || 1 + ctx.stroke(path) + } + ctx.restore() + }, + line(x1, y1, x2, y2, color, width, alpha, cx, cy0) { + ctx.globalAlpha = alpha + ctx.strokeStyle = color + ctx.lineWidth = width + ctx.beginPath() + ctx.moveTo(x1, y1) + if (cx !== undefined) ctx.quadraticCurveTo(cx, cy0, x2, y2) + else ctx.lineTo(x2, y2) + ctx.stroke() + }, + circle(x, y, r, fill, alpha, stroke, width) { + ctx.globalAlpha = alpha + ctx.beginPath() + ctx.arc(x, y, r, 0, Math.PI * 2) + if (fill) { + ctx.fillStyle = fill + ctx.fill() + } + if (stroke && width > 0) { + ctx.strokeStyle = stroke + ctx.lineWidth = width + ctx.stroke() + } + }, + wedge(x, y, r, a0, a1, fill, alpha) { + ctx.globalAlpha = alpha + ctx.fillStyle = fill + ctx.beginPath() + ctx.moveTo(x, y) + ctx.arc(x, y, r, a0, a1) + ctx.closePath() + ctx.fill() + }, + poly(pts, fill, alpha, stroke, width) { + ctx.globalAlpha = alpha + ctx.beginPath() + pts.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]))) + ctx.closePath() + if (fill) { + ctx.fillStyle = fill + ctx.fill() + } + if (stroke && width > 0) { + ctx.strokeStyle = stroke + ctx.lineWidth = width + ctx.stroke() + } + }, + shade(x, y, r, alpha) { + const g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.35, r * 0.1, x, y, r) + g.addColorStop(0, 'rgba(255,255,255,0.55)') + g.addColorStop(0.45, 'rgba(255,255,255,0.05)') + g.addColorStop(1, 'rgba(0,0,0,0.35)') + ctx.globalAlpha = alpha + ctx.fillStyle = g + ctx.beginPath() + ctx.arc(x, y, r, 0, Math.PI * 2) + ctx.fill() + }, + text(str, x, y, o) { + const font = `${Math.round(o.size * 2) / 2}px Inter, Helvetica, Arial, sans-serif` + if (!o.rotate && !o.bg) { + // common case: no state to save + ctx.globalAlpha = o.alpha + if (ctx.font !== font) ctx.font = font + ctx.textAlign = o.align + ctx.textBaseline = o.baseline + if (o.halo && o.haloWidth > 0) { + ctx.lineWidth = o.haloWidth * 2 + ctx.strokeStyle = o.halo + ctx.lineJoin = 'round' + ctx.strokeText(str, x, y) + } + ctx.fillStyle = o.color + ctx.fillText(str, x, y) + return + } + ctx.save() + ctx.globalAlpha = o.alpha + ctx.font = font + ctx.textAlign = o.align + ctx.textBaseline = o.baseline + ctx.translate(x, y) + if (o.rotate) ctx.rotate(o.rotate) + if (o.bg) { + const w = ctx.measureText(str).width + const h = o.size * 1.2 + const left = o.align === 'center' ? -w / 2 : o.align === 'right' ? -w : 0 + ctx.fillStyle = o.bg + ctx.globalAlpha = o.alpha * 0.85 + ctx.fillRect(left - 2, -h / 2, w + 4, h) + ctx.globalAlpha = o.alpha + } + if (o.halo && o.haloWidth > 0) { + ctx.lineWidth = o.haloWidth * 2 + ctx.strokeStyle = o.halo + ctx.lineJoin = 'round' + ctx.strokeText(str, 0, 0) + } + ctx.fillStyle = o.color + ctx.fillText(str, 0, 0) + ctx.restore() + }, + done() { + ctx.globalAlpha = 1 + }, + } +} + +export function svgPainter(W, H) { + const out = [] + const f = (v) => (Math.round(v * 100) / 100).toString() + const esc = (v) => + String(v).replace( + /[&<>"]/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] + ) + const op = (a) => (a < 1 ? ` opacity="${f(a)}"` : '') + let blurId = 0 + const defs = [ + '', + ] + return { + kind: 'svg', + clear(w, h, bg) { + if (bg) out.push(``) + }, + pathD(d, st) { + let filter = '' + if (st.blur) { + const id = 'b' + ++blurId + defs.push( + `` + ) + filter = ` filter="url(#${id})"` + } + out.push( + `` + ) + }, + line(x1, y1, x2, y2, color, width, alpha, cx, cy0) { + const d = + cx !== undefined + ? `M${f(x1)},${f(y1)}Q${f(cx)},${f(cy0)} ${f(x2)},${f(y2)}` + : `M${f(x1)},${f(y1)}L${f(x2)},${f(y2)}` + out.push( + `` + ) + }, + circle(x, y, r, fill, alpha, stroke, width) { + out.push( + ` 0 ? ` stroke="${stroke}" stroke-width="${f(width)}"` : ''}${op(alpha)}/>` + ) + }, + wedge(x, y, r, a0, a1, fill, alpha) { + const large = a1 - a0 > Math.PI ? 1 : 0 + out.push( + `` + ) + }, + poly(pts, fill, alpha, stroke, width) { + out.push( + ` 0 ? ` stroke="${stroke}" stroke-width="${f(width)}"` : ''}${op(alpha)}/>` + ) + }, + shade(x, y, r, alpha) { + out.push(``) + }, + text(str, x, y, o) { + const anchor = o.align === 'center' ? 'middle' : o.align === 'right' ? 'end' : 'start' + const base = + o.baseline === 'middle' + ? 'central' + : o.baseline === 'top' + ? 'text-before-edge' + : 'text-after-edge' + const rot = o.rotate + ? ` transform="rotate(${f((o.rotate * 180) / Math.PI)} ${f(x)} ${f(y)})"` + : '' + const halo = + o.halo && o.haloWidth > 0 + ? ` stroke="${o.halo}" stroke-width="${f(o.haloWidth * 2)}" stroke-linejoin="round" paint-order="stroke"` + : '' + const bgHalo = o.bg + ? ` stroke="${o.bg}" stroke-width="${f(o.size * 0.5)}" stroke-linejoin="round" paint-order="stroke"` + : '' + out.push( + `${esc(str)}` + ) + }, + done() {}, + toString(title) { + return `\n\n${esc(title || 'NORMA 3D network')}\n${defs.join('')}\n${out.join('\n')}\n` + }, + } +} diff --git a/frontend/src/view3d/state.ts b/frontend/src/view3d/state.ts new file mode 100644 index 0000000..822397b --- /dev/null +++ b/frontend/src/view3d/state.ts @@ -0,0 +1,86 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { cy } from '../cy' +import { normalizeCssColor } from '../layouts/controls' +import { render3d } from './draw' + +/* ---------- state ---------- */ +export const net3d = { + active: false, + pos: new Map(), // node id -> [x, y, z] + cam: { yaw: 0.6, pitch: 0.35, dist: 900, tx: 0, ty: 0, tz: 0 }, + hasLayout: false, + cache: null, + cacheDirty: true, + frameQueued: false, + interacting: false, + interactTimer: null, + autoTimer: null, + lastFrame: 0, + proj: [], // last projected nodes, near first + projEdges: [], + layoutSeq: 0, + anim: null, + spreadApplied: 0, +} + +export function el3(id) { + return document.getElementById(id) +} + +export function canvas3d() { + return el3('cy3d') +} + +export function markDirty3d() { + net3d.cacheDirty = true + requestRender3d() +} + +export function requestRender3d() { + if (!net3d.active || net3d.frameQueued) return + net3d.frameQueued = true + requestAnimationFrame(() => { + net3d.frameQueued = false + render3d() + }) +} + +/* ---------- colors ---------- */ +const colorCache3d = new Map() + +export function rgbOf(color) { + if (colorCache3d.has(color)) return colorCache3d.get(color) + let rgb = [136, 136, 136] + const s = String(color || '').trim() + let m + if ((m = /^#([0-9a-f]{3})$/i.exec(s))) rgb = [...m[1]].map((c) => parseInt(c + c, 16)) + else if ((m = /^#([0-9a-f]{6})/i.exec(s))) + rgb = [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16)) + else if ((m = /rgba?\(([^)]+)\)/i.exec(s))) + rgb = m[1] + .split(',') + .slice(0, 3) + .map((v) => parseFloat(v)) + else { + const hex = typeof normalizeCssColor === 'function' ? normalizeCssColor(s) : null + if (hex) return rgbOf(hex) + } + colorCache3d.set(color, rgb) + return rgb +} + +export function mixColor(color, bg, t) { + if (t <= 0) return color + const a = rgbOf(color), + b = rgbOf(bg) + return `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round(a[1] + (b[1] - a[1]) * t)},${Math.round(a[2] + (b[2] - a[2]) * t)})` +} + +// page wiring, run by main.ts in the original order +export function init() { + cy.on('add remove data select unselect style class', () => { + if (net3d.active) markDirty3d() + else net3d.cacheDirty = true + }) +} diff --git a/frontend/src/view3d/tab.ts b/frontend/src/view3d/tab.ts new file mode 100644 index 0000000..5daa36a --- /dev/null +++ b/frontend/src/view3d/tab.ts @@ -0,0 +1,119 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { cy } from '../cy' +import { el3, net3d, requestRender3d } from './state' +import { fit3d, wrapAngle } from './camera' +import { render3d } from './draw' +import { runLayout3d, updateSpread3dReadout } from './layouts' +import { updateZoomReadout } from '../profiler' + +/* ---------- entering and leaving the 3D tab ---------- */ +export function setMode3d(on) { + net3d.active = on + document.getElementById('canvas').classList.toggle('mode3d', on) + el3('cy3d').hidden = !on + el3('nav3d').hidden = !on + el3('section3d').hidden = !on + const layout2d = document.getElementById('layoutSection') + if (layout2d) layout2d.hidden = on + document.getElementById('hint').textContent = on + ? 'Drag to rotate · drag a node to move it · right-drag to pan · scroll to zoom · Shift-drag to select · double-click to fit' + : 'Drag nodes · Shift-drag or Shift-click to select several · scroll to zoom · double-click to recenter' + if (on) { + net3d.cacheDirty = true + if (!net3d.hasLayout && cy.nodes().length) { + // first visit for this view: start from the 2D picture, then lay out in 3D + cy.nodes().forEach((n) => { + if (!net3d.pos.has(n.id())) { + const p = n.position() + net3d.pos.set(n.id(), [p.x, p.y, 0]) + } + }) + fit3d(null, false) + runLayout3d(el3('layout3d').value) + } else { + render3d() + } + toggleAutoRotate3d(el3('autoRotate3d').checked) + } else { + toggleAutoRotate3d(false) + updateZoomReadout() + } +} + +// per-view state +export function capture3d() { + const pos = {} + net3d.pos.forEach((p, id) => { + pos[id] = p + }) + return { pos, cam: { ...net3d.cam }, hasLayout: net3d.hasLayout, spread: net3d.spreadApplied } +} + +export function restore3d(state) { + net3d.pos = new Map(state && state.pos ? Object.entries(state.pos) : []) + net3d.cam = + state && state.cam + ? { ...state.cam } + : { yaw: 0.6, pitch: 0.35, dist: 900, tx: 0, ty: 0, tz: 0 } + net3d.hasLayout = !!(state && state.hasLayout) + net3d.spreadApplied = (state && state.spread) || 0 + el3('spread3d').value = net3d.spreadApplied + updateSpread3dReadout() + net3d.cacheDirty = true + net3d.layoutSeq++ + if (net3d.active) { + if (!net3d.hasLayout && cy.nodes().length) { + cy.nodes().forEach((n) => { + if (!net3d.pos.has(n.id())) { + const p = n.position() + net3d.pos.set(n.id(), [p.x, p.y, 0]) + } + }) + fit3d(null, false) + runLayout3d(el3('layout3d').value) + } else render3d() + } +} + +// new data in the current view: 3D positions start over +export function reset3dForNewData() { + net3d.pos = new Map() + net3d.hasLayout = false + net3d.cacheDirty = true + net3d.layoutSeq++ + if (net3d.active && cy.nodes().length) { + cy.nodes().forEach((n) => { + const p = n.position() + net3d.pos.set(n.id(), [p.x, p.y, 0]) + }) + fit3d(null, false) + runLayout3d(el3('layout3d').value) + } +} + +/* ---------- auto-rotate ---------- */ +export function toggleAutoRotate3d(on) { + cancelAnimationFrame(net3d.autoTimer) + net3d.autoTimer = null + el3('btnAuto3d').setAttribute('aria-pressed', on ? 'true' : 'false') + if (!on || !net3d.active) return + let last = performance.now() + const tick = (now) => { + const speed = parseFloat(el3('autoSpeed3d').value) || 1 + net3d.cam.yaw = wrapAngle(net3d.cam.yaw + ((now - last) / 1000) * 0.35 * speed) + last = now + const big = net3d.cache && net3d.cache.nodes.length + net3d.cache.edges.length > 3000 + if (big) { + net3d.interacting = true + clearTimeout(net3d.interactTimer) + net3d.interactTimer = setTimeout(() => { + net3d.interacting = false + requestRender3d() + }, 200) + } + render3d() + net3d.autoTimer = requestAnimationFrame(tick) + } + net3d.autoTimer = requestAnimationFrame(tick) +} diff --git a/frontend/src/viewbar.ts b/frontend/src/viewbar.ts new file mode 100644 index 0000000..f23e936 --- /dev/null +++ b/frontend/src/viewbar.ts @@ -0,0 +1,54 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { activeView, createView, renderViewBar, switchView, uniqueViewName } from './profiler' + +function setRenaming(on) { + document.getElementById('viewSelectRow').hidden = on + document.getElementById('viewRenameRow').hidden = !on + const input = document.getElementById('viewNameInput') + if (on) { + input.value = activeView() ? activeView().name : '' + input.focus() + input.select() + } else { + document.getElementById('viewSelect').focus() + } +} + +function saveRename() { + const v = activeView() + const name = document.getElementById('viewNameInput').value.trim() + if (v && name && name !== v.name) { + v.name = uniqueViewName(name) + v.autoName = false + } + setRenaming(false) + renderViewBar() +} + +// page wiring, run by main.ts in the original order +export function init() { + /* ---------- view bar controls ---------- */ + document + .getElementById('viewSelect') + .addEventListener('change', (e) => switchView(e.target.value)) + + document.getElementById('btnViewDuplicate').addEventListener('click', () => { + const cur = activeView() + createView(`${cur ? cur.name : 'View'} copy`, { copy: true }) + }) + + document.getElementById('btnViewRename').addEventListener('click', () => setRenaming(true)) + + document.getElementById('btnViewRenameSave').addEventListener('click', saveRename) + + document.getElementById('viewNameInput').addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault() + saveRename() + } else if (e.key === 'Escape') { + e.preventDefault() + setRenaming(false) + } + }) +} diff --git a/frontend/src/welcome.ts b/frontend/src/welcome.ts new file mode 100644 index 0000000..6fe6950 --- /dev/null +++ b/frontend/src/welcome.ts @@ -0,0 +1,132 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { cy } from './cy' +import { switchSideTab } from './side_tabs' +import { switchTab } from './wiring' + +/* ---------- welcome card ---------- */ +export function updateEmptyState() { + const el = document.getElementById('emptyState') + if (!el) return + const empty = !cy.nodes().length + el.hidden = !empty + document.getElementById('canvas').classList.toggle('is-empty', empty) +} + +function openWelcomeExample() { + switchSideTab('data') + const sel = document.getElementById('sampleSelect') + sel.value = 'norma:cascade' + document.getElementById('btnSample').click() + switchTab('network') +} + +/* ============================================================ + COMMUNITY DETECTION + All algorithms work on the profiler's simple undirected graph + ({ ids, n, m, adj }) and return { membership, count, sizes, modularity }. + Randomized steps use a fixed seed, so results are repeatable. + louvain Blondel et al. 2008 (resolution parameter) + leidenCommunities Traag, Waltman & van Eck 2019 + labelPropagation Raghavan, Albert & Kumara 2007 + walktrap Pons & Latapy 2005 (random walks of length t) + markovClustering van Dongen 2000 (MCL, inflation parameter) + ============================================================ */ +export const COMMUNITY_ALGORITHMS = { + louvain: { label: 'Louvain', short: 'Louvain' }, + leiden: { label: 'Leiden', short: 'Leiden' }, + lpa: { label: 'Label propagation', short: 'label propagation' }, + walktrap: { label: 'Walktrap', short: 'Walktrap' }, + mcl: { label: 'Markov clustering (MCL)', short: 'MCL' }, +} + +export const WALKTRAP_NODE_LIMIT = 2000 + +// Renumbers communities 0..k-1 in order of first appearance and scores the +// partition by (resolution-free) modularity on the original graph. +export function partitionStats(g, raw) { + const n = g.n + const remap = new Map() + const membership = new Int32Array(n) + for (let i = 0; i < n; i++) { + if (!remap.has(raw[i])) remap.set(raw[i], remap.size) + membership[i] = remap.get(raw[i]) + } + const count = remap.size + const sizes = new Array(count).fill(0) + const inW = new Float64Array(count), + totW = new Float64Array(count) + const twoM = 2 * g.m + for (let u = 0; u < n; u++) { + const cu = membership[u] + sizes[cu]++ + totW[cu] += g.adj[u].length + const a = g.adj[u] + for (let k = 0; k < a.length; k++) if (membership[a[k]] === cu) inW[cu] += 1 + } + let q = 0 + if (twoM) for (let c = 0; c < count; c++) q += inW[c] / twoM - Math.pow(totW[c] / twoM, 2) + return { membership, count, sizes, modularity: twoM ? q : NaN } +} + +// Splits communities that fall apart into connected pieces. +export function splitDisconnected(g, raw) { + const n = g.n + const out = new Int32Array(n).fill(-1) + let next = 0 + for (let s0 = 0; s0 < n; s0++) { + if (out[s0] >= 0) continue + const c = raw[s0] + out[s0] = next + const stack = [s0] + while (stack.length) { + const v = stack.pop() + const a = g.adj[v] + for (let k = 0; k < a.length; k++) { + const u = a[k] + if (out[u] < 0 && raw[u] === c) { + out[u] = next + stack.push(u) + } + } + } + next++ + } + return out +} + +// page wiring, run by main.ts in the original order +export function init() { + cy.on('add remove', () => requestAnimationFrame(updateEmptyState)) + + document.getElementById('btnEmptyExample').addEventListener('click', openWelcomeExample) + + document.getElementById('btnEmpty2Example').addEventListener('click', openWelcomeExample) + + document.getElementById('btnEmpty2Db').addEventListener('click', () => switchSideTab('db')) + + document.getElementById('btnEmpty2Upload').addEventListener('click', () => { + switchSideTab('data') + document.getElementById('btnNormaAdd').click() + }) + + document.getElementById('btnEmpty2Welcome').addEventListener('click', () => switchTab('welcome')) + + document.getElementById('btnEmptyString').addEventListener('click', () => { + switchSideTab('db') + document.getElementById('stringSection').classList.remove('collapsed') + document.querySelector('#stringSection > h3').setAttribute('aria-expanded', 'true') + const q = document.getElementById('stringQuery') + q.scrollIntoView({ block: 'center', behavior: 'smooth' }) + q.focus() + }) + + document.getElementById('btnEmptyUpload').addEventListener('click', () => { + switchSideTab('data') + document.getElementById('btnNormaAdd').click() + }) + + document + .getElementById('btnEmptyHelp') + .addEventListener('click', () => switchTab('help', 'help-start')) +} diff --git a/frontend/src/wiring.ts b/frontend/src/wiring.ts new file mode 100644 index 0000000..864f915 --- /dev/null +++ b/frontend/src/wiring.ts @@ -0,0 +1,1154 @@ +// @ts-nocheck +// ponytail: split verbatim from the former single-file script; typed one file at a time (remove @ts-nocheck as it lands) +import { S } from './state' +import { + activeView, + nameForSelection, + openInNewView, + renderProfilerNetworkList, + renderViewBar, + selectionFromKey, + uniqueViewName, +} from './profiler' +import { buildModuleNetwork, generateDirectedMultiNetwork, mulberry32 } from './sample_data' +import { convertArena3dNetwork } from './arena3d' +import { cy } from './cy' +import { demoToNormaTexts } from './demo_downloads' +import { drawGroupHulls, resizeHullCanvas } from './hulls' +import { exportNormaAnnotation, exportNormaColors, exportNormaNetwork } from './export_norma' +import { handleNormaUploads } from './uploads' +import { libSelection, plural, setStatus } from './layouts/controls' +import { net3d } from './view3d/state' +import { refreshEnrichmentChoices } from './enrichment' +import { refreshLibraryView } from './library' +import { renderCompareList } from './label_colors' +import { setMode3d } from './view3d/tab' + +// Example files from NORMA (github.com/PavlopoulosLab/NORMA, MIT license), +// www/Examples/, embedded verbatim so they work offline. +export const NORMA_EXAMPLE_SETS = { + 'string-tp53': { + title: 'STRING: TP53 interactors', + source: 'STRING database export', + link: null, + files: [ + { + kind: 'network', + name: 'STRING TP53', + fileName: 'string_interactions.txt', + text: 'Source\tTarget\tWeight\nCDKN1A\tTP53\t70\nTP53\tMDM2\t2\nMDM4\tTP53\t3\nBCL2L1\tTP53\t4\nCHEK2\tATM\t5\nTP53\tEP300\t20\nATM\tTP53\t7\nTP53\tCREBB\t1\nMDM4\tMDM2\t1\nCHEK2\tTP53\t1\nTP53BP2\tTP53\t1\nCDKN2A\tTP53\t1\nCDKN2A\tMDM2\t1\nATM\tMDM2\t2\nEP300\tCREBBP\t1\nMDM4\tATM\t2\nCHEK2\tMDM4\t1\nCHEK2\tMDM2\t1\nCDKN2A\tCDKN1A\t1\nCDKN2A\tMDM4\t1\nEP300\tMDM2\t1\nCDKN1A\tEP300\t1\nCREBBP\tMDM2\t1\nCDKN1A\tMDM2\t1\nCDKN2A\tBCL2L1\t1\nBCL2L1\tMDM2\t2\nCDKN2A\tATM\t1\nCDKN1A\tMDM4\t1\nCDKN1A\tCHEK2\t1\nCDKN1A\tATM\t2\nCDKN1A\tCREBBP\t1\nCDKN1A\tBCL2L1\t1\nCDKN2A\tCHEK2\t1\nATM\tEP300\t1\nTP53BP2\tBCL2L1\t3\nCDKN2A\tEP300\t3\nBCL2L1\tATM\t3\nMDM4\tEP300\t1\nATM\tCREBBP\t1\nCHEK2\tEP300\t1\nTP53BP2\tMDM2\t1\nCHEK2\tBCL2L1\t1\nCDKN2A\tCREBBP\t1\nTP53BP2\tEP300\t1\nBCL2L1\tEP300\t1\nMDM4\tBCL2L1\t1\n', + }, + { + kind: 'annotation', + name: 'STRING TP53 groups', + fileName: 'string_interactions_groups_comma_duplicate.txt', + text: 'Group-1\tBCL2L1,MDM4,MDM2,CHEK2\nGroup-2\tCDKN2A,ATM,TP53BP2,MDM2\nGroup-3\tCHEK2,MDM2\nGroup-4\tTP53,EP300\nGroup-5\tMDM4,MDM2\n', + }, + { + kind: 'colors', + name: 'STRING TP53 expression', + fileName: 'string_expression_colors.txt', + text: 'CDKN1A\tblue\nTP53\tblue\nMDM4\tblue\nBCL2L1\tred\nCHEK2\tred\nATM\tred\nTP53BP2\tred\nCDKN2A\tblue\nEP300\tred\nCREBBP\tred\n', + }, + ], + }, + 'string-bcar3': { + title: 'STRING: BCAR3 interactors', + source: 'STRING database export', + link: null, + files: [ + { + kind: 'network', + name: 'STRING BCAR3', + fileName: 'BCAR3.txt', + text: 'Source\tTarget\nVCP\tNPLOC4\nBCAR1\tPXN\nVCP\tDERL2\nNSFL1C\tVCP\nCDC42\tWASL\nVCP\tDERL1\nNPLOC4\tUFD1L\nSYVN1\tVCP\nVCP\tUFD1L\nCDC42\tWAS\nCDC42\tPAK1\nCDC42\tBAIAP2\nVCP\tFAF2\nCDC42\tARHGAP1\nVIMP\tVCP\nCDC42\tPARD6A\nCDC42\tTNK2\nCDC42\tITSN1\nCDC42\tARHGDIA\nCDC42\tPARD6B\nSYVN1\tDERL2\nCDC42\tPAK2\nVIMP\tDERL1\nDERL1\tDERL2\nWAS\tBAIAP2\nCDC42\tPXN\nFAF2\tDERL2\nSYVN1\tDERL1\nUFD1L\tFAF2\nNPLOC4\tFAF2\nBCAR1\tBCAR3\nITSN1\tWASL\nBAIAP2\tWASL\nBCAR1\tTNK2\nBCAR1\tCDC42\nPAK1\tPXN\nPAK1\tARHGDIA\nFAF2\tDERL1\nNEDD9\tPXN\nPAK2\tPAK1\nVIMP\tDERL2\nSYVN1\tFAF2\nVIMP\tSYVN1\nCDC42\tBCAR3\nUFD1L\tDERL1\nPAK2\tPXN\nNSFL1C\tUFD1L\nNEDD9\tBCAR3\nPARD6B\tPARD6A\nBAIAP2\tPXN\nNSFL1C\tNPLOC4\nBCAR1\tBAIAP2\nPAK2\tPARD6A\nSYVN1\tUFD1L\nWAS\tWASL\nTULP4\tSPSB1\nSYVN1\tNPLOC4\nVIMP\tFAF2\nUFD1L\tDERL2\nNPLOC4\tDERL1\nNPLOC4\tDERL2\nITSN1\tWAS\nVIMP\tNPLOC4\nTNK2\tWAS\nNSFL1C\tFAF2\nTULP4\tKCNE4\nBCAR3\tTULP4\nBCAR3\tKCNE4\nNSFL1C\tDERL2\nSAT1\tBCAR3\nNSFL1C\tDERL1\nBCAR3\tVCP\nPAK2\tARHGDIA\nKCNE4\tTULP1\nBCAR3\tTULP1\nPXN\tWASL\nVIMP\tUFD1L\nWAS\tARHGAP1\nWAS\tPXN\nWAS\tPAK1\nBCAR3\tSPSB1\nARHGAP1\tARHGDIA\nBCAR1\tPARD6A\nPAK1\tWASL\nBCAR1\tWASL\nARHGDIA\tPXN\nBCAR1\tWAS\nPAK2\tWASL\nWAS\tPAK2\nARHGAP1\tPXN\nARHGDIA\tWASL', + }, + { + kind: 'annotation', + name: 'BCAR3 KEGG pathways', + fileName: 'BCAR3_KEGG.txt', + text: 'Protein processing in endoplasmic reticulum\tDERL1,DERL2,NPLOC4,NSFL1C,SYVN1,UFD1L,VCP,VIMP\nRegulation of actin cytoskeleton\tBAIAP2,BCAR1,CDC42,PAK1,PAK2,PXN,WAS,WASL\nChemokine signaling pathway\tBCAR1,CDC42,PAK1,PXN,WAS,WASL\nBacterial invasion of epithelial cells\tBCAR1,CDC42,PXN,WAS,WASL\nTight junction\tCDC42,PARD6A,PARD6B,WAS,WASL\nAxon guidance\tCDC42,PAK1,PAK2,PARD6A,PARD6B\nFocal adhesion\tBCAR1,CDC42,PAK1,PAK2,PXN\nEndocytosis\tCDC42,PARD6A,PARD6B,WAS,WASL\nAdherens junction\tBAIAP2,CDC42,WAS,WASL\nFc gamma R-mediated phagocytosis\tCDC42,PAK1,WAS,WASL\nRap1 signaling pathway\tBCAR1,CDC42,PARD6A,PARD6B\nHuman papillomavirus infection\tCDC42,PARD6A,PARD6B,PXN\nPathogenic Escherichia coli infection\tCDC42,WAS,WASL\nShigellosis\tCDC42,WAS,WASL\nRenal cell carcinoma\tCDC42,PAK1,PAK2\nSalmonella infection\tCDC42,WAS,WASL\nT cell receptor signaling pathway\tCDC42,PAK1,PAK2\nLeukocyte transendothelial migration\tBCAR1,CDC42,PXN\nProteoglycans in cancer\tCDC42,PAK1,PXN\nRas signaling pathway\tCDC42,PAK1,PAK2\nMAPK signaling pathway\tCDC42,PAK1,PAK2\nVEGF signaling pathway\tCDC42,PXN\nEpithelial cell signaling in Helicobacter pylori infection\tCDC42,PAK1\nErbB signaling pathway\tPAK1,PAK2\nCholine metabolism in cancer\tWAS,WASL\nNeurotrophin signaling pathway\tARHGDIA,CDC42\nHippo signaling pathway\tPARD6A,PARD6B', + }, + { + kind: 'annotation', + name: 'BCAR3 GO molecular function', + fileName: 'BCAR3_GO_MF.txt', + text: 'protein binding\tARHGAP1,BAIAP2,BCAR1,BCAR3,CDC42,DERL1,FAF2,ITSN1,KCNE4,NPLOC4,NSFL1C,PAK1,PAK2,PARD6A,PXN,SAT1,SYVN1,TNK2,TULP1,UFD1L,VCP,VIMP,WAS,WASL\nenzyme binding\tARHGAP1,BCAR1,BCAR3,CDC42,DERL1,FAF2,ITSN1,NPLOC4,NSFL1C,PAK1,PAK2,PARD6A,PXN,SYVN1,TNK2,UFD1L,VCP,VIMP,WAS\nmolecular function regulator\tARHGAP1,ARHGDIA,BCAR3,FAF2,ITSN1,NSFL1C,PAK2,TNK2,VCP,WAS,WASL\nenzyme regulator activity\tARHGAP1,ARHGDIA,FAF2,ITSN1,NSFL1C,PAK2,TNK2,VCP,WAS,WASL\nidentical protein binding\tBAIAP2,CDC42,PAK1,PAK2,SAT1,TNK2,VCP,WAS\nprotein kinase binding\tBCAR1,CDC42,PAK1,PAK2,PARD6A,PXN,WAS\nGTPase binding\tARHGAP1,BCAR3,ITSN1,PAK1,PAK2,PARD6A,WAS\nprotein domain specific binding\tARHGAP1,BAIAP2,BCAR1,CDC42,TNK2,VCP,WAS\nubiquitin protein ligase binding\tDERL1,FAF2,NPLOC4,PXN,TNK2,VCP\nRas GTPase binding\tARHGAP1,ITSN1,PAK1,PAK2,PARD6A,WAS\nATPase binding\tDERL1,NSFL1C,SYVN1,UFD1L,VIMP\nRho GTPase binding\tITSN1,PAK1,PAK2,PARD6A,WAS\nGTPase regulator activity\tARHGAP1,ARHGDIA,TNK2,WAS,WASL\nenzyme activator activity\tARHGAP1,ARHGDIA,ITSN1,PAK2,VCP\nubiquitin-specific protease binding\tDERL1,SYVN1,VCP,VIMP\nprotein serine/threonine kinase activity\tCDC42,PAK1,PAK2,TNK2\nRac GTPase binding\tPAK1,PAK2,WAS\nubiquitin binding\tFAF2,NPLOC4,NSFL1C\nSH3 domain binding\tARHGAP1,BCAR1,WAS\nprotein binding, bridging\tARHGAP1,BAIAP2,BCAR3\n', + }, + { + kind: 'annotation', + name: 'BCAR3 GO biological process', + fileName: 'BCAR3_GO_BP.txt', + text: 'cellular process\tARHGAP1,ARHGDIA,BAIAP2,BCAR1,BCAR3,CDC42,DERL1,DERL2,FAF2,ITSN1,KCNE4,NEDD9,NPLOC4,NSFL1C,PAK1,PAK2,PARD6A,PARD6B,PXN,SAT1,SPSB1,SYVN1,TNK2,TULP1,TULP4,UFD1L,VCP,VIMP,WAS,WASL\nbiological regulation\tARHGAP1,ARHGDIA,BAIAP2,BCAR1,BCAR3,CDC42,DERL1,DERL2,FAF2,ITSN1,KCNE4,NEDD9,NPLOC4,NSFL1C,PAK1,PAK2,PARD6A,PARD6B,PXN,SAT1,SYVN1,TNK2,TULP1,UFD1L,VCP,VIMP,WAS,WASL\nregulation of biological process\tARHGAP1,ARHGDIA,BAIAP2,BCAR1,BCAR3,CDC42,DERL1,DERL2,ITSN1,KCNE4,NEDD9,NPLOC4,NSFL1C,PAK1,PAK2,PARD6A,PARD6B,PXN,SAT1,SYVN1,TNK2,TULP1,UFD1L,VCP,VIMP,WAS,WASL\nregulation of cellular process\tARHGAP1,ARHGDIA,BAIAP2,BCAR1,BCAR3,CDC42,DERL1,DERL2,ITSN1,NEDD9,NPLOC4,NSFL1C,PAK1,PAK2,PARD6A,PARD6B,PXN,SAT1,SYVN1,TNK2,TULP1,UFD1L,VCP,VIMP,WAS,WASL\nresponse to stimulus\tARHGAP1,ARHGDIA,BAIAP2,BCAR1,BCAR3,CDC42,DERL1,DERL2,FAF2,ITSN1,NEDD9,NPLOC4,PAK1,PAK2,PARD6A,PXN,SYVN1,TNK2,TULP1,UFD1L,VCP,VIMP,WAS,WASL\nlocalization\tARHGAP1,BAIAP2,BCAR1,CDC42,DERL1,DERL2,FAF2,ITSN1,KCNE4,NPLOC4,NSFL1C,PAK1,PAK2,PXN,SYVN1,TNK2,TULP1,TULP4,UFD1L,VCP,VIMP,WAS,WASL\ncellular response to stimulus\tARHGAP1,ARHGDIA,BAIAP2,BCAR1,BCAR3,CDC42,DERL1,DERL2,FAF2,ITSN1,NEDD9,NPLOC4,PAK1,PAK2,PARD6A,PXN,SYVN1,TNK2,UFD1L,VCP,VIMP,WAS,WASL\nsignal transduction\tARHGAP1,ARHGDIA,BAIAP2,BCAR1,BCAR3,CDC42,DERL1,DERL2,ITSN1,NEDD9,PAK1,PAK2,PARD6A,PXN,SYVN1,TNK2,VCP,VIMP,WAS,WASL\nestablishment of localization\tARHGAP1,BAIAP2,CDC42,DERL1,DERL2,FAF2,ITSN1,KCNE4,NPLOC4,NSFL1C,PAK1,SYVN1,TNK2,TULP1,UFD1L,VCP,VIMP,WAS,WASL\npositive regulation of biological process\tARHGAP1,ARHGDIA,BAIAP2,BCAR1,BCAR3,CDC42,DERL1,DERL2,ITSN1,NSFL1C,PAK1,PAK2,PARD6A,PXN,TNK2,TULP1,VCP,WAS,WASL\nregulation of response to stimulus\tARHGAP1,ARHGDIA,BAIAP2,BCAR1,BCAR3,CDC42,DERL2,ITSN1,NPLOC4,PAK1,PAK2,PXN,SYVN1,UFD1L,VCP,VIMP,WAS,WASL\ntransport\tARHGAP1,BAIAP2,CDC42,DERL1,DERL2,FAF2,ITSN1,KCNE4,NPLOC4,PAK1,SYVN1,TNK2,TULP1,UFD1L,VCP,VIMP,WAS,WASL\npositive regulation of cellular process\tARHGAP1,ARHGDIA,BAIAP2,BCAR1,BCAR3,CDC42,DERL1,DERL2,ITSN1,NSFL1C,PAK1,PAK2,PXN,TNK2,TULP1,VCP,WAS,WASL\ncellular component organization\tBAIAP2,BCAR1,CDC42,DERL1,FAF2,ITSN1,NEDD9,NPLOC4,NSFL1C,PAK1,PAK2,PARD6A,PARD6B,PXN,TULP1,VCP,WAS,WASL\nresponse to chemical\tBAIAP2,BCAR1,BCAR3,CDC42,DERL1,DERL2,FAF2,NPLOC4,PAK1,PAK2,PARD6A,PXN,SYVN1,UFD1L,VCP,VIMP,WAS\ncellular localization\tARHGAP1,CDC42,DERL1,DERL2,FAF2,ITSN1,NPLOC4,NSFL1C,SYVN1,TULP1,TULP4,UFD1L,VCP,VIMP,WAS,WASL\nresponse to organic substance\tBAIAP2,BCAR1,CDC42,DERL1,DERL2,FAF2,NPLOC4,PAK1,PAK2,PARD6A,PXN,SYVN1,UFD1L,VCP,VIMP,WAS\nnegative regulation of biological process\tARHGAP1,ARHGDIA,CDC42,DERL2,ITSN1,NPLOC4,NSFL1C,PAK1,PAK2,PARD6A,SYVN1,TNK2,UFD1L,VIMP,WAS,WASL\norganonitrogen compound metabolic process\tCDC42,DERL1,DERL2,FAF2,NPLOC4,NSFL1C,PAK1,PAK2,SAT1,SPSB1,SYVN1,TNK2,TULP4,UFD1L,VCP,VIMP\ncellular protein metabolic process\tCDC42,DERL1,DERL2,FAF2,NPLOC4,NSFL1C,PAK1,PAK2,SPSB1,SYVN1,TNK2,TULP4,UFD1L,VCP,VIMP\nestablishment of localization in cell\tARHGAP1,CDC42,DERL1,DERL2,FAF2,ITSN1,NPLOC4,NSFL1C,SYVN1,UFD1L,VCP,VIMP,WAS,WASL\ncellular response to organic substance\tBAIAP2,BCAR1,CDC42,DERL1,DERL2,PAK1,PAK2,PARD6A,PXN,SYVN1,UFD1L,VCP,VIMP,WAS\nregulation of signal transduction\tARHGAP1,ARHGDIA,BAIAP2,BCAR3,CDC42,ITSN1,NPLOC4,PAK1,PAK2,PXN,SYVN1,UFD1L,VCP,VIMP\nresponse to stress\tCDC42,DERL1,DERL2,FAF2,NPLOC4,PAK1,PAK2,PXN,SYVN1,TNK2,UFD1L,VCP,VIMP,WAS\nnegative regulation of cellular process\tARHGAP1,ARHGDIA,CDC42,DERL2,ITSN1,NPLOC4,PAK1,PAK2,PARD6A,SYVN1,UFD1L,VIMP,WAS,WASL\nprotein localization\tARHGAP1,BAIAP2,DERL1,DERL2,FAF2,NPLOC4,SYVN1,TULP1,TULP4,UFD1L,VCP,VIMP,WASL\ncell surface receptor signaling pathway\tARHGDIA,BAIAP2,BCAR1,CDC42,ITSN1,NEDD9,PAK1,PAK2,PARD6A,PXN,TNK2,WAS,WASL\nregulation of cellular component organization\tARHGDIA,BAIAP2,BCAR1,CDC42,DERL2,NSFL1C,PAK1,PXN,TNK2,TULP1,VCP,WAS,WASL\nregulation of localization\tARHGAP1,BCAR1,DERL2,KCNE4,NSFL1C,PAK1,PARD6A,PARD6B,TNK2,TULP1,VIMP,WAS,WASL\nregulation of molecular function\tARHGAP1,ARHGDIA,BCAR3,DERL1,FAF2,ITSN1,NSFL1C,PAK1,PAK2,TNK2,VCP,WAS,WASL\nresponse to organonitrogen compound\tBAIAP2,BCAR1,DERL1,DERL2,FAF2,NPLOC4,PAK1,PXN,SYVN1,UFD1L,VCP,VIMP\nintracellular transport\tARHGAP1,CDC42,DERL1,DERL2,FAF2,NPLOC4,SYVN1,UFD1L,VCP,VIMP,WAS,WASL\ncellular protein modification process\tCDC42,DERL1,DERL2,PAK1,PAK2,SPSB1,SYVN1,TNK2,TULP4,UFD1L,VCP,VIMP\norganelle organization\tBAIAP2,BCAR1,CDC42,FAF2,NEDD9,NPLOC4,NSFL1C,PAK1,PAK2,PARD6A,WAS,WASL\ncellular protein localization\tDERL1,DERL2,FAF2,NPLOC4,SYVN1,TULP1,TULP4,UFD1L,VCP,VIMP,WASL\nvesicle-mediated transport\tARHGAP1,BAIAP2,CDC42,FAF2,ITSN1,PAK1,TNK2,TULP1,VCP,WAS,WASL\npositive regulation of response to stimulus\tARHGAP1,BAIAP2,BCAR1,BCAR3,CDC42,ITSN1,PAK1,PAK2,VCP,WAS,WASL\nregulation of catalytic activity\tARHGAP1,ARHGDIA,FAF2,ITSN1,NSFL1C,PAK1,PAK2,TNK2,VCP,WAS,WASL\ncellular component assembly\tBAIAP2,CDC42,DERL1,NEDD9,NSFL1C,PARD6A,PARD6B,PXN,VCP,WAS,WASL\nenzyme linked receptor protein signaling pathway\tBAIAP2,BCAR1,CDC42,ITSN1,PAK1,PAK2,PARD6A,PXN,TNK2,WASL\nregulation of immune response\tBAIAP2,BCAR1,CDC42,NPLOC4,PAK1,PAK2,UFD1L,VIMP,WAS,WASL\ncytoskeleton organization\tBAIAP2,BCAR1,CDC42,NEDD9,NSFL1C,PAK1,PAK2,PARD6A,WAS,WASL\norganonitrogen compound catabolic process\tDERL1,DERL2,FAF2,NPLOC4,NSFL1C,SAT1,SYVN1,UFD1L,VCP,VIMP\npositive regulation of cellular component organization\tARHGDIA,BAIAP2,CDC42,NSFL1C,PAK1,PXN,TULP1,VCP,WAS,WASL\ncellular response to stress\tDERL1,DERL2,FAF2,NPLOC4,PAK2,PXN,SYVN1,UFD1L,VCP,VIMP\ncellular catabolic process\tDERL1,DERL2,FAF2,NPLOC4,NSFL1C,SAT1,SYVN1,UFD1L,VCP,VIMP\nregulation of intracellular signal transduction\tARHGAP1,ARHGDIA,CDC42,ITSN1,NPLOC4,PAK1,PAK2,SYVN1,UFD1L,VIMP\nmulti-organism process\tBAIAP2,CDC42,DERL1,DERL2,ITSN1,PAK2,PARD6A,VCP,VIMP,WASL\nimmune system process\tBAIAP2,BCAR1,CDC42,FAF2,PAK1,PAK2,TNK2,VCP,WAS,WASL\nproteasome-mediated ubiquitin-dependent protein catabolic process\tDERL1,DERL2,FAF2,NPLOC4,NSFL1C,SYVN1,UFD1L,VCP,VIMP\ntransmembrane receptor protein tyrosine kinase signaling pathway\tBAIAP2,BCAR1,CDC42,ITSN1,PAK1,PAK2,PXN,TNK2,WASL\nimport into cell\tARHGAP1,BAIAP2,CDC42,ITSN1,PAK1,TNK2,TULP1,WAS,WASL\nprotein transport\tARHGAP1,DERL1,DERL2,FAF2,NPLOC4,SYVN1,UFD1L,VCP,VIMP\nregulation of cellular protein metabolic process\tBCAR3,DERL1,DERL2,NSFL1C,PAK1,PAK2,PARD6A,TNK2,VCP\nretrograde protein transport, ER to cytosol\tDERL1,DERL2,FAF2,NPLOC4,SYVN1,UFD1L,VCP,VIMP\nsmall GTPase mediated signal transduction\tARHGAP1,BCAR3,CDC42,ITSN1,PAK1,PAK2,TNK2,WAS\nactin cytoskeleton organization\tBAIAP2,BCAR1,CDC42,NEDD9,PAK1,PAK2,WAS,WASL\nregulation of cytoskeleton organization\tARHGDIA,BAIAP2,CDC42,NSFL1C,PAK1,PXN,WAS,WASL\nendocytosis\tBAIAP2,CDC42,ITSN1,PAK1,TNK2,TULP1,WAS,WASL\nprotein modification by small protein conjugation or removal\tCDC42,DERL1,SPSB1,SYVN1,TULP4,UFD1L,VCP,VIMP\ngeneration of neurons\tARHGDIA,BAIAP2,CDC42,PAK1,PAK2,PARD6B,TULP1,WASL\nregulation of apoptotic process\tARHGDIA,BCAR1,ITSN1,PAK1,PAK2,SYVN1,VCP,VIMP\nregulation of phosphate metabolic process\tBCAR3,ITSN1,NSFL1C,PAK1,PAK2,PARD6A,TNK2,VCP\nregulation of transport\tARHGAP1,DERL2,KCNE4,PAK1,TNK2,TULP1,VIMP,WASL\nregulation of protein modification process\tBCAR3,DERL1,NSFL1C,PAK1,PAK2,PARD6A,TNK2,VCP\nanatomical structure morphogenesis\tBAIAP2,BCAR3,CDC42,PAK1,PARD6B,SAT1,TULP1,WASL\nubiquitin-dependent ERAD pathway\tDERL1,DERL2,FAF2,NPLOC4,SYVN1,VCP,VIMP\nresponse to topologically incorrect protein\tDERL1,DERL2,FAF2,SYVN1,UFD1L,VCP,VIMP\npositive regulation of cytoskeleton organization\tBAIAP2,CDC42,NSFL1C,PAK1,PXN,WAS,WASL\nimmune response-activating cell surface receptor signaling pathway\tBAIAP2,BCAR1,CDC42,PAK1,PAK2,WAS,WASL\nregulation of actin cytoskeleton organization\tARHGDIA,BAIAP2,CDC42,PAK1,PXN,WAS,WASL\npositive regulation of cellular component biogenesis\tBAIAP2,CDC42,PAK1,PXN,VCP,WAS,WASL\nneuron projection development\tBAIAP2,CDC42,PAK1,PAK2,PARD6B,TULP1,WASL\nprotein ubiquitination\tCDC42,DERL1,SPSB1,SYVN1,TULP4,VCP,VIMP\nimmune effector process\tBAIAP2,CDC42,FAF2,PAK1,VCP,WAS,WASL\nregulation of response to stress\tDERL2,NPLOC4,PAK1,PAK2,SYVN1,UFD1L,VIMP\nmovement of cell or subcellular component\tBCAR1,CDC42,PAK2,PXN,TNK2,WAS,WASL\nregulation of phosphorylation\tBCAR3,ITSN1,PAK1,PAK2,PARD6A,TNK2,VCP\npositive regulation of signal transduction\tARHGAP1,BAIAP2,BCAR3,ITSN1,PAK1,PAK2,VCP\nFc receptor signaling pathway\tBAIAP2,CDC42,PAK1,PAK2,WAS,WASL\ncellular response to topologically incorrect protein\tDERL1,DERL2,SYVN1,UFD1L,VCP,VIMP\nresponse to unfolded protein\tDERL1,DERL2,FAF2,SYVN1,VCP,VIMP\npositive regulation of supramolecular fiber organization\tBAIAP2,CDC42,PAK1,PXN,WAS,WASL\nphagocytosis\tBAIAP2,CDC42,PAK1,TULP1,WAS,WASL\nactin filament organization\tBAIAP2,BCAR1,CDC42,NEDD9,WAS,WASL\nregulation of actin filament organization\tBAIAP2,CDC42,PAK1,PXN,WAS,WASL\nregulation of protein complex assembly\tBAIAP2,CDC42,PAK1,VCP,WAS,WASL\nviral process\tCDC42,DERL1,ITSN1,PAK2,PARD6A,VCP\nregulation of plasma membrane bounded cell projection organization\tARHGDIA,BAIAP2,CDC42,PAK1,WAS,WASL\nregulation of growth\tBCAR1,CDC42,DERL2,NEDD9,PAK1,PAK2\npositive regulation of phosphorylation\tBCAR3,ITSN1,PAK1,PAK2,TNK2,VCP\ncellular response to endogenous stimulus\tBAIAP2,BCAR1,PAK1,PARD6A,PXN,VIMP\npositive regulation of protein modification process\tBCAR3,DERL1,PAK1,PAK2,TNK2,VCP\nregulation of hydrolase activity\tARHGAP1,ARHGDIA,NSFL1C,PAK2,TNK2,VCP\nvascular endothelial growth factor receptor signaling pathway\tBAIAP2,BCAR1,CDC42,PAK2,PXN\nFc-gamma receptor signaling pathway involved in phagocytosis\tBAIAP2,CDC42,PAK1,WAS,WASL\ndendrite development\tBAIAP2,CDC42,PAK2,TULP1,WASL\nendoplasmic reticulum unfolded protein response\tDERL1,DERL2,SYVN1,VCP,VIMP\nestablishment or maintenance of cell polarity\tCDC42,NSFL1C,PAK1,PARD6A,PARD6B\n', + }, + ], + }, + tau: { + title: 'Drosophila Tau network', + source: 'PMID 31488613, PMCID PMC6794924', + link: 'https://doi.org/10.1523/JNEUROSCI.0391-19.2019', + files: [ + { + kind: 'network', + name: 'Drosophila Tau', + fileName: 'TAU_network_DEGs_NORMA.txt', + text: "Source\tTarget\nCG6178\tyip2\nshep\tScpX\nnito\tRef1\nbol\tHrb27C\nLar\tAbl\nLar\tCadN\nCG31715\tcpb\nMapmodulin\tAats-ile\nCG5590\tCG17597\nSc2\tOst48\nfrj\tCG10932\nKap-alpha3\temb\nUch\tUsp5\nPrx2540-1\tPrx2540-2\nCG2082\tCG2091\nlig\tCapr\nlig\trin\nclu\tDp1\nclu\teIF3-S10\nclu\trin\nclu\teIF3-S8\nclu\teIF3-S9\nCG9090\tCyt-c-p\nCG9090\tCOX5A\nAce\tCat\nDbp80\temb\nSsadh\tCG33791\nSsadh\tCG32026\nSsadh\tMdh2\nCG4572\tCG10576\nCG4572\tHsc70-3\nPlexA\talpha-Cat\nl(1)G0156\tCG11876\nl(1)G0156\tSsadh\nl(1)G0156\tCG33791\nPlc21C\tGbeta76C\nPlc21C\tPkc53E\nHrb27C\tpoe\nHrb27C\theph\nHrb27C\tPyK\nHrb27C\tbel\nHrb27C\tme31B\nHrb27C\tpAbp\nHrb27C\tTER94\nKhc\tTAU\nCG32026\tCG33791\nbel\tRef1\nbel\tpoe\nbel\temb\nbel\tvig\nbel\tme31B\nDp1\tCG2918\nSgt\tTrap1\nSgt\tTcp-1zeta\nMen-b\tCG11876\nMen-b\tPyK\nMen-b\tMdh2\nCG11899\tCG3011\nMgstl\tCG3529\nMgstl\tOst48\nCG14715\tCG2852\nLpin\tPkc53E\ngammaSnap2\tzetaCOP\ngammaSnap2\tbetaCOP\ngammaSnap2\tepsilonCOP\ngammaSnap2\tbeta'COP\ngammaSnap2\tdeltaCOP\nCG10359\tGp93\nCG10359\tCG1354\nCG10359\tCaBP1\nCG10359\tPdi\nKP78a\tCG32683\nKP78a\tArr2\nCG9257\tpoe\nCG9257\tCG3529\nEct3\tRpn5\nEct3\tCG6891\nEct3\tCat\nEct3\tCalpB\nCG4882\tbonsai\nCalpB\tCG6891\nCalpB\tCat\npar-1\tCG32683\npar-1\tKP78a\npar-1\tArr2\nPrx5\tPrx2540-2\nPrx5\tCat\nPkc53E\tzip\nCG31064\tRab5\nNlp\tmago\nNlp\teEF1delta\nNlp\tsmt3\nCG10186\tbetaCOP\nCG10186\tcpb\nCG10186\tcpa\nCG10186\tbeta'COP\nCG10186\tbai\nCG10186\tepsilonCOP\nCG10186\tDlic\nCG10186\tp115\nCG10186\tzetaCOP\nCG10186\tdeltaCOP\nrin\tCapr\nLpR1\tArr2\nLpR1\tArr1\nLpR1\tUbqn\nLpR1\tCG32683\nLpR1\tlqf\nLpR1\tHrs\nLpR1\tLpR2\nLpR1\tRab5\nCG16935\tCG12262\nCG16935\tyip2\npoe\tbetaCOP\npoe\tCG3529\nCG43367\tbetaCOP\nCG43367\tpoe\nRab39\tRab5\nRab39\tGdi\nemb\tRae1\nemb\talphaCOP\nemb\tCG10576\nAGBE\tEno\nAGBE\tPyK\nLpR2\tUbqn\nLpR2\tCG32683\nLpR2\tArr2\nLpR2\tHrs\nLpR2\tlqf\nLpR2\tRab5\nCG3529\tOst48\nOst48\tCG2918\nHsp60C\tHop\nHsp60C\tHsc70-3\nPrx2540-2\tCat\nEfTuM\tRpS14a\nEfTuM\tHsc70-5\nEfTuM\tRpL10Ab\nEfTuM\tRpS13\nEfTuM\tRpS16\nEfTuM\tRpS23\nEfTuM\tRpS15Aa\nEfTuM\tRpL4\nEfTuM\tCG11876\nEfTuM\tbonsai\nEfTuM\tmRpS10\nEfTuM\tRpL12\nEfTuM\tsta\nEfTuM\tRpL3\nEfTuM\tHsp60\nEfTuM\tRpS18\nEfTuM\tRpS2\nEfTuM\tmRpS9\nEfTuM\tRpS3\nEfTuM\tmRpL12\nEfTuM\tRpL11\nfne\tTango4\nfne\tSrp54\nfne\tCG10777\nfne\tPrp8\nfne\tmago\nfne\tCG16941\nfne\typs\nfne\tCG10077\nfne\tRbp9\nfne\tU2A\nfne\tPkc53E\nfne\tHrb98DE\nfne\tLS2\nfne\tSF2\nfne\tmub\nfne\theph\nKlp10A\tdeltaCOP\nMtl\tzip\nMtl\ttsr\nMtl\tRhoGDI\nHsp60\tCG7033\nRanBPM\tCG6617\nyps\tTango4\nyps\tLS2\nyps\tmago\nyps\tCG10777\nArt4\tCG10777\nArt4\tCG10077\nArt4\tpAbp\nImpL3\tCat\nImpL3\tMen-b\nImpL3\tPfk\nImpL3\tMen\nImpL3\tEno\nImpL3\tTpi\nImpL3\tPyK\nImpL3\tCG11876\nCG10077\tCG10777\nCG10077\tLS2\nCG10077\tTango4\nCG10077\typs\nCG10077\tmago\nLam\tRab5\nLamC\tLam\nUbqn\tHDAC6\nUbqn\tCG32683\nmub\tCG3800\nmub\tmago\nmub\tU2A\nmub\tSrp54\nmub\tTango4\nmub\tPrp8\nmub\tCG10777\nmub\tCG16941\nmub\typs\nmub\tCG10077\nmub\tLS2\nmub\tHrb98DE\nmub\tRbp9\nmub\tSF2\nAGO1\tRbp9\nAGO1\tpAbp\nAGO1\tRae1\nAGO1\tme31B\nglo\tHrb98DE\nSap-r\tCG3529\nSap-r\tMgstl\nSap-r\tpins\nSap-r\tRab5\nSap-r\tOst48\nNdg\tCg25C\nNdg\tLanB2\ntrol\tCp1\ntrol\tLanB2\ntrol\tCg25C\ntrol\tNdg\nHsp70Ab\tPdi\nHsp70Ab\tDnaJ-H\nHsp70Ab\tHop\nHsp70Ab\tHsp27\nbai\tSrp19\nbai\tCG2852\nbai\tgammaSnap2\nbai\tKlp10A\nbai\tKlc\nbai\tDlic\nbai\tcpa\nbai\tp115\nbai\tcpb\nbai\tdeltaCOP\nbai\tbetaCOP\nbai\tepsilonCOP\nbai\tbeta'COP\nbai\tzetaCOP\nNc73EF\tSsadh\nNc73EF\tCG32026\nNc73EF\tl(1)G0156\nNc73EF\tCG33791\nNc73EF\tCG11876\nSrp54\typs\nSrp54\tCG10777\nSrp54\tmago\nSrp54\tLS2\nSrp54\tCG10077\nSrp54\tTango4\nUgt\tCG2918\nRbp9\tCG10777\nRbp9\tTango4\nRbp9\tSrp54\nRbp9\tPrp8\nRbp9\tCG10077\nRbp9\tCG16941\nRbp9\tU2A\nRbp9\tPkc53E\nRbp9\tLS2\nRbp9\tHrb98DE\nRbp9\typs\nRbp9\tmago\nAct79B\tcpb\nAct79B\tflr\nAct79B\tzip\nRbp2\tpAbp\nRbp2\tRpL10Ab\nRbp2\tRpS4\nRbp2\tRpL18A\nRbp2\tRpS3\nRbp2\tRpS11\nRbp2\teIF3-S9\nRbp2\tRpL11\nRbp2\teIF3-S10\nRbp2\tRpL13\nRbp2\teIF3-S8\nRbp2\tRpS10b\nRbp2\tRpS10a\nRbp2\tRpS27\nRbp2\tRpL3\nRbp2\tRpS12\nRbp2\tRpS7\nRbp2\tRpL12\nRbp2\tTango7\nRbp2\tRpS23\nRbp2\tRpS13\nRbp2\tsta\nRbp2\tRpS18\nRbp2\tRpS16\nRbp2\tRpL4\nRbp2\tRpS6\nRbp2\tTrip1\nRbp2\tRpS2\nRbp2\teIF-3p66\nRbp2\tCG5642\nRbp2\tCG9769\nIswi\tHis1\nIswi\tBap55\nIswi\tsmt3\nIswi\tmor\nVps60\tHrs\neEF1delta\teIF3-S10\neEF1delta\tRpS10b\neEF1delta\tRpS13\neEF1delta\tRpS6\neEF1delta\tRpS16\neEF1delta\tRpL12\neEF1delta\tsta\neEF1delta\tRpS2\neEF1delta\tCG5642\neEF1delta\tRpL13\neEF1delta\tTrip1\nCG10576\tRpS6\nCG10576\tRpS4\nCG10576\tCapr\nCG10576\tsta\nCG10576\tRpS16\nCG10576\tTcp-1zeta\nCG10576\twuho\nCG10576\tRpL12\nCG10576\tCG7033\nAbl\tDscam1\nAbl\tsyd\nAbl\talpha-Cat\nAbl\tarm\nRae1\tmago\nMtor\tmago\nMtor\tAGO1\nMtor\tHsc70-3\nMtor\tHsc70-5\nMtor\tsmt3\nMtor\tRae1\nRef1\tLS2\nRef1\tmago\nTrap1\tHsp70Ab\nTrap1\tHop\nTrap1\tHsp60C\nTrap1\tHsp60\nTrap1\tCG2918\nTrap1\tHsc70-3\nTrap1\tCG1416\nCG17597\tCat\nCG17597\tScpX\nCG17597\tMfe2\nlqf\tCG32683\nlqf\tUbqn\nlqf\tArr2\nlqf\tRab5\nlqf\tHrs\nTim17b\tTim9a\nCG3902\tCG10932\nCat\tCG6891\np115\tdeltaCOP\nSec24AB\tdeltaCOP\nSec24AB\tbetaCOP\nSec24AB\tepsilonCOP\nSec24AB\tzetaCOP\nSec24AB\talphaCOP\nSec24AB\tbeta'COP\nSec24AB\tp115\nninaC\tAct79B\nninaC\tzip\nninaC\tArr2\nmago\tsta\nmago\tRpS6\nmago\tCG10777\nCG11876\tMen\nCG11876\tMdh2\nCG11876\tPyK\nCG11876\tND-PDSW\nCG11876\tAcCoAS\nCG11876\tEno\nCG11876\tCG33791\nPdk\tade3\nPdk\tCG11876\nB52\tRpS3\nB52\tHrb27C\nB52\tglo\nB52\tCG10777\nB52\tfne\nB52\tCG10077\nB52\typs\nB52\tRbp9\nB52\tCG16941\nB52\tTango4\nB52\tU2A\nB52\tmago\nB52\tSrp54\nB52\tmub\nB52\theph\nB52\tPrp8\nB52\tLS2\nB52\tHrb98DE\nB52\tU2af50\nB52\tSF2\nVha100-1\tsun\nVha100-1\tVha100-2\nVha100-1\tVha68-2\nrobl\tDlic\nHsc70-5\tCG7033\nHsc70-5\tT-cp1\nHsc70-5\tTpi\nHsc70-5\tDnaJ-H\nHsc70-5\tTcp-1eta\nHsc70-5\tHop\nHsc70-5\tRae1\nHsc70-5\tTrap1\nHsc70-5\tHsp60C\nHsc70-5\tHsc70-3\nHsc70-5\tHsp60\nHsc70-5\tGp93\ntsr\tsqh\ntsr\tcpa\ntsr\tRpS6\nflr\tcpa\nflr\ttsr\nAcCoAS\tCG12262\nAcCoAS\tCG10932\nACC\tEno\nACC\tCG10932\nACC\tAcCoAS\nLS2\tmago\nLS2\tCG10777\nFKBP59\tninaC\nFKBP59\tDlic\nFKBP59\tcpb\nFKBP59\tcpa\nFKBP59\tHop\nU2af50\tRef1\nU2af50\tLS2\nU2af50\typs\nU2af50\tCG10777\nU2af50\tCG10077\nU2af50\tRbp9\nU2af50\tfne\nU2af50\tSrp54\nU2af50\tmago\nU2af50\tmub\nU2af50\tTango4\nU2af50\theph\nU2af50\tCG16941\nU2af50\tHrb98DE\nU2af50\tPrp8\nU2af50\tSF2\nU2af50\tU2A\nFmr1\tdock\nFmr1\tPyK\nFmr1\tpoe\nFmr1\tbel\nFmr1\tpAbp\nFmr1\teIF4G\nFmr1\tDscam1\nFmr1\tRanBPM\nFmr1\tHrb98DE\nFmr1\tHrb27C\nFmr1\tCapr\nFmr1\tTER94\nFmr1\tRbp9\nFmr1\tme31B\nFmr1\tAGO1\nFmr1\tvig\nScpX\tCat\nScpX\tMfe2\nTop2\tRpS13\nTop2\tPkc53E\nTop2\tUba2\nTop2\tIswi\nTop2\tsmt3\nCG8209\tUfd1-like\nheph\tTango4\nheph\tSrp54\nheph\tCG16941\nheph\tCG10077\nheph\tCG10777\nheph\tU2A\nheph\tmago\nheph\typs\nheph\tPrp8\nheph\tmub\nheph\tLS2\nheph\tpAbp\nheph\tHrb98DE\nheph\tRbp9\nheph\tSF2\nHrs\tCG4572\nHrs\tCG32683\nHrs\tArr2\nHrs\tUbqn\nHrs\tRab5\nHrb98DE\tCG10777\nHrb98DE\tmago\nHrb98DE\tSrp54\nHrb98DE\tCG16941\nHrb98DE\tCG10077\nHrb98DE\typs\nHrb98DE\tLS2\nHrb98DE\tTango4\nbic\tRpS3\nNacalpha\tRpS13\nNacalpha\tRpS2\nNacalpha\tsta\nNacalpha\tRpS6\nNacalpha\tRpS16\nNacalpha\tRpS27\nNacalpha\tRpS4\nNacalpha\tRpS23\nNacalpha\tRpS18\nNacalpha\tRpL12\nNacalpha\tRpL18A\nNacalpha\tRpL11\nNacalpha\tRpS10b\nNacalpha\tRpS17\nNacalpha\tRpL13\nNacalpha\tRpS3\nNacalpha\tRpL4\nNacalpha\tbic\nAats-val\teEF1delta\nAats-val\tEf1beta\nAats-val\tAats-asp\nAats-val\tAats-ile\nAats-val\tCG33123\nAats-val\tAats-arg\nBicD\tLam\nBicD\tbic\nBicD\tKhc\nBicD\tcpb\nBicD\tcpa\nBicD\tDlic\nDnaJ-H\tCG2918\nDnaJ-H\tHsc70-3\nDnaJ-H\tHop\nmRpS34\tCG4882\nmRpS34\tbonsai\nCadN\talpha-Cat\nCadN\tarm\nvig\tRpS15Aa\nvig\tRpS13\nvig\tRpS16\nvig\tRpS17\nvig\tRpL12\nTudor-SN\tbel\nTudor-SN\tAGO1\nTudor-SN\tFmr1\nTudor-SN\tvig\nMdh2\tAldh\nMdh2\tND-51\nMdh2\tCOX4\nMdh2\tCyt-c-p\nMdh2\tCOX5A\nMdh2\tMen\nMdh2\tEno\nCyt-c-p\tCat\nCyt-c-p\tporin\nCyt-c-p\tND-PDSW\nCyt-c-p\tCOX6B\nAldh\tCat\nAldh\tEno\nAldh\tCG12262\nAldh\tAcCoAS\nCG15093\tCG12262\nCG15093\tAcCoAS\nCG15093\tCG3902\nCG15093\tAldh\nCG4679\tmRpS34\nCG4679\tbsf\nCG4679\tCG4882\nCG4679\tmRpL12\nCG4679\tmRpL44\nCG4679\tbonsai\nCG4679\tmRpS10\nCG4679\tmRpS23\nCG4679\tmRpS22\nCG4679\tmRpS9\nCG4679\tmRpS18B\nSF2\tCG10777\nSF2\tCG10077\nSF2\tU2A\nSF2\tTango4\nSF2\typs\nSF2\tPrp8\nSF2\tRbp9\nSF2\tCG16941\nSF2\tSrp54\nSF2\tRef1\nSF2\tLS2\nSF2\tmago\nSF2\tHrb98DE\np47\tCG8209\np47\tUfd1-like\np47\tRpt2\nRad23\tme31B\nRad23\tUch-L5\nRad23\tRpt1\nRad23\tHDAC6\nRad23\tUsp5\nRad23\tUba2\nRad23\tUfd1-like\nRad23\tSgt\nRad23\tRpt5\nRad23\tBap55\nRad23\tpont\nRad23\tCG8209\nRad23\tIswi\nRad23\tRpn13\nRad23\tUbqn\nRad23\tsmt3\nRad23\tRpt2\nRad23\tTER94\nme31B\tpoe\nme31B\tCG2091\nme31B\typs\nND-51L1\tND-51\nND-51L1\tNP15.6\nND-51L1\tND-PDSW\nND-51L1\tND-B16.6\nsyd\talpha-Cat\nsyd\tDlic\nsyd\tKlc\nArr2\tUbqn\nArr2\tCG32683\nArr1\tninaC\nArr1\tchp\nArr1\tKP78a\nArr1\tLpR2\nArr1\tUbqn\nArr1\tCG32683\nArr1\tpar-1\nArr1\tHrs\nArr1\tlqf\nArr1\tRab5\nArr1\tArr2\nND-51\tND-B16.6\nND-51\tND-PDSW\nCG16941\typs\nCG16941\tmago\nCG16941\tCG10777\nCG16941\tCG10077\nCG16941\tSrp54\nCG16941\tLS2\nCG16941\tTango4\nCnx99A\tOst48\nCnx99A\tHsp70Ab\nCnx99A\tCG2918\nCnx99A\tUgt\nCnx99A\tGp93\nCnx99A\tPdi\nCnx99A\tHsc70-3\nCnx99A\tERp60\nAats-his\tAats-asp\nAats-his\tCct5\nAats-his\tAats-arg\nAats-his\tAats-val\nAats-his\tCG33123\nAats-his\tAats-ile\nmRpS35\tRpS2\nmRpS35\tCG4882\nmRpS35\tmRpL12\nmRpS35\tmRpS34\nmRpS35\tmRpL44\nmRpS35\tCG4679\nmRpS35\tmRpS23\nmRpS35\tmRpS18B\nmRpS35\tmRpS10\nmRpS35\tmRpS9\nmRpS35\tmRpS22\nmRpS35\tbonsai\nmRpS35\tmRpS5\nVha68-2\tsun\nVha68-2\tEno\nVha100-2\tsun\nVha100-2\tVha68-2\nRac1\tsqh\nRac1\tFmr1\nRac1\ttsr\nRac1\tDscam2\nRac1\tGdi\nRac1\tAbl\nRac1\tdock\nRac1\tzip\nRac1\tRhoGDI\nmRpS7\tade3\nmRpS7\tCG9769\nmRpS7\tCG4882\nmRpS7\teIF3-S8\nmRpS7\tSrp19\nmRpS7\tEfTuM\nmRpS7\teIF3-S10\nmRpS7\teIF-2gamma\nmRpS7\tSrp54k\nmRpS7\teRF1\nmRpS7\tTango7\nmRpS7\tRpLP2\nmRpS7\teIF3-S9\nmRpS7\tRpL4\nmRpS7\tRpS10a\nmRpS7\tRpS12\nmRpS7\tRpS10b\nmRpS7\tCG7048\nmRpS7\tRpS15Ab\nmRpS7\tRpS15Aa\nmRpS7\tRpL10Ab\nmRpS7\tRpS14a\nmRpS7\tRpL18A\nmRpS7\tRack1\nmRpS7\tRpS4\nmRpS7\tRpS17\nmRpS7\tRpL13\nmRpS7\tRpS7\nmRpS7\tRpS6\nmRpS7\tRpS8\nmRpS7\tRpS16\nmRpS7\tsta\nmRpS7\tRpS13\nmRpS7\tRpL3\nmRpS7\tRpS23\nmRpS7\tRpS27\nmRpS7\tRpL12\nmRpS7\tRpS18\nmRpS7\tRpS3A\nmRpS7\tRpS2\nmRpS7\tRpS3\nmRpS7\tRpS11\nmRpS7\tRpL11\nmRpS7\tRpL30\nmRpS7\tmRpL44\nmRpS7\tmRpS22\nmRpS7\tmRpS34\nmRpS7\tCG4679\nmRpS7\tmRpS23\nmRpS7\tbonsai\nmRpS7\tmRpS10\nmRpS7\tmRpS35\nmRpS7\tmRpL12\nmRpS7\tmRpS18B\nmRpS7\tmRpS9\nmRpS7\tmRpS5\nPrp8\typs\nPrp8\tSrp54\nPrp8\tCG10777\nPrp8\tCG10077\nPrp8\tLS2\nPrp8\tmago\nPrp8\tHrb98DE\nPrp8\tCG16941\nPrp8\tTango4\npont\tTcp-1zeta\npont\tTcp-1eta\npont\tRpt6\npont\tUch-L5\nmRpL44\tmRpS34\nmRpL44\tmRpL12\nmRpL44\tbonsai\nmRpS22\tCG4882\nmRpS22\tmRpL12\nmRpS22\tmRpS10\nmRpS22\tmRpL44\nmRpS22\tmRpS34\nmRpS22\tmRpS23\nmRpS22\tbonsai\nmRpS22\tmRpS18B\nmRpS22\tmRpS9\nCp1\tEct3\nCp1\tPyK\nCp1\tRpn5\nCp1\tCG6891\nCp1\tCalpB\nCp1\tRpn6\nCp1\tCat\nCp1\tRpt5\ncathD\tCG4572\ncathD\tCG6891\ncathD\tPyK\ncathD\tCG8258\ncathD\tEct3\ncathD\tPfk\ncathD\tRpn5\ncathD\tCalpB\ncathD\tRpt5\ncathD\tRpn6\ncathD\tCat\ncathD\tSap-r\ncathD\tCp1\nTango4\tCG10777\nTango4\tLS2\nTango4\tmago\nU2A\typs\nU2A\tCG10077\nU2A\tCG10777\nU2A\tSrp54\nU2A\tHrb98DE\nU2A\tmago\nU2A\tLS2\nU2A\tCG16941\nU2A\tPrp8\nU2A\tTango4\nUba2\tUsp5\nUba2\tUbqn\nsmt3\ttsr\nsmt3\tmago\nsmt3\tUfd1-like\nsmt3\tCG10777\nsmt3\tCG10077\nsmt3\tRae1\nsmt3\tUba2\nzip\tsqh\nND-15\tCG11752\nND-15\tND-51L1\nND-15\tCOX5A\nND-15\tND-51\nND-15\tNP15.6\nND-15\tND-B16.6\nND-15\tND-PDSW\npAbp\trin\npAbp\tRpL3\npAbp\tRpL4\npAbp\temb\npAbp\tDp1\npAbp\tRpS6\npAbp\ttyf\npAbp\tbel\npAbp\typs\npAbp\tCG11505\npAbp\tme31B\neIF4G\tDp1\neIF4G\tkra\neIF4G\tbel\neIF4G\tPlexA\neIF4G\tme31B\neIF4G\tRpL18A\neIF4G\tRpL12\neIF4G\tRpS12\neIF4G\tHsp67Bc\neIF4G\tRpS4\neIF4G\tRpL11\neIF4G\tRpS23\neIF4G\tRpS7\neIF4G\tRpS27\neIF4G\tRpLP2\neIF4G\tRpS10b\neIF4G\tRpS10a\neIF4G\tsta\neIF4G\tRpS16\neIF4G\tRpL4\neIF4G\tRpL13\neIF4G\tRpS18\neIF4G\tRpS13\neIF4G\tRpL3\neIF4G\tRpL10Ab\neIF4G\tRpS11\neIF4G\tRpS8\neIF4G\tRpS2\neIF4G\tHsp27\neIF4G\teIF-3p66\neIF4G\tmago\neIF4G\tTango7\neIF4G\tRpS3\neIF4G\tCG5642\neIF4G\tCG9769\neIF4G\tTrip1\neIF4G\tRbp2\neIF4G\tRpS6\neIF4G\talpha-Cat\neIF4G\teIF3-S10\neIF4G\teIF3-S8\neIF4G\teIF3-S9\neIF4G\tpAbp\nPfk\tCG11876\nPfk\tCG6891\nPfk\tEct3\nPfk\tRpt5\nPfk\tCp1\nPfk\tRpn6\nPfk\tCalpB\nPfk\tRpn5\nPfk\tCat\nPfk\tEno\nPfk\tTpi\nPfk\tPyK\nbonsai\tRpS14a\nbonsai\tRpS16\nbonsai\tsta\nbonsai\tRpS6\nCG12262\tCG10932\nyip2\tUch\nyip2\tAcCoAS\nyip2\tCG10932\nyip2\tCG3902\nyip2\tCG12262\nRab5\tMgstl\nRab5\tCG32683\nRab5\tOst48\nRab5\tCG3529\nRab5\tArr2\nGdi\tCat\nGdi\tCG6891\nGdi\tPfk\nGdi\tRpt1\nGdi\tRpt5\nGdi\tRpn5\nGdi\tCG8258\nGdi\tRpn6\nGdi\tRpn3\nGdi\tPyK\nGdi\tTER94\nGdi\tRhoGDI\nGdi\tRab5\nmRpS10\tSrp54k\nmRpS10\tCG4882\nmRpS10\tRpL10Ab\nmRpS10\tRpS14a\nmRpS10\tRpS16\nmRpS10\tRpS10b\nmRpS10\tRpS13\nmRpS10\tRpS2\nmRpS10\tRpS15Aa\nmRpS10\tRpL3\nmRpS10\tRpL12\nmRpS10\tRpL13\nmRpS10\tRpS4\nmRpS10\tRpS17\nmRpS10\tRpS6\nmRpS10\tsta\nmRpS10\tmRpS23\nmRpS10\tmRpS18B\nmRpS10\tmRpS34\nmRpS10\tmRpL12\nmRpS10\tmRpL44\nmRpS10\tbonsai\nmRpS10\tmRpS9\nSrp54k\tbonsai\nSrp54k\tRpS14a\nSrp54k\tRpS15Aa\nSrp54k\tRpS17\nSrp54k\tRpL10Ab\nSrp54k\tRpS10b\nSrp54k\tRpL12\nSrp54k\tRpS4\nSrp54k\tRpS6\nSrp54k\tRpS16\nSrp54k\tRpS12\nSrp54k\tsta\nSrp54k\tSrp19\nmRpL12\tRpS14a\nmRpL12\tRpL10Ab\nmRpL12\tRpS16\nmRpL12\tsta\nmRpL12\tmRpS34\nmRpL12\tRpS15Aa\nmRpL12\tRpL12\nmRpL12\tbonsai\nTctp\tRpS10b\nTctp\teEF1delta\nTctp\tRpL10Ab\nTctp\tRpS14a\nTctp\tRpS15Aa\nTctp\tRpS4\nTctp\tRpS17\nTctp\tsta\nTctp\tRpS6\nTctp\tRpS16\nTctp\tRpL12\nTctp\tRpS13\nTctp\tRpS12\nTctp\tRpS2\nTctp\tRpL13\neIF-2gamma\tTcp-1zeta\neIF-2gamma\tTcp-1eta\neIF-2gamma\tCG10576\neIF-2gamma\tCctgamma\neIF-2gamma\tCG7033\neIF-2gamma\tpAbp\neIF-2gamma\tCct5\neIF-2gamma\tRpS14a\neIF-2gamma\tCG8258\neIF-2gamma\tCG33123\neIF-2gamma\tCG1354\neIF-2gamma\tRpS15Ab\neIF-2gamma\tRpS15Aa\neIF-2gamma\tT-cp1\neIF-2gamma\tRpS17\neIF-2gamma\tRpL10Ab\neIF-2gamma\tRpLP2\neIF-2gamma\tRpL18A\neIF-2gamma\tRbp2\neIF-2gamma\tCG5642\neIF-2gamma\tRpL30\neIF-2gamma\teIF-3p66\neIF-2gamma\teIF4G\neIF-2gamma\tRpS3A\neIF-2gamma\tCG9769\neIF-2gamma\tRpL12\neIF-2gamma\tRpL11\neIF-2gamma\tRpS8\neIF-2gamma\tRpS10a\neIF-2gamma\tRpS10b\neIF-2gamma\tRpL13\neIF-2gamma\tRpS27\neIF-2gamma\tRpS23\neIF-2gamma\tRpS18\neIF-2gamma\tRpS11\neIF-2gamma\tRpS7\neIF-2gamma\tRpL3\neIF-2gamma\tRpS12\neIF-2gamma\tRpL4\neIF-2gamma\tRpS4\neIF-2gamma\tRpS13\neIF-2gamma\tTrip1\neIF-2gamma\tRpS2\neIF-2gamma\teIF3-S10\neIF-2gamma\tRpS16\neIF-2gamma\tRpS6\neIF-2gamma\tTango7\neIF-2gamma\tsta\neIF-2gamma\tRpS3\neIF-2gamma\teIF3-S8\neIF-2gamma\teIF3-S9\nUch-L5\tUsp5\nUch-L5\tRpt4R\nUch-L5\tRpn12\nRpt4R\tRpn12\nProsalpha1\tRpt4R\nProsalpha1\tRpn2\nProsalpha1\tRpn12\nProsalpha1\tRpn6\nProsalpha1\tRpt6R\nProsalpha1\tRpn5\nProsalpha1\tRpt6\nProsalpha1\tRpt5\nProsalpha1\tProsalpha6\nProsalpha1\tRpt2\nProsalpha1\tProsbeta7\nmRpS23\tCG4882\nmRpS23\tmRpL44\nmRpS23\tmRpL12\nmRpS23\tbonsai\nmRpS23\tmRpS34\nmRpS18B\tCG4882\nmRpS18B\tmRpL12\nmRpS18B\tbonsai\nmRpS18B\tmRpS34\nmRpS18B\tmRpL44\nmRpS18B\tmRpS23\nmor\tpont\nBap55\tUch-L5\nBap55\tpont\nBap55\tmor\nade5\tCG3011\nCg25C\tLanA\nvkg\tGlt\nvkg\tLanA\nvkg\tNdg\nvkg\ttrol\nvkg\tCg25C\nUfd1-like\tUbqn\nUfd1-like\tUsp5\nTER94\tpoe\nTER94\tbel\nTER94\tCnx99A\nTER94\tERp60\nTER94\tPdi\nTER94\tsmt3\nTER94\tHop\nTER94\tHsc70-3\nTER94\tCalpB\nTER94\tPfk\nTER94\tCG6891\nTER94\tEct3\nTER94\tPyK\nTER94\tRpn2\nTER94\tCat\nTER94\tCp1\nTER94\tme31B\nTER94\tCG8258\nTER94\tcathD\nTER94\tUbqn\nTER94\tRpn5\nTER94\tRpn6\nTER94\tRpt1\nTER94\tRpt5\nTER94\tCG8209\nTER94\tRpt2\nTER94\tp47\nTER94\tUfd1-like\nade3\tRpS15Aa\nade3\tRpL12\nade3\tRpS14a\nade3\tRpL10Ab\nade3\tsu(r)\nade3\tRpS6\nade3\tRpS4\nade3\tade5\nade3\tCG3011\nmRpS9\tCG4882\nmRpS9\tade3\nmRpS9\tRpS14a\nmRpS9\tRpL10Ab\nmRpS9\tRpS16\nmRpS9\tsta\nmRpS9\tRpS13\nmRpS9\tRpS15Aa\nmRpS9\tRpL12\nmRpS9\tRpS2\nmRpS9\tmRpS34\nmRpS9\tmRpL44\nmRpS9\tmRpS18B\nmRpS9\tmRpS23\nmRpS9\tbonsai\nmRpS9\tmRpL12\nmRpS5\tCG11876\nmRpS5\tRpS16\nmRpS5\tRpS14a\nmRpS5\tRpL10Ab\nmRpS5\tRpLP2\nmRpS5\tCG4882\nmRpS5\tSrp54k\nmRpS5\tRpS10b\nmRpS5\tRpL4\nmRpS5\tRpS10a\nmRpS5\tRpS8\nmRpS5\tRpS13\nmRpS5\tsta\nmRpS5\tRpL3\nmRpS5\tRpL12\nmRpS5\tRpS15Aa\nmRpS5\tRpS23\nmRpS5\tRpS15Ab\nmRpS5\tRpS27\nmRpS5\tRpS7\nmRpS5\tEfTuM\nmRpS5\tRpL18A\nmRpS5\tRpL13\nmRpS5\tRpS4\nmRpS5\tRpS17\nmRpS5\tRpS6\nmRpS5\tRpS18\nmRpS5\tRpS11\nmRpS5\tRpL11\nmRpS5\tRpS3\nmRpS5\tmRpS34\nmRpS5\tmRpS18B\nmRpS5\tmRpS23\nmRpS5\tmRpS22\nmRpS5\tCG4679\nmRpS5\tmRpL44\nmRpS5\tmRpL12\nmRpS5\tmRpS10\nmRpS5\tbonsai\nmRpS5\tmRpS9\nNot1\tIswi\nNot1\teIF4G\nNot1\tpAbp\nNot1\tPrp8\nNot1\tme31B\nNot3\tfrj\nNot3\tme31B\nNot3\tNot1\nHsc70-3\tCG2852\nHsc70-3\tHsp60\nHsc70-3\tOst48\nHsc70-3\tRae1\nHsc70-3\tCG2918\nPyK\tbel\nPyK\tpoe\nPyK\tme31B\nPyK\tMen\nPyK\tAldh\nPyK\tEct3\nPyK\tCG6891\nPyK\tCalpB\nPyK\tND-51\nPyK\tRpn5\nPyK\tVha68-2\nPyK\tCat\nPyK\tMdh2\nPyK\tEno\nLanA\tLanB2\nalphaCOP\tdeltaCOP\nSrp19\tRpS14a\nSrp19\tRpS15Aa\nSrp19\tRpS17\nSrp19\tRpL10Ab\nSrp19\tRpS10b\nSrp19\tRpS6\nSrp19\tRpS4\nSrp19\tsta\nSrp19\tRpS12\nSrp19\tRpL12\nSrp19\tRpS16\nSrp72\tRpS12\nSrp72\tRpS10b\nSrp72\tRpS6\nSrp72\tRpS13\nSrp72\tRpL10Ab\nSrp72\tRpL13\nSrp72\tsta\nSrp72\tRpS4\nSrp72\tRpS2\nSrp72\tRpL3\nSrp72\tRpS16\nSrp72\tRpL12\nSrp72\tSrp54k\nSrp72\tSrp19\nHsp83\tTcp-1zeta\nHsp83\tRack1\nHsp83\tzip\nHsp83\tERp60\nHsp83\tpont\nHsp83\tPdi\nHsp83\tTpi\nHsp83\tHDAC6\nHsp83\tbai\nHsp83\tCctgamma\nHsp83\tCnx99A\nHsp83\tCG2918\nHsp83\tCct5\nHsp83\tCG7033\nHsp83\tSgt\nHsp83\tHsp27\nHsp83\tT-cp1\nHsp83\tCalpB\nHsp83\tEct3\nHsp83\tCG6891\nHsp83\tDlic\nHsp83\tcpa\nHsp83\tCp1\nHsp83\tDhc64C\nHsp83\tHsp60C\nHsp83\tcpb\nHsp83\tcathD\nHsp83\tPfk\nHsp83\tGdi\nHsp83\tRac1\nHsp83\tPyK\nHsp83\tRpn6\nHsp83\tHsp60\nHsp83\tRpn5\nHsp83\tRpt1\nHsp83\tRpn3\nHsp83\tRpt5\nHsp83\tHsp70Ab\nHsp83\tCat\nHsp83\tCG8258\nHsp83\tHsc70-5\nHsp83\tFKBP59\nHsp83\tTER94\nHsp83\tDnaJ-H\nHsp83\tHsc70-3\nHsp83\tCG1416\nHsp83\tHop\nCG11089\tade5\nCG11089\tade3\nCG11089\tCG3011\nERp60\tPdi\nERp60\tOst48\nERp60\tCG2852\nERp60\tCG2918\nERp60\tCaBP1\nERp60\tHsc70-3\nERp60\tGp93\nzetaCOP\tKlp10A\nzetaCOP\tDlic\nzetaCOP\tp115\nzetaCOP\tcpa\nzetaCOP\talphaCOP\nzetaCOP\tbetaCOP\nzetaCOP\tdeltaCOP\nCOX6B\tND-B16.6\nCG7048\tcpa\nCG7048\tSrp19\nCG7048\tNlp\nCG7048\tmRpS23\nCG7048\tCG8498\nCG7048\trobl\nCG7048\tTim9a\nCG7048\teEF1delta\nCG7770\tRpS18\nCG7770\tpont\nCG7770\tCG7048\narm\tHsc70-3\narm\tProsalpha6\narm\tUch-L5\narm\tRpn5\narm\tProsbeta7\narm\tRpn12\narm\tRpt6R\narm\tRpt6\narm\tsyd\narm\tRpn2\narm\tRpn6\narm\tRpt1\narm\tRpt2\narm\tRpt5\narm\temb\narm\tRpt4R\narm\tpont\narm\talpha-Cat\neIF-3p66\tTcp-1eta\neIF-3p66\tCct5\neIF-3p66\tCctgamma\neIF-3p66\tRpS12\neIF-3p66\tRpS16\neIF-3p66\tRpS11\neIF-3p66\tRpS27\neIF-3p66\tRpS23\neIF-3p66\tRpS10a\neIF-3p66\tRpS10b\neIF-3p66\tRpS13\neIF-3p66\tRpL10Ab\neIF-3p66\tRpL13\neIF-3p66\tRpS7\neIF-3p66\tRpL12\neIF-3p66\tsta\neIF-3p66\tRpS4\neIF-3p66\tRpL11\neIF-3p66\tRpS18\neIF-3p66\tRpS2\neIF-3p66\tRpL3\neIF-3p66\tRpL18A\neIF-3p66\tRpS3\neIF-3p66\tRpL4\neIF-3p66\tRpS6\neIF-3p66\tCG9769\neIF-3p66\teIF3-S10\neIF-3p66\tTango7\neIF-3p66\teIF3-S8\neIF-3p66\teIF3-S9\neIF-3p66\tTrip1\neIF-3p66\tCG5642\nCG11752\tND-B16.6\nCOX4\tCG11752\nCOX4\tporin\nCOX4\tND-B16.6\nCOX4\tND-PDSW\nCOX4\tCyt-c-p\nCOX4\tCOX6B\nCOX5A\tsun\nCOX5A\tND-51\nCOX5A\tCG7603\nCOX5A\tCG11752\nCOX5A\tND-B16.6\nCOX5A\tCyt-c-p\nCOX5A\tND-PDSW\nCOX5A\tCOX6B\nCOX5A\tCOX4\nEno\tHsc70-3\nEno\tsta\nTpi\tCat\nTpi\tCG11899\nTpi\tRpS13\nTpi\tCG11876\nTpi\tade3\nTpi\tCG3011\nTpi\tHsc70-3\nTpi\tMdh2\nTpi\tPyK\nTpi\tEno\nCctgamma\tTrip1\nCctgamma\tCG10576\nCctgamma\tCG3800\nCctgamma\tHop\nCctgamma\tTcp-1zeta\nCctgamma\tTcp-1eta\nCctgamma\tCG7033\nRpt1\tUch\nRpt1\tRpS15Aa\nRpt1\tRpS15Ab\nRpt1\tRpS6\nRpt1\tUbqn\nRpt1\tRpL3\nRpt1\tRpS11\nRpt1\tRpS18\nRpt1\tUfd1-like\nRpt1\tCct5\nRpt1\tCalpB\nRpt1\tEct3\nRpt1\tCG6891\nRpt1\tPyK\nRpt1\tCp1\nRpt1\tCat\nRpt1\tPfk\nRpt1\tCG8258\nRpt1\tcathD\nRpt1\tProsalpha1\nRpt1\tUch-L5\nRpt1\tRpt6R\nRpt1\tRpt4R\nRpt1\tProsalpha6\nRpt1\tRpn12\nRpt1\tRpt5\nRpt1\tRpn5\nRpt1\tProsbeta7\nRpt1\tRpn2\nRpt1\tRpt6\nRpt1\tRpt2\nRpt1\tRpn6\nDlic\tp115\nDlic\tdeltaCOP\nDlic\tcpa\nDhc64C\tCLIP-190\nDhc64C\tbetaTub56D\nDhc64C\tKlc\nDhc64C\tpoe\nDhc64C\tbai\nDhc64C\tFKBP59\nDhc64C\tbetaCOP\nDhc64C\tdeltaCOP\nDhc64C\tepsilonCOP\nDhc64C\tbeta'COP\nDhc64C\tCG10186\nDhc64C\tzetaCOP\nDhc64C\tp115\nDhc64C\tcpa\nDhc64C\tcpb\nDhc64C\trobl\nDhc64C\tKhc\nDhc64C\tBicD\nDhc64C\tDlic\nEf1beta\tFeCH\nEf1beta\tCG7048\nEf1beta\tAats-asp\nEf1beta\tRpS15Aa\nEf1beta\tRpS10a\nEf1beta\tRpS15Ab\nEf1beta\tCG5642\nEf1beta\tRpL3\nEf1beta\tRpS3A\nEf1beta\tRpS12\nEf1beta\tRpS13\nEf1beta\tRpS10b\nEf1beta\tRpS16\nEf1beta\tRpS2\nEf1beta\tRpS6\nEf1beta\tRpL12\nEf1beta\tRpL30\nEf1beta\tRpS23\nEf1beta\tRpS11\nEf1beta\teEF1delta\nEf1beta\tRpL13\nEf1beta\tsta\nEf1beta\tRpS7\nEf1beta\tTctp\nEf1beta\tTrip1\nEf1beta\tRpS8\nEf1beta\tRpS18\nEf1beta\tRack1\nEf1beta\tRpS17\nEf1beta\tRpL10Ab\nEf1beta\tRpL18A\nEf1beta\tRpS3\nEf1beta\tRpS4\nEf1beta\tRpL11\nEf1beta\tRpLP2\nEf1beta\tRpL4\nRpn13\tTER94\nRpn13\tUfd1-like\nRpn13\tUbqn\nRpn13\tProsalpha6\nRpn13\tProsbeta7\nRpn13\tRpt4R\nRpn13\tProsalpha7\nRpn13\tRpn12\nRpn13\tRpt6R\nRpn13\tRpn5\nRpn13\tRpt6\nRpn13\tRpn6\nRpn13\tUch-L5\nRpn13\tRpt2\nRpn13\tRpt5\nRpn13\tRpt1\nRpn13\tRpn2\nTango7\tCG7033\nTango7\tTcp-1zeta\nTango7\tmRpS10\nTango7\tRpn5\nTango7\tbonsai\nTango7\tRpn2\nTango7\tRpn6\nTango7\tRpS14a\nTango7\tRpS15Aa\nTango7\tRpS17\nTango7\tRpL10Ab\nTango7\tRpL12\nTango7\tRpL13\nTango7\tRpL18A\nTango7\tRpL3\nTango7\tRpS10a\nTango7\tRpL11\nTango7\tRpS10b\nTango7\tRpS2\nTango7\tRpS27\nTango7\tRpS23\nTango7\tRpS18\nTango7\tRpS12\nTango7\tRpS13\nTango7\tRpS4\nTango7\tRpS6\nTango7\tRpS16\nTango7\tsta\nTango7\teIF3-S10\nTango7\tRpS3\nTango7\tTrip1\nTango7\tRpL4\nTango7\tCG9769\nTango7\teIF3-S9\nTango7\tCG5642\nTango7\teIF3-S8\nRpt2\tUbqn\nRpt2\tUfd1-like\nRpt2\tCG8209\nRpt2\tUch-L5\nRpt2\tRpt4R\nRpt2\tRpn12\nRpt2\tRpt5\nRpt2\tRpt6\nRpt2\tProsalpha6\nRpt2\tRpn5\nRpt2\tProsbeta7\nRpn5\tUbqn\nRpn5\tUfd1-like\nRpn5\tCat\nRpn5\tCalpB\nRpn5\tCG6891\nRpn5\tUch-L5\nRpn5\tRpt4R\nRpn5\tRpt6\nRpn5\tRpn12\nbetaCOP\tcpa\nbetaCOP\tDlic\nbetaCOP\tKlp10A\nbetaCOP\tp115\nbetaCOP\talphaCOP\nbetaCOP\tdeltaCOP\nRpt6R\tRpS4\nRpt6R\tpont\nRpt6R\tRpt6\nRpt6R\tUch-L5\nRpt6R\tRpt4R\nRpt6R\tProsalpha6\nRpt6R\tRpt2\nRpt6R\tRpt5\nRpt6R\tProsbeta7\nRpt6R\tRpn12\nRpt6R\tRpn2\nRpt6R\tRpn5\nRpn2\tTrip1\nRpn2\tRpS2\nRpn2\tUsp5\nRpn2\tUch-L5\nRpn2\tProsalpha6\nRpn2\tRpt4R\nRpn2\tProsbeta7\nRpn2\tRpn12\nRpn2\tRpn5\nRpn2\tRpt6\nRpn2\tRpt2\nRpn2\tRpt5\nRpL10Ab\tbonsai\nRpL10Ab\tmago\nRpL10Ab\tCG5642\nRpL10Ab\tRpS15Aa\nRpL10Ab\tRpS14a\nRpL10Ab\tRpL12\nRpL10Ab\tRpS16\nRpL10Ab\tRpS12\nRpL10Ab\tRpS10b\nRpL10Ab\tRpS4\nRpL10Ab\tsta\nRpL10Ab\tRpS6\nTrip1\tRpS14a\nTrip1\tTcp-1zeta\nTrip1\tCG10576\nTrip1\tRpS17\nTrip1\tCG7033\nTrip1\tRpL10Ab\nTrip1\tRpS10b\nTrip1\tRpL12\nTrip1\tRpS12\nTrip1\tRpS4\nTrip1\tRpS6\nTrip1\tRpS16\nTrip1\tsta\nTrip1\tCG9769\nTrip1\teIF3-S10\nTrip1\tCG5642\ncpa\tp115\ncpa\tdeltaCOP\ncpb\tCG6891\ncpb\tflr\ncpb\tp115\ncpb\tdeltaCOP\ncpb\ttsr\ncpb\tbetaCOP\ncpb\tzetaCOP\ncpb\tDlic\ncpb\tcpa\neIF3-S10\tCG33123\neIF3-S10\tAats-ile\neIF3-S10\tRpS14a\neIF3-S10\tRpS17\neIF3-S10\tRpS15Aa\neIF3-S10\tRpL10Ab\neIF3-S10\tRpL12\neIF3-S10\tRpS12\neIF3-S10\tRpS10b\neIF3-S10\tRpS6\neIF3-S10\tRpS16\neIF3-S10\tRpS4\neIF3-S10\tsta\neIF3-S10\tCG5642\neIF3-S10\tCG9769\neIF3-S9\tCctgamma\neIF3-S9\trin\neIF3-S9\tRpS14a\neIF3-S9\tRpS15Aa\neIF3-S9\tRpS17\neIF3-S9\tTcp-1eta\neIF3-S9\tCG7033\neIF3-S9\tT-cp1\neIF3-S9\tCG10576\neIF3-S9\tpAbp\neIF3-S9\tRpL10Ab\neIF3-S9\tRpL12\neIF3-S9\tRpS12\neIF3-S9\tRpS10a\neIF3-S9\tRpS27\neIF3-S9\tRpS10b\neIF3-S9\tRpS18\neIF3-S9\tRpL11\neIF3-S9\tRpL13\neIF3-S9\tRpL3\neIF3-S9\tRpL4\neIF3-S9\tRpS16\neIF3-S9\tsta\neIF3-S9\tRpS2\neIF3-S9\tRpS13\neIF3-S9\tRpS4\neIF3-S9\tRpS6\neIF3-S9\tRpS3\neIF3-S9\tCG5642\neIF3-S9\teIF3-S8\neIF3-S9\tCG9769\neIF3-S9\tTrip1\neIF3-S9\teIF3-S10\nPdi\tUgt\nPdi\tOst48\nPdi\tCG2852\nPdi\tCG2918\nPdi\tHsc70-3\nepsilonCOP\tDlic\nepsilonCOP\tKlc\nepsilonCOP\tKlp10A\nepsilonCOP\tcpb\nepsilonCOP\tcpa\nepsilonCOP\tp115\nepsilonCOP\tdeltaCOP\nepsilonCOP\tzetaCOP\nepsilonCOP\talphaCOP\nepsilonCOP\tbetaCOP\nbeta'COP\tcpb\nbeta'COP\tcpa\nbeta'COP\tDlic\nbeta'COP\tKlp10A\nbeta'COP\tKlc\nbeta'COP\tp115\nbeta'COP\tzetaCOP\nbeta'COP\talphaCOP\nbeta'COP\tdeltaCOP\nbeta'COP\tbetaCOP\nbeta'COP\tepsilonCOP\nCaBP1\tUgt\nCaBP1\tOst48\nCaBP1\tCG2918\nCaBP1\tHsc70-3\nCaBP1\tPdi\nGp93\tCctgamma\nGp93\tbai\nGp93\tHsp27\nGp93\tHop\nGp93\tHsp70Ab\nGp93\tCG11577\nGp93\tPdi\nGp93\tHsc70-3\nGp93\tCG2918\nGp93\tCaBP1\nTcp-1eta\tRpL12\nTcp-1eta\tRpS6\nTcp-1eta\tRpL13\nTcp-1eta\tRpL10Ab\nTcp-1eta\tsta\nTcp-1eta\tRpS2\nTcp-1eta\tRpS12\nTcp-1eta\tRpS13\nTcp-1eta\tCG10576\nTcp-1eta\tRpS4\nTcp-1eta\tRpS16\nTcp-1eta\tTrip1\nTcp-1eta\tHop\nTcp-1eta\tade3\nTcp-1eta\tTcp-1zeta\nTcp-1eta\tCG7033\nRpL13\tmRpL12\nRpL13\tCG7033\nRpL13\tade3\nRpL13\tbonsai\nRpL13\tSrp19\nRpL13\tmago\nRpL13\tCG5642\nRpL13\teIF3-S10\nRpL13\tCG10576\nRpL13\tSrp54k\nRpL13\tCG9769\nRpL13\tTrip1\nRpL13\tRpS15Aa\nRpL13\tRpL12\nRpL13\tRpS12\nRpL13\tRpS13\nRpL13\tRpS6\nRpL13\tRpS14a\nRpL13\tRpL10Ab\nRpL13\tRpS4\nRpL13\tsta\nRpL13\tRpS17\nRpL13\tRpS10b\nRpL13\tRpS16\nRpt4\tProsalpha1\nRpt4\tarm\nRpt4\tRpt4R\nRpt4\tUch-L5\nRpt4\tRpt6R\nRpt4\tRpn13\nRpt4\tRpt1\nRpt4\tRpt6\nRpt4\tProsalpha6\nRpt4\tProsbeta7\nRpt4\tRpn6\nRpt4\tRpt2\nRpt4\tRpn3\nRpt4\tProsalpha7\nRpt4\tRpn2\nRpt4\tRpn12\nRpt4\tRpn5\nRpt4\tRpt5\nRpS11\tvig\nRpS11\tbic\nRpS11\tCG7033\nRpS11\tTctp\nRpS11\tTcp-1eta\nRpS11\teEF1delta\nRpS11\tmago\nRpS11\tEfTuM\nRpS11\tSrp72\nRpS11\tbonsai\nRpS11\tmRpS10\nRpS11\tCG5642\nRpS11\tmRpS9\nRpS11\tmRpL12\nRpS11\teIF3-S8\nRpS11\tCG9769\nRpS11\teIF3-S10\nRpS11\tSrp19\nRpS11\tade3\nRpS11\teIF3-S9\nRpS11\tSrp54k\nRpS11\tTango7\nRpS11\tTrip1\nRpS11\tRpL10Ab\nRpS11\tRpS23\nRpS11\tRpL3\nRpS11\tRpL12\nRpS11\tRpL11\nRpS11\tRpS14a\nRpS11\tRpL4\nRpS11\tRpS18\nRpS11\tRpS6\nRpS11\tsta\nRpS11\tRpS10a\nRpS11\tRpS17\nRpS11\tRpS2\nRpS11\tRpL18A\nRpS11\tRpS12\nRpS11\tRpS3\nRpS11\tRpS13\nRpS11\tRpS27\nRpS11\tRpS10b\nRpS11\tRpL13\nRpS11\tRpS16\nRpS11\tRpS15Aa\nRpS11\tRpS4\neIF3-S8\tCG33123\neIF3-S8\tTcp-1zeta\neIF3-S8\teEF1delta\neIF3-S8\tpAbp\neIF3-S8\tRpS14a\neIF3-S8\tRpS17\neIF3-S8\tRpS15Aa\neIF3-S8\tCG10576\neIF3-S8\tRpL12\neIF3-S8\tRpL10Ab\neIF3-S8\tRpL11\neIF3-S8\tRpL13\neIF3-S8\tRpL3\neIF3-S8\tRpS12\neIF3-S8\tsta\neIF3-S8\tRpS2\neIF3-S8\tRpS13\neIF3-S8\tRpS4\neIF3-S8\tRpS10b\neIF3-S8\tRpS27\neIF3-S8\tRpS10a\neIF3-S8\tRpS16\neIF3-S8\tRpL4\neIF3-S8\tRpS18\neIF3-S8\tRpS6\neIF3-S8\tRpS3\neIF3-S8\teIF3-S10\neIF3-S8\tTrip1\neIF3-S8\tCG9769\neIF3-S8\tCG5642\nHop\tHsp60\nHop\tHsc70-3\nHop\tUbqn\nCG1416\tCG7033\nCG1416\tHsp60\nCG1416\tGp93\nCG1416\tCctgamma\nCG1416\tHop\nRpS6\tRpS14a\nRpS6\tsta\nRpS2\tCG10576\nRpS2\tade3\nRpS2\tbonsai\nRpS2\tCG7033\nRpS2\tmago\nRpS2\tmRpL12\nRpS2\tCG5642\nRpS2\teIF3-S10\nRpS2\tSrp19\nRpS2\tCG9769\nRpS2\tSrp54k\nRpS2\tTrip1\nRpS2\tRpS6\nRpS2\tRpS15Aa\nRpS2\tRpL12\nRpS2\tRpS14a\nRpS2\tRpS10b\nRpS2\tRpS16\nRpS2\tRpL10Ab\nRpS2\tRpS17\nRpS2\tsta\nRpS2\tRpS4\nRpS2\tRpS13\nRpS2\tRpL13\nRpS2\tRpS12\nProsalpha6\tTrip1\nProsalpha6\tTcp-1zeta\nProsalpha6\tUch-L5\nProsalpha6\tRpn5\nProsalpha6\tRpt4R\nProsalpha6\tRpt6\nProsalpha6\tProsbeta7\nProsalpha6\tRpn12\nND-PDSW\tCOX6B\nND-PDSW\tCG7603\nND-PDSW\tND-B16.6\nND-PDSW\tCG11752\nNP15.6\tCG7603\nNP15.6\tCOX4\nNP15.6\tCG11752\nNP15.6\tND-51\nNP15.6\tCOX5A\nNP15.6\tND-B16.6\nNP15.6\tND-PDSW\nKlc\tDlic\nKlc\tbetaCOP\nKlc\tdeltaCOP\nKlc\tzetaCOP\nKlc\tKlp10A\nKhc\tDlic\nKhc\tzetaCOP\nKhc\tepsilonCOP\nKhc\tbai\nKhc\tdeltaCOP\nKhc\tbeta'COP\nKhc\tsyd\nKhc\tbetaCOP\nKhc\tKlp10A\nKhc\tbetaTub56D\nKhc\tKlc\nRpL12\tbonsai\nRpL12\tmago\nRpL12\tRpS16\nRpL12\tsta\nRpL12\tRpS14a\nRpL12\tRpS6\nRpt5\tUbqn\nRpt5\tCat\nRpt5\tCalpB\nRpt5\tCG6891\nRpt5\tEct3\nRpt5\tPyK\nRpt5\tTcp-1eta\nRpt5\tUfd1-like\nRpt5\tUch-L5\nRpt5\tRpt4R\nRpt5\tProsalpha6\nRpt5\tProsbeta7\nRpt5\tRpn5\nRpt5\tRpt6\nRpt5\tRpn12\nProsalpha7\tRpS16\nProsalpha7\tCct5\nProsalpha7\tTcp-1eta\nProsalpha7\tCG7033\nProsalpha7\tTrip1\nProsalpha7\tCG8258\nProsalpha7\tarm\nProsalpha7\tUch-L5\nProsalpha7\tRpt4R\nProsalpha7\tProsalpha1\nProsalpha7\tRpt6R\nProsalpha7\tRpn12\nProsalpha7\tRpt6\nProsalpha7\tRpn6\nProsalpha7\tRpt2\nProsalpha7\tRpt1\nProsalpha7\tProsbeta7\nProsalpha7\tProsalpha6\nProsalpha7\tRpn5\nProsalpha7\tRpn2\nProsalpha7\tRpt5\nRpn6\tTrip1\nRpn6\tRpS2\nRpn6\tUbqn\nRpn6\tCalpB\nRpn6\tCat\nRpn6\tPyK\nRpn6\tEct3\nRpn6\tCG6891\nRpn6\tUch-L5\nRpn6\tProsalpha6\nRpn6\tRpt4R\nRpn6\tProsbeta7\nRpn6\tRpn2\nRpn6\tRpt5\nRpn6\tRpn12\nRpn6\tRpn5\nRpn6\tRpt2\nRpn6\tRpt6R\nRpn6\tRpt6\nATPsynbeta\tRack1\nATPsynbeta\tCOX4\nATPsynbeta\tCOX6B\nATPsynbeta\tsta\nATPsynbeta\tHsc70-5\nATPsynbeta\tEno\nATPsynbeta\tRpL4\nATPsynbeta\tRpS3A\nATPsynbeta\tCG11876\nATPsynbeta\tMpcp\nATPsynbeta\tporin\nATPsynbeta\tVha100-1\nATPsynbeta\tRpS2\nATPsynbeta\tVha100-2\nATPsynbeta\tHsp83\nATPsynbeta\tND-51\nATPsynbeta\tVha68-2\nATPsynbeta\tEfTuM\nATPsynbeta\tMdh2\nATPsynbeta\tCG9090\nATPsynbeta\tCOX5A\nATPsynbeta\tsun\nATPsynCF6\tVha100-1\nATPsynCF6\tVha100-2\nATPsynCF6\tNP15.6\nATPsynCF6\tVha68-2\nATPsynCF6\tND-B16.6\nATPsynCF6\tCOX6B\nATPsynCF6\tCG11752\nATPsynCF6\tCyt-c-p\nATPsynCF6\tCOX4\nATPsynCF6\tND-PDSW\nATPsynCF6\tCOX5A\nATPsynCF6\tsun\nATPsynCF6\tATPsynbeta\nCG8258\tRpS13\nCG8258\tRpL11\nCG8258\teIF3-S9\nCG8258\tTango7\nCG8258\tHsp60C\nCG8258\tRpn2\nCG8258\tRpS3\nCG8258\tRpL12\nCG8258\tRpL3\nCG8258\tHsc70-5\nCG8258\tRpL4\nCG8258\tRpS4\nCG8258\tRpS2\nCG8258\tRpS16\nCG8258\tHsp60\nCG8258\tCG10576\nCG8258\tCat\nCG8258\tCG6891\nCG8258\tCalpB\nCG8258\tEct3\nCG8258\tCp1\nCG8258\tPfk\nCG8258\tPyK\nCG8258\tade3\nCG8258\tTrip1\nCG8258\tRpt5\nCG8258\tRpn6\nCG8258\tRpn5\nCG8258\tHop\nCG8258\tCct5\nCG8258\tCctgamma\nCG8258\tTcp-1zeta\nCG8258\tCG7033\nCG8258\tTcp-1eta\nCG8258\tT-cp1\nRpS7\tRpt6R\nRpS7\tRpt6\nRpS7\tvig\nRpS7\teEF1delta\nRpS7\tRpt1\nRpS7\tRpt2\nRpS7\tade3\nRpS7\tmRpS10\nRpS7\tSrp72\nRpS7\tmago\nRpS7\tbonsai\nRpS7\tTctp\nRpS7\tCG5642\nRpS7\tCG9769\nRpS7\teIF3-S10\nRpS7\tSrp54k\nRpS7\tSrp19\nRpS7\tTrip1\nRpS7\teIF3-S9\nRpS7\teIF3-S8\nRpS7\tTango7\nRpS7\tRpS15Ab\nRpS7\tRpS10a\nRpS7\tRpS15Aa\nRpS7\tRpS14a\nRpS7\tRpL4\nRpS7\tRpL13\nRpS7\tsta\nRpS7\tRpS2\nRpS7\tRpS23\nRpS7\tRpS16\nRpS7\tRpL10Ab\nRpS7\tRpS6\nRpS7\tRpS18\nRpS7\tRpL11\nRpS7\tRpS12\nRpS7\tRpS4\nRpS7\tRpS3\nRpS7\tRpS13\nRpS7\tRpS27\nRpS7\tRpL3\nRpS7\tRpS10b\nRpS7\tRpS17\nRpS7\tRpL18A\nRpS7\tRpS11\nRpS7\tRpL12\nRpL10\tmRpS10\nRpL10\tade3\nRpL10\tCct5\nRpL10\tmRpS5\nRpL10\teEF1delta\nRpL10\tmRpL12\nRpL10\tmRpS9\nRpL10\tEf1beta\nRpL10\teIF4G\nRpL10\tSrp72\nRpL10\tmago\nRpL10\tRbp2\nRpL10\teIF3-S10\nRpL10\teIF3-S8\nRpL10\tCG5642\nRpL10\tCG10576\nRpL10\tTango7\nRpL10\tTrip1\nRpL10\tSrp19\nRpL10\tmRpS7\nRpL10\teIF-2gamma\nRpL10\tElf\nRpL10\teIF-3p66\nRpL10\tCG9769\nRpL10\teIF3-S9\nRpL10\tSrp54k\nRpL10\teRF1\nRpL10\tRpS15Ab\nRpL10\tTctp\nRpL10\tRpS14a\nRpL10\tRpS15Aa\nRpL10\tRpS10b\nRpL10\tRpS17\nRpL10\tRpL13\nRpL10\tRpS8\nRpL10\tRpS16\nRpL10\tsta\nRpL10\tRpL12\nRpL10\tRpS3\nRpL10\tRpL4\nRpL10\tRpS27\nRpL10\tRpL3\nRpL10\tRpS12\nRpL10\tRpL11\nRpL10\tRpLP2\nRpL10\tRpL18A\nRpL10\tRpL26\nRpL10\tRpS4\nRpL10\tRack1\nRpL10\tRpS3A\nRpL10\tRpS11\nRpL10\tRpS13\nRpL10\tRpS10a\nRpL10\tRpL30\nRpL10\tRpS2\nRpL10\tRpS18\nRpL10\tRpS23\nRpL10\tRpL10Ab\nRpL10\tRpS6\nRpL10\tRpS7\nAats-asp\tCG5642\nAats-asp\tCG31739\nAats-asp\tCG33123\nAats-asp\tAats-arg\nAats-asp\tAats-ile\nCG33123\tAats-arg\nAats-ile\tCG33123\nAats-ile\tAats-arg\nRpS8\tT-cp1\nRpS8\tvig\nRpS8\tRpn2\nRpS8\tCct5\nRpS8\teEF1delta\nRpS8\tmRpS10\nRpS8\tCG8258\nRpS8\tTcp-1eta\nRpS8\tbonsai\nRpS8\tCG7033\nRpS8\tade3\nRpS8\tCG10576\nRpS8\tSrp72\nRpS8\tmago\nRpS8\tRbp2\nRpS8\tNacalpha\nRpS8\tCG5642\nRpS8\teIF-3p66\nRpS8\tSrp54k\nRpS8\tSrp19\nRpS8\teIF3-S10\nRpS8\tTctp\nRpS8\tTrip1\nRpS8\tTango7\nRpS8\teIF3-S8\nRpS8\tCG9769\nRpS8\teIF3-S9\nRpS8\tRpL13\nRpS8\tRpS17\nRpS8\tRpL11\nRpS8\tRpS12\nRpS8\tRpL4\nRpS8\tRpS3\nRpS8\tRpS27\nRpS8\tRpS18\nRpS8\tRpS2\nRpS8\tRpS13\nRpS8\tRpS7\nRpS8\tRpS6\nRpS8\tsta\nRpS8\tRpL10Ab\nRpS8\tRpS10b\nRpS8\tRpS10a\nRpS8\tRpS14a\nRpS8\tRpS4\nRpS8\tRpL12\nRpS8\tRpS11\nRpS8\tRpL18A\nRpS8\tRpLP2\nRpS8\tRpL3\nRpS8\tRpS23\nRpS8\tRpS15Ab\nRpS8\tRpS15Aa\nRpS8\tRpS16\nRpt6\tUbqn\nRpt6\tRpS4\nRpt6\tUch-L5\nRpt6\tRpt4R\nRpt6\tRpn12\nRpn3\tbel\nRpn3\tUbqn\nRpn3\tCalpB\nRpn3\tEct3\nRpn3\tPfk\nRpn3\tCG6891\nRpn3\tCat\nRpn3\tCp1\nRpn3\tPyK\nRpn3\tarm\nRpn3\tcathD\nRpn3\tCG8258\nRpn3\tProsalpha1\nRpn3\tUch-L5\nRpn3\tTER94\nRpn3\tProsalpha6\nRpn3\tProsbeta7\nRpn3\tProsalpha7\nRpn3\tRpt4R\nRpn3\tRpn2\nRpn3\tRpt6R\nRpn3\tRpt2\nRpn3\tRpn5\nRpn3\tRpt1\nRpn3\tRpt5\nRpn3\tRpn6\nRpn3\tRpn13\nRpn3\tRpn12\nRpn3\tRpt6\nRpL30\tvig\nRpL30\tNacalpha\nRpL30\tCG10576\nRpL30\tSrp72\nRpL30\teIF4G\nRpL30\tbonsai\nRpL30\teIF3-S8\nRpL30\tmago\nRpL30\teIF3-S10\nRpL30\tCG5642\nRpL30\tmRpS5\nRpL30\tRbp2\nRpL30\tmRpS10\nRpL30\teIF-3p66\nRpL30\tTctp\nRpL30\teIF3-S9\nRpL30\tTango7\nRpL30\tCG9769\nRpL30\tTrip1\nRpL30\tSrp54k\nRpL30\tSrp19\nRpL30\tRpS15Aa\nRpL30\tRpS15Ab\nRpL30\tRpS10a\nRpL30\tRpL18A\nRpL30\tRpS27\nRpL30\tRpL12\nRpL30\tRpS12\nRpL30\tRpS18\nRpL30\tRpS17\nRpL30\tRpS2\nRpL30\tRpL11\nRpL30\tRpS7\nRpL30\tRpS14a\nRpL30\tRpS4\nRpL30\tRpS8\nRpL30\tRpS10b\nRpL30\tRpLP2\nRpL30\tRpL13\nRpL30\tRpS16\nRpL30\tRpS3\nRpL30\tRpS13\nRpL30\tRpS11\nRpL30\tRpS6\nRpL30\tRpL3\nRpL30\tRpL10Ab\nRpL30\tRpS23\nRpL30\tRpL4\nRpL30\tsta\nRpS10a\tTctp\nRpS10a\teEF1delta\nRpS10a\tbonsai\nRpS10a\tmRpS10\nRpS10a\tRpS10b\nRpS10a\tSrp72\nRpS10a\tmago\nRpS10a\tCG5642\nRpS10a\tCG9769\nRpS10a\teIF3-S10\nRpS10a\tTrip1\nRpS10a\tSrp19\nRpS10a\tSrp54k\nRpS10a\tRpS15Aa\nRpS10a\tsta\nRpS10a\tRpS2\nRpS10a\tRpS3\nRpS10a\tRpL10Ab\nRpS10a\tRpS4\nRpS10a\tRpS13\nRpS10a\tRpS6\nRpS10a\tRpS12\nRpS10a\tRpS16\nRpS10a\tRpS14a\nRpS10a\tRpS27\nRpS10a\tRpL13\nRpS10a\tRpL12\nRpS10a\tRpS17\nRpS10a\tRpL3\nRpS17\tCG7033\nRpS17\tCG5642\nRpS17\tbonsai\nRpS17\tRpS6\nRpS17\tRpL12\nRpS17\tRpL10Ab\nRpS17\tRpS15Aa\nRpS17\tRpS12\nRpS17\tsta\nRpS17\tRpS14a\nRpS17\tRpS4\nRpS17\tRpS16\nRpS17\tRpS10b\nRpLP0\tATPsynbeta\nRpLP0\tTcp-1zeta\nRpLP0\tT-cp1\nRpLP0\tCG7033\nRpLP0\tEfTuM\nRpLP0\tNacalpha\nRpLP0\tbonsai\nRpLP0\tTcp-1eta\nRpLP0\tmRpS10\nRpLP0\tade3\nRpLP0\tCct5\nRpLP0\tmRpS5\nRpLP0\teEF1delta\nRpLP0\tSrp72\nRpLP0\teIF4G\nRpLP0\tmRpS9\nRpLP0\tRbp2\nRpLP0\tmago\nRpLP0\tCG5642\nRpLP0\teIF3-S8\nRpLP0\tElf\nRpLP0\teIF3-S10\nRpLP0\tCG10576\nRpLP0\tmRpL12\nRpLP0\tmRpS7\nRpLP0\tTctp\nRpLP0\tSrp19\nRpLP0\tTango7\nRpLP0\teIF-2gamma\nRpLP0\teIF-3p66\nRpLP0\tCG9769\nRpLP0\teIF3-S9\nRpLP0\tRpS15Aa\nRpLP0\tSrp54k\nRpLP0\tTrip1\nRpLP0\tRpS15Ab\nRpLP0\teRF1\nRpLP0\tRpS14a\nRpLP0\tRpS10a\nRpLP0\tEf1beta\nRpLP0\tRpS6\nRpLP0\tRpS13\nRpLP0\tRpS23\nRpLP0\tsta\nRpLP0\tRpS11\nRpLP0\tRpS16\nRpLP0\tRpS7\nRpLP0\tRpS27\nRpLP0\tRpL10\nRpLP0\tRpS3\nRpLP0\tRpS10b\nRpLP0\tRpS8\nRpLP0\tRpLP2\nRpLP0\tRpS12\nRpLP0\tRpL3\nRpLP0\tRack1\nRpLP0\tRpL13\nRpLP0\tRpL26\nRpLP0\tRpL30\nRpLP0\tRpL18A\nRpLP0\tRpL4\nRpLP0\tRpL10Ab\nRpLP0\tRpS18\nRpLP0\tRpS3A\nRpLP0\tRpL12\nRpLP0\tRpL11\nRpLP0\tRpS2\nRpLP0\tRpS4\nRpLP0\tRpS17\neRF1\tbonsai\neRF1\tT-cp1\neRF1\teIF3-S8\neRF1\tCG8635\neRF1\tCG7033\neRF1\tCctgamma\neRF1\tmRpS5\neRF1\tmRpS10\neRF1\twuho\neRF1\tRpS15Ab\neRF1\tRpS14a\neRF1\tRpS15Aa\neRF1\teIF3-S10\neRF1\tpAbp\neRF1\tCG10576\neRF1\tRpS17\neRF1\teIF-2gamma\neRF1\tTrip1\neRF1\tmago\neRF1\tRpLP2\neRF1\teIF3-S9\neRF1\teIF4G\neRF1\tRack1\neRF1\tRpS10a\neRF1\tRpS10b\neRF1\tRpL3\neRF1\tRpL10Ab\neRF1\tRpS27\neRF1\tRpS12\neRF1\tRpS7\neRF1\tRpS18\neRF1\tRpL12\neRF1\tRpS23\neRF1\tRpL18A\neRF1\tRpS16\neRF1\tRpL30\neRF1\tRpS11\neRF1\tRpS3\neRF1\tRpS3A\neRF1\tsta\neRF1\tRpS13\neRF1\tRpS6\neRF1\tRpS8\neRF1\tRpL4\neRF1\tRpS4\neRF1\tRpL11\neRF1\tRpS2\neRF1\tRpL13\nElf\tade5\nElf\tmRpS7\nElf\tRpS15Aa\nElf\tRpS15Ab\nElf\tT-cp1\nElf\teIF3-S9\nElf\tCctgamma\nElf\tRpS17\nElf\tCG33123\nElf\tCct5\nElf\tRpS14a\nElf\teIF3-S8\nElf\teIF3-S10\nElf\teIF-2gamma\nElf\tRpL10Ab\nElf\tRpLP2\nElf\tmago\nElf\tRpL13\nElf\tRpL3\nElf\tRpL30\nElf\tRpL18A\nElf\tRpL12\nElf\tRpL11\nElf\tRack1\nElf\tRpL4\nElf\tRpS4\nElf\tRpS10b\nElf\tRpS10a\nElf\tRpS12\nElf\teIF4G\nElf\tRpS11\nElf\tRpS13\nElf\tRpS27\nElf\tRpS8\nElf\tRpS16\nElf\tRpS3\nElf\tsta\nElf\tRpS23\nElf\tRpS18\nElf\tRpS7\nElf\tRpS6\nElf\tRpS3A\nElf\tRpS2\nElf\tpAbp\nElf\teRF1\nRpS15Aa\tbonsai\nRpS15Aa\tRpL12\nRpS15Aa\tsta\nRpS15Aa\tRpS6\nRpS15Aa\tRpS16\nRpS15Aa\tRpS14a\nRpS27\tvig\nRpS27\tSrp72\nRpS27\tmRpS10\nRpS27\tbonsai\nRpS27\tmago\nRpS27\tTctp\nRpS27\tCG5642\nRpS27\tCG9769\nRpS27\tTrip1\nRpS27\teIF3-S10\nRpS27\tSrp19\nRpS27\tSrp54k\nRpS27\tRpS13\nRpS27\tRpS17\nRpS27\tRpS4\nRpS27\tRpS2\nRpS27\tRpL13\nRpS27\tRpL12\nRpS27\tRpS6\nRpS27\tRpS12\nRpS27\tRpS10b\nRpS27\tsta\nRpS27\tRpS16\nRpS27\tRpS14a\nRpS27\tRpL10Ab\nRpS27\tRpL3\nRpS27\tRpS3\nRpS27\tRpS15Aa\nRpS18\tRpt6R\nRpS18\tRpt6\nRpS18\tTcp-1eta\nRpS18\tRpt2\nRpS18\tvig\nRpS18\tade3\nRpS18\tSrp72\nRpS18\tmago\nRpS18\tbonsai\nRpS18\tTctp\nRpS18\tCG5642\nRpS18\tmRpL12\nRpS18\tmRpS10\nRpS18\tmRpS9\nRpS18\teIF3-S10\nRpS18\tCG9769\nRpS18\tSrp19\nRpS18\tSrp54k\nRpS18\tTrip1\nRpS18\tRpS12\nRpS18\tRpS2\nRpS18\tRpS16\nRpS18\tsta\nRpS18\tRpL10Ab\nRpS18\tRpL4\nRpS18\tRpS14a\nRpS18\tRpS17\nRpS18\tRpS3\nRpS18\tRpS4\nRpS18\tRpL3\nRpS18\tRpS10a\nRpS18\tRpS13\nRpS18\tRpS10b\nRpS18\tRpL13\nRpS18\tRpL12\nRpS18\tRpS15Aa\nRpS18\tRpS6\nRpS18\tRpS27\nRpS10b\tbonsai\nRpS10b\tmago\nRpS10b\tRpS15Aa\nRpS10b\tRpS16\nRpS10b\tRpS14a\nRpS10b\tRpL12\nRpS10b\tsta\nRpS10b\tRpS6\nRpS3A\tCG7048\nRpS3A\tNacalpha\nRpS3A\tvig\nRpS3A\teEF1delta\nRpS3A\tCct5\nRpS3A\tCG8258\nRpS3A\tCG7033\nRpS3A\tTcp-1eta\nRpS3A\tSrp72\nRpS3A\tmRpS5\nRpS3A\tRbp2\nRpS3A\tmRpS10\nRpS3A\teIF4G\nRpS3A\tbonsai\nRpS3A\tmago\nRpS3A\tCG5642\nRpS3A\teIF-3p66\nRpS3A\teIF3-S8\nRpS3A\tSrp54k\nRpS3A\teIF3-S10\nRpS3A\tSrp19\nRpS3A\tCG9769\nRpS3A\tade3\nRpS3A\tTrip1\nRpS3A\tTango7\nRpS3A\teIF3-S9\nRpS3A\tTctp\nRpS3A\tRpS12\nRpS3A\tRpS13\nRpS3A\tRpS2\nRpS3A\tRpS17\nRpS3A\tRpS15Ab\nRpS3A\tRpL13\nRpS3A\tRpS15Aa\nRpS3A\tRpS8\nRpS3A\tRpS10a\nRpS3A\tRpS4\nRpS3A\tRpL11\nRpS3A\tRpS23\nRpS3A\tRpS7\nRpS3A\tRpLP2\nRpS3A\tsta\nRpS3A\tRpS6\nRpS3A\tRpL12\nRpS3A\tRpS11\nRpS3A\tRpL30\nRpS3A\tRpL3\nRpS3A\tRpS3\nRpS3A\tRpS14a\nRpS3A\tRpL10Ab\nRpS3A\tRpL18A\nRpS3A\tRpS27\nRpS3A\tRpL4\nRpS3A\tRpS16\nRpS3A\tRpS18\nRpS3A\tRpS10b\nRpS16\tmago\nRpS16\tRpS14a\nRpS16\tRpS6\nRpS16\tsta\nRpS15Ab\tvig\nRpS15Ab\tCG9769\nRpS15Ab\tade3\nRpS15Ab\tEfTuM\nRpS15Ab\tSrp19\nRpS15Ab\teIF3-S9\nRpS15Ab\teIF3-S10\nRpS15Ab\teIF3-S8\nRpS15Ab\tbonsai\nRpS15Ab\tmRpS10\nRpS15Ab\tSrp54k\nRpS15Ab\tTango7\nRpS15Ab\tTctp\nRpS15Ab\tmRpS9\nRpS15Ab\tmRpL12\nRpS15Ab\tRpS15Aa\nRpS15Ab\tRpS12\nRpS15Ab\tRpL10Ab\nRpS15Ab\tRpL13\nRpS15Ab\tRpL18A\nRpS15Ab\tRpL12\nRpS15Ab\tRpL3\nRpS15Ab\tRpL4\nRpS15Ab\tRpS10a\nRpS15Ab\tRpL11\nRpS15Ab\tRpS6\nRpS15Ab\tRpS2\nRpS15Ab\tRpS13\nRpS15Ab\tRpS23\nRpS15Ab\tRpS18\nRpS15Ab\tRpS3\nRpS15Ab\tRpS10b\nRpS15Ab\tRpS17\nRpS15Ab\tRpS14a\nRpS15Ab\tsta\nRpS15Ab\tRpS11\nRpS15Ab\tRpS27\nRpS15Ab\tRpS4\nRpS15Ab\tRpS16\nRpL3\teEF1delta\nRpL3\tbonsai\nRpL3\tTcp-1zeta\nRpL3\tTcp-1eta\nRpL3\tCG7033\nRpL3\tTctp\nRpL3\tmago\nRpL3\tSrp19\nRpL3\tmRpL12\nRpL3\tmRpS9\nRpL3\teIF3-S10\nRpL3\tCG10576\nRpL3\tCG5642\nRpL3\tCG9769\nRpL3\tSrp54k\nRpL3\tTrip1\nRpL3\tade3\nRpL3\tRpS14a\nRpL3\tRpS10b\nRpL3\tRpS15Aa\nRpL3\tRpS13\nRpL3\tRpS16\nRpL3\tRpS2\nRpL3\tRpS4\nRpL3\tRpS17\nRpL3\tsta\nRpL3\tRpL12\nRpL3\tRpL13\nRpL3\tRpL10Ab\nRpL3\tRpS12\nRpL3\tRpS6\nTcp-1zeta\tTim9a\nTcp-1zeta\tRpL12\nTcp-1zeta\tHsp60\nTcp-1zeta\tsta\nTcp-1zeta\tCG7033\nT-cp1\tRpS13\nT-cp1\tRpS2\nT-cp1\tRpL13\nT-cp1\tHop\nT-cp1\tsta\nT-cp1\tRpL12\nT-cp1\tAats-ile\nT-cp1\tRpS4\nT-cp1\tRpL3\nT-cp1\tCG10576\nT-cp1\tCG7033\nT-cp1\tCctgamma\nT-cp1\tTcp-1eta\nT-cp1\tTcp-1zeta\nRpS3\tTcp-1zeta\nRpS3\tvig\nRpS3\tCG7033\nRpS3\teEF1delta\nRpS3\tade3\nRpS3\tTcp-1eta\nRpS3\tSrp72\nRpS3\tmago\nRpS3\tbonsai\nRpS3\tmRpS10\nRpS3\tmRpS9\nRpS3\tmRpL12\nRpS3\tSrp19\nRpS3\tCG5642\nRpS3\tSrp54k\nRpS3\tCG9769\nRpS3\tTctp\nRpS3\teIF3-S10\nRpS3\tRpS15Aa\nRpS3\tRpL13\nRpS3\tRpS6\nRpS3\tRpS2\nRpS3\tRpL12\nRpS3\tRpL10Ab\nRpS3\tRpS14a\nRpS3\tRpS12\nRpS3\tRpS13\nRpS3\tRpS16\nRpS3\tRpS10b\nRpS3\tsta\nRpS3\tRpS4\nRpS3\tRpL3\nRpS3\tRpS17\nRpS3\tTrip1\nRpL26\tCG10576\nRpL26\teEF1delta\nRpL26\tEfTuM\nRpL26\tNacalpha\nRpL26\tbonsai\nRpL26\tade3\nRpL26\tmRpS10\nRpL26\tmRpS5\nRpL26\tTctp\nRpL26\teIF4G\nRpL26\tCG5642\nRpL26\tmago\nRpL26\teIF3-S10\nRpL26\tRbp2\nRpL26\tmRpS9\nRpL26\teIF3-S9\nRpL26\tmRpL12\nRpL26\teIF-3p66\nRpL26\teIF3-S8\nRpL26\tSrp72\nRpL26\teIF-2gamma\nRpL26\tmRpS7\nRpL26\tCG9769\nRpL26\tElf\nRpL26\tTango7\nRpL26\tTrip1\nRpL26\tEf1beta\nRpL26\tSrp19\nRpL26\teRF1\nRpL26\tSrp54k\nRpL26\tRpS15Aa\nRpL26\tRpS15Ab\nRpL26\tRpL10Ab\nRpL26\tRpL11\nRpL26\tRpL3\nRpL26\tRpS13\nRpL26\tRpS2\nRpL26\tRpS6\nRpL26\tRpS4\nRpL26\tRpS27\nRpL26\tRpL30\nRpL26\tRpL18A\nRpL26\tRpS12\nRpL26\tRpS10a\nRpL26\tRpS18\nRpL26\tRpS3A\nRpL26\tRpS8\nRpL26\tRpS17\nRpL26\tsta\nRpL26\tRpS14a\nRpL26\tRpS11\nRpL26\tRpS23\nRpL26\tRpL13\nRpL26\tRpL12\nRpL26\tRpS10b\nRpL26\tRack1\nRpL26\tRpL4\nRpL26\tRpS3\nRpL26\tRpS7\nRpL26\tRpS16\nRpL26\tRpLP2\nRpS12\tmago\nRpS12\tRpS14a\nRpS12\tRpS15Aa\nRpS12\tRpS10b\nRpS12\tRpS6\nRpS12\tRpS16\nRpS12\tRpL12\nRpS12\tsta\nCG5642\tRpL12\nCG5642\tRpS6\nCG5642\tsta\nCG5642\tRpS10b\nCG5642\tRpS12\nCG5642\tRpS16\nCG5642\tRpS4\nCG9769\tRpS15Aa\nCG9769\tRpS17\nCG9769\tRpS12\nCG9769\tRpL12\nCG9769\tRpS10b\nCG9769\tRpL10Ab\nCG9769\tRpS4\nCG9769\tsta\nCG9769\tRpS6\nCG9769\tRpS16\nCG9769\tCG5642\nCG7033\tRpS14a\nCG7033\tsta\nCct5\tRpS13\nCct5\tade3\nCct5\tProsalpha6\nCct5\tHsc70-5\nCct5\tRpt6\nCct5\tRpS3\nCct5\tRef1\nCct5\tRpS16\nCct5\tRpL12\nCct5\tRpL13\nCct5\tCG5642\nCct5\tRpL18A\nCct5\tRpS6\nCct5\tsta\nCct5\tRpL11\nCct5\tAats-ile\nCct5\tpont\nCct5\tTrip1\nCct5\tRpS4\nCct5\tTango7\nCct5\teIF3-S9\nCct5\tRpL3\nCct5\tRpS2\nCct5\tCG10576\nCct5\tRpL4\nCct5\tHop\nCct5\tCctgamma\nCct5\tTcp-1eta\nCct5\tT-cp1\nCct5\tTcp-1zeta\nCct5\tCG7033\nRpLP2\tCG10576\nRpLP2\tbonsai\nRpLP2\tmRpS10\nRpLP2\tSrp72\nRpLP2\tSrp54k\nRpLP2\tSrp19\nRpLP2\tmago\nRpLP2\teIF3-S8\nRpLP2\teIF3-S10\nRpLP2\tCG5642\nRpLP2\tRbp2\nRpLP2\tTango7\nRpLP2\teIF3-S9\nRpLP2\teIF-3p66\nRpLP2\tNacalpha\nRpLP2\tRpS15Aa\nRpLP2\tCG9769\nRpLP2\tTrip1\nRpLP2\tTctp\nRpLP2\tRpS10a\nRpLP2\tRpS10b\nRpLP2\tRpS27\nRpLP2\tRpS3\nRpLP2\tRpS13\nRpLP2\tRpS18\nRpLP2\tRpL4\nRpLP2\tRpS7\nRpLP2\tRpL13\nRpLP2\tRpS23\nRpLP2\tRpL10Ab\nRpLP2\tRpS15Ab\nRpLP2\tRpS6\nRpLP2\tRpS14a\nRpLP2\tRpS2\nRpLP2\tRpL3\nRpLP2\tRpL12\nRpLP2\tRpL18A\nRpLP2\tRpL11\nRpLP2\tRpS11\nRpLP2\tsta\nRpLP2\tRpS16\nRpLP2\tRpS17\nRpLP2\tRpS12\nRpLP2\tRpS4\nRpS23\tCG7033\nRpS23\tvig\nRpS23\teEF1delta\nRpS23\tbonsai\nRpS23\tmRpS10\nRpS23\tSrp72\nRpS23\tmago\nRpS23\tCG5642\nRpS23\tmRpL12\nRpS23\tmRpS9\nRpS23\tTctp\nRpS23\teIF3-S9\nRpS23\teIF3-S8\nRpS23\teIF3-S10\nRpS23\tCG9769\nRpS23\tTrip1\nRpS23\tSrp19\nRpS23\tSrp54k\nRpS23\tRpS16\nRpS23\tRpS27\nRpS23\tRpL18A\nRpS23\tRpS10b\nRpS23\tRpS15Aa\nRpS23\tRpS12\nRpS23\tRpS3\nRpS23\tRpS18\nRpS23\tRpS14a\nRpS23\tRpL11\nRpS23\tRpL3\nRpS23\tRpS2\nRpS23\tsta\nRpS23\tRpS6\nRpS23\tRpL10Ab\nRpS23\tRpL12\nRpS23\tRpL13\nRpS23\tRpS17\nRpS23\tRpL4\nRpS23\tRpS4\nRpS23\tRpS10a\nRpS23\tRpS13\nRpS14a\tsta\nRack1\tvig\nRack1\tbel\nRack1\tProsalpha7\nRack1\tRpn6\nRack1\tRpn2\nRack1\teEF1delta\nRack1\tCctgamma\nRack1\tEno\nRack1\tpAbp\nRack1\tNacalpha\nRack1\teIF3-S8\nRack1\tCG10576\nRack1\tCG9769\nRack1\tade3\nRack1\tDp1\nRack1\tT-cp1\nRack1\tTcp-1zeta\nRack1\tSrp54k\nRack1\tTcp-1eta\nRack1\tSrp19\nRack1\tCG8258\nRack1\tCG7033\nRack1\tCct5\nRack1\tmRpS5\nRack1\tbonsai\nRack1\tmRpS10\nRack1\teIF3-S10\nRack1\tTctp\nRack1\teIF3-S9\nRack1\teIF-2gamma\nRack1\tTrip1\nRack1\tRpS15Ab\nRack1\tTango7\nRack1\tRpS15Aa\nRack1\tRpS10a\nRack1\tRpS14a\nRack1\tRpS10b\nRack1\tRpL10Ab\nRack1\tRpS8\nRack1\tRpL3\nRack1\tRpS11\nRack1\tRpS17\nRack1\tRpS2\nRack1\tRpS16\nRack1\tRpS13\nRack1\tRpS12\nRack1\tRpS4\nRack1\tRpS3A\nRack1\tRpS3\nRack1\tRpS18\nRack1\tRpS23\nRack1\tRpS6\nRack1\tRpL12\nRack1\tRpL13\nRack1\tRpS27\nRack1\tRpL11\nRack1\tRpL18A\nRack1\tRpL30\nRack1\tRpS7\nRack1\tRpLP2\nRack1\tRpL4\nRack1\tsta\nRpL11\tCG7033\nRpL11\tvig\nRpL11\teEF1delta\nRpL11\tTcp-1eta\nRpL11\tmago\nRpL11\teIF3-S10\nRpL11\tCG10576\nRpL11\tCG5642\nRpL11\tbonsai\nRpL11\tmRpS10\nRpL11\tSrp72\nRpL11\tmRpS9\nRpL11\tTctp\nRpL11\tmRpL12\nRpL11\tCG9769\nRpL11\tSrp19\nRpL11\tSrp54k\nRpL11\tTrip1\nRpL11\tRpS15Aa\nRpL11\tRpS13\nRpL11\tRpS18\nRpL11\tRpL3\nRpL11\tRpL12\nRpL11\tRpS10b\nRpL11\tRpS17\nRpL11\tRpL10Ab\nRpL11\tRpS10a\nRpL11\tRpL13\nRpL11\tRpS4\nRpL11\tRpS16\nRpL11\tRpS12\nRpL11\tRpS27\nRpL11\tRpS2\nRpL11\tRpS3\nRpL11\tRpS6\nRpL11\tRpS14a\nRpL11\tRpL4\nRpL11\tsta\nRpn12\tUbqn\nProsbeta7\tUba2\nProsbeta7\tCG7033\nProsbeta7\tUch-L5\nProsbeta7\tRpt4R\nProsbeta7\tRpn5\nProsbeta7\tRpt6\nProsbeta7\tRpn12\nRpS13\tCG10576\nRpS13\tCG7033\nRpS13\tmRpL12\nRpS13\tade3\nRpS13\tmago\nRpS13\tCG5642\nRpS13\teIF3-S10\nRpS13\tCG9769\nRpS13\tSrp19\nRpS13\tSrp54k\nRpS13\tTrip1\nRpS13\tRpL10Ab\nRpS13\tRpS15Aa\nRpS13\tRpS12\nRpS13\tRpS17\nRpS13\tRpL12\nRpS13\tRpS10b\nRpS13\tsta\nRpS13\tRpS16\nRpS13\tRpS4\nRpS13\tRpS6\nRpS13\tRpS14a\nRpL18A\tTcp-1zeta\nRpL18A\tvig\nRpL18A\tCG7033\nRpL18A\teEF1delta\nRpL18A\tTcp-1eta\nRpL18A\tade3\nRpL18A\tmRpS10\nRpL18A\tmago\nRpL18A\tbonsai\nRpL18A\tCG10576\nRpL18A\tCG5642\nRpL18A\teIF3-S8\nRpL18A\teIF3-S10\nRpL18A\tSrp72\nRpL18A\teIF3-S9\nRpL18A\tTctp\nRpL18A\tSrp19\nRpL18A\tCG9769\nRpL18A\tSrp54k\nRpL18A\tTrip1\nRpL18A\tRpS15Aa\nRpL18A\tRpS14a\nRpL18A\tsta\nRpL18A\tRpL10Ab\nRpL18A\tRpS2\nRpL18A\tRpL3\nRpL18A\tRpS17\nRpL18A\tRpL4\nRpL18A\tRpL13\nRpL18A\tRpS4\nRpL18A\tRpL11\nRpL18A\tRpS10a\nRpL18A\tRpS6\nRpL18A\tRpL12\nRpL18A\tRpS18\nRpL18A\tRpS16\nRpL18A\tRpS12\nRpL18A\tRpS27\nRpL18A\tRpS10b\nRpL18A\tRpS3\nRpL18A\tRpS13\nRpS4\tTcp-1zeta\nRpS4\tCG7033\nRpS4\tmago\nRpS4\tbonsai\nRpS4\tRpS15Aa\nRpS4\tRpL12\nRpS4\tsta\nRpS4\tRpS10b\nRpS4\tRpS14a\nRpS4\tRpS16\nRpS4\tRpS6\nRpS4\tRpS12\nRpL4\tvig\nRpL4\tbonsai\nRpL4\tCctgamma\nRpL4\tmRpS10\nRpL4\tTcp-1eta\nRpL4\tTctp\nRpL4\tT-cp1\nRpL4\tCG7033\nRpL4\tTcp-1zeta\nRpL4\tade3\nRpL4\tmRpS9\nRpL4\tmRpL12\nRpL4\tSrp72\nRpL4\tmago\nRpL4\tSrp19\nRpL4\tCG5642\nRpL4\tCG10576\nRpL4\tSrp54k\nRpL4\tCG9769\nRpL4\teIF3-S10\nRpL4\tRpS15Aa\nRpL4\tRpS10a\nRpL4\tRpL10Ab\nRpL4\tTrip1\nRpL4\tRpS16\nRpL4\tRpS6\nRpL4\tRpS12\nRpL4\tRpS14a\nRpL4\tRpS2\nRpL4\tRpL12\nRpL4\tRpS27\nRpL4\tsta\nRpL4\tRpS13\nRpL4\tRpS10b\nRpL4\tRpS3\nRpL4\tRpS17\nRpL4\tRpL13\nRpL4\tRpL3\nRpL4\tRpS4", + }, + { + kind: 'annotation', + name: 'Tau Louvain communities', + fileName: 'TAU_Louvain.txt', + text: "Group-1\tMgstl,KP78a,CG9257,par-1,CG31064,LpR1,Rab39,LpR2,CG3529,Lam,LamC,Sap-r,Vps60,lqf,ninaC,Hrs,Arr2,Arr1,Rab5,CG32683,pins,chp\nGroup-2\tSc2,Uch,Prx2540-1,Ace,CG4572,Sgt,CG14715,CG10359,Ect3,CalpB,Prx5,Ost48,Hsp60C,Prx2540-2,Hsp60,Ubqn,Hsp70Ab,Ugt,Iswi,Trap1,Cat,Hsc70-5,CG8209,DnaJ-H,p47,Rad23,Cnx99A,pont,Cp1,cathD,Uba2,Pfk,Gdi,Uch-L5,Rpt4R,Prosalpha1,mor,Bap55,Ufd1-like,TER94,Hsc70-3,PyK,Hsp83,ERp60,arm,Rpt1,Rpn13,Rpt2,Rpn5,Rpt6R,Rpn2,Pdi,CaBP1,Gp93,Rpt4,Hop,CG1416,Prosalpha6,Rpt5,Prosalpha7,Rpn6,CG8258,Rpt6,Rpn3,Rpn12,Prosbeta7,Usp5,CG2918,CG2852,CG1354,CG6891,HDAC6,Hsp27,His1,CG11577\nGroup-3\tshep,CG5590,CG17597,ScpX,Mfe2\nGroup-4\tCG4882,EfTuM,Rbp2,eEF1delta,CG10576,Nacalpha,mRpS34,vig,CG4679,mRpS35,mRpS7,mRpL44,mRpS22,eIF4G,bonsai,mRpS10,Srp54k,mRpL12,Tctp,eIF-2gamma,mRpS23,mRpS18B,ade3,mRpS9,mRpS5,Srp19,Srp72,eIF-3p66,Cctgamma,Ef1beta,Tango7,RpL10Ab,Trip1,eIF3-S10,eIF3-S9,Tcp-1eta,RpL13,RpS11,eIF3-S8,RpS6,RpS2,RpL12,RpS7,RpL10,RpS8,RpL30,RpS10a,RpS17,RpLP0,eRF1,Elf,RpS15Aa,RpS27,RpS18,RpS10b,RpS3A,RpS16,RpS15Ab,RpL3,Tcp-1zeta,T-cp1,RpS3,RpL26,RpS12,CG5642,CG9769,CG7033,Cct5,RpLP2,RpS23,RpS14a,Rack1,RpL11,RpS13,RpL18A,RpS4,RpL4,sta,wuho,bsf,kra,Hsp67Bc,su(r),FeCH,CG8635\nGroup-5\tnito,bol,Kap-alpha3,CG2082,lig,clu,Dbp80,Plc21C,Hrb27C,bel,Dp1,Lpin,Pkc53E,Nlp,rin,poe,CG43367,emb,fne,RanBPM,yps,Art4,CG10077,mub,AGO1,glo,Srp54,Rbp9,Rae1,Mtor,Ref1,mago,B52,LS2,U2af50,Fmr1,Top2,heph,Hrb98DE,Tudor-SN,SF2,me31B,CG16941,Prp8,Tango4,U2A,smt3,pAbp,Not1,Not3,CG2091,Capr,Gbeta76C,CG10777,CG6617,CG3800,tyf,CG11505\nGroup-6\tCG6178,frj,CG9090,Ssadh,l(1)G0156,CG32026,Men-b,CG11899,CG16935,AGBE,ImpL3,Nc73EF,CG3902,CG11876,Pdk,Vha100-1,AcCoAS,ACC,Mdh2,Cyt-c-p,Aldh,CG15093,ND-51L1,ND-51,Vha68-2,Vha100-2,ND-15,CG12262,yip2,ade5,CG11089,COX6B,CG11752,COX4,COX5A,Eno,Tpi,ND-PDSW,NP15.6,ATPsynbeta,ATPsynCF6,CG10932,CG33791,CG3011,Men,sun,porin,ND-B16.6,CG7603,Mpcp\nGroup-7\tNdg,trol,Cg25C,vkg,LanA,LanB2,Glt\nGroup-8\tLar,CG31715,PlexA,gammaSnap2,CG10186,Klp10A,Mtl,bai,Act79B,Abl,Tim17b,p115,Sec24AB,robl,tsr,flr,FKBP59,bic,BicD,CadN,syd,Rac1,zip,alphaCOP,zetaCOP,CG7048,CG7770,Dlic,Dhc64C,betaCOP,cpa,cpb,epsilonCOP,beta'COP,Klc,Khc,alpha-Cat,deltaCOP,RhoGDI,Dscam1,Tim9a,sqh,dock,Dscam2,CG8498,CLIP-190,betaTub56D\nGroup-9\tMapmodulin,Aats-val,Aats-his,Aats-asp,CG33123,Aats-ile,Aats-arg,CG31739\n", + }, + { + kind: 'annotation', + name: 'Tau KEGG pathways', + fileName: 'TAU_KEGG_Annotation_NORMA.txt', + text: 'Ribosome\tRpL10,RpL10Ab,RpL11,RpL12,RpL13,RpL18A,RpL26,RpL3,RpL30,RpL4,RpLP0,RpLP2,RpS10a,RpS10b,RpS11,RpS12,RpS13,RpS15Aa,RpS15Ab,RpS16,RpS17,RpS18,RpS2,RpS23,RpS27,RpS3,RpS3A,RpS4,RpS6,RpS7,RpS8,bonsai,mRpL12,mRpS10,mRpS7,mRpS9,sta\nProteasome\tProsalpha6,Prosalpha7,Prosbeta7,Rpn12,Rpn13,Rpn2,Rpn3,Rpn5,Rpn6,Rpt1,Rpt2,Rpt4,Rpt4R,Rpt5,Rpt6,Rpt6R\nSpliceosome\tB52,CG10077,CG10777,CG16941,Hrb98DE,LS2,Prp8,Ref1,SF2,Tango4,U2A,U2af50,mago\nPhagosome\tAct79B,Cnx99A,Cp1,Dhc64C,Dlic,Hrs,Mtl,Rab5,Rac1,Vha100-1,Vha100-2,Vha68-2,betaTub56D\nCarbon metabolism\tAcCoAS,CG10932,CG11876,CG11899,CG12262,CG3011,CG32026,CG33791,Cat,Eno,Mdh2,Men,Men-b,Nc73EF,Pfk,PyK,Tpi,l(1)G0156\nOxidative phosphorylation\tATPsynCF6,ATPsynbeta,COX4,COX5A,COX6B,ND-15,ND-51,ND-51L1,ND-B16.6,ND-PDSW,NP15.6,Vha100-1,Vha100-2,Vha68-2,sun\nRNA degradation\tCG2091,Eno,Hsc70-5,Hsp60,Hsp60C,Not1,Not3,Pfk,me31B,pAbp\nRNA transport\tCG9769,Fmr1,Mtor,Rae1,Ref1,Trip1,eIF-3p66,eIF3-S10,eIF3-S8,eIF3-S9,eIF4G,emb,mago,pAbp,smt3\nGlycolysis\tAcCoAS,Aldh,CG11876,Eno,ImpL3,Pfk,PyK,Tpi\nPyruvate metabolism\tACC,AcCoAS,Aldh,CG10932,CG11876,ImpL3,Mdh2,Men,Men-b,PyK\n', + }, + { + kind: 'colors', + name: 'Tau expression', + fileName: 'TAU_expressions.txt', + text: "CG7603\tred\nHis1\tred\nAct79B\tred\nGbeta76C\tred\nCG11876\tred\nKap-alpha3\tred\nCG2082\tred\nLanA\tred\nVha68-2\tred\nCalpB\tred\nSsadh\tred\nCG43367\tred\nl(1)G0156\tred\nCG9090\tred\nPrx5\tred\nCG1354\tred\nporin\tred\nGstD9\tred\nCG32026\tred\nAldh\tred\nGlt\tred\nTpi\tred\nPyK\tred\nATPsynbeta\tred\nEno\tred\nMtl\tred\nArr1\tred\nMpcp\tred\nArr2\tred\nNdg\tred\nFeCH\tred\nNc73EF\tred\nCG10932\tred\nCG33791\tred\nbetaTub56D\tred\nCyt-c-p\tred\nCG16935\tred\nPrx2540-2\tred\nGstE4\tred\npins\tred\nchp\tred\nMen-b\tred\nGstD3\tred\nHDAC6\tred\nCG10359\tred\nmago\tred\nLamC\tred\nAce\tred\nMdh2\tred\nninaC\tred\nLanB2\tred\nCg25C\tred\nsun\tred\nvkg\tred\nPfk\tred\nPrx2540-1\tred\nEfTuM\tred\nVha100-1\tred\nVha100-2\tred\nAGBE\tred\nfrj\tred\nDnaJ-H\tred\ntrol\tred\nMen\tgreen\np47\tgreen\nmRpS10\tgreen\nNP15.6\tgreen\nRpS15Ab\tgreen\nNot3\tgreen\nTim17b\tgreen\neIF4G\tgreen\ncathD\tgreen\nCG12262\tgreen\nProsalpha7\tgreen\nUsp5\tgreen\nyip2\tgreen\nDscam1\tgreen\nCG2918\tgreen\ncpa\tgreen\nRpS4\tgreen\nade3\tgreen\nCG8209\tgreen\nCG11505\tgreen\nFKBP59\tgreen\nRpS11\tgreen\nRab39\tgreen\nRpL13\tgreen\nCG1371\tgreen\nPrp8\tgreen\nProsalpha6\tgreen\nRpn12\tgreen\nRpn2\tgreen\nRpn5\tgreen\nNacalpha\tgreen\nHsp60\tgreen\nTudor-SN\tgreen\nRpt5\tgreen\nRpt4\tgreen\nlqf\tgreen\nRpt1\tgreen\nTop2\tgreen\nRpn6\tgreen\nND-PDSW\tgreen\nsta\tgreen\nsqh\tgreen\nCG6178\tgreen\nCG32683\tgreen\nAats-asp\tgreen\nLpR2\tgreen\neIF-2gamma\tgreen\nRpL11\tgreen\nElf\tgreen\nERp60\tgreen\nSec24AB\tgreen\nCG11089\tgreen\nRae1\tgreen\nCG3011\tgreen\nAbl\tgreen\nRpt6R\tgreen\nUch\tgreen\nTrap1\tgreen\nCG7048\tgreen\nRpt6\tgreen\nCG10186\tgreen\nwuho\tgreen\nAats-arg\tgreen\nLam\tgreen\nHsp60C\tgreen\nTrip1\tgreen\nRpS3\tgreen\nTctp\tgreen\nCG31064\tgreen\nDscam2\tgreen\nemb\tgreen\nUba2\tgreen\npAbp\tgreen\nCapr\tgreen\nCG4972\tgreen\nAats-his\tgreen\nAats-ile\tgreen\nPlexA\tgreen\nCctgamma\tgreen\nRab5\tgreen\nzip\tgreen\nMi-2\tgreen\nAcCoAS\tgreen\nsu(r)\tgreen\nmRpS23\tgreen\narm\tgreen\nCG10077\tgreen\nRef1\tgreen\nRpS7\tgreen\nCG10777\tgreen\nAats-val\tgreen\nImpL3\tgreen\nPdi\tgreen\nSgt\tgreen\nbeta'COP\tgreen\nalphaCOP\tgreen\nFmr1\tgreen\nCG31715\tgreen\nmRpS7\tgreen\nEf1beta\tgreen\nND-B16.6\tgreen\nScpX\tgreen\nCat\tgreen\nlig\tgreen\nCG10576\tgreen\np115\tgreen\nade5\tgreen\nBap55\tgreen\nHsp83\tgreen\nRpL4\tgreen\nProsbeta7\tgreen\nRpLP2\tgreen\nCG4572\tgreen\nCG31739\tgreen\nTim9a\tgreen\npont\tgreen\nHsc70-5\tgreen\nHsp27\tgreen\nbic\tgreen\nHsp67Bc\tgreen\nKP78a\tgreen\nCG3529\tgreen\nBicD\tgreen\nRpS12\tgreen\nbsf\tgreen\nHsc70-3\tgreen\nHrb98DE\tgreen\nRpS8\tgreen\nCG8498\tgreen\nDp1\tgreen\nOst48\tgreen\nSrp54k\tgreen\nRad23\tgreen\nTango4\tgreen\nkra\tgreen\nHsp70Ab\tgreen\nRpS15Aa\tgreen\ntyf\tgreen\nLpin\tgreen\nCnx99A\tgreen\nmRpS18B\tgreen\neEF1delta\tgreen\nCG3800\tgreen\nRpL18A\tgreen\npar-1\tgreen\nme31B\tgreen\nCOX4\tgreen\neIF3-S10\tgreen\nCG11752\tgreen\nCG14715\tgreen\nMtor\tgreen\nU2af50\tgreen\nmRpS5\tgreen\nKhc\tgreen\nCct5\tgreen\nRpL12\tgreen\nyps\tgreen\nB52\tgreen\nCG1416\tgreen\nbonsai\tgreen\nTER94\tgreen\nLar\tgreen\nalpha-Cat\tgreen\nglo\tgreen\nCadN\tgreen\nCG7033\tgreen\nCG2091\tgreen\nRpS17\tgreen\nArt4\tgreen\nsyd\tgreen\nRpL3\tgreen\nTcp-1eta\tgreen\nCG4679\tgreen\nSrp54\tgreen\nmRpS34\tgreen\nvig\tgreen\nSrp72\tgreen\nND-15\tgreen\nMapmodulin\tgreen\nRack1\tgreen\nCp1\tgreen\nDlic\tgreen\nRpS18\tgreen\nfne\tgreen\nRpS14a\tgreen\neIF3-S8\tgreen\nCG5642\tgreen\nRpLP0\tgreen\ngammaSnap2\tgreen\nrobl\tgreen\nRpS16\tgreen\nUgt\tgreen\nHrs\tgreen\nKlc\tgreen\neIF-3p66\tgreen\nND-51L1\tgreen\nbetaCOP\tgreen\nzetaCOP\tgreen\nUbqn\tgreen\nrin\tgreen\neRF1\tgreen\nLS2\tgreen\nDbp80\tgreen\nUfd1-like\tgreen\neIF3-S9\tgreen\nND-51\tgreen\nCOX6B\tgreen\nmRpS9\tgreen\nCG33123\tgreen\nCG9769\tgreen\nbel\tgreen\nSap-r\tgreen\ndeltaCOP\tgreen\nRbp9\tgreen\nRpS13\tgreen\nCG9257\tgreen\nmRpL12\tgreen\nMfe2\tgreen\nCaBP1\tgreen\nAGO1\tgreen\nMgstl\tgreen\nCG17597\tgreen\nCG6617\tgreen\nmub\tgreen\nATPsynCF6\tgreen\nRbp2\tgreen\nUch-L5\tgreen\ndock\tgreen\nLpR1\tgreen\nCG6891\tgreen\nRpL10\tgreen\nheph\tgreen\nHop\tgreen\nRpL10Ab\tgreen\nCOX5A\tgreen\nRpL30\tgreen\nmRpS22\tgreen\nTcp-1zeta\tgreen\nNlp\tgreen\nRpt4R\tgreen\nEct3\tgreen\npoe\tgreen\nVps60\tgreen\ntsr\tgreen\nRanBPM\tgreen\nPlc21C\tgreen\nmor\tgreen\nepsilonCOP\tgreen\nRpt2\tgreen\nACC\tgreen\nRpn3\tgreen\nbol\tgreen\nRpS10a\tgreen\nCG4882\tgreen\nCG8258\tgreen\nbai\tgreen\nshep\tgreen\nSrp19\tgreen\nCG2852\tgreen\nCG11899\tgreen\nclu\tgreen\nIswi\tgreen\nDhc64C\tgreen\nRpS6\tgreen\nRpS10b\tgreen\nCG5590\tgreen\nKlp10A\tgreen\nGdi\tgreen\nRpS2\tgreen\nRpS27\tgreen\nNot1\tgreen\nflr\tgreen\nPkc53E\tgreen\nCG11577\tgreen\nCG15093\tgreen\nCLIP-190\tgreen\nsmt3\tgreen\nRac1\tgreen\ncpb\tgreen\nCG16941\tgreen\nRhoGDI\tgreen\nProsalpha1\tgreen\nmRpL44\tgreen\nGp93\tgreen\nPdk\tgreen\nRpS23\tgreen\nT-cp1\tgreen\nRpL26\tgreen\nCG3902\tgreen\nCG7770\tgreen\nmRpS35\tgreen\nCG8635\tgreen\nRpS3A\tgreen\nTango7\tgreen\nHrb27C\tgreen\nnito\tgreen\nSc2\tgreen\nU2A\tgreen\nRpn13\tgreen\nSF2\tgreen\n", + }, + ], + }, + coexpr: { + title: 'Human gene co-expression', + source: 'PMID 19081792, PMCID PMC2597745', + link: 'https://doi.org/10.1371/journal.pone.0003911', + files: [ + { + kind: 'network', + name: 'Human co-expression', + fileName: 'NORMA_Human_coexpression_NETWORK.txt', + text: 'Source\tTarget\nRPL24\tRPL41\nRPL24\tRPL26\nRPL24\tRPL30\nRPL24\tTPT1\nRPL24\tRPL9\nRPL24\tRPS25\nRPL24\tRPS23\nRPL24\tRPL31\nRPL24\tRPS27A\nRPL24\tU16\nRPL24\tRPS4X\nRPL24\tRPL7\nRPL24\tRPL4\nRPL24\tRPL5\nRPL24\tRPS3A\nRPL24\tRPS7\nRPL24\tRPL34\nRPL24\tRPL35A\nRPL24\tRPS24\nRPL24\tRPL6\nRPL41\tRPL5\nRPL41\tRPS3A\nRPL41\tTPT1\nRPL41\tRPS23\nRPL41\tRPS4X\nHNRPA1\tRPS25\nHNRPA1\tRPS24\nHNRPA1\tRPS4X\nHNRPA1\tRPS6\nHNRPA1\tRPL22\nHNRPA1\tNCL\nHNRPA1\tRPL24\nHNRPA1\tNPM1\nHNRPA1\tRPL4\nHNRPA1\tMDS1\nHNRPA1\tU16\nHNRPA1\tRPS23\nHNRPA1\tRPS7\nHNRPA1\tSF3B1\nHNRPA1\tNAP1L1\nHNRPA1\tRPL17\nHNRPA1\tRPL11\nHNRPA1\tRPL6\nHNRPA1\tRPL34\nHNRPA1\tLOC440055\nHNRPA1\tRPS3A\nHNRPA1\tRPL7\nRPS25\tRPS4X\nRPS25\tRPS3A\nRPS25\tRPS6\nRPS25\tU16\nRPS25\tRPS7\nRPS25\tRPS27A\nH3F3A\tRPA1\nH3F3A\tRAP1B\nH3F3A\tRPLP2\nH3F3A\tRPS20\nH3F3A\tRPL27\nH3F3A\tRPS11\nH3F3A\tRPL27A\nCOX8A\tCYC1\nCOX8A\tNDUFS8\nCOX8A\tSLC25A11\nCOX8A\tSLC25A3\nCOX8A\tNDUFAB1\nCOX8A\tNDUFB11\nCOX8A\tMRPS12\nCYC1\tETFB\nCYC1\tUQCRC1\nATP8B1\tPDE4C\nATP8B1\tFLJ42393\nATP8B1\tDKFZP566N034\nATP8B1\tCEP27\nATP8B1\tKNS2\nATP8B1\tRIOK3\nATP8B1\tDBT\nATP8B1\tPGF\nATP8B1\tZNF611\nATP8B1\tSLC35E1\nATP8B1\tLOC152719\nATP8B1\tFBXW12\nATP8B1\tZNF160\nPDE4C\tSPG21\nPDE4C\tUBE2D2\nPDE4C\tPRKAR2A\nPDE4C\tZNF611\nPDE4C\tPRR11\nPDE4C\tPGF\nPDE4C\tZNF160\nPDE4C\tRIOK3\nPDE4C\tSLC35E1\nLOC58486\tSUB1\nLOC58486\tMGEA5\nLOC58486\tRNPC2\nLOC58486\tPFAAP5\nLOC58486\tWAC\nLOC58486\tMATR3\nLOC58486\tPNRC2\nLOC58486\tPAPOLA\nLOC58486\tZNF638\nLOC58486\tSF3B1\nLOC58486\tUSP34\nLOC402057\tRPS7\nLOC402057\tRPS15A\nLOC402057\tRPL34\nLOC402057\tRPS4X\nLOC402057\tRPS6\nLOC402057\tRPL31\nLOC402057\tRPL35A\nLOC402057\tRPL37\nLOC402057\tRPL22\nLOC402057\tRPL4\nLOC402057\tRPL9\nLOC402057\tRPS23\nLOC402057\tRPL11\nLOC402057\tLOC440055\nLOC402057\tRPL30\nLOC402057\tRPL23\nLOC402057\tRPL36A\nLOC402057\tRPL39\nLOC402057\tMDS1\nLOC402057\tRPL32\nLOC402057\tRPS17\nRPS7\tSFRS5\nRPS7\tSF3B1\nRPS7\tU16\nMDS1\tRPL23\nMDS1\tRNPC2\nMDS1\tRPL17\nMDS1\tRPL5\nMDS1\tRPS3A\nMDS1\tRPL41\nMDS1\tRPL4\nMDS1\tTPT1\nMDS1\tNPM1\nMDS1\tRPS6\nMDS1\tRPL7\nMDS1\tRPL24\nMDS1\tRPL34\nMDS1\tRPL26\nMDS1\tRPL22\nMDS1\tRPS27A\nMDS1\tRPL6\nMDS1\tRPS25\nMDS1\tRPS15A\nMDS1\tU16\nMDS1\tRPS7\nMDS1\tRPS24\nMDS1\tRPL36A\nMDS1\tRPS4X\nMDS1\tRPL21\nMDS1\tRPL31\nMDS1\tRPL35A\nMDS1\tRPL30\nMDS1\tRPS17\nMDS1\tRPL9\nMDS1\tRPL11\nMDS1\tRPS23\nRPL23\tRPL7\nRPL23\tRPL26\nRPL23\tRPL5\nRPL23\tRPL24\nRPL23\tRPS27\nRPL23\tRPS15A\nRPL23\tRPS27A\nRPL23\tRPL6\nRPL23\tRPL36A\nRPL23\tU16\nRPL23\tRPS25\nRPL23\tRPS17\nRPL23\tRPS4X\nRPL23\tRPS7\nRPL23\tRPS3A\nRPL23\tRPS23\nRPL23\tRPL30\nRPL23\tRPS24\nRPL23\tRPL9\nRPL23\tRPL31\nRPL23\tRPS6\nRPL23\tRPL35A\nRPL23\tRPL4\nRPL23\tRPL34\nRPL5\tRPS6\nRPL5\tRPS23\nRPL5\tTPT1\nRPL5\tRPS25\nRPL5\tRPS3A\nRPL5\tRPS4X\nRPL5\tRPL9\nRPL5\tRPS27A\nRPL5\tRPL7\nRPL5\tRPS7\nRPL5\tU16\nRPL5\tRPS24\nRPL5\tRPL6\nATP5G3\tCOX8A\nATP5G3\tNDUFB11\nATP5G3\tSLC25A3\nATP5G3\tUQCRC1\nATP5G3\tUQCRQ\nATP5G3\tCOX5A\nATP5G3\tCOX4I1\nATP5G3\tNDUFAB1\nATP5G3\tUQCRFS1\nATP5G3\tCOX5B\nATP5G3\tMDH2\nCOPB\tTRAM1\nCOPB\tLOC58486\nCOPB\tRNPC2\nCOPB\tHSP90B1\nCOPB\tYME1L1\nCOPB\tPAPOLA\nPRKACB\tSEPT7\nSEPT7\tSH3BGRL\nSEPT7\tZFR\nUBE2D2\tZNF160\nRNPC2\tTAF7\nRNPC2\tTCEA1\nRNPC2\tZNF638\nRNPC2\tRPS3A\nRNPC2\tSF3B1\nRNPC2\tRPL5\nRNPC2\tRPL6\nRNPC2\tRPL17\nRNPC2\tSRP72\nIGHM\tIGKC\nIGHM\tIGLC2\nIGHM\tIGKV1-5\nIGHM\tLOC91316\nIGHM\tIGLV3-10\nIGHM\tIGL@\nIGHM\tIGHV1-69\nIGHM\tIGKV1D-13\nIGHM\tIGLV3-25\nIGHM\tIGLC1\nIGHM\tIGKV1OR2-108\nIGKC\tIGLJ3\nIGKC\tIGLC2\nIGKC\tIGKV1D-13\nIGKC\tIGLV3-25\nIGKC\tIGKV1OR2-108\nIGKC\tIGKV1-5\nIGKC\tIGLV3-10\nIGKC\tIGL@\nIGKC\tLOC91316\nIGKC\tIGLC1\nIGKC\tIGKV1OR15-118\nLOC342346\tYTHDC1\nLOC342346\tXPO1\nLOC342346\tSON\nLOC342346\tTAF7\nLOC342346\tPAPOLA\nLOC342346\tSH3BGRL\nEEF1A1\tRPL17\nEEF1A1\tMDS1\nEEF1A1\tRPS24\nEEF1A1\tRPS3A\nEEF1A1\tRPL22\nEEF1A1\tNPM1\nEEF1A1\tRPL11\nEEF1A1\tRPS7\nEEF1A1\tRPS27A\nEEF1A1\tTPT1\nEEF1A1\tRPS6\nEEF1A1\tRPL7\nEEF1A1\tRPS4X\nEEF1A1\tRPL23\nRPL17\tSFRS5\nRPL17\tRPS25\nRPL17\tRPL9\nRPL17\tTPT1\nRPL17\tRPL30\nRPL17\tRPL26\nRPL17\tRPL7\nRPL17\tRPL36A\nRPL17\tRPL22\nRPL17\tRPS6\nRPL17\tSF3B1\nRPL17\tRPS4X\nRPL17\tTINP1\nRPL17\tRPL35A\nRPL17\tRPS23\nRPL17\tU16\nRPL17\tRPS3A\nRPL17\tRPL24\nRPL17\tRPS7\nRPL17\tRPL23\nRPL17\tRPL31\nRPL17\tRPS27A\nRPL17\tRPL21\nRPL17\tRPL4\nRPL17\tRPL5\nRPL17\tRPL34\nRPL17\tRPL6\nRPL17\tRPS24\nRPL13A\tRPS9\nRPL13A\tRPS18\nRPL13A\tRPL18A\nRPL13A\tRPL27A\nRPL13A\tRPL14\nRPL13A\tRPS16\nRPL13A\tRPS14\nRPL13A\tRPL15\nRPL13A\tRPLP2\nRPL13A\tRPS21\nRPL13A\tRPL29\nRPL13A\tRPS19\nRPL13A\tRPL36\nRPL13A\tRPS5\nNDUFAB1\tNDUFB4\nNDUFAB1\tNDUFS3\nNDUFAB1\tUQCRC1\nNDUFAB1\tNDUFV1\nNDUFAB1\tUQCR\nNDUFAB1\tNDUFB2\nNDUFAB1\tUQCRQ\nNDUFAB1\tNDUFS7\nNDUFAB1\tNDUFB11\nNDUFAB1\tNDUFC1\nNDUFAB1\tPOLR2I\nNDUFAB1\tNDUFS8\nNDUFAB1\tUQCRFS1\nNDUFB4\tNDUFS3\nNDUFB4\tNDUFS8\nNDUFB4\tUQCRFS1\nNDUFB4\tUCRC\nAKR7A2\tCLPP\nAKR7A2\tTIMM13\nAKR7A2\tNDUFV1\nAKR7A2\tMRPL34\nAKR7A2\tC21orf33\nAKR7A2\tNDUFS8\nCLPP\tTUFM\nCLPP\tMRPL34\nhfl-B5\tHNRPA2B1\nhfl-B5\tHIATL1\nhfl-B5\tHNRPH1\nhfl-B5\tHNRPA1\nHNRPA2B1\tXPO1\nHNRPA2B1\tSRP46\nSET\tSFRS3\nSET\tSRP46\nSET\tSON\nSET\tSFRS10\nHLA-C\tISG20\nHLA-C\tMAN2B1\nHLA-C\tHLA-G\nHLA-C\tHLA-F\nHLA-C\tHLA-E\nHLA-C\tTAPBP\nHLA-C\tHLA-J\nISG20\tRAC2\nISG20\tPSMB9\nISG20\tUCP2\nRPS24\tTPT1\nRPS24\tTCEA1\nRPS24\tSF3B1\nRPS24\tRPS25\nRPS24\tRPS3A\nRPS24\tRPS6\nRPS24\tRPS4X\nRPS24\tU16\nRPS24\tRPS27A\nRPS24\tRPS7\nDDX1\tKTN1\nKTN1\tUSP47\nRPS4X\tU16\nRPS4X\tTPT1\nRPS4X\tRPS7\nRPS4X\tRPS6\nCCNL1\tRPL4\nCCNL1\tZNF638\nCCNL1\tNACA\nCCNL1\tRPL5\nCCNL1\tRPS27A\nCCNL1\tRPL17\nCCNL1\tRNPC2\nCCNL1\tRPL21\nCCNL1\tRPL6\nCCNL1\tEIF3S6\nRPL4\tTPT1\nRPL4\tRPL7\nRPL4\tSFRS5\nRPL4\tRPS15A\nRPL4\tRPL5\nRPL4\tRPS17\nRPL4\tRPS25\nRPL4\tRPL9\nRPL4\tRPS7\nRPL4\tRPS27A\nRPL4\tRPL6\nRPL4\tRPS23\nRPL4\tRPS4X\nRPL4\tRPS3A\nRPL4\tRPS24\nRPL4\tU16\nRPL4\tRPS6\nFLJ11021\tNAP1L1\nFLJ11021\tZNF403\nFLJ11021\tHIATL1\nFLJ11021\tKIAA0907\nFLJ11021\tWAC\nFLJ11021\tTUG1\nFLJ11021\tPAPOLA\nFLJ11021\tRNPC2\nFLJ11021\tXPO1\nFLJ11021\tLOC58486\nFLJ11021\tNPM1\nNAP1L1\tRPL17\nNAP1L1\tYTHDC1\nNAP1L1\tPAPOLA\nNAP1L1\tZNF9\nNAP1L1\tXPO1\nC21orf33\tETFB\nC21orf33\tCOX5B\nC21orf33\tNDUFS7\nC21orf33\tNDUFS8\nETFB\tNDUFS8\nETFB\tUQCRQ\nCOX5A\tNDUFS8\nCOX5A\tNDUFB11\nCOX5A\tNDUFS7\nCOX5A\tMDH2\nCOX5A\tCOX5B\nCOX5A\tNDUFAB1\nCOX5A\tUQCRFS1\nCOX5A\tUQCRC1\nNDUFS8\tSLC25A3\nNDUFS8\tPOLR2I\nNDUFS8\tPOLR2L\nNDUFS8\tTIMM13\nNDUFS8\tUQCRFS1\nNDUFS8\tTMEM93\nNDUFS8\tUQCRQ\nCROP\tIL6ST\nCROP\tZNF638\nCROP\tHNRPA3P1\nCROP\tRNPC2\nCROP\tMATR3\nCROP\tSFRS5\nCROP\tXPO1\nCROP\tSFPQ\nCROP\tLOC58486\nCROP\tHNRPDL\nCROP\tTUG1\nCROP\tSFRS11\nCROP\tZC3H11A\nCROP\tPFAAP5\nCROP\tNPM1\nCROP\tFLJ10154\nCROP\tGOLGA8A\nIL6ST\tSFRS11\nPOLR2E\tSIAHBP1\nRPL26\tRPS3A\nRPL26\tRPL4\nRPL26\tRPL7\nRPL26\tRPS7\nRPL26\tRPS6\nRPL26\tRPS27A\nRPL26\tRPL34\nRPL26\tRPS24\nRPL26\tRPS25\nRPL26\tRPL5\nRPL26\tRPL6\n216858_x_at\tCEP27\n216858_x_at\tZNF611\n216858_x_at\tFLJ14346\n216858_x_at\tLOC152719\nCEP27\tPRR11\nCEP27\tPGF\nCEP27\tPRKAR2A\nCEP27\tLOC152719\nCEP27\tPDE4C\nCEP27\tRIOK3\nCEP27\tZNF611\nCEP27\tKNS2\nCEP27\tFBXW12\nCEP27\tDBT\nCEP27\tZNF160\nCEP27\tSLC35E1\nCEP27\tFLJ12151\nRPL6\tRPL9\nRPL6\tSFRS5\nRPL6\tTPT1\nRPL6\tRPL7\nRPL6\tRPS23\nRPL6\tSF3B1\nRPL6\tRPS4X\nRPL6\tRPS25\nRPL6\tRPS3A\nRPL6\tRPS6\nRPL6\tU16\nRPL6\tRPS7\nRPL6\tRPS27A\nRPL6\tRPS24\nRPL9\tRPS27A\nRPL9\tRPS24\nRPL9\tU16\nRPL9\tRPS25\nRPL9\tRPS4X\nRPL9\tRPS23\nRPL9\tRPS7\nRPL9\tRPS3A\nRPL9\tRPS17\nRPL9\tRPS6\nRPL9\tRPS27\nRPL9\tRPS15A\nDDX3X\tXPO1\nDDX3X\tNAP1L1\nDDX3X\tPAPOLA\nDDX3X\tSON\nDDX3X\tHNRPR\nDDX3X\tTHRAP1\nDDX3X\tSH3BGRL\nDDX3X\tHNRPH1\nDDX3X\tMGEA5\nDDX3X\tHIATL1\nDDX3X\tDDX5\nDDX3X\tLOC342346\nDDX3X\tEIF4G2\nDDX3X\tSF3B1\nDDX3X\tYTHDC1\nCOPE\tSCAND1\nCOPE\tMRPS12\nCOPE\tNAPA\nCOPE\tFIBP\nSCAND1\tTHRAP5\nCOPS2\tRPL15\nCOPS2\tHSP90B1\nCOPS2\tHNRPH3\nCOPS2\tZNF638\nCOPS2\tKIDINS220\nCOPS2\tVDP\nCOPS2\tTLOC1\nCOPS2\tCSDE1\nRPL15\tRPL35\nRPL15\tRPS16\nITGB2\tRAC2\nITGB2\tRGS19\nRAC2\tTRA@\nRAC2\tTRB@\nDBT\tPRR11\nDBT\tDIP2A\nDBT\tFLJ42393\nDBT\tZNF611\nDBT\tFLJ12151\nDBT\tPGF\nDBT\tLOC152719\nDBT\tRIOK3\nDBT\tPDE4C\nDBT\tWDR45\nDBT\tZNF160\nDBT\tFBXW12\nDBT\tKNS2\nDBT\tSLC35E1\nPRR11\tZNF611\nPRR11\tSLC35E1\nPRR11\tZNF160\n207730_x_at\t216858_x_at\n207730_x_at\tUBE2D2\n207730_x_at\tFLJ42393\n207730_x_at\t208246_x_at\n207730_x_at\tPRKAR2A\n207730_x_at\t217679_x_at\n207730_x_at\tPDE4C\n207730_x_at\tPRR11\n207730_x_at\tPGF\n207730_x_at\tRIOK3\n207730_x_at\t208238_x_at\n207730_x_at\tKNS2\n207730_x_at\t216524_x_at\n207730_x_at\tATP8B1\n207730_x_at\tFLJ12151\n207730_x_at\tLOC152719\n207730_x_at\tDBT\n207730_x_at\tZNF611\n207730_x_at\tSLC35E1\n207730_x_at\tZNF160\n207730_x_at\tCEP27\n207730_x_at\tFBXW12\nEEF1D\tRPS9\nEEF1D\tGLTSCR2\nEEF1D\tRPL38\nPRDX2\tRIOK3\nRIOK3\tZNF611\nRIOK3\tZNF160\nRIOK3\tSLC35E1\nRPS23\tRPS27A\nRPS23\tU16\nRPS23\tRPS7\nRPS23\tRPS24\nRPS23\tRPS25\nRPS23\tRPS6\nRPS23\tRPS4X\nRPS23\tRPS3A\nRPS27A\tSFRS5\nRPS27A\tRPS4X\nRPS27A\tU16\nRPS27A\tTINP1\nRPS27A\tRPS3A\nRPS27A\tRPS7\nRPS27A\tRPS6\nRPL34\tTPT1\nRPL34\tSF3B1\nRPL34\tRPL7\nRPL34\tRPS25\nRPL34\tRPL36A\nRPL34\tRPL5\nRPL34\tRPS27A\nRPL34\tRPS4X\nRPL34\tRPS3A\nRPL34\tRPS6\nRPL34\tRPS7\nRPL34\tRPL4\nRPL34\tRPL9\nRPL34\tRPS23\nRPL34\tRPL6\nRPL34\tRPL35A\nRPL34\tRPS24\nHNRPDL\tMATR3\nHNRPDL\tSON\nHNRPDL\tNAP1L1\nHNRPDL\tNPM1\nHNRPDL\tSF3B1\nHNRPDL\tIL6ST\nHNRPDL\tSERBP1\nHNRPDL\tSFRS11\nMATR3\tWAC\nMATR3\tSERBP1\nMATR3\tNARS\nMATR3\tXPO1\nMATR3\tYTHDC1\nMATR3\tSFRS11\nMATR3\tSON\nMATR3\tMGEA5\nMATR3\tPAPOLA\nMATR3\tSH3BGRL\nMATR3\tSEPT7\nMATR3\tSEPT2\nMATR3\tTAF7\nMATR3\tNPM1\nMATR3\tPCNP\nMATR3\tRNPC2\nMATR3\tTLOC1\nEIF3S3\tMATR3\nEIF3S3\tHSP90AA1\nEIF3S3\tTTC3\nEIF3S3\tNARS\nEIF3S3\tFNTA\nEIF3S3\tSEPT2\nWAC\tZNF403\nRPL22\tRPL5\nRPL22\tTPT1\nRPL22\tRPS15A\nRPL22\tRPS23\nRPL22\tRPS27A\nRPL22\tRPS7\nRPL22\tRPL30\nRPL22\tRPS3A\nRPL22\tRPL6\nRPL22\tRPL34\nRPL22\tRPL26\nRPL22\tRPL9\nRPL22\tRPL24\nRPL22\tRPS25\nRPL22\tRPL35A\nRPL22\tRPS17\nRPL22\tRPS24\nRPL22\tRPL4\nRPL22\tRPL41\nRPL22\tRPL7\nRPL22\tRPS6\nRPL22\tRPL36A\nRPL22\tRPL23\nRPL22\tRPS4X\nRPL22\tRPL31\nSH3BGRL\tYTHDC1\nSH3BGRL\tTAF7\nSH3BGRL\tSON\nMRPL34\tNDUFS3\nMRPL34\tUQCRFS1\nMRPL34\tUQCRC1\nMRPL34\tNDUFV1\nMRPL34\tPOLR2I\nNDUFS3\tPOLR2I\nNDUFS3\tNDUFS8\nNDUFS3\tUQCRFS1\nFIBP\tSIAHBP1\nFIBP\tWIPI2\nFIBP\tPOLR2E\nAURKAIP1\tCOX5A\nAURKAIP1\tC12orf10\nAURKAIP1\tMRPL34\nAURKAIP1\tGPX4\nAURKAIP1\tNDUFS3\nAURKAIP1\tTUFM\nAURKAIP1\tNDUFS8\nAURKAIP1\tNDUFS7\nAURKAIP1\tNDUFA2\nAURKAIP1\tC3orf60\nAURKAIP1\tNDUFA3\nAURKAIP1\tUQCRFS1\nAURKAIP1\tTMEM93\nAURKAIP1\tUQCRC1\nAURKAIP1\tFIBP\nAURKAIP1\tEXOSC4\nDIP2A\tPGF\nCD52\tIL7R\nCD52\tTRBV3-1\nCD52\tFAIM3\nCD52\tLCK\nCD52\tISG20\nCD52\tTRA@\nCD52\tTRB@\nCD52\tRAC2\nCD52\tTRBC1\nCSNK1A1\tPAPOLA\nCSNK1A1\tEIF4G2\nPAPOLA\tTAF7\nPAPOLA\tPUM2\nPAPOLA\tSON\nPAPOLA\tZNF9\nPAPOLA\tSSR1\nPAPOLA\tSRP72\nPAPOLA\tTRAM1\nPAPOLA\tTHRAP1\nPAPOLA\tTMEM123\nPAPOLA\tUSP34\nPAPOLA\tRNPC2\nPAPOLA\tPNRC2\nPAPOLA\tYTHDC1\nPAPOLA\tSF3B1\nHLA-G\tMAN2B1\nHLA-G\tTGFB1\nHLA-G\tISG20\nHLA-G\tIFI30\nHLA-G\tHLA-J\nMAN2B1\tTGFB1\nATP5D\tCOX5B\nATP5D\tCYC1\nCOX5B\tFLJ14346\nCOX5B\tNDUFS3\nCOX5B\tNDUFB2\nCOX5B\tNDUFA3\nCOX5B\tMRPS12\nCOX5B\tNDUFS8\nCOX5B\tUQCRC1\nCOX5B\tCYC1\nCOX5B\tMSRB2\nCOX5B\tUQCRQ\nCOX5B\tNDUFB11\nCOX5B\tMDH2\nCOX5B\tNDUFV1\nCOX5B\tUQCRFS1\nCOX5B\tNDUFS7\nCOX5B\tNDUFAB1\nCOX5B\tCOX8A\nCOX5B\tUQCR\nIGLJ3\tIGLV3-25\nIGLJ3\tLOC91316\n215176_x_at\tIGHG3\n215176_x_at\t217258_x_at\n215176_x_at\tIGHV1-69\n215176_x_at\t217281_x_at\n215176_x_at\tIGLC2\n215176_x_at\tIGHA1\n215176_x_at\tIGKC\n215176_x_at\tIGHM\n215176_x_at\tIGLV3-25\n215176_x_at\tIGKV1OR2-108\n215176_x_at\tIGLJ3\n215176_x_at\tIGKV1-5\n215176_x_at\tIGKV1OR15-118\n215176_x_at\tIGLC1\n215176_x_at\tIGL@\n215176_x_at\tLOC91316\n215176_x_at\tIGKV1D-13\n215176_x_at\t221651_x_at\n215176_x_at\tCTA-246H3.1\nIGHG3\tIGHM\nIGHG3\tIGHV1-69\nIGHG3\tIGKV1-5\nIGHG3\tIGLJ3\nIGHG3\tIGKV1D-13\nIGHG3\tIGKC\nIGHG3\tIGKV1OR2-108\nIGHG3\tIGLC2\nIGHG3\tIGLC1\nIGHG3\tLOC91316\n217258_x_at\tIGLC2\n217258_x_at\tIGKC\n217258_x_at\tIGL@\n217258_x_at\tCTA-246H3.1\n217258_x_at\tIGLJ3\n217258_x_at\tIGKV1-5\n217258_x_at\tIGLV3-25\nRPL14\tRPL17\nRPL14\tRPS4X\nRPL14\tRPS3A\nRPL14\tRPL5\nRPL14\tRPS7\nRPL14\tU16\nRPL14\tRPL21\nRPL14\tRPL4\nRPL14\tRPL6\nRPL14\tRPL35A\nRPL14\tRPL39\nRPL14\tRPL23\nDYNLT3\tPAFAH1B1\nPAFAH1B1\tPRKACB\nPAFAH1B1\tPOPDC3\nPAFAH1B1\tREEP5\nPAFAH1B1\tZFR\nPAFAH1B1\tPCNP\nPAFAH1B1\tSEPT7\nRPS18\tRPS19\nSF3B1\tWAC\nSF3B1\tSON\nSF3B1\tTUG1\nSF3B1\tSFRS5\nSF3B1\tYTHDC1\nSF3B1\tTCEA1\nIQGAP1\tMBNL1\nIQGAP1\tROCK1\nMBNL1\tOSBPL8\nMBNL1\tROCK1\nMBNL1\tPNRC2\nMBNL1\tPUM2\nNPM1\tRPS3A\nNPM1\tSFRS5\nNPM1\tSFRS11\nNPM1\tRPS7\nNPM1\tRPS6\nNPM1\tRPL7\nNPM1\tRPL34\nNPM1\tTCEA1\nNPM1\tTUG1\nNPM1\tRPL5\nNPM1\tRPL17\nNPM1\tRPL24\nNPM1\tSF3B1\nNPM1\tTINP1\nNPM1\tRPL6\nNPM1\tRPS24\nNPM1\tRPL4\nNPM1\tRPS27A\nNPM1\tRNPC2\nRPS3A\tSF3B1\nRPS3A\tTPT1\nRPS3A\tU16\nRPS3A\tRPS4X\nRPS3A\tRPS7\nRPS3A\tRPS6\nSPN\tZNF552\nDDX50\tRNPC2\nEIF5\tPCNP\nEIF5\tPAPOLA\nEIF5\tTAF7\nEIF5\tMATR3\nEIF5\tHSP90AA1\nPCNP\tSEPT2\nPCNP\tTLOC1\nPCNP\tRNPC2\n217281_x_at\tIGHM\n217281_x_at\tIGHV1-69\n217281_x_at\tIGLJ3\n217281_x_at\tIGKC\n217281_x_at\tIGLC2\n217281_x_at\tIGHA1\n217281_x_at\tIGHG3\n217281_x_at\tIGKV1OR2-108\nCHCHD2\tMIF\nCHCHD2\tNDUFAB1\nCHCHD2\tCOX5B\nCHCHD2\tMRPS12\nCHCHD2\tMRP63\nCHCHD2\tCOX8A\nCHCHD2\tSLC25A3\nCHCHD2\tNDUFB11\nMIF\tTIMM13\nARPC5\tCAP1\nARPC5\tPRG1\nSFRS11\tTUG1\nACTR10\tSKP1A\nACTR10\tPRKAR1A\nSKP1A\tSUMO2\nACTR2\tARPC3\nACTR2\tACTR3\nACTR2\tCAPZA1\nACTR2\tPDCD10\nARPC3\tCAPZA1\nCAV1\tMMP2\nCAV1\tCOL6A1\nCAV1\tFBLN1\nCAV1\tCOL1A2\nCAV1\tPTRF\nCAV1\tLAMC1\nACTG2\tMYH11\nMYH11\tMYLK\nMYH11\tTPM1\nMYH11\tTAGLN\nRPL38\tRPS20\nRPL38\tRPLP2\nRPL38\tRPS19\nRPS20\tRPS27\nCSDE1\tFLJ11021\nCSDE1\tWAC\nCSDE1\tNFE2L2\nCOL1A2\tCOL3A1\nCOL3A1\tCOL6A3\nCD7\tLCK\nCD7\tHMHA1\nLCK\tRAC2\nLCK\tTRA@\nLCK\tTRB@\nLCK\tTRBC1\nLCK\tTRBV3-1\nNDUFB11\tNDUFS3\nNDUFB11\tUQCRQ\nNDUFB11\tUQCR\nNDUFB11\tNDUFB4\nNDUFB11\tUQCRFS1\nNDUFB11\tSLC25A3\nNDUFB11\tNDUFS8\nNDUFB11\tPOLR2L\nC3orf60\tEXOSC4\nC3orf60\tPOLR2I\nC3orf60\tMRPL34\nC3orf60\tGPX4\n211639_x_at\tIGKC\nRPL11\tRPS15A\nRPL11\tRPL5\nRPL11\tRPS24\nRPL11\tRPL17\nRPL11\tRPL7\nRPL11\tU16\nRPL11\tRPL24\nRPL11\tRPL21\nRPL11\tRPL35A\nRPL11\tRPS25\nRPL11\tRPL14\nRPL11\tRPS6\nRPL11\tRPL22\nRPL11\tRPL34\nRPL11\tRPL4\nRPL11\tRPS4X\nRPL11\tRPS3A\nRPL11\tRPS7\nRPL11\tRPL39\nRPL11\tRPL31\nRPL11\tRPL36A\nRPL11\tRPL23\nRPL11\tRPS17\nRPL11\tRPL30\nRPL11\tRPS23\nRPS15A\tRPS6\nRPS15A\tRPS17\nRPS15A\tRPS7\nRPS15A\tRPS3A\nRPS15A\tRPS20\nRPS15A\tRPS27\nRPS15A\tRPS23\nGNB2L1\tRPL13A\nGNB2L1\tRPS19\nGNB2L1\tRPL29\nGNB2L1\tRPS16\nRPL29\tRPS9\nRPL29\tRPS16\nRPL29\tRPL35\nRPL13\tRPL18A\nRPL13\tRPL28\nRPL13\tRPS2\nRPL13\tRPL18\nRPL13\tRPLP2\nRPL13\tRPS15\nRPL18A\tRPS2\nRPL18A\tRPS19\nRPL18A\tRPLP2\nRPL18A\tRPL28\nHLA-A\tHLA-F\nHLA-A\tMAN2B1\nHLA-A\tHLA-E\nHLA-A\tHLA-G\nHLA-A\tHLA-B\nHLA-A\tHLA-J\nHLA-A\tHLA-C\nHLA-F\tISG20\nHLA-F\tHLA-G\nHLA-F\tIFI30\nHLA-F\tMAN2B1\nHLA-F\tHLA-J\nBTF3\tMDS1\nBTF3\tRPS27\nBTF3\tRPS17\nBTF3\tRPS15A\nBTF3\tRPL30\nBTF3\tLOC402057\nBTF3\tRPL22\nNACA\tRPL35A\nNACA\tRPS3A\nNACA\tRPL5\nNACA\tNAP1L1\nNACA\tSFRS5\nNACA\tRPS7\nNACA\tRPL24\nNACA\tU16\nNACA\tRPL21\nNACA\tRPL9\nNACA\tNPM1\nNACA\tRPS23\nNACA\tRPL4\nNACA\tRPS27A\nNACA\tRPS24\nNACA\tRPL17\nNACA\tRPL6\nNACA\tTINP1\nRPL35A\tRPL7\nRPL35A\tRPS17\nRPL35A\tRPS27A\nRPL35A\tRPS20\nRPL35A\tRPS27\nRPL35A\tRPS15A\nRPL35A\tRPS3A\nRPL35A\tRPS25\nRPL35A\tRPL36A\nRPL35A\tRPL39\nRPL35A\tRPL6\nRPL35A\tRPS6\nRPL35A\tRPS4X\nRPL35A\tRPL4\nRPL35A\tRPL5\nRPL35A\tRPS23\nRPL35A\tRPS24\nRPL35A\tU16\nRPL35A\tRPL9\nRPL35A\tRPS7\nBIRC2\tDDX5\nBIRC2\tZNF403\nBIRC2\tLOC58486\nBIRC2\tTHRAP1\nBIRC2\tSERBP1\nBIRC2\tPAPOLA\nBIRC2\tUSP34\nBIRC2\tC1orf63\nDDX5\tSERBP1\nDDX5\tSRP46\nDDX5\thfl-B5\nDDX5\tTUG1\nDDX5\tNAP1L1\nDDX5\tHNRPH1\nDDX5\tNBPF1\nDDX5\tSFRS5\nDDX5\tLOC342346\nDDX5\tNBPF12\nDDX5\tMGEA5\nDDX5\tHNRPA2B1\nDDX5\tSON\nDDX5\tFLJ10154\nDDX5\tUSP34\nDDX5\tNPM1\nDDX5\tTARDBP\nDDX5\tEIF4G2\nDDX5\tPAPOLA\nDDX5\tHIATL1\nDDX5\tYTHDC1\nDDX5\tSF3B1\nRPL30\tU16\nRPL30\tRPS7\nRPL30\tRPS25\nRPL30\tRPS13\nRPL30\tRPS3A\nRPL30\tRPS4X\nRPL30\tRPL4\nRPL30\tRPS6\nRPL30\tRPL37\nRPL30\tRPL31\nRPL30\tRPL36A\nRPL30\tRPL35A\nRPL30\tRPS27\nRPL30\tRPS17\nRPL30\tRPS15A\nRPL30\tRPL9\nRPL30\tRPS20\nRPL30\tRPS23\nRPL30\tRPL32\nRPL30\tRPL39\nTMEM123\tTRAM1\nIGHA1\tIGHV1-69\nIGHA1\tIGKC\nIGHA1\tIGLJ3\nIGHA1\tIGLV3-25\nIGHA1\tIGHM\nIGHA1\tIGKV1OR15-118\nIGHA1\tIGLC2\nIGHA1\tIGL@\nIGHA1\tIGKV1-5\nIGHA1\tIGKV1D-13\nIGHA1\tIGHG3\nIGHA1\tIGKV1OR2-108\nIGHA1\tLOC91316\nIGHA1\tIGLC1\nIGHV1-69\tIGLC2\nIGHV1-69\tIGKC\nIGHV1-69\tIGLC1\nIGHV1-69\tIGKV1OR2-108\nNDUFS7\tPOLR2I\nNDUFS7\tNDUFV1\nNDUFS7\tNDUFS8\nPOLR2I\tUQCRFS1\n214836_x_at\t217258_x_at\n214836_x_at\t217281_x_at\n214836_x_at\tIGLC2\n214836_x_at\tIGLV3-25\n214836_x_at\tIGL@\n214836_x_at\tIGKV1OR2-108\n214836_x_at\tIGKV1OR15-118\n214836_x_at\tIGLJ3\n214836_x_at\tIGHG3\n214836_x_at\tIGKC\n214836_x_at\tLOC91316\n214836_x_at\tIGKV1-5\n214836_x_at\tIGKV1D-13\n214836_x_at\tIGLC1\n214836_x_at\tIGHA1\n214836_x_at\t221651_x_at\n214836_x_at\tCTA-246H3.1\n214836_x_at\t215176_x_at\nRPS6\tTPT1\nRPS6\tRPS7\nRPS6\tU16\nGTF2I\tSRP9\nGTF2I\tTMEM66\nSRP9\tSUMO1\nATP2A3\tTRA@\nATP2A3\tTRB@\nATP2A3\tTRBC1\nTRA@\tTRBC1\nTRA@\tTRB@\nIGLC2\tIGLJ3\nIGLC2\tLOC91316\nIGLC2\tIGLV3-25\nIGLC2\tIGLV3-10\nHSP90B1\tZNF638\nHSP90B1\tRNPC2\nHSP90B1\tVDP\nRPL7\tTPT1\nRPL7\tRPS3A\nRPL7\tRPS6\nRPL7\tRPS24\nRPL7\tTCEA1\nRPL7\tSF3B1\nRPL7\tRPS7\nRPL7\tRPS25\nRPL7\tRPL9\nRPL7\tRPS23\nRPL7\tRPS27A\nRPL7\tRPS4X\nMGEA5\tNAP1L1\nMGEA5\tPAPOLA\nMGEA5\tXPO1\nMGEA5\tSF3B1\nMGEA5\tRNPC2\nMGEA5\tYTHDC1\nMGEA5\tPNRC2\nBCLAF1\tPCM1\nBCLAF1\tRNPC2\nBCLAF1\tPUM2\nBCLAF1\tEIF3S6\nBCLAF1\tUSP47\nBCLAF1\tSF3B1\nBCLAF1\tSLC38A2\nBCLAF1\tFLJ11021\nBCLAF1\tCOPS2\nBCLAF1\tPCNP\nBCLAF1\tCSDE1\nBCLAF1\tMATR3\nBCLAF1\tWAC\nBCLAF1\tCROP\nBCLAF1\tSRP72\nBCLAF1\tLOC58486\nBCLAF1\tPHIP\nBCLAF1\tZNF638\nBCLAF1\tVDP\nBCLAF1\tNPM1\nBCLAF1\tHSP90B1\nBCLAF1\tTLOC1\nPCM1\tVDP\nCXorf9\tGPSM3\nCXorf9\tTRA@\nCXorf9\tIL2RG\nCXorf9\tIL16\n216342_x_at\tRPL7\n216342_x_at\tTPT1\n216342_x_at\tRPL14\n216342_x_at\tRPL9\n216342_x_at\tRPS27A\n216342_x_at\tRPL17\n216342_x_at\tRPS17\n216342_x_at\tRPL5\n216342_x_at\tU16\n216342_x_at\tRPL22\n216342_x_at\tRPL36A\n216342_x_at\tRPS25\n216342_x_at\tLOC402057\n216342_x_at\tRPS7\n216342_x_at\tRPL21\n216342_x_at\tRPL24\n216342_x_at\tRPL41\n216342_x_at\tRPS24\n216342_x_at\tMDS1\n216342_x_at\tRPL6\n216342_x_at\tLOC440055\n216342_x_at\tRPL4\n216342_x_at\tRPL34\n216342_x_at\tRPS3A\n216342_x_at\tRPL11\n216342_x_at\tRPS6\n216342_x_at\tRPL23\n216342_x_at\tRPL30\n216342_x_at\tRPL31\n216342_x_at\tRPS23\n216342_x_at\tRPL35A\n216342_x_at\tRPS4X\nNDUFA13\tNDUFA3\nNDUFA13\tNDUFB2\nNDUFA3\tNDUFAB1\nNDUFA3\tUCRC\nNDUFA3\tNDUFB2\nNDUFA3\tNDUFB11\nNDUFA3\tUQCR\nNDUFA3\tUQCRC1\nNDUFA3\tNDUFB4\nCDKN1B\tPPP2CA\nCDKN1B\tMARCKS\nHMGB1\tHNRPR\nHMGB1\tHNRPA2B1\nHNRPR\tSRP9\nHNRPR\tSON\nHNRPR\tSFRS10\nAP2S1\tATP6V0C\nAP2S1\tPOLR2E\nAP2S1\tGPX1\nAP2S1\tGUK1\nAP2S1\tNAPA\nAP2S1\tCOPE\nAP2S1\tATP6V0B\nAP2S1\tMRPS12\n211637_x_at\tIGKC\n211637_x_at\tIGHA1\n211637_x_at\tIGLC2\n211637_x_at\t211639_x_at\n211637_x_at\tIGHM\n211637_x_at\t211908_x_at\n211637_x_at\tIGHV1-69\n211637_x_at\tCTA-246H3.1\n211637_x_at\t211641_x_at\n211637_x_at\t215176_x_at\n211637_x_at\t211645_x_at\n211637_x_at\tIGHG3\n211637_x_at\t217281_x_at\n211637_x_at\tLOC91316\nCOL4A1\tCOL4A2\nCOL4A1\tFBLN1\nCOL4A2\tRBPMS\nFLJ42393\tPDE4C\nFLJ42393\tZNF160\nFLJ42393\tPOLR1B\nFLJ42393\tKNS2\nFLJ42393\tSLC35E1\nFLJ42393\tLOC152719\nMORF4L1\tPTGES3\nMORF4L1\tSKP1A\nPTGES3\tSUMO2\nSON\tXPO1\nSON\tSRP46\nSON\tYTHDC1\nIFI30\tTGFB1\nIFI30\tNCF1\nAASDHPPT\tPOPDC3\nAASDHPPT\tPAFAH1B1\nPOPDC3\tRAB6C\nPOPDC3\tPRKACB\nFXYD5\tITGB2\nFXYD5\tPSMB9\nCD79A\tIGKC\nCD79A\tIGHA1\nCD79A\tIGLC2\nCD79A\tIGLJ3\nFLJ14346\tZNF611\nFLJ14346\tGPAA1\nFLJ14346\tMRPS12\nFLJ14346\tLOC152719\nFLJ14346\tSLC35E1\nHLA-DPA1\tHLA-DQB1\nHLA-DPA1\tHLA-DQA1\nHLA-DPA1\tHLA-DRA\nHLA-DPA1\tHLA-DRB5\nHLA-DPA1\tHLA-DRB4\nHLA-DPA1\tHLA-DRB1\nHLA-DPA1\tHLA-DPB1\nHLA-DQB1\tHLA-DRB4\nHLA-DQB1\tHLA-DRB5\nHLA-DQB1\tHLA-DRB1\nIFNGR1\tROCK1\nC6orf12\tHLA-F\nC6orf12\tHLA-G\nC6orf12\tHLA-B\nC6orf12\tHLA-C\nC6orf12\tHLA-J\nC6orf12\tHLA-A\nNARS\tTLOC1\nNARS\tTTC3\nNARS\tSRP9\nRPL36A\tRPL6\nRPL36A\tRPS7\nRPL36A\tRPS24\nRPL36A\tRPS3A\nRPL36A\tRPL4\nRPL36A\tRPS4X\nRPL36A\tRPS25\nRPL36A\tRPS15A\nRPL36A\tRPS6\nRPL36A\tRPS17\nRPL36A\tRPS23\nRPL36A\tRPL39\n217718_s_at\tYWHAZ\n217718_s_at\tCAB39\nRPL23A\tRPS20\nTAF7\tUBE4A\nTAF7\tYTHDC1\nTAF7\tXPO1\nDHX15\tOGT\nDHX15\tKIAA0907\nDHX15\tNPM1\nCD2\tTRA@\nCD2\tCD52\nCD2\tIL7R\nDST\tPLEKHC1\nDST\tSPARCL1\nCOL6A1\tCOL6A3\nCOL6A1\tFBLN1\nCOL6A1\tPTRF\nFIS1\tNDUFA13\nEIF4G2\tSRP46\nEIF4G2\tHSP90AA1\nEIF4G2\tSERBP1\nEIF4G2\tSON\nEIF4G2\tEIF5\nEIF4G2\tNPM1\nEIF4G2\tPUM2\nEIF4G2\tSET\nEIF4G2\tPCNP\nEIF4G2\tLOC342346\nEIF4G2\tHNRPA2B1\nEIF4G2\tYTHDC1\nEIF4G2\tXPO1\nEIF4G2\tMATR3\nEIF4G2\tPAPOLA\nEIF4G2\tTHRAP1\nEIF4G2\tSF3B1\nEIF4G2\tHIATL1\nSRP46\tXPO1\nCOL1A1\tCOL6A3\nCOL1A1\tCOL1A2\nCOL1A1\tCOL3A1\nC12orf10\tFIBP\nC12orf10\tC3orf60\nLMOD1\tMYH11\n208246_x_at\tPOLR1B\n208246_x_at\tZNF611\n208246_x_at\tPGF\n208246_x_at\tATP8B1\n208246_x_at\tRIOK3\n208246_x_at\tKNS2\n208246_x_at\tDBT\n208246_x_at\tLOC152719\n208246_x_at\tFBXW12\n208246_x_at\tPDE4C\n208246_x_at\tSLC35E1\n208246_x_at\tZNF160\n208246_x_at\t216524_x_at\nHNRPH1\tSON\nHNRPH1\tSF3B1\nHNRPH1\tYTHDC1\nRPS14\tRPS16\nRPS14\tRPS5\nRPS14\tRPS9\nRPS16\tRPS19\nRPS16\tRPS9\nRPS16\tRPS5\nARHGDIB\tLAPTM5\nARHGDIB\tPRG1\nARHGDIB\tUCP2\nARHGDIB\tRAC2\nARHGDIB\tCXCR4\nARHGDIB\tCECR1\nLAPTM5\tRAC2\nLAPTM5\tPRG1\nATRX\tCOPS2\nATRX\tPCNP\nATRX\tPUM2\nATRX\tMATR3\nATRX\tSH3BGRL\nATRX\tSEPT7\nATRX\tBCLAF1\nATRX\tTLOC1\nHIATL1\tSFRS5\nHIATL1\tRPL6\nHIATL1\tRPL17\nHIATL1\tTUG1\nHIATL1\tHNRPA2B1\nHIATL1\tRAVER2\nHIATL1\tRNPC2\nHIATL1\tHNRPH1\nHIATL1\tYTHDC1\nHIATL1\tTCEA1\nHIATL1\tNPM1\nHIATL1\tLOC58486\nHIATL1\tTHRAP1\nHIATL1\tPAPOLA\nHIATL1\tSF3B1\nFAIM3\tTRA@\nFAIM3\tHCLS1\nFAIM3\tRAC2\nIGKV1D-13\tIGLJ3\nIGKV1D-13\tIGLV3-25\nIGKV1D-13\tIGLC2\nIGKV1D-13\tLOC91316\nIGKV1D-13\tIGL@\nIGKV1D-13\tIGLC1\nIGKV1D-13\tIGKV1OR15-118\nHSP90AA1\tMATR3\nHSP90AA1\tSRP9\nHSP90AA1\tSERBP1\nHSP90AA1\tSEPT2\nHSP90AA1\tNARS\nRPL19\tRPS20\nLOC283412\tRPL29\nLOC283412\tRPS9\nDGCR6\tPOLR2I\nLTB\tTRBC1\nLTB\tTRB@\nHMHA1\tPSMB8\nHMHA1\tTRBC1\nHMHA1\tRAC2\nPSMB8\tTAPBP\nPSMB8\tPSMB9\nIGKV1-5\tIGL@\nIGKV1-5\tIGKV1OR2-108\nIGKV1-5\tIGLC2\nIGKV1-5\tIGLJ3\nIGKV1-5\tIGLV3-25\nIGKV1-5\tIGLC1\nIGKV1-5\tIGKV1D-13\nIGKV1-5\tIGKV1OR15-118\nRPL28\tRPS10\nRPL28\tRPS2\nRPL28\tRPS19\nRPL28\tRPS16\nRPL28\tRPLP2\nHCLS1\tITGB2\nHCLS1\tSELL\nHCLS1\tRAC2\nHCLS1\tTRA@\nFBLN1\tMMP2\nFBLN1\tPTRF\nNDUFA2\tUQCRFS1\nNDUFA2\tUQCR\nNDUFA2\tNDUFS8\nNDUFA2\tUQCRC1\nNDUFA2\tNDUFB4\nNDUFA2\tTCEB2\nNDUFA2\tNDUFA3\nNDUFA2\tUCRC\nHLA-DRB4\tHLA-DRB5\nFAM96B\tNDUFV1\nFAM96B\tNDUFS8\nFAM96B\tNDUFS3\nFAM96B\tTUFM\nFAM96B\tNDUFS7\nFAM96B\tNDUFA10\nFAM96B\tNDUFA2\nFAM96B\tNDUFA3\nCOQ9\tNDUFB4\nCOQ9\tUQCRFS1\nEEF1B2\tRPL30\nEEF1B2\tRPL10A\nEEF1B2\tRPS23\nEEF1B2\tRPS15A\nEEF1B2\tRPL35A\nEEF1B2\tRPL14\nEEF1B2\tRPL36A\nEEF1B2\tRPL37\nEEF1B2\tRPL39\nEEF1B2\tLOC440055\nPUM2\tWAC\nPUM2\tSF3B1\nALOX5AP\tLAPTM5\nALOX5AP\tITGB2\nDKFZP566N034\tZNF160\nDKFZP566N034\tSLC35E1\nARL6IP5\tPAFAH1B1\nTRB@\tTRBV3-1\nTRB@\tTRBC1\nIFI16\tROCK1\nIFI16\tIQGAP1\nIFI16\tTRIM22\nIFI16\tMBNL1\nIFI16\tIFNGR1\n211641_x_at\t211908_x_at\n211641_x_at\t211645_x_at\n211641_x_at\tIGHG3\n211641_x_at\tIGKV1-5\n211641_x_at\t217281_x_at\n211641_x_at\tIGHA1\n211641_x_at\tIGHM\n211908_x_at\tIGHV1-69\n211908_x_at\t217281_x_at\n211908_x_at\tIGHM\nWSB1\tXPO1\nARPC1B\tHLA-C\nARPC1B\tHLA-A\nARPC1B\tHLA-B\nARPC1B\tISG20\nARPC1B\tHLA-G\nCD53\tITGB2\nCD53\tIL10RA\nCD53\tHCLS1\nHLA-E\tHLA-F\nHLA-E\tPSMB8\nHLA-E\tHLA-J\nHLA-E\tMAN2B1\nHLA-E\tHLA-G\n211645_x_at\tIGHA1\n211645_x_at\tIGKC\n211645_x_at\tIGHM\n211645_x_at\tIGLC2\n211645_x_at\tIGHG3\n211645_x_at\tIGL@\n211645_x_at\tIGLV3-25\n211645_x_at\tIGLJ3\n211645_x_at\tLOC91316\n211645_x_at\tIGLC1\n211645_x_at\tIGKV1-5\n211645_x_at\t221651_x_at\n211645_x_at\tIGKV1OR15-118\n211645_x_at\tCTA-246H3.1\n211645_x_at\tIGKV1D-13\n211645_x_at\t214836_x_at\n211645_x_at\t215176_x_at\nNDUFB2\tNDUFS7\nNDUFB2\tUQCRC1\nNDUFB2\tUQCR\nNDUFB2\tNDUFV1\nFLJ10154\tLOC58486\nFLJ10154\tXPO1\nFLJ10154\tPFAAP5\nFLJ10154\tRNPC2\nFLJ10154\tHNRPDL\nFLJ10154\tSFPQ\nFLJ10154\tSFRS11\nFLJ10154\tNPM1\nEIF4A2\tTTC3\nEIF4A2\tMATR3\nGPX4\tR3HCC1\nGPX4\tPOLR2I\nGPX4\tMRPL34\nPRKAR2A\tSLC35E1\nSLC35E1\tUBE2D2\nSLC35E1\tWDR45\nSLC35E1\tZNF611\nSLC35E1\tZNF160\nIGLC1\tIGLJ3\nIGLC1\tIGLV3-25\nIGLC1\tIGLC2\nIGLC1\tLOC91316\nUQCRC1\tUQCRFS1\nTMED5\tWAC\nMDH2\tNDUFB11\nMDH2\tUQCRQ\nMDH2\tUQCRC1\nMDH2\tUQCRFS1\nMDH2\tNDUFS8\nIGL@\tIGLV3-25\nIGL@\tIGLC2\nIGL@\tIGLC1\nIGL@\tIGLJ3\nHLA-DRB1\tHLA-F\nHLA-DRB1\tHLA-DRB4\nHLA-DRB1\tHLA-DRB5\nCD74\tHLA-DQB1\nCD74\tHLA-DRB4\nCD74\tHLA-DRB1\nCD74\tHLA-DRA\nCD74\tHLA-DMA\nCD74\tHLA-DPB1\nCD74\tHLA-DRB5\nCD74\tHLA-DPA1\nCLTC\tNARS\nRPS17\tRPS25\nRPS17\tRPS4X\nRPS17\tRPS6\nRPS17\tRPS7\nRPS17\tRPS3A\nRPS17\tRPS20\nRPS17\tRPS23\nRPS17\tRPS27\nACTR3\tCAP1\nACTR3\tARPC3\nACTR3\tYWHAZ\nACTR3\tCAPZA1\nRPS10\tRPS16\nRPS10\tRPS21\nRPS10\tRPS19\nRPL27A\tRPS11\nRPL27A\tRPLP2\nRPL27A\tRPS19\nRPL27A\tRPL38\nRPL27A\tRPS20\nRPL27A\tRPS18\nDDX17\tSON\nDDX17\tHNRPH1\nC11orf58\tYTHDC1\nC11orf58\tHNRPK\nC11orf58\tLOC342346\nC11orf58\tSON\nNAPA\tWIPI2\nRGPD5\tTTC3\nCD3D\tTRA@\nCD3D\tTRB@\nCD3D\tTRBC1\nIGKV1OR2-108\tLOC91316\nIGKV1OR2-108\tIGLC2\nIGKV1OR2-108\tIGLC1\nIGKV1OR2-108\tIGLJ3\nCAB39\tSUB1\nATP6V0B\tGPX1\nATP6V0B\tCOPE\nATP6V0B\tMRPS12\nATP6V0B\tATP6V0D1\nATP6V0B\tATP6V0C\nGPX1\tPPP1CA\nGPX1\tPGLS\nGPX1\tOAZ1\nENDOG\tNDUFS7\nLOC388344\tRPS2\nLOC388344\tRPL18\nLOC388344\tRPL18A\nLOC388344\tRPL13\nLOC388344\tRPS15\nLOC440055\tRPL22\nLOC440055\tRPL17\nLOC440055\tRPS7\nLOC440055\tRPL9\nLOC440055\tRPS3A\nLOC440055\tRPS15A\nLOC440055\tRPL21\nLOC440055\tRPS4X\nLOC440055\tU16\nLOC440055\tRPL31\nLOC440055\tRPL37\nLOC440055\tRPL35A\nLOC440055\tMDS1\nLOC440055\tRPS6\nLOC440055\tRPS17\nLOC440055\tRPL32\nLOC440055\tRPL23\nLOC440055\tRPL4\nLOC440055\tRPL39\nLOC440055\tRPS20\nLOC440055\tRPL36A\nLOC440055\tRPL30\nLOC440055\tRPS23\nLOC440055\tRPL11\nCYBA\tIFI30\nCYBA\tHLA-G\nCYBA\tHLA-B\nCYBA\tHLA-J\nCYBA\tTGFB1\nRBPMS\tTGFB1I1\nKNS2\tPRR11\nKNS2\tSPG21\nKNS2\tLOC152719\nKNS2\tPGF\nKNS2\tUBE2D2\nKNS2\tPRKAR2A\nKNS2\tRIOK3\nKNS2\tZNF611\nKNS2\tZNF160\nKNS2\tPDE4C\nKNS2\tSLC35E1\nNPEPPS\tSEPT7\nMRPS12\tZNHIT1\nGLTSCR2\tRPS2\nGLTSCR2\tRPL13\nGLTSCR2\tLOC388344\nHLA-DMB\tHLA-DRA\n220725_x_at\tCDC5L\n220725_x_at\tSLC25A16\n220725_x_at\tC12orf38\n220725_x_at\tOPHN1\n220725_x_at\tMCM3AP\n220725_x_at\tLOC56902\nCDC5L\tLOC56902\nCDC5L\tMCM3AP\nARL8B\tHSP90AA1\nARL8B\tNARS\nCTA-246H3.1\tIGLC2\nCTA-246H3.1\tIGLV3-25\nCTA-246H3.1\tIGKC\nCTA-246H3.1\tIGKV1OR15-118\nCTA-246H3.1\tIGHM\nCTA-246H3.1\tIGLJ3\nCTA-246H3.1\tIGKV1OR2-108\nCTA-246H3.1\tIGL@\nCTA-246H3.1\tIGHG3\nCTA-246H3.1\tIGKV1-5\nCTA-246H3.1\tIGKV1D-13\nCTA-246H3.1\tLOC91316\nCTA-246H3.1\tIGLC1\nCTA-246H3.1\tIGHA1\n218041_x_at\tBCLAF1\n218041_x_at\tPUM2\n218041_x_at\tSLC38A2\nMEFV\tSPN\nSMTN\tTGFB1I1\nZNF160\tZNF611\nTUFM\tUQCRFS1\nTUFM\tUQCRC1\nCD164\tCOPB\nARF5\tPOLR2J\nARF5\tATP6V0D1\nHLA-B\tMYH9\nHLA-B\tMAN2B1\nHLA-B\tHLA-G\nHLA-B\tHLA-F\nHLA-B\tHLA-E\nHLA-B\tHLA-C\nHLA-B\tHLA-J\nIGHD\tIGHM\nADRM1\tEXOSC4\nADRM1\tAP2S1\nADRM1\tFIBP\nADRM1\tAURKAIP1\n211650_x_at\tIGLV3-25\n211650_x_at\tIGHG3\n211650_x_at\tIGKC\n211650_x_at\tIGHV1-69\n211650_x_at\tIGHM\nFLJ12151\tPPP2CA\nFLJ12151\tLOC152719\nFLJ12151\tPRR11\nFLJ12151\tPDE4C\nFLJ12151\tZNF611\nFLJ12151\tSLC35E1\nFLJ12151\tZNF160\nHNRPK\tHNRPU\nHNRPK\tSET\nTOMM20\tZMYND11\nTOMM20\tTTC3\nTOMM20\tTTC19\nCORO1A\tLAPTM5\nAD7C-NTP\tARL6IP2\nAD7C-NTP\tMEFV\nAD7C-NTP\tCDC5L\nARL6IP2\tCDC5L\nARL6IP2\tLOC56902\nARL6IP2\tMEFV\nC1orf63\tPAPOLA\nC1orf63\tLOC342346\nRPL31\tRPL5\nRPL31\tTPT1\nRPL31\tRPS27A\nRPL31\tRPS7\nRPL31\tRPS17\nRPL31\tRPL9\nRPL31\tRPS3A\nRPL31\tRPL6\nRPL31\tRPL35A\nRPL31\tRPL36A\nRPL31\tRPL7\nRPL31\tRPS6\nRPL31\tRPL4\nRPL31\tRPS24\nRPL31\tRPS23\nRPL31\tRPL34\nRPL31\tRPS4X\nSDHA\tTUFM\nUQCR\tUQCRQ\nUQCR\tUQCRC1\nFNBP4\tRPL26\nFNBP4\tRNPC2\nFNBP4\tRPL17\nFNBP4\tOGT\nFNBP4\tRPL6\nFNBP4\tRPS27A\nPNRC2\tZNF638\nPNRC2\tPUM2\nPNRC2\tSF3B1\nPNRC2\tROCK1\nIGLV3-10\tIGLV3-25\nMSN\tPRG1\nPPIB\tRPN1\nPPIB\tSEC61A1\nTHRAP1\tZNF638\nIRF7\tPRKD2\nIRF7\tMAN2B1\n221651_x_at\tIGLJ3\n221651_x_at\tIGKV1OR15-118\n221651_x_at\tIGLC2\n221651_x_at\tIGKV1-5\n221651_x_at\tIGKC\n221651_x_at\tIGKV1OR2-108\n221651_x_at\tIGHG3\n221651_x_at\tLOC91316\n221651_x_at\tIGKV1D-13\n221651_x_at\tCTA-246H3.1\n221651_x_at\tIGHA1\n221651_x_at\tIGLC1\nLOC152719\tPRR11\nLOC152719\tLRRFIP1\nLOC152719\tPDE4C\nLOC152719\tRIOK3\nLOC152719\tZNF611\nLOC152719\tSLC35E1\nLOC152719\tZNF160\nPRKAR1A\tRNF11\nPRKAR1A\tSRP9\nPRKAR1A\tTMEM66\nINPP5D\tITGB2\n216524_x_at\tZNF611\n216524_x_at\tZNF160\n216524_x_at\tPGF\n216524_x_at\tKNS2\n216524_x_at\tCEP27\n216524_x_at\tRIOK3\n216524_x_at\tDBT\n216524_x_at\tPDE4C\n216524_x_at\tFBXW12\nRPL10A\tRPL39\nRPL10A\tRPL14\nRPL10A\tRPL12\nRPL27\tRPS20\nRPL27\tRPS11\nRPL27\tRPLP2\nRPL27\tRPL38\nRPL27\tRPS19\nRPL27\tRPL27A\nERH\tHNRPK\nERH\tSUMO2\nPGF\tPOLR1B\nPGF\tPRR11\nPGF\tZNF160\nPGF\tWDR45\nPGF\tRIOK3\nPGF\tZNF611\nCAPNS1\tCLPTM1\nCLPTM1\tNAPA\nRPS19\tRPS2\nRPS19\tRPS21\nRPS19\tRPS20\nDDX42\tMT1H\nDDX42\tMT1M\nDDX42\tMT1G\nDDX42\tMT1F\nMT1H\tMT1L\nMT1H\tMT2A\nMT1H\tMT1M\nMT1H\tMT1X\nCD37\tPTPRCAP\nCD37\tISG20\nPTPRCAP\tTRBV3-1\nEIF3S6\tRPS4X\nEIF3S6\tRPL26\nEIF3S6\tZNF638\nEIF3S6\tMDS1\nEIF3S6\tRPS3A\nEIF3S6\tRPL24\nEIF3S6\tRPL7\nEIF3S6\tRPL31\nEIF3S6\tRPL4\nEIF3S6\tRPL21\nEIF3S6\tU16\nEIF3S6\tNPM1\nEIF3S6\tRNPC2\nEIF3S6\tNACA\nEIF3S6\tRPS7\nEIF3S6\tTINP1\nEIF3S6\tVDP\nEIF3S6\tRPS27A\nEIF3S6\tRPL34\nEIF3S6\tRPL5\nEIF3S6\tRPS24\nEIF3S6\tRPL17\nEIF3S6\tRPL6\nIGKV1OR15-118\tIGL@\nIGKV1OR15-118\tIGLC2\nIGKV1OR15-118\tIGLV3-25\nNDUFB6\tTCEB2\nACTA2\tLMOD1\nACTA2\tMYH11\nACTA2\tTAGLN\nGMFG\tITGB2\nHNRPU\tSET\nHNRPH3\tLOC58486\nHNRPH3\tRNPC2\nHNRPH3\tMATR3\nHNRPH3\tKIDINS220\nC16orf24\tSCAND1\nNBPF1\tNBPF12\nARPP-19\tPRKACB\n217052_x_at\tG3BP\n217052_x_at\t220725_x_at\nCCL5\tHLA-F\nCCL5\tHLA-C\nCCL5\tHLA-G\nRPLP2\tRPS16\nRPLP2\tRPS20\nRPLP2\tRPS19\nRPLP2\tRPS11\nECH1\tUQCRQ\nRPL12\tRPL14\nRPL12\tRPL39\nRPL12\tRPL19\nCECR1\tLAPTM5\nATP6V0D1\tCHMP2A\nATP6V0D1\tPOLR2J\nATP6V0D1\tPSMB3\nATP6V0D1\tOAZ2\nCBX3\tHNRPA2B1\nCBX3\tXPO1\nSFRS10\tSON\nTIMM8B\tUQCRC1\nCD48\tIL16\nARF1\tCOPE\nARF1\tYIPF3\nARF1\tCAPNS1\nARF1\tRPN1\nKPNB1\tPAFAH1B1\nHLA-J\tIFI30\nHLA-J\tMAN2B1\nUSP47\tZNF638\nUSP47\tVDP\nRPL36\tRPS16\nRPL36\tRPS18\nRPL36\tRPS19\nRPL36\tRPS10\nCUL4B\tSH3BGRL\nADD3\tWAC\nADD3\tSEPT7\nADD3\tMATR3\nDEXI\tNDUFB11\nLYSMD4\tSEPT7\nPFAAP5\tZNF638\nPFAAP5\tRNPC2\n215182_x_at\tSPN\n215182_x_at\tCDC5L\n215182_x_at\t220725_x_at\n215182_x_at\tLOC56902\n215182_x_at\tMCM3AP\nHLA-DQA1\tHLA-DRB4\nHLA-DQA1\tHLA-DRA\nDDT\tZNHIT1\nDDT\tMRPS12\nRAVER2\tSF3B1\nHLA-DMA\tHLA-DQB1\nHLA-DMA\tHLA-DRA\nHLA-DMA\tHLA-DRB1\nHLA-DMA\tHLA-DPA1\nHLA-DMA\tHLA-DQA1\nHLA-DMA\tHLA-DRB4\nHLA-DMA\tHLA-DPB1\nHLA-DMA\tHLA-DRB5\nRAB6A\tRAB6C\nC1orf160\tTCEB2\nC1orf160\tNDUFA13\n217679_x_at\tPDE4C\n217679_x_at\tDBT\n217679_x_at\tPGF\n217679_x_at\tCEP27\n217679_x_at\tZNF160\n217679_x_at\tFLJ12151\n217679_x_at\tSLC35E1\n217679_x_at\tKNS2\n217679_x_at\tFBXW12\n217679_x_at\tZNF611\nRPL21\tRPL26\nRPL21\tRPL36A\nRPL21\tRPL5\nRPL21\tRPL9\nRPL21\tRPL30\nRPL21\tRPS17\nRPL21\tTPT1\nRPL21\tRPS7\nRPL21\tRPL39\nRPL21\tRPS23\nRPL21\tRPL24\nRPL21\tRPS27A\nRPL21\tRPS3A\nRPL21\tU16\nRPL21\tRPL22\nRPL21\tRPS6\nRPL21\tRPL6\nRPL21\tRPL23\nRPL21\tRPL7\nRPL21\tRPS4X\nRPL21\tRPL4\nRPL21\tRPS24\nRPL21\tRPL34\nRPL21\tRPL31\nRPS13\tRPS20\nRPS13\tRPS27\nRPL18\tRPS2\nRPL18\tRPL18A\nRPS11\tRPS20\nNPTN\tRAB1A\nNPTN\tPRKAR1A\nMCM3AP\tOPHN1\nMCM3AP\tORC6L\nEIF4G1\tTHRAP5\n216412_x_at\tIGLV3-10\n216412_x_at\tIGLC2\nCXCR4\tLAPTM5\nCXCR4\tPRG1\nCXCR4\tHCLS1\nFAU\tRPS9\nFAU\tRPL35\nFAU\tRPS5\nFAU\tRPS16\nFAU\tRPL28\nRPL39\tRPL4\nRPL39\tRPS4X\nRPL39\tRPS20\nRPL39\tRPS15A\nRPL39\tRPS17\nRPL39\tRPS23\nIL2RG\tTRA@\nKIDINS220\tZFR\nKIDINS220\tSEPT7\nCOX4I1\tNDUFA7\nORC6L\tSLC25A16\nORC6L\tRPS11\nGBL\tGPX4\nATPIF1\tFIBP\nMGC2474\tTXNL2\nSEPT2\tSFRS11\nMT1F\tMT1L\nMT1F\tMT1X\nMT1F\tMT2A\nMT1F\tMT1G\nMT1F\tMT1H\nMT1F\tMT1M\nMT1L\tMT1M\nMT1L\tMT2A\nMT1L\tMT1X\nC11orf2\tFLJ14346\nATP6AP2\tPRKAR1A\nSTAT1\tTRIM22\nRPL35\tRPS9\nRPL35\tRPS5\nRPL35\tRPS16\nMT1E\tMT1L\nMT1E\tMT1F\nMT1E\tMT1G\nMT1E\tMT1X\nMT1E\tMT2A\nMT1E\tMT1M\nMT1E\tMT1H\nAKT1\tPGLS\nLOC56902\tMCM3AP\nLOC56902\tMGC2474\nHNRPM\tSERBP1\nCALD1\tPLEKHC1\nCALD1\tTPM1\nMT1M\tMT2A\nMT1M\tMT1X\nC17orf62\tIRF7\nKIAA1840\tPAPOLA\nDES\tTPM1\nMASP1\tPOLR2J\n212498_at\tTTC19\nHLA-DPB1\tHLA-DQB1\nHLA-DPB1\tHLA-DQA1\nHLA-DPB1\tHLA-DRB1\nHLA-DPB1\tHLA-DRB4\nHLA-DPB1\tHLA-DRB5\nFBXW12\tPRR11\nFBXW12\tRIOK3\nFBXW12\tPDE4C\nFBXW12\tFLJ42393\nFBXW12\tFLJ12151\nFBXW12\tPGF\nFBXW12\tSLC35E1\nFBXW12\tZNF611\nFBXW12\tLOC152719\nFBXW12\tKNS2\nFBXW12\tZNF160\nREEP5\tZMYND11\nPHIP\tZNF638\nRPS5\tRPS9\nWDR45\tZNF611\nMT1X\tMT2A\nCNOT2\tFLJ11021\nP4HB\tSEC61A1\nTMEM93\tUQCRFS1\nRPL37\tRPS15A\nRPL37\tRPS17\nRPL37\tRPS13\nRPL37\tRPL39\nACO2\tNDUFB2\nMT1G\tMT1L\nMT1G\tMT1M\nMT1G\tMT2A\nMT1G\tMT1H\nMT1G\tMT1X\nATP5J2\tUCRC\nRPL32\tRPL35A\nRPL32\tRPS15A\nRPL32\tRPS17\nRPL32\tRPL37\nGOLGA8A\tGOLGA8B\nCAST\tNFE2L2\nLOC645745\tMT1L\nLOC645745\tMT1F\nLOC645745\tMT2A\nLOC645745\tMT1M\nLOC645745\tMT1E\nLOC645745\tMT1X\nLOC645745\tMT1G\nLOC645745\tMT1H\nITGAV\tZMYND11\nCNN1\tMYH11\n208238_x_at\tRIOK3\nFLJ20294\tRPLP2\nFLJ20294\tRPS11\nFLJ20294\tRPL38\nFLJ20294\tH3F3A\nFLJ20294\tRPS20\nFLJ20294\tRPS19\nFLJ20294\tRPL27A\nFLJ20294\tRPL27\nSPCS2\tSRP9\n208120_x_at\tORC6L\n', + }, + { + kind: 'annotation', + name: 'Co-expression KEGG pathways', + fileName: 'NORMA_Human_coexpression_Annotation_KEGG.txt', + text: 'Ribosome\tRPS5,RPS5,RPL35,FAU,RPL18,RPL21,RPL36,RPL12,RPLP2,RPS19,RPL27,RPL10A,LOC440055,RPS27,LOC388344,RPL27A,RPS10,RPS17,RPL28,LOC283412,RPL23A,RPL36A,RPL7,RPS2,U16,RPL35A,RPL18A,RPL13,RPL29,RPS15A,RPS3A,RPL14,RPL22,RPL34,RPS27A,RPL9,RPL6,RPL4,RPS4X,RPS9,RPL13A,RPL17,RPL5,MDS1,LOC402057\nOxidative phosphorylation\tNDUFC1,NDUFC1,ATP5J2,NDUFA10,NDUFA7,COX4I1,ATP6V0D1,NDUFB6,UCRC,SDHA,ATP6V0B,UQCRC1,NDUFB2,UQCRFS1,NDUFA2,NDUFV1,ATP6V0C,NDUFA3,NDUFA13,NDUFS7,COX5B,ATP5D,NDUFS3,NDUFS8,COX5A,NDUFB4,NDUFAB1,ATP5G3,CYC1,COX8A,RPA1\nAntigen processing and presentation\tHLA-DPB1,HLA-DPB1,TAPBP,HLA-DMA,HLA-DQA1,HLA-J,HLA-B,HLA-DRA,HLA-DMB,HLA-DRB5,CD74,HLA-DRB1,HLA-E,HLA-DRB4,HSP90AA1,C6orf12,HLA-DQB1,HLA-DPA1,IFI30,HLA-A,HLA-G,EIF3S3,HLA-C\nCell adhesion molecules (CAMs)\tITGAV,ITGAV,HLA-DPB1,HLA-DMA,HLA-DQA1,HLA-J,HLA-B,HLA-DRA,HLA-DMB,SELL,HLA-DRB5,HLA-DRB1,HLA-E,HLA-DRB4,CD2,C6orf12,HLA-DQB1,HLA-DPA1,HLA-A,SPN,HLA-G,ITGB2,HLA-C\nType I diabetes mellitus\tHLA-DPB1,HLA-DPB1,HLA-DMA,HLA-DQA1,HLA-J,HLA-B,HLA-DRA,HLA-DMB,HLA-DRB5,HLA-DRB1,HLA-E,HLA-DRB4,C6orf12,HLA-DQB1,HLA-DPA1,HLA-A,HLA-G,HLA-C\nFocal adhesion\tITGAV,ITGAV,AKT1,RAP1B,LAMC1,PGF,MYLK,COL6A3,COL1A1,COL6A1,ROCK1,COL4A2,COL4A1,BIRC2,COL3A1,COL1A2,CAV1,RAC2\nNatural killer cell mediated cytotoxicity\tHLA-J,HLA-J,CD48,HLA-B,HLA-E,C6orf12,IFNGR1,HLA-A,LCK,HLA-G,RAC2,ITGB2,HLA-C\nRegulation of actin cytoskeleton\tITGAV,ITGAV,MSN,MYH9,MYLK,ARPC1B,ROCK1,ARPC3,ARPC5,IQGAP1,RAC2,ITGB2\nCell Communication\tDES,DES,LAMC1,COL6A3,COL1A1,COL6A1,COL4A2,COL4A1,COL3A1,COL1A2,RPL38\nPyrimidine metabolism\tPOLR2L,POLR2L,POLR2J,HMHA1,POLR1B,208246_x_at,POLR2I,RPS15A,POLR2E\nECM-receptor interaction\tITGAV,ITGAV,LAMC1,COL6A3,COL1A1,COL6A1,COL4A2,COL4A1,COL3A1,COL1A2\nPurine metabolism\tGUK1,GUK1,POLR2L,POLR2J,HMHA1,POLR1B,POLR2I,RPS15A,POLR2E,PDE4C\nHematopoietic cell lineage\tCD37,CD37,HLA-DRA,CD3D,HLA-DRB5,HLA-DRB1,HLA-DRB4,CD2,CD7,IL7R\nCytokine-cytokine receptor interaction\tIL2RG,IL2RG,CXCR4,CCL5,IL10RA,LTB,IFNGR1,TGFB1,IL7R,IL6ST\nLeukocyte transendothelial migration\tRAP1B,RAP1B,CXCR4,NCF1,MSN,CYBA,ROCK1,MMP2,RAC2,ITGB2\nJak-STAT signaling pathway\tAKT1,AKT1,STAT1,IL2RG,RAVER2,IL10RA,IFNGR1,IL7R,IL6ST\nCell cycle\tORC6L,ORC6L,YWHAZ,217718_s_at,TGFB1,CDKN1B,SKP1A\nRNA polymerase\tPOLR2L,POLR2L,POLR2J,HMHA1,POLR1B,POLR2I,POLR2E\nApoptosis\tAKT1,AKT1,PRKAR1A,ENDOG,PRKAR2A,BIRC2,PRKACB\nCholera - Infection\tSEC61A1,SEC61A1,ATP6V0D1,ATP6V0B,ATP6V0C\nPathogenic Escherichia coli infection - EPEC\tHCLS1,HCLS1,YWHAZ,ROCK1,NCL,RPL38,ARPC5\nPathogenic Escherichia coli infection - EHEC\tHCLS1,HCLS1,YWHAZ,ROCK1,NCL,RPL38,ARPC5\nEpithelial cell signaling in Helicobacter pylori infection\tATP6V0D1,ATP6V0D1,CCL5,ATP6V0B,ATP6V0C\nWnt signaling pathway\tROCK1,ROCK1,SKP1A,CSNK1A1,RAC2,PRKACB\nPancreatic cancer\tAKT1,AKT1,STAT1,RAVER2,PGF,TGFB1,RAC2\nMAPK signaling pathway\tAKT1,AKT1,RAP1B,TGFB1,RAC2,PRKACB\nInsulin signaling pathway\tAKT1,AKT1,PRKAR1A,PRKAR2A,PRKACB\nProtein export\tSEC61A1,SEC61A1,SRP72,SRP9\nUbiquitin mediated proteolysis\tTCEB2,TCEB2,SKP1A,UBE2D2\nCitrate cycle (TCA cycle)\tACO2,ACO2,SDHA,MDH2,RPA1\nTGF-beta signaling pathway\tROCK1,ROCK1,TGFB1,SKP1A\nCalcium signaling pathway\tMYLK,MYLK,ATP2A3,PRKACB\nHedgehog signaling pathway\tCSNK1A1,CSNK1A1,PRKACB\nChronic myeloid leukemia\tAKT1,AKT1,TGFB1,CDKN1B\nAxon guidance\tCXCR4,CXCR4,ROCK1,RAC2\nToll-like receptor signaling pathway\tAKT1,AKT1,STAT1,CCL5\nTight junction\tAKT1,AKT1,MYH9,HCLS1\nGlycan structures - degradation\tPOPDC3,POPDC3,MAN2B1\nColorectal cancer\tAKT1,AKT1,TGFB1,RAC2\nB cell receptor signaling pathway\tAKT1,AKT1,CD79A,RAC2\nPrion disease\tNFE2L2,NFE2L2,LAMC1\nT cell receptor signaling pathway\tAKT1,AKT1,CD3D,LCK\nLong-term potentiation\tRAP1B,RAP1B,PRKACB\nAdherens junction\tIQGAP1,IQGAP1,RAC2\nGlycan structures - biosynthesis 2\tCOL6A3,COL6A3,OGT\nGnRH signaling pathway\tMMP2,MMP2,PRKACB\nAdipocytokine signaling pathway\tAKT1,AKT1,RAVER2\nBasal transcription factors\tTAF7,TAF7,GTF2I\nVEGF signaling pathway\tAKT1,AKT1,RAC2\nReductive carboxylate cycle (CO2 fixation)\tACO2,ACO2,MDH2\nGlyoxylate and dicarboxylate metabolism\tACO2,ACO2,MDH2\nGlutathione metabolism\tGPX1,GPX1,GPX4\nFc epsilon RI signaling pathway\tAKT1,AKT1,RAC2\nArachidonic acid metabolism\tGPX1,GPX1,GPX4\nmTOR signaling pathway\tAKT1,AKT1,PGF\nTyrosine metabolism\tECH1,ECH1,MIF\n', + }, + { + kind: 'annotation', + name: 'Co-expression GO biological process', + fileName: 'NORMA_Human_coexpression_Annotation_GO_BP.txt', + text: 'protein biosynthesis\tRPS15,RPS15,RPL37,RPS5,RPL35,FAU,RPS11,RPS21,RPL18,RPS13,RPL21,RPL36,RPL12,RPLP2,RPS19,RPL27,RPL31,MRPS12,LOC440055,RPS27,LOC388344,RPL27A,RPS10,RPS17,RPL28,LOC283412,RPL19,RPS16,RPS14,NARS,RPL7,RPS6,RPS2,U16,NACA,RPL18A,RPL13,RPL29,RPS15A,RPS20,RPS3A,RPS18,RPL14,MRPL34,RPL22,RPL34,RPS27A,EEF1D,RPL15,RPL9,RPL6,RPL26,RPL4,RPS4X,RPS24,RPS9,RPL13A,RPL17,EEF1A1,RPL5,RPL23,MDS1,RPS7,LOC402057,RPS25\nimmune response\tHLA-DPB1,HLA-DPB1,TAPBP,IL2RG,HLA-DMA,HLA-DQA1,IL16,221651_x_at,IGLV3-10,ARL6IP2,HLA-B,CD164,HLA-DRA,HLA-DMB,TRIM22,HLA-DRB5,HLA-DRB1,IGL@,IGLC1,IGLV3-25,TRB@,HLA-DRB4,LTB,ARHGDIB,C6orf12,HLA-DQB1,HLA-DPA1,TRBV3-1,GPSM3,IGLC2,TRA@,214836_x_at,IGHV1-69,IGHA1,HLA-A,CD7,IGHG3,IGLJ3,IL7R,IL6ST,HLA-C,IGKC,IGHM\nsignal transduction\tAKT1,AKT1,STAT1,IL2RG,CXCR4,OPHN1,CCL5,PGF,INPP5D,PRKAR1A,CD164,RPS27,HLA-DRB5,CD74,HLA-DRB1,PRKAR2A,CD53,HLA-DRB4,LTB,HSP90AA1,OGT,ROCK1,IFNGR1,PTGES3,HMGB1,GTF2I,GNB2L1,CAP1,SPN,NPM1,IQGAP1,PAFAH1B1,EIF3S3,RAC2,COPS2,PRKACB,PDE4C\nantigen presentation, endogenous antigen\tHLA-J,HLA-J,RPLP2,221651_x_at,IGLV3-10,IGHD,HLA-B,CD74,IGL@,IGLC1,HLA-E,IGLV3-25,C6orf12,IGLC2,214836_x_at,IGHV1-69,IGHA1,HLA-F,HLA-A,IGHG3,IGLJ3,HLA-G,HLA-C,IGKC,IGHM\nantigen processing, endogenous antigen via ...\tCAST,CAST,HLA-J,RPLP2,221651_x_at,IGLV3-10,IGHD,HLA-B,IGL@,IGLC1,HLA-E,IGLV3-25,C6orf12,IGLC2,214836_x_at,IGHV1-69,IGHA1,HLA-F,HLA-A,IGHG3,IGLJ3,HLA-G,HLA-C,IGKC,IGHM\nregulation of transcription, DNA-dependent\tNFE2L2,NFE2L2,STAT1,MGC2474,LYSMD4,ZMYND11,ZNF160,CDC5L,TRIM22,SFPQ,IFI16,HCLS1,ATRX,HMGB1,CSDE1,SCAND1,RPL6,LOC342346,SUB1\nelectron transport\tMT1X,MT1X,MT1L,COX4I1,NCF1,SDHA,UQCRQ,UQCRC1,GPX4,UQCRFS1,NDUFV1,COX5B,NDUFS3,NDUFS8,COX5A,NDUFB4,CYC1,COX8A,RPA1\nprotein amino acid phosphorylation\tAKT1,AKT1,RAVER2,GMFG,PRKAR1A,PRKD2,MYLK,PRKAR2A,ROCK1,TGFB1,PCM1,U16,LCK,CSNK1A1,HNRPDL,RPL4,PRKACB\nantigen processing, exogenous antigen via M...\tHLA-DPB1,HLA-DPB1,HLA-DMA,HLA-DQA1,HLA-DRA,HLA-DMB,HLA-DRB5,HLA-DRB1,HLA-DRB4,HLA-DQB1,HLA-DPA1\nantigen presentation, exogenous antigen\tHLA-DPB1,HLA-DPB1,HLA-DMA,HLA-DQA1,HLA-DRA,HLA-DMB,HLA-DRB5,HLA-DRB1,HLA-DRB4,HLA-DQB1,HLA-DPA1\ncell adhesion\tITGAV,ITGAV,LAMC1,CCL5,FNBP4,ADRM1,CD164,TGFB1I1,SELL,DGCR6,COL6A1,PLEKHC1,CD2,ITGB2\nintracellular signaling cascade\tSTAT1,STAT1,RAVER2,INPP5D,PRKAR1A,PRKD2,NCF1,PRKAR2A,WSB1,HCLS1,HMHA1,ROCK1,LCK\ntranscription from RNA polymerase II promoter\tTARDBP,TARDBP,NFE2L2,STAT1,POLR2J,TGFB1I1,TCEA1,HMHA1,POLR2I,BTF3,COPS2,POLR2E\ncell motility\tCALD1,CALD1,CAPZA1,CCL5,TPM1,MSN,ACTR3,ARPC1B,ARHGDIB,ARPC3,ARPC5,PAFAH1B1\ndetection of pest, pathogen or parasite\tHLA-DPB1,HLA-DPB1,HLA-DMA,HLA-J,HLA-DMB,HLA-DRB5,HLA-DRB1,HLA-DRB4,HLA-G\nnuclear mRNA splicing, via spliceosome\tHNRPM,HNRPM,SFRS10,HNRPH3,SFPQ,HIATL1,RPL35A,SF3B1,HNRPA2B1,HNRPA1\nmitochondrial electron transport, NADH to u...\tNDUFC1,NDUFC1,NDUFB6,NDUFB2,NDUFV1,NDUFS7,NDUFS3,NDUFS8,NDUFB4\ndevelopment\tCECR1,CECR1,CLPTM1,CD164,ARHGDIB,COL6A3,MYH11,ITGB2,DDX1\nphosphate transport\tCOL6A3,COL6A3,COL1A1,COL6A1,COL4A2,COL4A1,COL3A1,COL1A2\nantigen presentation\tHLA-J,HLA-J,HLA-B,HLA-E,C6orf12,HLA-F,HLA-A,HLA-G,HLA-C\ntransport\tG3BP,G3BP,SLC25A16,UCP2,SLC25A11,TMED5,SLC25A3,ATP2A3\ncell proliferation\tRPS21,RPS21,PGF,ZMYND11,RPS27,CD74,IFI16,MIF,ISG20\nanti-apoptosis\tAKT1,AKT1,FAIM3,SON,HMGB1,HSP90B1,NPM1,TPT1,PRDX2\ncell surface receptor linked signal transdu...\tCD3D,CD3D,CD2,CD79A,BIRC2,MIF,CSNK1A1,IL7R,IL6ST\ngeneration of precursor metabolites and energy\tACO2,ACO2,ATPIF1,COX4I1,ECH1,UQCR,SLC25A3,COX8A\nprotein complex assembly\tTAPBP,TAPBP,CAPZA1,GPAA1,LAMC1,TCEB2,CD3D,CD74\nmRNA processing\tSFPQ,SFPQ,SRP46,DHX15,HNRPR,SFRS11,SFRS5,RNPC2\npositive regulation of cell proliferation\tSSR1,SSR1,CAPNS1,PGF,RPL31,HCLS1,TGFB1,NAP1L1\nRNA splicing\tHNRPH3,HNRPH3,SFPQ,DHX15,PPP2CA,ZNF638,SFRS11\ntranscription\tLYSMD4,LYSMD4,POLR2J,ZNF160,SFPQ,POLR1B,PCM1\nregulation of translational initiation\tEIF4G1,EIF4G1,EIF4A2,EIF4G2,EIF5,EIF3S3,DDX1\npathogenesis\tHLA-DPB1,HLA-DPB1,HLA-DRB5,HLA-DRB1,HLA-DRB4\nproton transport\tATP5J2,ATP5J2,ATP6V0D1,UCP2,ATP6V0B,ATP6V0C\nRNA processing\tHNRPU,HNRPU,RBPMS,DDX17,HNRPH1,HNRPDL,RNPC2\nregulation of transcription from RNA polyme...\tTHRAP5,THRAP5,PRKAR1A,HMGB1,LOC342346,SUB1\nprotein transport\tRAB1A,RAB1A,RAB6A,RAB6C,TLOC1,HSP90B1,COPB\nsmall GTPase mediated signal transduction\tRAP1B,RAP1B,RGS19,RAB1A,RAB6A,RAB6C,RPL29\nresponse to virus\tCCL5,CCL5,IRF7,TRIM22,IFI16,IFNGR1,ISG20\ninflammatory response\tCCL5,CCL5,IRF7,MEFV,ALOX5AP,TGFB1,ITGB2\nactin cytoskeleton organization and biogenesis\tARHGDIB,ARHGDIB,PLEKHC1,DST,ROCK1,ARPC5\nprotein folding\tPPIB,PPIB,HSP90AA1,HSP90B1,EIF3S3,KTN1\ninduction of apoptosis\tCD2,CD2,PPP2CA,BCLAF1,LCK,RPS3A,SCAND1\nskeletal development\tCNOT2,CNOT2,COL1A1,TGFB1,COL1A2,MYH11\nregulation of progression through cell cycle\tSTAT1,STAT1,PGF,ERH,PPP2CA,LCK,SCAND1\nnegative regulation of cell proliferation\tCD164,CD164,TGFB1I1,TGFB1,CDKN1B,NPM1\ncellular defense response\tHLA-J,HLA-J,CCL5,NCF1,FAIM3,SPN,HLA-G\ntranslational elongation\tRPLP2,RPLP2,TUFM,EEF1B2,EEF1D,EEF1A1\nmuscle development\tTAGLN,TAGLN,LYSMD4,SMTN,COL6A3,MBNL1\nATP synthesis coupled proton transport\tATP6V0B,ATP6V0B,ATP6V0C,ATP5D,ATP5G3\nproteolysis\tCAST,CAST,MASP1,YME1L1,MMP2,IGHG3\nmicrotubule-based movement\t220725_x_at,220725_x_at,KNS2,KTN1\nmetabolism\tACO2,ACO2,ECH1,POPDC3,IGLC2,DIP2A\napoptosis\tAD7C-NTP,AD7C-NTP,FIS1,ITGB2,CROP\namino acid transport\tSLC38A2,SLC38A2,218041_x_at,RPL24\nresponse to unfolded protein\tHSP90AA1,HSP90AA1,HSP90B1,EIF3S3\nintracellular protein transport\tVDP,VDP,NAPA,CLTC,CD74,NPM1,COPE\ntranscription initiation from RNA polymeras...\tTHRAP5,THRAP5,IRF7,THRAP1,GTF2I\nresponse to oxidative stress\tCCL5,CCL5,GPX1,GPX4,CSDE1,PRDX2\nnucleobase, nucleoside, nucleotide and nucl...\tNDUFA10,NDUFA10,ERH,208246_x_at\ncarbohydrate metabolism\tLOC91316,LOC91316,MAN2B1,AKR7A2\nprotein modification\tSUMO2,SUMO2,RPN1,MAN2B1,RPS27A\nubiquitin-dependent protein catabolism\tUSP34,USP34,UBE4A,PSMB9,PSMB8\ndefense response\tCD48,CD48,PTPRCAP,HLA-B,CD79A\nDNA replication\tMCM3AP,MCM3AP,NARS,NAP1L1,SET\nandrogen receptor signaling pathway\tTHRAP5,THRAP5,THRAP1,TGFB1I1\nprotein targeting to mitochondrion\tTIMM8B,TIMM8B,TOMM20,TIMM13\noxygen transport\tSLC38A2,SLC38A2,218041_x_at\nG-protein coupled receptor protein signalin...\tAKT1,AKT1,RGS19,CXCR4,FNBP4\nnervous system development\tOPHN1,OPHN1,MBNL1,PAFAH1B1\nmitochondrial transport\tUCP2,UCP2,HSP90AA1,EIF3S3\nembryo implantation\tLOC283412,LOC283412,RPL29\nDNA repair\tSFPQ,SFPQ,ATRX,HMGB1,RPA1\nprotein refolding\tHSP90AA1,HSP90AA1,EIF3S3\npositive regulation of nitric oxide biosynt...\tHSP90AA1,HSP90AA1,EIF3S3\nlipid metabolism\tPAFAH1B1,PAFAH1B1,ATP8B1\ncell cycle arrest\tEIF4G2,EIF4G2,DST,CDKN1B\ncell cycle\tCUL4B,CUL4B,RPS27A,SEPT7\nprotein ubiquitination\tUBE4A,UBE4A,PCNP,RPS27A\nprotein targeting\tTOMM20,TOMM20,SRP9,COPB\nnucleosome assembly\tNAP1L1,NAP1L1,SET,H3F3A\nnegative regulation of cell adhesion\tCD164,CD164,ARHGDIB,SPN\ncell-cell signaling\tCCL5,CCL5,PGF,LTB,ITGB2\npositive regulation of transcription\tTGFB1I1,TGFB1I1,RPS27A\nnegative regulation of transcription\tIRF7,IRF7,SUMO1,BCLAF1\nhemopoiesis\tCD164,CD164,ZNF160,LCK\ncellular defense response (sensu Vertebrata)\tIGHA1,IGHA1,IGHG3,IGHM\nretrograde vesicle-mediated transport, Golg...\tTAPBP,TAPBP,COPE,COPB\nprotein import into nucleus\tMCM3AP,MCM3AP,NDUFA13\npositive regulation of transcription, DNA-d...\tTGFB1I1,TGFB1I1,TGFB1\nnegative regulation of cell growth\tPPP2CA,PPP2CA,NDUFA13\nintegrin-mediated signaling pathway\tITGAV,ITGAV,DST,ITGB2\ncotranslational protein targeting to membrane\tSSR1,SSR1,TLOC1,TRAM1\nRho protein signal transduction\tARHGDIB,ARHGDIB,ROCK1\nsensory perception of sound\tTIMM8B,TIMM8B,TIMM13\nregulation of translation\tPPP2CA,PPP2CA,EEF1A1\nregulation of transcription\tPOLR2L,POLR2L,PPP2CA\nprostaglandin biosynthesis\tCD74,CD74,PTGES3,MIF\nmitosis\tTARDBP,TARDBP,CORO1A\ninsulin receptor signaling pathway\tPHIP,PHIP,AKT1,MYH11\nDNA recombination\tATRX,ATRX,HMGB1,RPA1\ntricarboxylic acid cycle\tACO2,ACO2,SDHA,RPA1\nnegative regulation of transcription, DNA-d...\tRPS14,RPS14,NDUFA13\nintra-Golgi vesicle-mediated transport\tNAPA,NAPA,COPE,COPB\ncell death\tEIF4G2,EIF4G2,TGFB1\nprotein amino acid autophosphorylation\tRPS2,RPS2,U16,RPL4\ncirculation\tRPL31,RPL31,COL3A1\ncalcium ion transport\tATP2A3,ATP2A3,TPT1\naxon guidance\tOPHN1,OPHN1,RPS27A\nER-associated protein catabolism\tRPL27,RPL27,RPS27A\nregulation of actin filament polymerization\tARPC3,ARPC3,ARPC5\npositive regulation of I-kappaB kinase/NF-k...\tBIRC2,BIRC2,EEF1D\norgan morphogenesis\tDGCR6,DGCR6,TGFB1\nnegative regulation of transcription from R...\tIRF7,IRF7,ZMYND11\nmRNA splice site selection\tSRP46,SRP46,SFRS5\nerythrocyte differentiation\tRPS19,RPS19,HCLS1\ncytokinesis\tSEPT2,SEPT2,SEPT7\nchromosome segregation\tARL8B,ARL8B,RIOK3\ncell-matrix adhesion\tITGAV,ITGAV,ITGB2\nactivation of NF-kappaB transcription factor\tU16,U16,NPM1,RPL4\nT cell activation\tCD3D,CD3D,CD2,CD7\nubiquitin cycle\tTPT1,TPT1,UBE2D2\nregulation of cell adhesion\tNPTN,NPTN,PPP2CA\ncell growth\tTGFB1,TGFB1,DDX5\nangiogenesis\tCAST,CAST,ATPIF1\ntranslational initiation\tEIF5,EIF5,RPS3A\ntransforming growth factor beta receptor si...\tFNTA,FNTA,TGFB1\nresponse to drug\tRAB6C,RAB6C,LCK\nregulation of apoptosis\tTPT1,TPT1,PRDX2\nchromosome organization and biogenesis (sen...\tATRX,ATRX,H3F3A\ncaspase activation\tSTAT1,STAT1,LCK\nantimicrobial humoral response (sensu Verte...\tIL7R,IL7R,ITGB2\nI-kappaB kinase/NF-kappaB cascade\tPHIP,PHIP,STAT1\ntransmembrane receptor protein tyrosine kin...\tCD7,CD7,COL1A2\nresponse to stress\tNPM1,NPM1,CROP\nresponse to metal ion\tMT1X,MT1X,MT1L\ncalcium ion homeostasis\tCCL5,CCL5,TPT1\nregulation of macrophage activation\tCD74,CD74,MIF\nnucleocytoplasmic transport\tNPM1,NPM1,SET\nnegative regulation of apoptosis\tCD74,CD74,MIF\nmuscle contraction\tDES,DES,CALD1\nestablishment and/or maintenance of cell po...\tCAP1,CAP1,SPN\nchemotaxis\tCCL5,CCL5,SPN\nRas protein signal transduction\tG3BP,G3BP,LCK\nregulation of heart contraction\tDES,DES,TPM1\nactivation of MAPKK activity\tU16,U16,RPL4\nJNK cascade\tU16,U16,RPL4\ncytoskeleton organization and biogenesis\tDES,DES,DST\n', + }, + { + kind: 'annotation', + name: 'Co-expression GO molecular function', + fileName: 'NORMA_Human_coexpression_Annotation_GO_MF.txt', + text: "protein binding\tTARDBP,TARDBP,ITGAV,CAST,RPS15,PHIP,NFE2L2,OAZ1,STAT1,SEPT2,TXNL2,ORC6L,IL2RG,RGS19,EIF4G1,RPS13,C1orf160,RAB6A,HLA-DMA,ZNHIT1,GPAA1,CUL4B,HLA-J,KPNB1,ARF1,IL16,CD48,CBX3,G3BP,SUMO2,ACTA2,TCEB2,EIF3S6,MT1H,RPS19,INPP5D,RAB6C,RNF11,PRKAR1A,NCF1,ZMYND11,HNRPK,SUMO1,TGFB1I1,CDC5L,KNS2,RBPMS,CAB39,SFPQ,CD3D,NAPA,ACTR3,CLTC,TTC3,EIF4A2,IFI16,ARL6IP5,EEF1B2,HCLS1,PSMB8,HMHA1,HSP90AA1,HNRPH1,SRP46,EIF4G2,FIS1,PLEKHC1,CD2,OGT,TAF7,217718_s_at,NARS,C6orf12,TGFB1,CDKN1B,NDUFA13,GPSM3,BCLAF1,HSP90B1,SERBP1,GTF2I,RPS6,U16,BIRC2,RPL35A,HLA-A,RPS15A,EXOSC4,RPS20,RPL38,ACTG2,CAV1,ACTR2,SKP1A,ACTR10,MIF,PCNP,RPS3A,RPL14,HLA-G,IL7R,AURKAIP1,FIBP,EIF3S3,HNRPDL,RPS27A,RAC2,ITGB2,COPS2,XPO1,SIAHBP1,POLR2E,NAP1L1,RPL4,SFRS5,HNRPA2B1,EEF1A1,UBE2D2,SEPT7,COPB,ATP5G3,RPL5,RPS7,RPA1,HNRPA1,RPL24\nRNA binding\tTARDBP,TARDBP,RPS5,HNRPM,LOC56902,EIF4G1,RPL18,RPL21,SFRS10,RPLP2,G3BP,HNRPH3,HNRPU,DDX42,RPS19,HNRPK,RBPMS,LOC440055,RPS27,LOC388344,SFPQ,DDX17,RPL27A,RPS10,RPS17,EIF4A2,RPL28,LOC283412,RPS14,HNRPH1,SRP46,EIF4G2,HNRPR,NCL,RPL7,HSP90B1,SRP9,HNRPA3P1,RPS2,U16,DDX5,ZNF638,RPL18A,RPL13,RPL29,SFRS11,DDX50,NPM1,RPS18,RPL14,PAPOLA,RPL22,MATR3,HNRPDL,RPL34,SCAND1,RPL9,RPL6,SIAHBP1,RPL4,RPS4X,SFRS5,SFRS3,HNRPA2B1,RPS9,RNPC2,RPL5,MDS1,LOC402057,HNRPA1\nstructural constituent of ribosome\tRPS15,RPS15,RPL37,RPS5,RPL35,FAU,RPS11,RPS21,RPL18,RPS13,RPL21,RPL36,RPL12,RPLP2,RPS19,RPL27,RPL31,MRPS12,LOC440055,RPS27,LOC388344,RPL27A,RPS10,RPS17,RPL28,LOC283412,RPL19,RPS16,RPS14,RPL7,RPS6,RPS2,U16,RPL18A,RPL13,RPL29,RPS15A,RPS20,RPS3A,RPS18,RPL14,MRPL34,RPL22,RPL34,RPS27A,RPS23,RPL15,RPL9,RPL6,RPL26,RPL4,RPS4X,RPS24,RPS9,RPL13A,RPL17,RPL5,RPL23,MDS1,RPS7,LOC402057,RPS25\nnucleotide binding\tTARDBP,TARDBP,HNRPM,RAP1B,MCM3AP,RAB1A,RAB6A,RAVER2,SFRS10,G3BP,HNRPH3,ACTA2,DDX42,RAB6C,PRKD2,RBPMS,YME1L1,SFPQ,DDX17,EIF4A2,HSP90AA1,HNRPH1,SRP46,NARS,HNRPR,NCL,PCM1,HSP90B1,HNRPA3P1,U16,DDX5,ACTG2,SFRS11,DDX50,CSNK1A1,EIF3S3,MATR3,HNRPDL,DDX3X,SIAHBP1,RPL4,SFRS5,HNRPA2B1,EEF1A1,RNPC2,SEPT7,PRKACB,HNRPA1\nnucleic acid binding\tTARDBP,TARDBP,ZFR,HNRPM,LOC56902,MGC2474,RPS11,SFRS10,G3BP,HNRPH3,ZNF9,DDX42,HNRPK,ZNF160,MRPS12,SFPQ,DDX17,R3HCC1,EIF4A2,IFI16,HNRPH1,SRP46,NARS,ZNF611,SON,HNRPR,DDX5,SFRS11,DDX50,ZNF552,NPM1,MBNL1,MATR3,HNRPDL,DDX3X,SIAHBP1,SFRS5\nATP binding\tNDUFA10,NDUFA10,AKT1,RAVER2,ACTA2,DDX42,PRKD2,MYH9,220725_x_at,YME1L1,DDX17,EIF4A2,HSP90AA1,208246_x_at,DHX15,NARS,ROCK1,NDUFA13,PCM1,HSP90B1,ATP2A3,RPS2,U16,DDX5,LCK,MYH11,ACTG2,DDX50,CSNK1A1,EIF3S3,DDX3X,RPL4,PRKACB\nreceptor activity\tITGAV,ITGAV,HNRPM,IL2RG,CXCR4,THRAP5,PTPRCAP,RPL27,IL10RA,THRAP1,TLOC1,HLA-DRB5,HLA-DRB1,HLA-DRB4,CD2,IFNGR1,TRBV3-1,IGHA1,U16,CD7,IGHG3,IL7R,IL6ST,RPL4,EEF1A1,IGHM,TRAM1\nMHC class I receptor activity\tHLA-J,HLA-J,RPLP2,221651_x_at,IGLV3-10,IGHD,HLA-B,IGL@,IGLC1,HLA-E,IGLV3-25,C6orf12,IGLC2,214836_x_at,IGHV1-69,IGHA1,HLA-F,HLA-A,IGHG3,IGLJ3,HLA-G,HLA-C,IGKC,IGHM\nzinc ion binding\tCAST,CAST,ZFR,MT1E,MT1F,KPNB1,MSRB2,TIMM8B,ZNF9,DDX42,RNF11,PRKD2,SDHA,ZMYND11,ZNF160,MEFV,RPS27,TTC3,ZNF611,TIMM13,POLR2I,MMP2,ZNF552,MBNL1,MATR3\nbinding\tSRP72,SRP72,VDP,CAPZA1,TTC19,KPNB1,SLC25A16,MSN,UCP2,KNS2,CAB39,RGPD5,NAPA,SLC25A11,TTC3,SLC25A3,ALOX5AP,HIATL1,OGT,CHCHD2,SF3B1,COPS2,COPE,COPB\nDNA binding\tMCM3AP,MCM3AP,HNRPU,RNF11,IRF7,SDHA,POLR2L,POLR2J,CDC5L,SFPQ,POLR1B,SON,HMGB1,PCM1,BCLAF1,CSDE1,RPL6,LOC342346,SUB1,LOC58486,H3F3A\nNADH dehydrogenase (ubiquinone) activity\tNDUFC1,NDUFC1,NDUFA10,NDUFA7,NDUFB6,NDUFB2,NDUFA2,NDUFV1,NDUFA3,NDUFA13,NDUFS7,NDUFS3,NDUFS8,NDUFB4,NDUFAB1\nMHC class II receptor activity\tHLA-DPB1,HLA-DPB1,HLA-DMA,HLA-DQA1,HLA-DRA,HLA-DMB,HLA-DRB5,HLA-DRB1,HLA-DRB4,HLA-DQB1,HLA-DPA1,HLA-C\nelectron carrier activity\tP4HB,P4HB,MT1X,MT1L,NCF1,UQCR,CYBA,FLJ14346,NDUFS3,NDUFS8,COX5A,ETFB,KTN1,AKR7A2,CYC1\nGTPase activity\tSEPT2,SEPT2,RAB6A,ARF1,RAB6C,NCF1,ARL6IP2,ARF5,ARL8B,RPL29,EIF5,RAC2,EEF1A1\nantigen binding\tIGL@,IGL@,IGLC1,IGLV3-25,IGLC2,IGHV1-69,IGHA1,IGHG3,IGLJ3,IL7R,IGKC,IGHM\ntransferase activity\tFNTA,FNTA,PRKD2,POLR2J,POLR1B,COL6A3,NARS,RPS2,U16,CSNK1A1,RPL4,PRKACB\ntranscription factor activity\tTARDBP,TARDBP,NFE2L2,STAT1,LYSMD4,MSRB2,TRIM22,HCLS1,GTF2I,SCAND1,MDS1\nmetal ion binding\tMT1X,MT1X,ZFR,MT1M,MT1L,PRKD2,ZNF160,TTC3,ZNF611,ZNF552,MATR3,NDUFS8\nstructural molecule activity\tDES,DES,FNBP4,CORO1A,NPEPPS,CLTC,COL6A3,RPL38,CAV1,MATR3,SEPT7\nGTP binding\tRAP1B,RAP1B,RAB1A,RAB6A,RAB6C,NCF1,ARL6IP2,ARL8B,EEF1A1,SEPT7\nextracellular matrix structural constituent\tLAMC1,LAMC1,FBLN1,COL1A1,COL6A1,COL4A2,COL4A1,COL3A1,COL1A2\nstructural constituent of cytoskeleton\tDES,DES,ADD3,ACTA2,TPM1,MSN,ARPC1B,DST,ACTG2,ARPC3,ARPC5\nhydrolase activity\tDDX42,DDX42,PPP1CA,DDX17,EIF4A2,PPP2CA,DDX5,DDX50,DDX3X\nunfolded protein binding\tTAPBP,TAPBP,PPIB,HSP90AA1,PTGES3,HSP90B1,NPM1,EIF3S3\ntransporter activity\tG3BP,G3BP,UCP2,ATP6V0B,SLC25A11,SLC25A3,ATP5D,ATP5G3\nkinase activity\tGUK1,GUK1,MYLK,PRKAR2A,WSB1,208246_x_at,ROCK1,CDKN1B\niron ion binding\tACO2,ACO2,MT1X,MT1L,PPP1CA,NDUFV1,PPP2CA,NDUFS8,CYC1\nDNA-directed RNA polymerase activity\tPOLR2L,POLR2L,POLR2J,HMHA1,POLR1B,PCM1,POLR2I,POLR2E\nATP-dependent helicase activity\tDDX42,DDX42,DDX17,EIF4A2,DHX15,DDX5,DDX50,DDX3X,DDX1\nprotein serine/threonine kinase activity\tAKT1,AKT1,PRKD2,ROCK1,RPS2,U16,CSNK1A1,RPL4,PRKACB\ncalcium ion binding\tMASP1,MASP1,SPARCL1,STAT1,FBLN1,HSP90B1,IGHG3,TPT1\nactin binding\tCNN1,CNN1,CALD1,CAPZA1,GMFG,SMTN,MEFV,NPEPPS,FXYD5\nprotein homodimerization activity\tATPIF1,ATPIF1,HSP90AA1,U16,NPM1,EIF3S3,RPL4\nsignal transducer activity\tSTAT1,STAT1,RGS19,BIRC2,HNRPDL,EEF1D,COPS2\noxidoreductase activity\tNDUFA10,NDUFA10,GPX1,MDH2,GPX4,IGLC2,PRDX2\ntranscription coactivator activity\tTGFB1I1,TGFB1I1,TAF7,NPM1,LOC342346,SUB1\ntranslation initiation factor activity\tEIF4G1,EIF4G1,EIF4A2,EIF4G2,EIF5,EIF3S3\nprotein-tyrosine kinase activity\tRAVER2,RAVER2,PCM1,U16,LCK,HNRPDL,RPL4\ncatalytic activity\tKIAA1840,KIAA1840,LOC91316,DIP2A,PDE4C\nhydrogen-transporting ATPase activity, rota...\tATP6V0B,ATP6V0B,ATP6V0C,ATP5D,ATP5G3\nhydrogen-transporting ATP synthase activity...\tATP6V0B,ATP6V0B,ATP6V0C,ATP5D,ATP5G3\nhematopoietin/interferon-class (D200-domain...\tSTAT1,STAT1,IL2RG,IL10RA,IFNGR1,IL7R\noxidoreductase activity, acting on NADH or ...\tNDUFV1,NDUFV1,NDUFS7,NDUFS3,NDUFS8\nsingle-stranded DNA binding\tHNRPDL,HNRPDL,LOC342346,SUB1,RPA1\namino acid-polyamine transporter activity\tSLC38A2,SLC38A2,218041_x_at,RPL24\nreceptor binding\tCCL5,CCL5,ARPP-19,MSN,LTB,GNB2L1\ncalmodulin binding\tCALD1,CALD1,MARCKS,ATPIF1,IQGAP1\ncytochrome-c oxidase activity\tCOX4I1,COX4I1,COX5B,COX5A,COX8A\nubiquinol-cytochrome-c reductase activity\tUCRC,UCRC,UQCRQ,UQCRC1,UQCRFS1\nprotein transporter activity\tVDP,VDP,KPNB1,TLOC1,AP2S1,COPE\ntranslation elongation factor activity\tTUFM,TUFM,EEF1B2,EEF1D,EEF1A1\npeptide antigen binding\tTAPBP,TAPBP,TRB@,TRBV3-1,TRA@\nmicrotubule motor activity\t220725_x_at,220725_x_at,KNS2\nphosphotransferase activity, alcohol group ...\tNDUFA10,NDUFA10,208246_x_at\noxygen transporter activity\tSLC38A2,SLC38A2,218041_x_at\nRNA helicase activity\tDDX17,DDX17,DHX15,DDX5,DDX1\ngrowth factor activity\tCECR1,CECR1,GMFG,PGF,TGFB1\nRNA splicing factor activity, transesterifi...\tSFRS10,SFRS10,HIATL1,SF3B1\nheparin binding\tLOC283412,LOC283412,RPL29\ndouble-stranded DNA binding\tIFI16,IFI16,ZNF638,HNRPDL\nRNA polymerase II transcription mediator ac...\tCNOT2,CNOT2,THRAP5,THRAP1\nnitric-oxide synthase regulator activity\tHSP90AA1,HSP90AA1,EIF3S3\nisomerase activity\tP4HB,P4HB,ECH1,PPIB,KTN1\nTPR domain binding\tHSP90AA1,HSP90AA1,EIF3S3\nubiquitin-protein ligase activity\tUBE4A,UBE4A,TPT1,UBE2D2\nprotein domain specific binding\tYWHAZ,YWHAZ,217718_s_at\ncAMP-dependent protein kinase regulator act...\tPRKAR1A,PRKAR1A,PRKAR2A\ntranscriptional repressor activity\tIRF7,IRF7,IFI16,BCLAF1\nprotein kinase binding\tU16,U16,LCK,ITGB2,RPL4\ngeneral RNA polymerase II transcription fac...\tTCEA1,TCEA1,TAF7,GTF2I\nMHC protein binding\tTRB@,TRB@,TRBV3-1,TRA@\nATP-dependent RNA helicase activity\tG3BP,G3BP,RPL35A,DDX3X\nprotein heterodimerization activity\tCD3D,CD3D,PPP2CA,NPM1\npeptidase activity\tMASP1,MASP1,ZNF9,CLPP\nidentical protein binding\tAKT1,AKT1,CD74,SCAND1\nvitamin D receptor binding\tTHRAP5,THRAP5,THRAP1\nthyroid hormone receptor binding\tTHRAP5,THRAP5,THRAP1\nserine-type endopeptidase inhibitor activity\tNFE2L2,NFE2L2,COL6A3\nsequence-specific DNA binding\tNFE2L2,NFE2L2,LYSMD4\nprotein dimerization activity\tNFE2L2,NFE2L2,POLR2J\nmotor activity\tMYH9,MYH9,KNS2,MYH11\ncopper ion binding\tMT1E,MT1E,MT1F,DDX42\ncadmium ion binding\tMT1E,MT1E,MT1F,DDX42\ntransmembrane receptor activity\tCD3D,CD3D,CD79A,SPN\ntranslation factor activity, nucleic acid b...\tEIF3S3,EIF3S3,EEF1D\ntranscriptional activator activity\tTHRAP5,THRAP5,TGFB1\n3'-5'-exoribonuclease activity\tEXOSC4,EXOSC4,ISG20\ntropomyosin binding\tCALD1,CALD1,LMOD1\ntranscription factor binding\tYWHAZ,YWHAZ,HMGB1\nthreonine endopeptidase activity\tPSMB9,PSMB9,PSMB8\nserine-type endopeptidase activity\tMASP1,MASP1,IGHG3\nperoxidase activity\tCSDE1,CSDE1,PRDX2\nenzyme activator activity\tGMFG,GMFG,ALOX5AP\nantioxidant activity\tRPL27,RPL27,PRDX2\nactin filament binding\tMARCKS,MARCKS,DST\nacetylglucosaminyltransferase activity\tCOL6A3,COL6A3,OGT\nATPase binding\tATPIF1,ATPIF1,LCK\ntranscription regulator activity\tRPL7,RPL7,RPS27A\nsugar binding\tMASP1,MASP1,SELL\nligase activity\tNARS,NARS,UBE2D2\ncytokine binding\tCD74,CD74,IFNGR1\naminopeptidase activity\tCAST,CAST,NPEPPS\nprotein phosphatase inhibitor activity\tPSMB9,PSMB9,SET\nsuccinate dehydrogenase activity\tSDHA,SDHA,RPA1\nstructural constituent of muscle\tTPM1,TPM1,SMTN\nprotein disulfide oxidoreductase activity\tP4HB,P4HB,KTN1\nprotein disulfide isomerase activity\tP4HB,P4HB,KTN1\nornithine decarboxylase inhibitor activity\tOAZ2,OAZ2,OAZ1\nglutathione peroxidase activity\tGPX1,GPX1,GPX4\ndouble-stranded RNA binding\tSON,SON,MBNL1\nCD4 receptor binding\tLCK,LCK,SPG21\nMAP kinase kinase kinase activity\tU16,U16,RPL4\nprotein C-terminus binding\tDST,DST,LCK\n", + }, + { + kind: 'annotation', + name: 'Co-expression GO cellular component', + fileName: 'NORMA_Human_coexpression_Annotation_GO_CC.txt', + text: 'integral to membrane\tSPCS2,SPCS2,ITGAV,CAST,REEP5,HLA-DPB1,SSR1,C11orf2,TAPBP,IL2RG,CXCR4,HLA-J,RPLP2,CD37,SLC25A16,221651_x_at,RPN1,IGLV3-10,FNBP4,UCP2,AD7C-NTP,ADRM1,IGHD,HLA-B,HLA-DMB,TLOC1,ATP6V0B,HLA-DRB5,SLC25A11,CD74,HLA-DRB1,IGL@,TMED5,IGLC1,HLA-E,SLC25A3,IGLV3-25,TRB@,ARL6IP5,HLA-DRB4,TRBC1,LAPTM5,CD2,C6orf12,IFNGR1,HLA-DQB1,FXYD5,POPDC3,ATP6V0C,TRBV3-1,IGLC2,214836_x_at,IGHV1-69,IGHA1,U16,HLA-F,HLA-A,CD7,CAV1,IGHG3,215176_x_at,IGLJ3,HLA-G,IL7R,ITGB2,IL6ST,RPL4,HLA-C,IGKC,IGHM,TRAM1,RPA1\nmembrane\tHLA-DPB1,HLA-DPB1,SLC38A2,RAP1B,VDP,IL2RG,CXCR4,RAB1A,NPTN,RAB6A,HLA-DMA,HLA-DQA1,ADD3,SLC25A16,PGF,IL10RA,RAB6C,221651_x_at,MSN,IGLV3-10,RPL31,UCP2,IGHD,218041_x_at,HLA-DRA,HLA-DMB,YME1L1,SELL,ATP6V0B,HLA-DRB5,SLC25A11,HLA-DRB1,IGL@,IGLC1,SLC25A3,IGLV3-25,TRB@,HLA-DRB4,TRBC1,LTB,COL6A3,IFNGR1,HLA-DQB1,HLA-DPA1,CD79A,FXYD5,ATP6V0C,PPP2CA,TRBV3-1,IGLC2,ATP2A3,214836_x_at,IGHV1-69,IGHA1,IGHG3,215176_x_at,IGLJ3,IL7R,COPE,HLA-C,IGKC,IGHM,ATP5G3,RPA1,RPL24\nnucleus\tTARDBP,TARDBP,CNOT2,NFE2L2,ZFR,STAT1,SEPT2,MCM3AP,TINP1,LYSMD4,KPNB1,SFRS10,CBX3,THRAP5,G3BP,HNRPH3,IRF7,THRAP1,ZMYND11,POLR2J,ZNF160,MEFV,CDC5L,TRIM22,DDX17,IFI16,HCLS1,ATRX,HNRPH1,POLR1B,OGT,DHX15,ZNF611,MORF4L1,HNRPR,HMGB1,PPP2CA,CDKN1B,PCM1,BCLAF1,TIMM13,SRP9,RPS6,DDX5,ZNF638,RPL35A,SFRS11,PCNP,DDX50,ZNF552,RPS3A,NPM1,MBNL1,PAPOLA,AURKAIP1,FIBP,SH3BGRL,MATR3,RPS27A,SCAND1,DDX3X,SET,HNRPA2B1,RNPC2,SEPT7,H3F3A\ncytoplasm\tAKT1,AKT1,MT1E,FNTA,STAT1,MT1F,SEPT2,VDP,EIF4G1,KPNB1,DDX42,IRF7,MSN,ARL8B,CD3D,PRKAR2A,HCLS1,ARHGDIB,COL6A3,COL1A1,COL6A1,DST,217718_s_at,RPL36A,NARS,COL4A2,COL4A1,CDKN1B,NDUFA13,PCM1,ZNF638,NACA,EXOSC4,COL3A1,COL1A2,ARPC5,EIF5,NPM1,MBNL1,PAPOLA,SH3BGRL,TPT1,PRDX2,COPS2,COPE,XPO1,DDX3X,EEF1A1,HNRPA1\nribosome\tRPL37,RPL37,RPS11,RPS21,RPL21,RPL36,RPL12,RPLP2,RPS19,RPL27,RPL31,LOC440055,LOC388344,RPL27A,RPL28,LOC283412,RPL19,RPS16,RPL36A,RPS6,RPS2,U16,RPL18A,RPL13,RPL29,RPS3A,RPS18,RPL14,RPL22,RPL34,RPS23,RPL15,RPL9,RPL6,RPL4,RPS4X,RPS24,RPL5,RPL23,MDS1,RPS7\nintracellular\tRPL37,RPL37,RAP1B,MGC2474,RPS21,RPL21,RPLP2,G3BP,GMFG,RPS19,RAB6C,PRKD2,RPL31,ZNF160,TGFB1I1,MEFV,GLTSCR2,MRPS12,CD74,RPL28,LOC283412,RPL19,ROCK1,SON,RPS6,RPS2,U16,RPL18A,RPL29,RPS18,RPL14,RPL22,RPL34,RPL15,RPL4,RPS4X,RPS24,RPL13A,RPL5,MDS1,RPS7\nintegral to plasma membrane\tHNRPM,HNRPM,IL2RG,HLA-DQA1,CD48,CD37,CLPTM1,ADRM1,HLA-B,CD164,HLA-DRA,SELL,HLA-DRB5,SLC25A11,HLA-DRB1,SLC25A3,HLA-DRB4,LAPTM5,CD2,C6orf12,IFNGR1,HLA-DPA1,ATP2A3,IGHA1,HLA-A,CAV1,SPN,IGHG3,CD52,IL6ST,HLA-C,IGHM,ATP8B1\ncytosolic small ribosomal subunit (sensu Eu...\tRPS15,RPS15,RPS5,FAU,RPS11,RPS21,RPS13,RPS19,LOC440055,RPS27,RPS10,RPS17,RPS16,RPS14,RPS6,RPS2,RPS15A,RPS20,RPS3A,RPS18,RPS27A,RPS23,RPS24,RPS9,RPS7,LOC402057,RPS25\ncytosolic large ribosomal subunit (sensu Eu...\tRPL35,RPL35,RPL18,RPL21,RPL36,RPLP2,RPL27A,RPL28,LOC283412,RPL7,U16,RPL18A,RPL29,RPL14,RPL22,RPL34,RPL6,RPL4,RPL5,MDS1\nplasma membrane\tARF1,ARF1,CD48,PTPRCAP,CD37,IL10RA,MSN,CD164,HLA-DRA,SELL,PRKAR2A,CD53,TRB@,TRBV3-1,TRA@,LCK,CD7,IL6ST\nmitochondrion\tACO2,ACO2,ATPIF1,MSRB2,SDHA,UCP2,TUFM,LOC440055,GPX4,SLC25A3,PPP2CA,NDUFA13,FIBP,DBT,NDUFB4,CYC1,RPA1\ncytosol\tCAST,CAST,NCF1,NPEPPS,KNS2,PSMB9,PSMB8,HSP90AA1,OGT,PPP2CA,HSP90B1,EIF5,RPS3A,EIF3S3,SPG21,COPB\nmembrane fraction\tRGS19,RGS19,ADRM1,HLA-B,PRKAR2A,LMOD1,COL6A3,IGHV1-69,IGHA1,U16,CD7,IGHG3,CD52,FIBP,RPL4,IGHM\nendoplasmic reticulum\tCAST,CAST,P4HB,SSR1,TAPBP,RPN1,PPIB,TLOC1,NAPA,HSP90B1,CAV1,KTN1,SET,TRAM1\ncytoskeleton\tCALD1,CALD1,RAVER2,ACTA2,TPM1,MSN,NPEPPS,ARHGDIB,LMOD1,ACTG2,ARPC3,ARPC5\nribonucleoprotein complex\tHNRPM,HNRPM,SRP72,HNRPH3,HNRPU,HNRPK,HNRPR,HNRPA3P1,SIAHBP1,HNRPA1\nextracellular region\tCAST,CAST,P4HB,CNOT2,MASP1,IGL@,FBLN1,IFI30,COL4A1,MIF,IGHG3,IGKC\nMHC class I protein complex\tHLA-J,HLA-J,RPLP2,HLA-B,HLA-E,C6orf12,HLA-F,HLA-A,HLA-G,HLA-C\nheterogeneous nuclear ribonucleoprotein com...\tHNRPH3,HNRPH3,HNRPU,HNRPH1,HNRPR,HNRPDL,HNRPA2B1,HNRPA1\nmitochondrial inner membrane\tNDUFA7,NDUFA7,SLC25A16,UCP2,SLC25A11,SLC25A3,NDUFV1\nextracellular space\tCNOT2,CNOT2,IL16,AD7C-NTP,FBLN1,TGFB1,MMP2,SPN,TPT1\nnucleoplasm\tIFI16,IFI16,NDUFA13,ZNF638,XPO1,ISG20,RNPC2,HNRPA1\ncollagen\tCOL1A1,COL1A1,COL6A1,COL4A2,COL4A1,COL3A1,COL1A2\nnucleolus\tRPL35,RPL35,RPS19,IFI16,NCL,EXOSC4,NPM1,RPS7\nspliceosome complex\tHNRPM,HNRPM,HIATL1,SF3B1,HNRPA2B1,HNRPA1\nproton-transporting two-sector ATPase complex\tATP6V0B,ATP6V0B,ATP6V0C,ATP5D,ATP5G3\nextracellular matrix (sensu Metazoa)\tLAMC1,LAMC1,FBLN1,DGCR6,COL6A3,TGFB1\nperinuclear region\t217718_s_at,217718_s_at,HSP90B1,SET\nDNA-directed RNA polymerase II, core complex\tPOLR2J,POLR2J,HCLS1,HMHA1,POLR2E\nactin cytoskeleton\tMARCKS,MARCKS,FNBP4,CORO1A,SMTN\ncAMP-dependent protein kinase complex\tPRKAR1A,PRKAR1A,PRKAR2A,PRKACB\nArp2/3 protein complex\tACTR3,ACTR3,ARPC1B,ARPC3,ARPC5\nlysosome\tHLA-DRA,HLA-DRA,LAPTM5,IFI30\nlarge ribosomal subunit\tRPL7,RPL7,RPL26,RPL13A,RPL17\nGolgi apparatus\tRGS19,RGS19,RAB6A,RAB6C,NAPA\nsoluble fraction\tNCF1,NCF1,FBLN1,NARS,PPP2CA\neukaryotic translation initiation factor 4F...\tEIF4G1,EIF4G1,EIF4A2,EIF4G2\nproton-transporting ATP synthase complex (s...\tATP5J2,ATP5J2,ATP5D,ATP5G3\nmicrosome\tSPCS2,SPCS2,TAPBP,HSP90B1\ncytosolic ribosome (sensu Eukaryota)\tLOC388344,LOC388344,RPL13\nGolgi membrane\tTAPBP,TAPBP,VDP,CAV1,COPB\ntranscription factor complex\tLOC342346,LOC342346,SUB1\nendoplasmic reticulum membrane\tTAPBP,TAPBP,HSP90B1,KTN1\nactin filament\tACTA2,ACTA2,ACTG2,IQGAP1\nproteasome complex (sensu Eukaryota)\tPSMB3,PSMB3,PSMB9,PSMB8\nnuclear envelope\tPAFAH1B1,PAFAH1B1,XPO1\nendoplasmic reticulum lumen\tCAST,CAST,PPIB,HSP90B1\nstress fiber\tPLEKHC1,PLEKHC1,SEPT7\nsmall ribosomal subunit\tRPS5,RPS5,MRPS12,RPS2\nmitochondrial electron transport chain\tUQCRC1,UQCRC1,NDUFA13\nmitochondrial ribosome\tMRPS12,MRPS12,MRPL34\nmitochondrial inner membrane presequence tr...\tTIMM8B,TIMM8B,TIMM13\nmediator complex\tTHRAP5,THRAP5,THRAP1\ncollagen type VI\tCOL6A3,COL6A3,COL6A1\nER-Golgi intermediate compartment\tP4HB,P4HB,TMED5,KTN1\nrespiratory chain complex III (sensu Eukary...\tUQCRQ,UQCRQ,UQCRFS1\neukaryotic translation elongation factor 1 ...\tEEF1B2,EEF1B2,EEF1D\ncytoplasmic membrane-bound vesicle\tARHGDIB,ARHGDIB,DST\nmicrotubule associated complex\tMEFV,MEFV,PAFAH1B1\nubiquitin ligase complex\tRNF11,RNF11,UBE4A\nprotein complex\tPSMB9,PSMB9,PSMB8\nproteasome core complex (sensu Eukaryota)\tPSMB9,PSMB9,PSMB8\nnuclear pore\tKPNB1,KPNB1,RGPD5\nintegrin complex\tITGAV,ITGAV,ITGB2\nsignal recognition particle (sensu Eukaryota)\tSRP72,SRP72,SRP9\ncell surface\tP4HB,P4HB,ATPIF1\nmyosin\tMYH9,MYH9,MYH11\nmitochondrial envelope\tUCRC,UCRC,COX5B\ncentrosome\tNPM1,NPM1,CEP27\nbasement membrane\tLAMC1,LAMC1,DST\nmitochondrial matrix\tMDH2,MDH2,ETFB\nCOPI vesicle coat\tCOPE,COPE,COPB\npericentriolar material\tPCM1,PCM1,LCK\nintermediate filament\tDES,DES,RPL38\nlipid raft\tLCK,LCK,CAV1\n', + }, + { + kind: 'colors', + name: 'Co-expression MCODE clusters', + fileName: 'NORMA_Human_coexpression_Expression_MCODE.txt', + text: '207730_x_at\tgreen\n214836_x_at\tgreen\n215176_x_at\tgreen\n215182_x_at\tgreen\n216342_x_at\tgreen\n216524_x_at\tgreen\n217281_x_at\tgreen\n220725_x_at\tgreen\n221651_x_at\tgreen\nACTA2\tgreen\nACTR2\tgreen\nACTR3\tgreen\nAD7C-NTP\tgreen\nAP2S1\tgreen\nARF5\tgreen\nARHGDIB\tgreen\nARL6IP2\tgreen\nARPC3\tgreen\nATP8B1\tgreen\nATRX\tgreen\nAURKAIP1\tgreen\nBIRC2\tgreen\nC6orf12\tgreen\nCAV1\tgreen\nCD52\tgreen\nCD74\tgreen\nCDC5L\tgreen\nCEP27\tgreen\nCHCHD2\tgreen\nCOL1A1\tgreen\nCOL1A2\tgreen\nCOL3A1\tgreen\nCOL6A1\tgreen\nCOPE\tgreen\nCOX5A\tgreen\nCOX5B\tgreen\nCOX8A\tgreen\nCROP\tgreen\nCTA-246H3.1\tgreen\nCYBA\tgreen\nDBT\tgreen\nDDT\tgreen\nDDX3X\tgreen\nDDX5\tgreen\nEIF3S3\tgreen\nEIF3S6\tgreen\nEIF4G2\tgreen\nFAU\tgreen\nFBLN1\tgreen\nFBXW12\tgreen\nFLJ11021\tgreen\nFLJ12151\tgreen\nFLJ20294\tgreen\nFLJ42393\tgreen\nGNB2L1\tgreen\nH3F3A\tgreen\nHIATL1\tgreen\nHLA-A\tgreen\nHLA-B\tgreen\nHLA-C\tgreen\nHLA-DMA\tgreen\nHLA-DPA1\tgreen\nHLA-DQB1\tgreen\nHLA-DRB1\tgreen\nHLA-DRB4\tgreen\nHLA-DRB5\tgreen\nHLA-E\tgreen\nHLA-F\tgreen\nHLA-G\tgreen\nHLA-J\tgreen\nHNRPDL\tgreen\nHNRPH3\tgreen\nIFI30\tgreen\nIGHA1\tgreen\nIGHG3\tgreen\nIGHM\tgreen\nIGHV1-69\tgreen\nIGKC\tgreen\nIGKV1-5\tgreen\nIGKV1D-13\tgreen\nIGKV1OR15-118\tgreen\nIGKV1OR2-108\tgreen\nIGL@\tgreen\nIGLC1\tgreen\nIGLC2\tgreen\nIGLJ3\tgreen\nIGLV3-25\tgreen\nIQGAP1\tgreen\nKNS2\tgreen\nLAPTM5\tgreen\nLCK\tgreen\nLMOD1\tgreen\nLOC152719\tgreen\nLOC388344\tgreen\nLOC440055\tgreen\nLOC91316\tgreen\nMAN2B1\tgreen\nMATR3\tgreen\nMBNL1\tgreen\nMCM3AP\tgreen\nMDS1\tgreen\nMGEA5\tgreen\nMORF4L1\tgreen\nMRPL34\tgreen\nMT1E\tgreen\nMT1F\tgreen\nMT1G\tgreen\nMT1H\tgreen\nMT1L\tgreen\nMT1M\tgreen\nMT1X\tgreen\nMT2A\tgreen\nMYH11\tgreen\nNACA\tgreen\nNAP1L1\tgreen\nNARS\tgreen\nNDUFA2\tgreen\nNDUFA3\tgreen\nNDUFAB1\tgreen\nNDUFB11\tgreen\nNDUFB2\tgreen\nNDUFB4\tgreen\nNDUFS3\tgreen\nNDUFS7\tgreen\nNPM1\tgreen\nPAPOLA\tgreen\nPCNP\tgreen\nPDE4C\tgreen\nPFAAP5\tgreen\nPGF\tgreen\nPNRC2\tgreen\nPOLR2J\tgreen\nPRG1\tgreen\nPRR11\tgreen\nPTGES3\tgreen\nRAC2\tgreen\nRIOK3\tgreen\nROCK1\tgreen\nRPL11\tgreen\nRPL13\tgreen\nRPL13A\tgreen\nRPL17\tgreen\nRPL18\tgreen\nRPL21\tgreen\nRPL22\tgreen\nRPL23\tgreen\nRPL24\tgreen\nRPL27\tgreen\nRPL27A\tgreen\nRPL28\tgreen\nRPL29\tgreen\nRPL30\tgreen\nRPL34\tgreen\nRPL35A\tgreen\nRPL36\tgreen\nRPL36A\tgreen\nRPL38\tgreen\nRPL4\tgreen\nRPL5\tgreen\nRPL6\tgreen\nRPL7\tgreen\nRPL9\tgreen\nRPLP2\tgreen\nRPS14\tgreen\nRPS17\tgreen\nRPS18\tgreen\nRPS19\tgreen\nRPS2\tgreen\nRPS23\tgreen\nRPS24\tgreen\nRPS25\tgreen\nRPS27A\tgreen\nRPS3A\tgreen\nRPS4X\tgreen\nRPS5\tgreen\nRPS6\tgreen\nRPS7\tgreen\nRPS9\tgreen\nSEPT2\tgreen\nSERBP1\tgreen\nSFRS11\tgreen\nSH3BGRL\tgreen\nSKP1A\tgreen\nSLC35E1\tgreen\nSON\tgreen\nSUMO2\tgreen\nTAF7\tgreen\nTLOC1\tgreen\nTRA@\tgreen\nTRB@\tgreen\nTUG1\tgreen\nU16\tgreen\nUQCR\tgreen\nUQCRC1\tgreen\nUQCRFS1\tgreen\nUSP34\tgreen\nXPO1\tgreen\nYTHDC1\tgreen\nZNF160\tgreen\nZNF611\tgreen\nZNF638\tgreen\nZNHIT1\tgreen\n208246_x_at\tyellow\n211637_x_at\tyellow\n211645_x_at\tyellow\nATP5G3\tyellow\nATP6V0B\tyellow\nATP6V0D1\tyellow\nBCLAF1\tyellow\nCAPZA1\tyellow\nCOL6A3\tyellow\nCXCR4\tyellow\nHLA-DPB1\tyellow\nHNRPH1\tyellow\nIFI16\tyellow\nLOC342346\tyellow\nLOC56902\tyellow\nLOC58486\tyellow\nLOC645745\tyellow\nMEFV\tyellow\nMRPS12\tyellow\nNDUFS8\tyellow\nPTRF\tyellow\nRPL31\tyellow\nRPL35\tyellow\nRPS11\tyellow\nTAGLN\tyellow\nTGFB1\tyellow\nTRBC1\tyellow\n208120_x_at\tred\n208238_x_at\tred\n211639_x_at\tred\n211641_x_at\tred\n211650_x_at\tred\n211908_x_at\tred\n212498_at\tred\n216412_x_at\tred\n216858_x_at\tred\n217052_x_at\tred\n217258_x_at\tred\n217679_x_at\tred\n217718_s_at\tred\n218041_x_at\tred\nAASDHPPT\tred\nACO2\tred\nACTG2\tred\nACTR10\tred\nADD3\tred\nADRM1\tred\nAKR7A2\tred\nAKT1\tred\nALOX5AP\tred\nARF1\tred\nARL6IP5\tred\nARL8B\tred\nARPC1B\tred\nARPC5\tred\nARPP-19\tred\nATP2A3\tred\nATP5D\tred\nATP5J2\tred\nATP6AP2\tred\nATP6V0C\tred\nATPIF1\tred\nBTF3\tred\nC11orf2\tred\nC11orf58\tred\nC12orf10\tred\nC12orf38\tred\nC16orf24\tred\nC17orf62\tred\nC1orf160\tred\nC1orf63\tred\nC21orf33\tred\nC3orf60\tred\nCAB39\tred\nCALD1\tred\nCAP1\tred\nCAPNS1\tred\nCAST\tred\nCBX3\tred\nCCL5\tred\nCCNL1\tred\nCD164\tred\nCD2\tred\nCD37\tred\nCD3D\tred\nCD48\tred\nCD53\tred\nCD7\tred\nCD79A\tred\nCDKN1B\tred\nCECR1\tred\nCHMP2A\tred\nCLPP\tred\nCLPTM1\tred\nCLTC\tred\nCNN1\tred\nCNOT2\tred\nCOL4A1\tred\nCOL4A2\tred\nCOPB\tred\nCOPS2\tred\nCOQ9\tred\nCORO1A\tred\nCOX4I1\tred\nCSDE1\tred\nCSNK1A1\tred\nCUL4B\tred\nCXorf9\tred\nCYC1\tred\nDDX1\tred\nDDX17\tred\nDDX42\tred\nDDX50\tred\nDES\tred\nDEXI\tred\nDGCR6\tred\nDHX15\tred\nDIP2A\tred\nDKFZP566N034\tred\nDST\tred\nDYNLT3\tred\nECH1\tred\nEEF1A1\tred\nEEF1B2\tred\nEEF1D\tred\nEIF4A2\tred\nEIF4G1\tred\nEIF5\tred\nENDOG\tred\nERH\tred\nETFB\tred\nEXOSC4\tred\nFAIM3\tred\nFAM96B\tred\nFIBP\tred\nFIS1\tred\nFLJ10154\tred\nFLJ14346\tred\nFNBP4\tred\nFNTA\tred\nFXYD5\tred\nG3BP\tred\nGBL\tred\nGLTSCR2\tred\nGMFG\tred\nGOLGA8A\tred\nGOLGA8B\tred\nGPAA1\tred\nGPSM3\tred\nGPX1\tred\nGPX4\tred\nGTF2I\tred\nGUK1\tred\nHCLS1\tred\nHLA-DMB\tred\nHLA-DQA1\tred\nHLA-DRA\tred\nHMGB1\tred\nHMHA1\tred\nHNRPA1\tred\nHNRPA2B1\tred\nHNRPA3P1\tred\nHNRPK\tred\nHNRPM\tred\nHNRPR\tred\nHNRPU\tred\nHSP90AA1\tred\nHSP90B1\tred\nIFNGR1\tred\nIGHD\tred\nIGLV3-10\tred\nIL10RA\tred\nIL16\tred\nIL2RG\tred\nIL6ST\tred\nIL7R\tred\nINPP5D\tred\nIRF7\tred\nISG20\tred\nITGAV\tred\nITGB2\tred\nKIAA0907\tred\nKIAA1840\tred\nKIDINS220\tred\nKPNB1\tred\nKTN1\tred\nLAMC1\tred\nLOC283412\tred\nLOC402057\tred\nLRRFIP1\tred\nLTB\tred\nLYSMD4\tred\nMARCKS\tred\nMASP1\tred\nMDH2\tred\nMGC2474\tred\nMIF\tred\nMMP2\tred\nMRP63\tred\nMSN\tred\nMSRB2\tred\nMYH9\tred\nMYLK\tred\nNAPA\tred\nNBPF1\tred\nNBPF12\tred\nNCF1\tred\nNCL\tred\nNDUFA10\tred\nNDUFA13\tred\nNDUFA7\tred\nNDUFB6\tred\nNDUFC1\tred\nNDUFV1\tred\nNFE2L2\tred\nNPEPPS\tred\nNPTN\tred\nOAZ1\tred\nOAZ2\tred\nOGT\tred\nOPHN1\tred\nORC6L\tred\nOSBPL8\tred\nP4HB\tred\nPAFAH1B1\tred\nPCM1\tred\nPDCD10\tred\nPGLS\tred\nPHIP\tred\nPLEKHC1\tred\nPOLR1B\tred\nPOLR2E\tred\nPOLR2I\tred\nPOLR2L\tred\nPOPDC3\tred\nPPIB\tred\nPPP1CA\tred\nPPP2CA\tred\nPRDX2\tred\nPRKACB\tred\nPRKAR1A\tred\nPRKAR2A\tred\nPRKD2\tred\nPSMB3\tred\nPSMB8\tred\nPSMB9\tred\nPTPRCAP\tred\nPUM2\tred\nR3HCC1\tred\nRAB1A\tred\nRAB6A\tred\nRAB6C\tred\nRAP1B\tred\nRAVER2\tred\nRBPMS\tred\nREEP5\tred\nRGPD5\tred\nRGS19\tred\nRNF11\tred\nRNPC2\tred\nRPA1\tred\nRPL10A\tred\nRPL12\tred\nRPL14\tred\nRPL15\tred\nRPL18A\tred\nRPL19\tred\nRPL23A\tred\nRPL26\tred\nRPL32\tred\nRPL37\tred\nRPL39\tred\nRPL41\tred\nRPN1\tred\nRPS10\tred\nRPS13\tred\nRPS15\tred\nRPS15A\tred\nRPS16\tred\nRPS20\tred\nRPS21\tred\nRPS27\tred\nSCAND1\tred\nSDHA\tred\nSEC61A1\tred\nSELL\tred\nSEPT7\tred\nSET\tred\nSF3B1\tred\nSFPQ\tred\nSFRS10\tred\nSFRS3\tred\nSFRS5\tred\nSIAHBP1\tred\nSLC25A11\tred\nSLC25A16\tred\nSLC25A3\tred\nSLC38A2\tred\nSMTN\tred\nSPARCL1\tred\nSPCS2\tred\nSPG21\tred\nSPN\tred\nSRP46\tred\nSRP72\tred\nSRP9\tred\nSSR1\tred\nSTAT1\tred\nSUB1\tred\nSUMO1\tred\nTAPBP\tred\nTARDBP\tred\nTCEA1\tred\nTCEB2\tred\nTGFB1I1\tred\nTHRAP1\tred\nTHRAP5\tred\nTIMM13\tred\nTIMM8B\tred\nTINP1\tred\nTMED5\tred\nTMEM123\tred\nTMEM66\tred\nTMEM93\tred\nTOMM20\tred\nTPM1\tred\nTPT1\tred\nTRAM1\tred\nTRBV3-1\tred\nTRIM22\tred\nTTC19\tred\nTTC3\tred\nTUFM\tred\nTXNL2\tred\nUBE2D2\tred\nUBE4A\tred\nUCP2\tred\nUCRC\tred\nUQCRQ\tred\nUSP47\tred\nVDP\tred\nWAC\tred\nWDR45\tred\nWIPI2\tred\nWSB1\tred\nYIPF3\tred\nYME1L1\tred\nYWHAZ\tred\nZC3H11A\tred\nZFR\tred\nZMYND11\tred\nZNF403\tred\nZNF552\tred\nZNF9\tred\nhfl-B5\tred\n', + }, + ], + }, + covid: { + title: 'COVID-19 (IntAct)', + source: 'IntAct database', + link: null, + files: [ + { + kind: 'network', + name: 'COVID-19 IntAct', + fileName: 'Intact-data_COVID19_no_self_loops.txt', + text: 'Source\tTarget\nP31809\tP11224\nP33767\tP41811\nQ3T133\tP41811\nP39656\tP41811\nP41811\tO15503\nQ91AV1\tP41811\nQ96WV5\tP68978\nQ9Y5U4\tP41811\nP53622\tP41811\nP53622\tQ3T133\nP53622\tP41811\nP53622\tP33767\nP53622\tP41811\nP53622\tP68978\nP53622\tP41811\nP53622\tP39656\nQ3T133\tQ96WV5\nQ96WV5\tP39656\nQ96WV5\tP33767\nQ96WV5\tP68978\nP53622\tP41811\nP53622\tQ9Y5U4\nP53622\tP41811\nP53622\tQ91AV1\nP53622\tP41811\nP53622\tO15503\nQ96WV5\tQ9Y5U4\nQ96WV5\tO15503\nQ91AV1\tQ96WV5\nP53622\tP41811\nP53622\tP41811\nP53622\tP41811\nP53622\tP41811\nP53622\tP41811\nP27487\tK0BRG7\nP27487\tK0BRG7\nQ9BYF1-1\tP59594\nP27487\tK0BRG7\nQ9BYF1-1\tP59594\nP27487\tK0BRG7\nP27487\tK0BRG7\nP0C6U8\tP62753\nQ9BYF1-1\tQ6Q1S2\nQ9BYF1-1\tQ6Q1S2\nQ9BYF1-1\tQ6Q1S2\nQ7TLC7\tO14964\nQ7TLC7\tP05155\nQ7TLC7\tQ13561\nQ7TLC7\tQ9NRH1\nJ9TC74\tQ13561\nJ9TC74\tP62258\nJ9TC74\tQ99471\nP59633\tQ13561\nP59633\tP05155\nP59633\tP02768\nQ6S8E0\tP08708\nQ6S8E0\tP25787\nP59634\tQ13561\nP59634\tQ92994\nQ19QW4\tQ9BQB6\nQ19QW4\tQ9H4F8\nQ7T6S2\tP49069\nQ7T6S2\tP05155\nQ6S8E0\tP60866\nQ6S8E0\tP27448\nQ19QW2\tP46379\nQ6S8E0\tP60866\nQ19QW2\tP46379\nP59634\tQ13561\nQ7T6S2\tP49069\nQ19QW4\tQ9BQB6\nQ6S8E0\tP27448\nQ6S8E0\tP25787\nQ7TLC7\tO14964\nQ7TLC7\tQ13561\nQ1HVL3\tP15144\nQ1HVL8\tP15144\nQ1HVK9\tP15144\nQ9BYF1\tP0DTC2\nQ9BYF1\tP59594\nK9N5Q8\tP27487\nQ9BYF1\tP0DTC2\nQ9BYF1\tP59594\nQ9BYF1\tP0DTC2\nQ9BYF1\tP59594\nQ9BYF1\tP0DTC2\nQ9BYF1\tP59594\nQ9BYF1\tP59594\nP61769\tP04439\nP61769\tP59595\nP61769\tP04439\nP61769\tP03407\nP61769\tP04439\nP61769\tP59595\nP61769\tP04439\nP61769\tP59596\nP61769\tP04439\nP61769\tQ692E0\nP61769\tP04439\nP61769\tP59596\nP61769\tP04439\nP61769\tQ76R37\nP61769\tP04439\nP61769\tP59637\nQ9BYF1\tP0DTC2\nQ9BYF1\tP0DTC2\nQ9BYF1\tP0DTC2\nQ9BYF1\tP0DTC2\nQ9BYF1\tP59594\nQ9BYF1\tP0DTC2\nQ9BYF1\tP0DTC2\nQ9BYF1\tQ695T7\nQ9BYF1\tQ695T7\nQ9BYF1\tP0DTC2\nQ9BYF1\tQ695T7\nQ9BYF1\tP37173\nQ9BYF1\tP37173\nP11021\tK0BRG7\nP11021\tK0BRG7\nP11021\tK0BRG7\nP11021\tK0BRG7\nP11021\tK0BRG7\nP11021\tK0BRG7\nP11021\tK0BRG7\nP11021\tK0BRG7\nK0BRG7\tP11021\nP0DTC4\tQ8IWA5\nP0DTC4\tQ86VM9\nP0DTC4\tQ6UX04\nP0DTC4\tP25440\nP0DTC4\tO60885\nP0DTC4\tO00203\nP0DTC9\tQ9Y3U8\nP0DTC9\tQ9NW13\nP0DTC9\tQ9NR30\nP0DTC9\tQ9UN86\nP0DTC9\tQ9HCE1\nP0DTC9\tQ92900\nP0DTC9\tQ8TAD8\nP0DTC9\tQ8NCA5\nP0DTC9\tQ6PKG0\nP0DTC9\tQ13310\nP0DTC9\tQ13283\nP0DTC9\tP67870\nP0DTC9\tP19784\nP0DTC9\tP11940\nP0DTC9\tO43818\nP0DTC2\tQ9C0B5\nP0DTC2\tQ7Z5G4\nP0DTC5\tQ9Y6E2\nP0DTC5\tQ9Y312\nP0DTC5\tQ9ULX6\nP0DTC5\tQ9UDR5\nP0DTC5\tQ9UBU6\nP0DTC5\tQ9NQC3\nP0DTC5\tQ9BW92\nP0DTC5\tQ9BSJ2\nP0DTC5\tQ9BQT8\nP0DTC5\tQ96HW7\nP0DTC5\tQ96HR9\nP0DTC5\tQ96ER3\nP0DTC5\tQ96D53\nP0DTC5\tQ96CW5\nP0DTC5\tQ8NEW0\nP0DTC5\tQ7L8L6\nP0DTC5\tQ6PML9\nP0DTC5\tQ5JRX3\nP0DTC5\tQ4KMQ2\nP0DTC5\tQ10713\nP0DTC5\tQ00765\nP0DTC5\tP48556\nP0DTC5\tP38606\nP0DTC5\tP38435\nP0DTC5\tP27105\nP0DTC5\tP13804\nP0DTC5\tP11310\nP0DTC5\tP05026\nP0DTC5\tO95070\nP0DTC5\tO75439\nP0DTC6\tQ9NZJ7\nP0DTC6\tP78406\nP0DTC6\tP52948\nP0DTC3\tQ9Y673\nP0DTC3\tQ9UH99\nP0DTC3\tQ9H270\nP0DTC3\tQ96S66\nP0DTC3\tQ96JC1\nP0DTC3\tQ8N6S5\nP0DTC3\tQ8IWR1\nP0DTC3\tP09601\nP0DTC7\tQ9NU22\nP0DTC7\tQ7Z4Q2\nP0DTC8\tQ96IV0\nP0DTC8\tQ8N0Z8\nP0DTC8\tQ96AY3\nP0DTC8\tQ13443\nP0DTC8\tQ9Y680\nP0DTC8\tQ9Y4L1\nP0DTC8\tQ9UHI8\nP0DTC8\tQ9P2E5\nP0DTC8\tQ9NYU1\nP0DTC8\tQ9NXK8\nP0DTC8\tQ9H8W4\nP0DTC8\tQ9H4F8\nP0DTC8\tQ9H488\nP0DTC8\tQ9H173\nP0DTC8\tQ9BZQ6\nP0DTC8\tQ9BS26\nP0DTC8\tQ9BRN9\nP0DTC8\tQ99988\nP0DTC8\tQ99519\nP0DTC8\tQ99470\nP0DTC8\tQ96MM7\nP0DTC8\tQ96F46\nP0DTC8\tQ96DZ1\nP0DTC8\tQ92820\nP0DTC8\tQ8N766\nP0DTC8\tQ8IZ52\nP0DTC8\tQ8IWF2\nP0DTC8\tQ8IV08\nP0DTC8\tQ86YB8\nP0DTC8\tQ7Z4H8\nP0DTC8\tQ6UW63\nP0DTC8\tQ15818\nP0DTC8\tQ13438\nP0DTC8\tQ08431\nP0DTC8\tP61916\nP0DTC8\tP58166\nP0DTC8\tP29122\nP0DTC8\tP28300\nP0DTC8\tP26358\nP0DTC8\tP15151\nP0DTC8\tP12109\nP0DTC8\tP0C7P0\nP0DTC8\tP05556\nP0DTC8\tP00750\nP0DTC8\tO76061\nP0DTC8\tO14656\nP0DTC8\tO00469\nP0DTD2\tQ9UL15\nP0DTD2\tQ9UKA9\nP0DTD2\tQ9P0L2\nP0DTD2\tQ9H773\nP0DTD2\tQ9H2P9\nP0DTD2\tQ7KZI7\nP0DTD2\tP27448\nP0DTD2\tO94826\nP0DTD2\tO75534\nP0DTD2\tO43633\nP0DTD2\tO14745\nP0DTD3\tQ9Y3A6\nP0DTD3\tQ2PZI1\nP0DTD3\tQ96S52\nP0DTD3\tQ9Y6M9\nP0DTD3\tQ9Y375\nP0DTD3\tQ9Y276\nP0DTD3\tQ9NV92\nP0DTD3\tQ9H845\nP0DTD3\tQ9H3K2\nP0DTD3\tQ9GZU3\nP0DTD3\tQ9BVK2\nP0DTD3\tQ9BQ95\nP0DTD3\tQ96K12\nP0DTD3\tQ8TEQ8\nP0DTD3\tQ86VR2\nP0DTD3\tQ86UT6\nP0DTD3\tQ7Z2K6\nP0DTD3\tQ6NXT6\nP0DTD3\tQ6NXT4\nP0DTD3\tQ5BJF2\nP0DTD3\tQ12770\nP0DTD3\tP55085\nP0DTD3\tP33527\nP0DTD3\tO76024\nP0DTD3\tO43292\nP0DTD3\tO00124\nQ1LZX8\tP59594\nQ1LZX8\tP84198\nQ1LZX8\tP84198\nQ1LZX8\tP59594\nP84198\tP59594\nP84198\tP59594\nP59637\tQ64373\nP59637\tO35843\nQ64373\tQ07817\nQ64373\tP59637\nP59637\tO35843\nP59637\tQ07817\nP06731-1\tK0BRG7\nP06731\tK0BRG7\nK0BRG7\tP06731\nP06731\tK0BRG7\nP06731\tK0BRG7\nP59637\tP59636\nP59633\tP59637\nQ7TFA0\tP59636\nQ7TFA1\tQ19QW5\nQ80H93\tQ7TFA0\nP59596\tP59632\nP59594\tP59635\nQ7TFA1\tP59637\nP59594\tQ80H93\nQ7TFA1\tQ80H93\nQ7TFA0\tQ80H93\nQ7TLC7\tP59636\nQ7TFA1\tP59636\nQ80H93\tP59636\nQ7TLC7\tQ80H93\nQ7TFA1\tP59637\nP59595\tP59596\nQ7TFA1\tQ19QW5\nQ7TLC7\tP59636\nQ7TLC7\tP59636\nQ9BYF1\tP02768\nF1RG45\tK7GLM4\nQ9BYF1\tP05814\nQ9BYF1\tP0DTC2\nQ9BYF1\tP59594\nQ92793\tQ14653\nQ92793\tQ14653\nQ07817\tP59635\nP59635\tP10415\nQ07820\tP59635\nQ16548\tP59635\nP59635\tQ92843\nQ9BYF1\tP59594\nQ9BYF1\tQ6Q1S2\nQ9BYF1\tP59594\nQ9BYF1\tQ6Q1S2\nQ9BYF1\tQ6Q1S2\nP59594\tP00747\nP59594\tP00762\nQ6ZMR5\tP59594\nQ9BYF1\tP59594\nQ9BYF1\tP59594\nQ9BYF1\tP59594\nQ9BYF1\tP59594\nQ9BYF1\tP59594\nQ9BYF1\tP59594\nP59594\tQ56NL1\nP59595\tP63279\nP59595\tP63279\nP59595\tP63279\nP59595\tP63279\nP20290\tQ86VG3\nQ86U32\tQ86VG3\nP24928\tQ86VG3\nP20290\tQ86VG3\nP20290\tQ86VG3\nP20290\tQ86VG3\nO00303\tP59594\nO00303\tP59594\nO00303\tP11223\nO00303\tP59594\nO00303\tP11223\nO00303\tP59594\nO00303\tP59594\nP59595\tQ05639\nP03070\tP02340\nP59595\tQ05639\nQ05639\tP59595\nP59595\tQ05639\nP59595\tQ05639\nP15130\tQ05639\nQ05639\tP59595\nQ05639\tP08107\nQ05639\tP59595\nQ05639\tP08107\nP59595\tQ05639\nP59595\tQ05639\nP59595\tQ05639\nP59595\tP63279\nP59595\tP63279\nP59595\tP63279\nP59595\tP63279\nP59595\tP63279\nP59595\tP63279\nP59595\tP63279\nP59595\tP63279\nP59595\tP62937\nP59595\tP62937\nP35613\tP62937\nQ9BYF1\tP59594\nP59594\tQ5EGZ1\nQ9BYF1\tP59594\nP59594\tQ5EGZ1\nP59594\tQ56NL1\nQ9BYF1\tQ5GDB5\nQ9BYF1\tP59594\nQ56NL1\tP59594\nQ9BYF1\tP59594\nP59594\tQ56NL1\nQ56NL1\tQ5GDB5\nQ9BYF1\tQ5GDB5\nQ9BYF1\tP59594\nP0DTC2\tP62269\nP0DTC2\tQ96N67\nP0DTC2\tP63244\nP0DTC2\tP40227\nP0DTC2\tP07195\nP0DTC2\tP08621\nP0DTC2\tP60660\nP0DTC2\tP47756\nP0DTC2\tP52907\nP0DTC2\tQ9NYL9\nP0DTC2\tP13797\nP0DTC2\tQ9ULV4\nP0DTC2\tP59998\nP0DTC2\tP61158\nP0DTC2\tQ9UM54\nP0DTC4\tQ9ULX6\nP0DTC4\tP50402\nP0DTC4\tQ00325\nP0DTC4\tP05023\nP0DTC4\tO75746\nP0DTC4\tP16615\nP0DTC4\tQ9P035\nP0DTC4\tQ5H9R7\nP0DTC5\tP30876\nP0DTC5\tO15397\nP0DTC5\tQ9H078\nP0DTC5\tP62195\nP0DTC5\tP26599\nP0DTC5\tO60762\nP0DTC5\tQ9H936\nP0DTC5\tO00231\nP0DTC5\tQ9ULX6\nP0DTC5\tQ9UHI6\nP0DTC5\tQ7Z4H7\nP0DTC5\tQ96EY1\nP0DTC5\tQ00325\nP0DTC5\tQ15008\nP0DTC5\tO14818\nP0DTC5\tP40939\nP0DTC5\tP50402\nP0DTC5\tQ5H9R7\nP0DTC5\tO95757\nP0DTC5\tP57678\nP0DTC5\tP31689\nP0DTC5\tO15027\nP0DTC5\tP60900\nP0DTC5\tP25705\nP0DTC5\tP51665\nP0DTC5\tP62191\nP0DTC5\tP06576\nP0DTC5\tQ14257\nP0DTC5\tQ99615\nP0DTC5\tO95816\nP0DTC5\tO95071\nP0DTC5\tP46379\nP0DTC5\tO75746\nP0DTC5\tQ9Y5A9\nP0DTC5\tQ13200\nP0DTC5\tP53618\nP0DTC5\tQ9NZ01\nP0DTC5\tP35998\nP0DTC5\tQ16891\nP0DTC5\tO60884\nP0DTC5\tO43852\nP0DTC5\tQ99460\nP0DTC5\tQ9NTJ3\nP0DTC5\tP53621\nP0DTC5\tP55786\nP0DTC5\tQ15293\nP0DTC5\tP34932\nP0DTC5\tQ9UBF2\nP0DTC5\tO43592\nP0DTC5\tP35606\nP0DTC5\tP05023\nP0DTC5\tO95347\nP0DTC5\tP16615\nP0DTC5\tO14980\nP0DTC5\tQ96CS3\nP0DTC5\tQ9P035\nP0DTC9\tP09622\nP0DTC9\tP08621\nP0DTC9\tP38159\nP0DTC9\tQ13151\nP0DTC9\tQ9UN86\nP0DTC9\tQ13283\nP0DTC9\tQ9UM54\nP0DTC3\tP40939\nP0DTC3\tQ14257\nP0DTC3\tP22314\nP0DTC3\tP04406\nP0DTC3\tP57088\nP0DTC3\tQ8IXB1\nP0DTC3\tQ00325\nP0DTC3\tP31689\nP0DTC3\tP25705\nP0DTC3\tO43852\nP0DTC3\tO95757\nP0DTC3\tP46379\nP0DTC3\tP53621\nP0DTC3\tQ9H936\nP0DTC3\tO60884\nP0DTC3\tP26599\nP0DTC3\tO75746\nP0DTC3\tP11586\nP0DTC3\tQ9NZ01\nP0DTC3\tP53007\nP0DTC3\tP05023\nP0DTC3\tQ96CS3\nP0DTC3\tO43592\nP0DTC3\tO95347\nP0DTC3\tP16615\nP0DTC3\tQ16891\nP0DTC3\tO14980\nP0DTC3\tQ9P035\nP0DTC6\tQ5SW79\nP0DTC6\tP25705\nP0DTC6\tQ92552\nP0DTC6\tP06576\nP0DTC6\tP15924\nP0DTC6\tP53396\nP0DTC6\tP16615\nP0DTC6\tQ5T9A4\nP0DTC6\tP05023\nP0DTC6\tO75592\nP0DTC6\tO75934\nP0DTC6\tO14980\nP0DTC6\tQ96CS3\nP0DTC6\tP78406\nP0DTC6\tQ9NVI7\nP0DTC6\tP33176\nP0DTC6\tQ13561\nP0DTC6\tQ6P2E9\nP0DTC7\tP30153\nP0DTC7\tP06576\nP0DTC7\tP40939\nP0DTC7\tQ8IXB1\nP0DTC7\tQ9UHI6\nP0DTC7\tO95816\nP0DTC7\tP53007\nP0DTC7\tQ96EY1\nP0DTC7\tP50402\nP0DTC7\tO43852\nP0DTC7\tQ9H936\nP0DTC7\tP62195\nP0DTC7\tP30876\nP0DTC7\tQ99615\nP0DTC7\tP55786\nP0DTC7\tP57678\nP0DTC7\tQ00325\nP0DTC7\tO75746\nP0DTC7\tO15027\nP0DTC7\tP34932\nP0DTC7\tQ15008\nP0DTC7\tQ9UNM6\nP0DTC7\tO95071\nP0DTC7\tO60884\nP0DTC7\tP62191\nP0DTC7\tP05023\nP0DTC7\tP51665\nP0DTC7\tP53618\nP0DTC7\tQ9NZ01\nP0DTC7\tP35998\nP0DTC7\tQ13200\nP0DTC7\tP53621\nP0DTC7\tQ5H9R7\nP0DTC7\tP16615\nP0DTC7\tQ99460\nP0DTC7\tO43592\nP0DTC7\tQ9NXS2\nP0DTC7\tP46379\nP0DTC7\tQ16891\nP0DTC7\tO95347\nP0DTC7\tP35606\nP0DTC7\tQ9UBF2\nP0DTC7\tQ9P035\nP0DTC7\tO14980\nP0DTC7\tQ96CS3\nP0DTD8\tQ14257\nP0DTD8\tO60762\nP0DTD8\tO43852\nP0DTD8\tP25705\nP0DTD8\tP31689\nP0DTD8\tP06576\nP0DTD8\tQ8WTT2\nP0DTD8\tQ7KZF4\nP0DTD8\tQ9NVI7\nP0DTD8\tP05023\nP0DTD8\tP55786\nP0DTD8\tQ00325\nP0DTD8\tP53618\nP0DTD8\tP57088\nP0DTD8\tO43592\nP0DTD8\tO95347\nP0DTD8\tQ9NZ01\nP0DTD8\tQ16891\nP0DTD8\tP16615\nP0DTD8\tP53621\nP0DTD8\tQ9NXS2\nP0DTD8\tQ96CS3\nP0DTD8\tQ02978\nP0DTD8\tP35606\nP0DTD8\tQ9UBF2\nP0DTD8\tQ9P035\nP0DTD8\tO14980\nP0DTD8\tP10155\nP0DTC8\tQ14152\nP0DTC8\tP50402\nP0DTC8\tQ9UHI6\nP0DTC8\tP06576\nP0DTC8\tP62195\nP0DTC8\tP25705\nP0DTC8\tP40939\nP0DTC8\tQ5JWF2\nP0DTC8\tO14818\nP0DTC8\tP55786\nP0DTC8\tP57678\nP0DTC8\tO43852\nP0DTC8\tQ12789\nP0DTC8\tQ9H936\nP0DTC8\tP31689\nP0DTC8\tQ15008\nP0DTC8\tQ9UNM6\nP0DTC8\tQ00325\nP0DTC8\tO15027\nP0DTC8\tP30876\nP0DTC8\tQ7KZF4\nP0DTC8\tQ9NVI7\nP0DTC8\tO60884\nP0DTC8\tQ13200\nP0DTC8\tP05023\nP0DTC8\tO95071\nP0DTC8\tP35998\nP0DTC8\tO43592\nP0DTC8\tQ02978\nP0DTC8\tQ9NZ01\nP0DTC8\tP53618\nP0DTC8\tQ99460\nP0DTC8\tO75746\nP0DTC8\tP16615\nP0DTC8\tP53621\nP0DTC8\tQ16891\nP0DTC8\tO95347\nP0DTC8\tO14980\nP0DTC8\tQ9P035\nP0DTC8\tQ9UBF2\nP0DTC8\tP46379\nP0DTD2\tP19105\nP0DTD2\tQ14315\nP0DTD2\tP62140\nP0DTD2\tP0DP23\nP0DTD2\tP09622\nP0DTD2\tP52907\nP0DTD2\tP60660\nP0DTD2\tQ9NYL9\nP0DTD2\tP13797\nP0DTD2\tQ9UM54\nP0DTD2\tQ9ULV4\nP0DTD2\tP59998\nP0DTD3\tQ9BUQ8\nP0DTD3\tQ96N67\nP0DTD3\tQ15365\nP0DTD3\tP17987\nP0DTD3\tO00231\nP0DTD3\tQ14257\nP0DTD3\tQ16576\nP0DTD3\tP62269\nP0DTD3\tQ58FF8\nP0DTD3\tP27348\nP0DTD3\tP40227\nP0DTD3\tP63244\nP0DTD3\tP49368\nP0DTD3\tQ9Y265\nP0DTD3\tP07355\nP0DTD3\tP46379\nP0DTD3\tP48643\nP0DTD3\tQ99832\nP0DTD3\tQ9UHI6\nP0DTD3\tP31948\nP0DTD3\tQ3ZCQ8\nP0DTD3\tQ9NVI7\nP0DTD3\tP46782\nP0DTD3\tP25205\nP0DTD3\tP30153\nP0DTD3\tQ9ULX6\nP0DTD3\tP42677\nP0DTD3\tP61158\nP0DTD3\tP09874\nP0DTD3\tP50402\nP0DTD3\tP30876\nP0DTD3\tQ9NXS2\nP0DTD3\tP40939\nP0DTD3\tP06493\nP0DTD3\tP51570\nP0DTD3\tQ7Z4H7\nP0DTD3\tP57088\nP0DTD3\tQ14739\nP0DTD3\tP10155\nP0DTD3\tQ9UM54\nP0DTD3\tP04406\nP0DTD3\tQ9H936\nP0DTD3\tP11586\nP0DTD3\tQ9UBX3\nP0DTD3\tQ5H9R7\nP0DTD3\tP53618\nP0DTD3\tP53007\nP0DTD3\tP12004\nP0DTD3\tP05023\nP0DTD3\tQ16891\nP0DTD3\tQ9NZ01\nP0DTD3\tO14980\nP0DTD3\tO95347\nP0DTD3\tQ9NTJ3\nP0DTD3\tP53621\nP0DTD3\tP16615\nP0DTD3\tO43592\nP0DTD3\tQ9UBF2\nP0DTD3\tP35606\nP0DTD3\tQ9P035\nP59594\tP35247\nP52294\tP52630\nP52294\tP42224\nP52292\tQ14974\nP52294\tP42224\nP52294\tQ14974\nP52292\tQ14974\nP52294\tP42224\nP52292\tQ14974\nP52292\tQ14974\nQ99623\tP35232\nQ99623\tP35232\nO35129\tP67778\nO35129\tQ9JIA7\nO35129\tP67778\nO35129\tP19783\nO35129\tP00397\nO35129\tP67778\nP00405\tP19783\nP00405\tP12787\nP00405\tP19536\nP00405\tQ9CPQ1\nP00405\tP56391\nP00405\tP56392\nP12904\tP06782\nP33469\tP13010\nP33469\tP39019\nP33469\tP61254\nP33469\tP16402\nP33469\tP09651\nP33469\tP67809\nP33469\tP0DMV8\nP33469\tP11940\nP33469\tP12956\nP33469\tQ00839\nP33469\tP19338\nP33469\tP67809\nP33469\tP12956\nP33469\tQ00839\nP33469\tP19338\nK0BWD0\tP05161\nP05161\tK0BWD0\nK0BWD0\tP05161\nK0BWD0\tP05161\nR9QB93\tK0BWD0\nO43765\tP59635\nO43765\tP59635\nP59635\tP59596\nP59635\tP59637\nQ5MAG3\tP59635\nO43765\tP59635\nP59595\tP59596\nP0DTC2\tP35613\nP0DTC2\tP35613\nP0DTC2\tP35613\nP0DTC2\tP35613\nP0DTC2\tP35613\nP04135\tF1S215\nP04135\tF1RNN5\nP04135\tA6M930\nP04135\tA6M930\nP04135\tA6M930\nP04135\tA6M930\nQ9BYF1\tP0DTC2\nQ9BYF1\tP59594\nQ9BYF1\tP0DTC2\nQ9BYF1\tP59594\nQ9BYF1\tP0DTC2\nQ9BYF1\tP59594\nQ9BYF1\tP0DTC2\nQ9BYF1\tP0DTC2\nQ53F19\tP56270\nQ53F19\tQ14011\nQ53F19\tQ8N0Z6\nQ53F19\tQ8N8U2\nQ53F19\tP35052\nQ53F19\tO95373\nQ53F19\tQ9UH99\nQ53F19\tQ8NEM0\nQ53F19\tQ32M45\nQ53F19\tQ6L8Q7\nQ53F19\tQ6PJT7\nQ53F19\tQ02224\nQ53F19\tP31150\nQ53F19\tQ5DJT8\nQ53F19\tQ9UER7\nQ53F19\tP48668\nQ53F19\tO75940\nQ53F19\tJ3QSV6\nQ53F19\tO95619\nQ53F19\tQ7L590\nQ53F19\tQ96GX9\nQ53F19\tQ6IEG0\nQ53F19\tQ9BY42\nQ53F19\tP61326\nQ53F19\tQ96IZ7\nQ53F19\tQ8TF76\nQ53F19\tO00148\nQ53F19\tP49756\nQ53F19\tQ10589\nQ53F19\tP31431\nQ53F19\tP34741\nQ53F19\tP83916\nQ53F19\tP63104\nQ53F19\tO75400\nQ53F19\tQ8TDD1\nQ53F19\tP09603\nQ53F19\tQ86WX3\nQ53F19\tQ00403\nQ53F19\tQ13352\nQ53F19\tQ13011\nQ53F19\tQ9BRP8\nQ53F19\tQ9BSM1\nQ53F19\tQ6SPF0\nQ53F19\tO60684\nQ53F19\tQ16630\nQ53F19\tP55198\nQ53F19\tP78563\nQ53F19\tO00422\nQ53F19\tQ9ULL5\nQ53F19\tP52298\nQ53F19\tQ09161\nQ53F19\tQ9BXP5\nQ53F19\tQ96PV6\nQ53F19\tQ9UHI6\nQ53F19\tQ9UKV3\nQ53F19\tQ86U42\nQ53F19\tQ13155\nQ53F19\tP34897\nQ53F19\tQ9UKS6\nQ53F19\tO14950\nQ53F19\tP53582\nQ53F19\tP20700\nQ53F19\tP68400\nQ53F19\tQ5JVF3\nQ53F19\tP19784\nQ53F19\tO00629\nQ53F19\tP68036\nQ53F19\tQ96A72\nQ53F19\tQ9Y5S9\nQ53F19\tP15692\nQ53F19\tP35637\nQ53F19\tQ00688\nQ53F19\tP98179\nQ53F19\tQ9P289\nQ53F19\tO00505\nQ53F19\tQ96K17\nQ53F19\tQ9H307\nQ53F19\tQ96J01\nQ53F19\tQ8NI27\nQ53F19\tQ13769\nQ53F19\tQ13838\nQ53F19\tQ86W42\nQ53F19\tQ96FV9\nQ53F19\tP38919\nQ53F19\tQ6I9Y2\nQ53F19\tP51148\nQ53F19\tP50395\nP52298\tQ9UH99\nP52298\tP04003\nP52298\tQ8N0X7\nP52298\tK7ELV2\nP52298\tP14735\nP52298\tQ5VV67\nP52298\tP31150\nP52298\tQ5DJT8\nP52298\tP52294\nP52298\tP09603\nP52298\tP53007\nP52298\tQ9NWV8\nP52298\tQ8N5P1\nP52298\tQ9ULL5\nP52298\tO94762\nP52298\tP39880\nP52298\tQ8IXH7\nP52298\tQ9H3P2\nP52298\tQ6NZY4\nP52298\tQ8WX92\nP52298\tQ9Y580\nP52298\tQ86VM9\nP52298\tQ9H814\nP52298\tP18615\nP52298\tQ09161\nP52298\tQ9BXP5\nP52298\tQ9UHI6\nP52298\tQ8NC51\nP52298\tO75822\nP52298\tQ9P289\nP52298\tO00505\nP52298\tP51148\nP52298\tP50395\nP52298\tP61927\nP52298\tO00505\nP52298\tQ13769\nP52298\tQ9H814\nP52298\tQ09161\nP52298\tQ9BXP5\nP52298\tP38919\nQ53F19\tO00505\nQ53F19\tQ13769\nQ53F19\tQ9H814\nQ53F19\tP38919\nQ53F19\tQ09161\nQ53F19\tQ9BXP5\nQ09161\tQ53F19\nP33724\tP59632\nP33724\tP59632\nP33724\tP59632\nP33724\tP59632\nP59632\tP33724\nP0DTC9\tP0DTC4\nP0DTC3\tP0DTC4\nP0DTD2\tP0DTC4\nQ14160\tB7Z2Y1\nQ14160\tQ14155\nQ14160\tQ7Z628\nP22460\tQ14160\nQ96DN2\tQ14160\nO00429\tQ14160\nQ99569\tQ14160\nQ96DL1\tQ14160\nB7Z2Y1\tQ14160\nQ14155\tQ14160\nQ9UQB3\tQ14160\nQ9BR11\tQ14160\nQ96DL1\tQ14160\nQ14155\tQ14160\nB7Z2Y1\tQ14160\nQ9ULI0\tQ14160\nP25100\tQ14160\nP53778\tQ14160\nQ9NYB5\tQ14160\nQ15311\tQ14160\nP33402\tQ14160\nQ9P0K1\tQ14160\nQ92953\tQ14160\nO15439\tQ14160\nP35222\tQ14160\nP48065\tQ14160\nQ14160\tP08581\nP22460\tQ14160\nO00429\tQ14160\nQ14160\tQ6UY11\nQ14160\tQ9ULJ7\nQ14160\tA6NIM6\nP81408\tQ14160\nQ9NQG5\tQ14160\nQ6UXZ0\tQ14160\nQ92502\tQ14160\nQ8TA94\tQ14160\nQ9Y6R1\tQ14160\nQ9Y466\tQ14160\nQ8TBB1\tQ14160\nP22460\tQ12959\nQ9UQB3\tQ12959\nQ12959\tQ96DL1\nQ12959\tQ15311\nQ12959\tQ96A65\nQ6ZTQ3\tQ12959\nQ12959\tQ14CM0\nQ12959\tQ9NYB5\nQ12959\tQ14524\nQ12959\tQ13224\nQ12959\tP85299\nQ12959\tQ9NVW2\nQ12959\tQ99569\nQ12959\tQ6DN90\nQ12959\tQ86W11\nQ12959\tA1L4L8\nQ12959\tQ15303\nQ12959\tQ96GG9\nQ96GL9\tQ12959\nQ86UD3\tQ12959\nO60333\tQ12959\nA1A5B4\tQ12959\nQ7Z628\tQ12959\nP0C2L3\tQ12959\nQ9NS75\tQ12959\nQ96SF7\tQ12959\nB7Z2Y1\tQ12959\nQ9NS75\tQ12959\nQ9P021\tQ12959\nQ7Z628\tQ12959\nQ99569\tQ96NW7\nQ9UQB3\tQ96NW7\nQ9UQB3\tQ96RT1\nQ99569\tQ96RT1\nO00192\tQ96RT1\nQ9BY21\tQ96RT1\nQ96DL1\tQ96RT1\nQ8NHY3\tQ96RT1\nP50804\tQ12959\nP24835\tQ12959\nP50804\tQ12959\nP24835\tQ12959\nP27228\tQ12959\nQ06093\tQ12959\nP06463\tQ12959\nP21735\tQ12959\nP30911\tQ12959\nP06427\tQ12959\nP0C213\tQ12959\nP03126\tQ12959\nP27962\tQ12959\nP54667\tQ12959\nP54667\tQ12959\nP09708\tQ12959\nP17386\tQ12959\nP36807\tQ12959\nP26554\tQ12959\nQ0A442\tQ12959\nP30910\tQ12959\nP26555\tQ12959\nP16717\tQ12959\nP50804\tQ12959\nP24835\tQ12959\nP21735\tQ12959\nP06427\tQ12959\nP27228\tQ12959\nP09708\tQ12959\nA3EX99\tQ96RT1\nA3EXD5\tQ96RT1\nP06427\tQ96RT1\nQ9IDV3\tQ96RT1\nQ1A244\tQ96RT1\nP03126\tQ96RT1\nP50804\tQ96RT1\nP0C9G5\tQ96RT1\nA3EXD4\tQ96RT1\nP89432\tQ96RT1\nP0C213\tQ96RT1\nQ14160\tP06427\nQ14160\tP03126\nQ14160\tP17589\nP0C213\tQ14160\nQ14160\tP0C222\nP06427\tQ14160\nP03126\tQ14160\nP0C213\tQ14160\nP27228\tQ14160\nP24835\tQ14160\nP04299\tQ14160\nP16717\tQ14160\nP50804\tQ14160\nP21735\tQ14160\nP0C222\tQ14160\nP03333\tQ14160\nQ09SZ7\tQ14160\nQ08089\tQ14160\nQ18LE1\tQ96RT1\nQ14500\tQ14160\nP48050\tQ14160\nP12814\tQ14160\nP35609\tQ14160\nQ08043\tQ14160\nO43707\tQ14160\nQ8IWK6\tQ12959\nQ86SQ6\tQ12959\nQ96PE1\tQ12959\nP22459\tQ12959\nQ14500\tQ12959\nQ14155\tQ14160\nP33402\tQ14160\nP53778\tQ14160\nQ99569\tQ14160\nQ99569\tQ14160\nQ14155\tQ14160\nP53778\tQ14160\nP33402\tQ14160\nP35222\tQ14160\nP59595\tP63165\nP59595\tP63165\nP59595\tP09651\nP59595\tP09651\nP59595\tP09651\nP0C6X7\tO75348\nP0C6X7\tO75348\nP0C6X7\tO75348\nP0C6X7\tO75348\nP0C6X7\tO75348\n', + }, + { + kind: 'annotation', + name: 'COVID-19 KEGG pathways', + fileName: 'HomoSapiens_Pathways_KEGG_PATHWAY_FILTERED.txt', + text: 'Viral carcinogenesis\tQ00403,Q08043,Q14160,P04439,Q96EY1,Q92793,P06493,P35609,Q14653,P12814,P27348,Q7KZF4,P62258,O43707,P62191,P63104,Q12959\nInfluenza A\tP52294,P42224,P78406,Q92793,P53778,P52948,Q13838,Q14653,P08107,P52630,P0DMV8,O14980,P52292,Q86UT6,P00747,Q86U42\nHerpes simplex infection\tP19784,P42224,P62140,P67870,P04439,Q92793,P06493,P68400,P24928,Q14653,P52630,Q9UER7,Q86U32\nProximal tubule bicarbonate reclamation\tQ9Y6R1,Q9UBX3,P05023,P05026\n', + }, + { + kind: 'annotation', + name: 'COVID-19 SMART domains', + fileName: 'HomoSapiens_Protein_Domains_SMART_FILTERED.txt', + text: 'SM00265:BH4\tQ07817,Q92843,P10415\nSM00033:CH\tQ14315,P12814,Q08043,O43707,P13797,Q8NHY3,P35609,Q14155\nSM00361:RRM_1\tP11940,Q14011,Q13310,P19338,P38159\nSM00150:SPEC\tP12814,Q08043,P15924,O43707,P35609\nSM00088:PINT\tQ9UNM6,Q15008,Q14152,O00231\nSM00101:14_3_3\tP27348,P62258,P63104\nSM00948:SM00948\tP25787,O14818,P60900\nSM00557:IG_FLMN\tQ14315,Q7Z4H8,Q6UW63\nSM00490:HELICc\tQ9NR30,Q8TDD1,Q9BUQ8,Q9UHI6,O00148,P38919,Q13838,O94762\nSM00487:DEXDc\tQ9NR30,Q8TDD1,Q9BUQ8,Q9UHI6,O00148,P38919,Q13838,O94762\nSM00244:PHB\tP27105,P35232,Q99623\nSM00271:DnaJ\tQ8IXB1,Q99615,P31689,Q96EY1,O60884\n', + }, + { + kind: 'annotation', + name: 'COVID-19 InterPro domains', + fileName: 'HomoSapiens_Protein_Domains_INTERPRO_FILTERED.txt', + text: 'P-loop containing nucleoside triphosphate hydrolase\tQ9UM54,P35998,Q9NVI7,Q9ULI0,Q5T9A4,Q9HCE1,Q9NTJ3,P11586,P62195,Q02224,P33176,Q9H078,P38606,O00429,O15439,O00148,Q00839,Q9Y265,P38919,Q92900,Q96MM7,O14656,O94762,Q05639,Q5JWF2,Q9NR30,Q8TDD1,Q9UHI6,Q9BUQ8,Q13838,P51148,P25205,P06576,P33527,O95347,Q9NU22,P25705,O60333,P62191,Q9Y276,Q12959\nEF-hand-like domain\tQ08043,P42224,P60660,Q15293,Q07820,P35609,Q9Y680,O14950,P0DP23,P12814,P19105,P52630,O75746,Q9H4F8,Q96AY3,O43707,O43852,Q14257,P13797\nApoptosis regulator,Bcl-2,BH4 motif,conserved site\tQ07817,Q92843,P10415\nATPase,F1/V1/A1 complex,alpha/beta subunit,C-terminal\tP06576,P38606,P25705\nApoptosis regulator,Bcl-2 protein,BH4\tQ07817,Q92843,P10415\nDNA/RNA helicase,DEAD/DEAH box type,N-terminal\tQ9NR30,Q8TDD1,Q9BUQ8,Q9UHI6,O00148,P38919,Q13838,O94762\nFilamin/ABP280 repeat-like\tQ14315,O75592,Q7Z4H8,Q6UW63\nChaperone DnaJ\tP31689,Q96EY1,O60884\nCalponin homology domain\tQ14315,P12814,Q08043,O43707,P13797,Q8NHY3,P35609,Q14155\nRNA recognition motif domain,eukaryote\tP11940,Q14011,Q13310,P19338,P38159\nATPase,AAA-type,conserved site\tP35998,Q9ULI0,P62191,Q9Y276,P62195\nPeptidase M16,zinc-binding site\tQ10713,O75439,P14735\nKinase associated domain 1 (KA1)\tQ9P0L2,Q7KZI7,P27448\nATPase,alpha/beta subunit,N-terminal\tP06576,P38606,P25705\nHeat shock protein DnaJ,cysteine-rich domain\tP31689,Q96EY1,O60884\nATPase,F1/V1/A1 complex,alpha/beta subunit,nucleotide-binding domain\tP06576,P38606,P25705\nATPase,alpha/beta subunit,nucleotide-binding domain,active site\tP06576,P38606,P25705\nSpectrin/alpha-actinin\tP12814,Q08043,P15924,O43707,P35609\nImmunoglobulin E-set\tQ14315,Q96DL1,O75592,P08581,Q14500,Q7Z4H8,P48050,P61916,Q6UW63\nEF-Hand 1,calcium-binding site\tP0DP23,P12814,P19105,O75746,Q9H4F8,Q15293,Q96AY3,O43707,O43852,P13797,Q14257,Q9Y680,O14950\nEF-hand domain\tQ08043,P60660,Q15293,P35609,Q9Y680,O14950,P12814,P0DP23,P19105,O75746,Q96AY3,O43707,O43852,P13797,Q14257\n26S proteasome subunit P45\tP35998,P62191,P62195\nG-protein beta WD-40 repeat\tO43818,P53621,K7ELV2,P78406,P35606,P63244,Q16576,Q96J01\n14-3-3 protein\tP27348,P62258,P63104\n14-3-3 protein,conserved site\tP27348,P62258,P63104\n14-3-3 domain\tP27348,P62258,P63104\nApoptosis regulator,Bcl-2,BH3 motif,conserved site\tQ07817,Q07820,P10415\nProteasome A-type subunit\tP25787,O14818,P60900\nProteasome,alpha-subunit,N-terminal domain\tP25787,O14818,P60900\nChaperone DnaJ,C-terminal\tP31689,Q96EY1,O60884\nHSP40/DnaJ peptide-binding\tP31689,Q96EY1,O60884\nHelicase,C-terminal\tQ9NR30,Q8TDD1,Q9BUQ8,Q9UHI6,O00148,P38919,Q13838,O94762\nCation efflux protein\tQ8NEW0,Q6NXT4,Q6PML9\nCation efflux protein transmembrane domain\tQ8NEW0,Q6NXT4,Q6PML9\nFilamin/ABP280 repeat\tQ14315,Q7Z4H8,Q6UW63\nSpectrin repeat\tP12814,Q08043,O43707,P35609\nHelicase,superfamily 1/2,ATP-binding domain\tQ9NR30,Q8TDD1,Q9BUQ8,Q9UHI6,O00148,P38919,Q13838,O94762\nBromodomain,conserved site\tP25440,Q9ULI0,Q92793,O60885\nAnoctamin/TMEM 16\tA1A5B4,Q32M45,Q4KMQ2\nRNA helicase,ATP-dependent,DEAD-box,conserved site\tQ8TDD1,Q9BUQ8,Q9UHI6,P38919\nMitochondrial carrier protein\tP53007,O75746,Q9BQT8,Q9H936\nTetratricopeptide TPR-1\tQ99615,O94826,P31948,O43765\nBand 7 protein\tP27105,P35232,Q99623\nWD40-repeat-containing domain\tK7ELV2,Q9ULV4,P78406,P35606,Q86W42,Q16576,Q96JC1,O43818,B7Z2Y1,P53621,Q6P2E9,Q9H270,Q12770,P63244,Q96J01\nvon Willebrand factor,type A\tQ9NWV8,P05556,P12109,P12956,Q9NU22,P13010,P10155\nDnaJ domain\tQ8IXB1,Q99615,P31689,Q96EY1,O60884\nProteasome/cyclosome,regulatory subunit\tQ13200,Q99460\nApoptosis regulator,Bcl-2/ BclX\tQ07817,P10415\nTranscription factor TFIIB,cyclin-like domain\tQ00403,Q92994\nInsulin-induced protein family\tO15503,Q9Y5U4\nCoatomer,WD associated region\tP53621,P35606\nKu70/Ku80 C-terminal arm\tP12956,P13010\nKu70/Ku80,N-terminal alpha/beta\tP12956,P13010\nKu70/Ku80 beta-barrel domain\tP12956,P13010\nRab GDI protein\tP50395,P31150\nATPase,F1 complex beta subunit/V1 complex,C-terminal\tP06576,P38606\nMago nashi protein\tP61326,Q96A72\nClathrin/coatomer adaptor,adaptin-like,N-terminal\tQ9UBF2,O00203,P53618', + }, + { + kind: 'annotation', + name: 'COVID-19 GO biological process', + fileName: 'HomoSapiens_Gene_Ontology_GOTERM_BP_DIRECT_FILTERED.txt', + text: 'negative regulation of anoikis\tP06731,Q07817,P05556,Q07820,P10415\nT cell receptor signaling pathway\tP35998,P48556,Q13200,P25787,Q99460,P62195,Q9UNM6,O14818,Q15008,P51665,P60900,O00231,P62191\nregulation of cellular response to heat\tQ99615,P08107,K7ELV2,P62258,Q9UL15,O95816,P0DMV8,P78406,Q92793,P52948\nRNA secondary structure unwinding\tQ9NR30,Q8TDD1,Q9BUQ8,Q9UHI6,O00148,P38919,Q13838\nSRP-dependent cotranslational protein targeting to membrane\tP60866,P08708,P61254,P62269,P39019,P42677,P61927,P62753,Q9Y3U8,P46782\npositive regulation of establishment of protein localization to telomere\tP40227,P17987,P48643,Q99832\nFc-epsilon receptor signaling pathway\tP35998,P48556,Q13200,P25787,Q99460,P62195,P0DP23,Q9UNM6,O14818,Q15008,P51665,P60900,O00231,P62191\nmRNA transport\tQ09161,O14980,Q9Y5S9,Q53F19,P09651,Q96A72,Q9UN86\nnegative regulation of transcription elongation from RNA polymerase II promoter\tQ8WX92,Q9H3P2,P18615,O94762\nnuclear export\tQ09161,Q9H814,P52298,P09651\nresponse to hydrogen peroxide\tO00629,Q13443,P22460,P42224,Q9P289,P09601,P10415\nintracellular protein transport\tQ9UM54,Q9H173,Q9ULJ7,Q14974,O00203,P35606,O14964,Q96JC1,P53621,O95373,Q9UBF2,Q9H270,O15397,O14980,O43592,P53618\nrRNA processing\tP60866,Q9NR30,P39019,P62753,P61927,P57678,O43818,P08708,P61254,Q9NU22,P62269,P42677,P38919,P46782,Q9Y3U8\nrelease of cytochrome c from mitochondria\tQ07817,Q16548,Q3ZCQ8,O00429,P10415\npositive regulation of translation\tP11940,Q9BRP8,Q14011,Q6PKG0,P98179,P38919,Q13838\nfocal adhesion assembly\tP12814,Q08043,P35609,Q14155,P10415\ncalcium activated phosphatidylserine scrambling\tA1A5B4,Q32M45,Q4KMQ2\ngene silencing by RNA\tP30876,Q09161,Q7KZF4,P11940,K7ELV2,P52298,O15397,P78406,P52948,P24928\nnegative regulation of programmed cell death\tP02768,Q96EY1,O76024,P62195\nprotein folding in endoplasmic reticulum\tQ8N766,Q8IXB1,Q86YB8,P11021\npositive regulation of viral genome replication\tP62937,P11940,Q6PKG0,Q9P035,P78563\nprotein complex assembly\tP52907,O14745,Q9BSJ2,O43292,Q9Y375,Q9NU22,P30153,Q92793,P06493,O00505\nmitochondrion organization\tP06576,P35232,Q5VV67,O00429,Q96EY1,P09874,Q9Y276,Q99623\nT cell differentiation in thymus\tP61769,Q96EY1,P62753,P35222,P10415\nintra-Golgi vesicle-mediated transport\tP53621,Q9UBF2,P35606,P39880,P53618\nprotein homotetramerization\tQ9H773,Q96GX9,O00429,P34897,Q14500,P35609,P14735\ncalcium activated galactosylceramide scrambling\tA1A5B4,Q32M45,Q4KMQ2\ncalcium activated phosphatidylcholine scrambling\tA1A5B4,Q32M45,Q4KMQ2\nviral entry into host cell\tP08107,P27487,P05556,P15151,P0DMV8,Q9BYF1,P15144,O00505,P14735\nDNA duplex unwinding\tQ13283,P12956,P13010,Q9Y265,P25205,O94762\nplatelet degranulation\tP0DP23,P12814,P05155,P02768,O15439,P15692,O43707,P00747,P35609\nregulation of nucleic acid-templated transcription\tP12814,P35637,O43707,P35609\ncell proliferation\tO15503,P68036,Q14160,Q13561,P08581,P13010,Q16576,P06493,Q15303,P25100,Q07817,P09603,Q9BXP5,Q6PKG0,O95071,P42677,Q7L590,P12004,Q10589,P10415\nosteoblast differentiation\tP35998,Q9NR30,Q7KZF4,P06576,P35232,P12109,Q00839,J3QSV6,P38159\nintrinsic apoptotic signaling pathway in response to DNA damage\tQ07817,Q16548,Q92843,Q07820,P09601,P10415\nregulation of DNA recombination\tP52294,P52292,Q96FV9\npositive regulation of histone H3-K4 methylation\tP26358,P35222,Q9H3P2,P18615\ntransport\tP53007,Q9Y6R1,P02768,Q9UBX3,P52948,Q15818,Q9UN86,Q9NZJ7,Q15311,P46379,P33527,P38606,Q13283,Q00325,Q02978,Q9Y3A6,Q13224,Q9H936,P48065\npositive regulation of intrinsic apoptotic signaling pathway\tO14745,Q07817,O00429,P63244,P10415\n7-methylguanosine mRNA capping\tP30876,Q09161,P52298,Q53F19,P24928\nSREBP signaling pathway\tO15503,Q9Y5U4,Q12770\nIRES-dependent viral translational initiation\tP26599,Q14152,O00303\nextrinsic apoptotic signaling pathway in absence of ligand\tQ07817,Q16548,Q92843,Q07820,P10415\nribosomal small subunit assembly\tP08708,P39019,P42677,P46782\nubiquitin-dependent protein catabolic process\tP35998,P68036,P46379,Q9UNM6,P25787,O14818,Q9NXK8,O95071,P60900,O00231,P63279,Q9NVW2\nregulation of translational initiation\tQ09161,P52298,O75822,Q14152,O00303\nestablishment of integrated proviral latency\tP62937,P12956,P13010\nresponse to endoplasmic reticulum stress\tQ8IXB1,Q13438,P57088,Q9Y4L1,P16615,O76024,Q9BS26\nchaperone-mediated protein folding\tQ96AY3,P68400,O14656,Q9Y680,Q00688\ngastrulation\tP37173,P63244,P62753,P09622\nfibrinolysis\tP07355,P00750,P05155,P00747\nnegative regulation of mRNA splicing,via spliceosome\tO00422,P26599,Q9UKV3,P38159\nRNA processing\tQ13310,Q8TDD1,Q9UHI6,P98179,Q96FV9,Q00839,P78563,Q86U42\nnegative regulation of apoptotic process\tQ9NVI7,P31689,P02768,Q96GX9,Q92843,Q96EY1,P62753,Q86W42,P06493,Q07820,Q15303,P06731,Q07817,Q16548,P46379,Q9Y466,P11021,P15692,Q5JVF3,Q99623,P10415,P63104\nnegative regulation of intrinsic apoptotic signaling pathway\tQ07817,Q92843,Q07820,P10415\nprotein targeting\tP27348,Q13438,P62258,Q96RT1,P63104\nregulation of smooth muscle cell proliferation\tP12956,P13010,P35222\npositive regulation of RNA polymerase II transcriptional preinitiation complex assembly\tP35998,P62191,P62195\nresponse to hypoxia\tP22460,P00750,P37173,P27487,O00469,Q12770,P15692,Q92793,P09601,O43707,P05026\nresponse to drug\tQ9UM54,Q5JWF2,P42224,P13010,P06493,Q92820,P35222,P61769,P37173,P33527,O14980,P40939,O15439,P28300,P05023,P10415\nmembrane repolarization during cardiac muscle cell action potential\tP62258,P05023,P05026\nendoplasmic reticulum mannose trimming\tQ13438,Q9BZQ6,Q9NYU1\npositive regulation of potassium ion transport\tP33176,P35609,Q12959\npositive regulation of cellular component movement\tP39019,P15692,O43707\nresponse to unfolded protein\tP34932,Q96CS3,P31689,O95757,Q9BS26\ncellular protein modification process\tP00750,P68036,P38435,O00469,Q86YB8,P22314,P28300,P63279\nprotein peptidyl-prolyl isomerization\tQ6UX04,P62937,Q96AY3,Q9Y680,Q00688\nmuscle contraction\tQ9NYL9,P0DP23,P19105,Q08043,P60660,Q14500,P50402,Q86U42,O14950\ntype I interferon signaling pathway\tP42224,P52630,P04439,P05161,Q10589,Q14653\npositive regulation of protein ubiquitination\tQ9NV92,P68036,Q13155,Q96EY1,P11021,O76024\ngluconeogenesis\tP04406,P53007,O75746,Q9UBX3,Q02978\nmitotic nuclear envelope disassembly\tK7ELV2,P78406,P06493,P50402,P52948\npositive regulation of mesenchymal cell proliferation\tP37173,P42224,P15692,P35222\nprotein targeting to plasma membrane\tP07355,P19105,Q92953,P35613,O14950\npositive regulation of protein targeting to mitochondrion\tP19784,P48556,Q8TAD8,P68036,Q9H270,P55786,Q9Y265\nnuclear pore complex assembly\tP57088,Q9NQC3,P52948\npositive regulation of DNA-templated transcription,elongation\tQ13769,Q96FV9,Q13838\nendoplasmic reticulum unfolded protein response\tO76061,P11021,Q9BZQ6,Q9NYU1,O76024\nER-associated misfolded protein catabolic process\tQ99470,Q9NYU1,O14656\nrelaxation of cardiac muscle\tP05023,P16615,P05026\nproteasome assembly\tP48556,Q9UNM6,O00231\nregulation of cardiac muscle cell contraction\tQ14524,P05023,P63165\nlysine catabolic process\tQ9UDR5,Q9BQT8,P09622\npotassium ion import\tQ14500,P48050,P05023,P05026\nproteolysis involved in cellular protein catabolic process\tP25787,O14818,P11021,P60900,P14735\nsubstantia nigra development\tP0DP23,P27348,O15027,P62258,P11021\nestablishment or maintenance of epithelial cell apical/basal polarity\tQ7KZI7,Q96RT1,Q12959\nmicrotubule cytoskeleton organization\tQ9P0L2,P04406,Q96N67,Q7KZI7,P06493,P27448\npositive regulation of protein insertion into mitochondrial membrane involved in apoptotic signaling pathway\tP27348,P62258,P10415,P63104\n', + }, + { + kind: 'annotation', + name: 'COVID-19 GO molecular function', + fileName: 'HomoSapiens_Gene_Ontology_GOTERM_MF_DIRECT_FILTERED.txt', + text: 'poly(A) RNA binding\tP62753,J3QSV6,Q16630,Q13283,P62269,O00148,Q02978,O43707,Q00839,Q86WX3,Q13151,P68036,Q9BRP8,P17987,P39019,Q8TDD1,Q6NZY4,P11940,Q9UKV3,P62258,P56270,P16402,P67809,P61254,P63244,Q8NC51,P38159,P40227,Q9Y580,Q14974,P12956,P09874,Q00688,P78563,P30876,Q7KZF4,Q6PKG0,P63279,Q92900,P63104,Q7L8L6,Q8N5P1,Q9BUQ8,Q86VM9,Q14152,P63165,Q9UN86,Q13838,Q8WTT2,O43818,P50395,Q9BXP5,O75400,P61326,Q96A72,Q9Y3U8,P07355,Q09161,Q9Y5A9,P31948,P18615,Q6PJT7,Q9H307,Q5VV67,Q8NCA5,P09651,P15924,Q13310,P20290,P52298,P22314,P49756,P04439,P24928,P26599,P35637,Q9Y5S9,Q9NW13,P19338,P62191,Q15365,P04003,O75534,Q9ULX6,Q9HCE1,Q53F19,O00422,P52292,Q14739,P98179,P38919,Q9UKA9,P46782,Q86U42,Q16891,P60866,Q8TAD8,Q9NR30,P13010,P08708,P62937,P49368,Q14011,P25705,Q7KZI7,P42677,P08621,Q9NQC3,Q86U32,O75940,Q10589\nprotein binding\tQ9P021,Q13200,Q96GX9,Q13769,Q5H9R7,Q3ZCQ8,Q99988,Q86VR2,P62753,Q9NTJ3,P0DP23,P61769,Q9BSJ2,O43292,P62269,Q8TBB1,P59998,P50402,Q8IXH7,Q00839,P34741,Q99569,Q13561,Q07820,P55198,Q13352,Q4KMQ2,Q9NZJ7,Q96GG9,Q9H3K2,Q07817,Q9UKV3,P52630,P62258,Q86YB8,Q8NC51,P35998,P40227,Q00403,P26358,Q96PE1,P35606,Q92793,Q96FV9,Q15303,P09874,P35609,Q9Y680,Q00688,P78563,Q13443,Q15311,P27348,Q6PKG0,Q12789,P11021,Q92900,P63104,Q9ULJ7,Q8WX92,Q7L8L6,P35613,Q9BUQ8,Q9UBX3,Q9P035,P25100,Q13838,Q9NQG5,P06576,Q9Y5U4,Q13438,Q9BXP5,O60333,P55085,Q13224,Q9NS75,P53618,Q09161,O14745,Q9P0K1,Q9UL15,Q9Y5A9,P31948,P30153,Q9H773,Q6PJT7,O75592,Q8NCA5,Q9Y3A6,P57088,O00303,P05023,P09651,P05026,Q9NZ01,P52294,Q9BQ95,Q96CW5,O95619,Q13310,P20290,P52298,Q8N0Z6,P49756,P14735,P24928,P37173,Q9H270,Q9Y5S9,P51665,O00192,P60900,Q96HW7,P00747,P19338,Q15365,Q12959,P27105,Q9BSM1,P25787,P04003,Q9UQB3,Q14CM0,P81408,P67870,Q9HCE1,P68400,Q8IXB1,P33176,O00422,P00750,Q96IZ7,P08107,P46379,P83916,Q8NEM0,Q96DZ1,P48643,O00429,P52292,P05161,O43765,P46782,Q99623,Q9UKS6,Q9NR30,O75348,P52948,P51148,Q8N0X7,P49368,Q99615,P05155,O15397,P0DMV8,P08621,Q6ZTQ3,Q9NQC3,O43852,Q10589,O76024,P25440,P04406,Q9ULV4,Q14524,Q96EY1,P53778,P35247,P09601,Q16630,Q5BJF2,Q9H845,O15027,Q13283,P15151,Q9NXK8,O00148,O95071,O95070,P53396,Q7L590,O43707,O14656,P10415,Q05639,Q86WX3,P68036,Q9BRP8,Q9Y6R1,P05556,O75822,P17987,Q13155,P39019,Q00765,Q13011,Q15293,P48668,O60884,Q6NZY4,O60885,O60684,P11940,P61254,P56270,P67809,Q9NU22,Q15008,P63244,Q96HR9,P31150,P48065,P38159,Q9BS26,Q9H173,Q9UM54,P34932,Q96A65,Q14974,O43633,Q96IV0,P12956,Q14160,Q96PV6,P16615,P35222,Q99832,P62195,P12814,P30876,P19105,Q9H078,Q02224,P52907,Q7KZF4,P35232,Q9Y375,O95816,Q14257,P63279,Q5JWF2,O94826,Q8N5P1,Q86VM9,Q14152,P07195,Q9H3P2,P63165,Q9UN86,Q14155,Q6DN90,Q14653,Q96NW7,P09603,P50395,Q9H814,O95347,P40939,P31431,O75400,Q8IWR1,Q9UER7,P61326,Q96A72,P07355,P31689,P42224,K7ELV2,P39656,Q16576,Q9BYF1,P11586,P18615,P27487,Q9NWV8,Q7Z5G4,Q9H8W4,P13804,Q9Y6M9,Q8N8U2,Q9Y265,Q96SF7,O00629,Q5SW79,P15924,P62140,P22459,P22314,Q9UHI6,Q99471,Q9UH99,P04439,Q96RT1,O14950,Q96S52,P22460,P26599,O14818,P20700,P35637,Q12770,Q8NI27,Q9H4F8,Q86UT6,P15692,Q9P289,P61158,P62191,Q9Y276,P19784,Q8IV08,Q96CS3,O15503,P08581,Q92843,O75534,P06493,Q9ULX6,P61916,Q53F19,O14964,O95373,Q9NV92,Q16548,Q96F46,O00124,Q14739,P28300,O75934,P98179,P48050,P38919,P12004,Q86U42,Q16891,P60866,Q8IWF2,Q6I9Y2,Q8TAD8,Q08043,Q8TF76,P02768,P60660,P13010,P34897,P57678,Q9NYU1,O00505,P27448,P25205,Q14315,Q6P2E9,P62937,O60762,Q14011,Q92552,O14980,P25705,Q7KZI7,P42677,Q96K17,Q5JVF3,O00231,Q86U32,O75940,P49069\nprotein domain specific binding\tQ14974,O43633,Q14524,Q9UHI6,P67870,P35609,O14964,P0DP23,P27348,Q9H270,P62258,O14980,P11021,P05023,P00747,P63104\nion channel binding\tP0DP23,P12814,P27348,Q08043,P62258,Q14524,O43707,P35222,P63165,P35609,Q12959\nHsp70 protein binding\tQ8IXB1,P31689,P46379,P31948,Q96EY1,P06493\nglycoprotein binding\tQ8IWF2,P61769,P00750,Q13438,Q96DZ1,P11021,Q9BYF1,P14735\nTBP-class protein binding\tP35998,Q00403,Q92994,P62191,P62195\nestrogen receptor binding\tP35637,Q8TDD1,P09874,P12004,P35222,Q99623\nmisfolded protein binding\tQ8IXB1,P46379,P11021,O14656\nprotein transporter activity\tO00629,P52294,O60684,O95373,Q14974,O15397,P52292,O00505\nBH3 domain binding\tQ07817,Q07820,P10415\nactin filament binding\tQ14315,P12814,Q9UM54,P47756,Q9ULV4,P59998,O43707,P61158,P13797,Q8NHY3,P35609\npoly(A) binding\tP11940,Q6PJT7,Q13310,P38919\nidentical protein binding\tP04406,P42224,Q96GX9,P67870,P09874,Q99832,P35609,P61769,Q9H773,P11310,P27487,Q8NEM0,O95816,O00429,O00148,Q7L590,P12004,P10415,P63104,P02768,Q13561,Q9UH99,P34897,P07195,Q13838,Q14653,P06731,Q07817,O14818,P52630,P35637,P15692,Q9P289,P19338,P38159\nRan GTPase binding\tO95373,Q14974,O15397,O14980,O43592\nintegrin binding\tQ13443,P12814,Q08043,Q9P0K1,P05556,O43707,Q96RT1,Q08431,P35609\nproteasome-activating ATPase activity\tP35998,P62191,P62195\ndolichyl-phosphate-mannose-protein mannosyltransferase activity\tO60762,Q9Y673,Q99470\ncytoskeletal protein binding\tP07355,Q14315,Q9UKS6,O14656,P35609,Q12959\noxidoreductase activity,acting on the CH-CH group of donors\tQ9NZ01,Q9H845,P11310,Q14739\nvirus receptor activity\tP08107,P27487,P05556,P15151,P0DMV8,Q9BYF1,P15144,P14735\nprotein N-terminus binding\tP19784,Q96A65,P27348,Q9UER7,O43707,P09874,P68400,Q99623\nprotein homodimerization activity\tP27105,P42224,Q92843,O76061,P09601,Q15303,P35609,P12814,Q16548,P83916,P27487,P15151,O00429,O43707,Q96SF7,P10415,Q08043,P39019,Q07820,P14735,Q14653,Q4KMQ2,P06731,Q07817,P09603,P63244,P15692,P51665,Q9UER7,Q9P289,Q10589,Q6UY11\nNAD binding\tP04406,P40939,P07195,P09622,P09874\nprotein kinase binding\tQ05639,Q13151,P62140,Q14524,Q9UL15,P39019,Q96EY1,P62753,P09874,P35222,Q14155,P0DP23,Q07817,P22460,P62269,Q9UER7,P05023,P63104,Q12959\nrRNA binding\tQ9NR30,Q7L8L6,P62269,P61927,P46782\nhistone deacetylase binding\tP35232,P08107,P62258,P0DMV8,P52292,Q9UHI6,Q9Y466,Q9ULX6,P09874\ntelomeric DNA binding\tP12956,P13010,P19338,Q92900\nRNA cap binding\tQ09161,Q6PKG0,P52298\nchromatin binding\tP25440,P26358,Q8N0Z6,Q9ULI0,P67870,P34897,Q92793,P06493,O60885,Q9H3P2,P18615,P30876,Q8WTT2,P83916,P67809,Q92900,P12004,Q6PML9,P38159\ncation transmembrane transporter activity\tQ8NEW0,Q6NXT4,Q6PML9\nphospholipid scramblase activity\tA1A5B4,Q32M45,Q4KMQ2\npeptidyl-prolyl cis-trans isomerase activity\tQ6UX04,P62937,Q96AY3,Q9Y680,Q00688', + }, + { + kind: 'annotation', + name: 'COVID-19 GO cellular component', + fileName: 'HomoSapiens_Gene_Ontology_GOTERM_CC_DIRECT_FILTERED.txt', + text: 'membrane\tP04406,Q13200,O00203,P62753,Q9NXS2,P09601,Q16630,J3QSV6,P61769,P53621,Q9BSJ2,O43292,P62269,O00148,O95071,P53396,Q2PZI1,P50402,Q00839,Q8IXH7,Q8TEQ8,O14656,P10415,P05556,Q13561,Q13155,P39019,Q8TDD1,Q13011,O60884,Q9BZQ6,Q07820,P29122,Q13352,Q4KMQ2,Q9NZJ7,Q9P2E5,O60684,P11940,Q07817,P62258,P61254,Q9NU22,P38159,Q8NC51,Q9UM54,P35998,Q96A65,Q14974,O43633,P12956,P09874,P16615,P35222,P62195,P30876,Q02224,Q7KZF4,P27348,Q15311,P35232,Q9Y673,Q6PKG0,Q12789,P11021,Q5JWF2,O94826,P35613,Q14152,P07195,Q6DN90,P06576,P50395,P09603,P33527,O75400,Q9Y3U8,P53618,P07355,O14745,P31689,Q9H488,Q99519,Q9UL15,P39656,P30153,Q9Y6E2,Q99460,Q96S66,P11586,P27487,O75592,Q9H307,O00303,Q9Y265,P05023,P09651,P05026,P51570,Q96CW5,Q9UHI6,P04439,Q99470,Q7Z2K6,Q96S52,P26599,P20700,P47756,Q00325,P15692,P51665,P61158,P62191,P19338,Q15365,P27105,P06493,P33176,Q8IXB1,O95373,P46379,O15439,Q14739,P52292,O00429,P38919,O43765,P46782,Q99623,Q16891,P60866,Q9NR30,Q9C0B5,P60660,P13010,P57678,P25205,P08708,Q6P2E9,P62937,Q99615,Q8NEW0,O60762,P38435,P12109,Q9UNM6,O14980,P25705,Q7KZI7,Q9Y4L1,O43852,O00231,Q08431,P49069,Q6NXT4,Q10589\nnucleoplasm\tP48556,Q13200,Q13769,Q5H9R7,Q3ZCQ8,P53778,P62753,Q16630,Q9NTJ3,P0DP23,Q9BSJ2,P62269,O00148,O95071,P53396,Q7L590,Q00839,Q8IXH7,Q96J01,Q86WX3,Q13151,Q9BRP8,P39019,P39880,Q6NZY4,Q86W42,Q07820,O60885,Q13352,O60684,Q6IEG0,Q9UKV3,P52630,P67809,Q15008,Q9H936,P38159,Q9UM54,P35998,Q00403,Q9Y580,Q14974,P26358,Q14160,P12956,Q92793,P09622,Q96FV9,Q15303,P09874,P35222,P62195,P78563,P30876,P35232,Q6PKG0,Q12789,Q96MM7,Q92900,P63279,O94762,P63104,Q8WX92,Q86VM9,Q9BUQ8,Q92994,Q8N5P1,Q9H3P2,Q9NVW2,P63165,Q14653,Q13838,Q9NQG5,O43818,Q9BXP5,Q9H814,O95347,Q9Y466,O75400,O43592,Q9UER7,P61326,Q09161,P42224,Q99460,Q16576,P18615,Q6PJT7,Q9NWV8,Q5VV67,Q9H307,Q9Y265,P09651,O00629,P52294,Q9BQ95,O95619,P62140,P52298,Q8N0Z6,P49756,Q9UHI6,Q96RT1,P14735,P24928,P26599,P20700,O14818,P35637,Q9Y5S9,Q8NI27,P51665,Q96HW7,P60900,P62191,P19338,Q15365,P19784,Q7Z4H7,P25787,P67870,P06493,Q9ULX6,P68400,O00422,O95373,P08107,P83916,P46379,Q8NEM0,P52292,O75934,P98179,P38919,P12004,P05161,P46782,Q86U42,P60866,Q6I9Y2,Q9UKS6,Q9NR30,Q8TAD8,P13010,P57678,O00505,P52948,P25205,P08708,Q6P2E9,Q99615,Q14011,Q9UNM6,O15397,O14980,P0DMV8,P42677,P08621,Q86U32,O00231\npseudopodium\tP12814,Q08043,P55085,O43707,P35609\nbrush border\tP12814,P19105,Q08043,P47756,P60660,O00429,O43707,P61158,O14950\nnuclear matrix\tO95619,P20700,O75400,O43592,P60900,Q9ULX6,Q96FV9,Q9Y265,Q99623,Q13838\nzona pellucida receptor complex\tP49368,P17987,P48643,Q99832\nextracellular matrix\tP07355,P60866,P40227,P04406,P15924,P60660,P39019,P29122,P08708,P06576,P00750,P12109,P62269,P40939,P25705,P11021,Q00839,Q08431,P46782\ncytosolic small ribosomal subunit\tP60866,P08708,P62269,P39019,P42677,P62753,P46782\nactin filament\tP12814,Q08043,Q9H270,P47756,Q7KZI7,Q96EY1,P13797,P35609\ncytosolic proteasome complex\tP35998,P62191,P14735,P62195\nsarcolemma\tP07355,Q14315,P05556,P12109,Q14524,P35613,P05023,P05026,Q12959\nvesicle\tP07355,P0DP23,P27105,P33176,P50395,O14745,Q8NEW0,P04406,Q13561,Q9ULV4,P60660\nplatelet alpha granule lumen\tP12814,P05155,P02768,P15692,O43707,P00747,P35609\nperinuclear region of cytoplasm\tP07355,Q9UM54,P27105,O14745,P04406,P31689,P42224,Q9UL15,O76061,P62753,P09601,P16615,P35222,P33176,Q9NV92,P08107,O00429,O43707,Q5JWF2,P05556,Q99569,P25205,Q8NEW0,P09603,P22460,P35637,P0DMV8,P63244,Q9P289,Q8NC51,Q12959\nspliceosomal complex\tP49756,O00148,O75934,P08621,Q9NW13,Q9UKA9,O75940,P09651,Q13838\nendoplasmic reticulum quality control compartment\tQ13438,Q96DZ1,Q9BZQ6,Q9NYU1\nmembrane raft\tP07355,P27105,O14745,P22460,P37173,P27487,P05556,P35613,P31431,P35052,Q9BYF1,P07195,Q10589,Q12959\nZ disc\tQ14315,P12814,P19105,P22460,Q08043,P47756,Q14524,O43707,P35222,P35609,O14950\nCOPI vesicle coat\tP53621,Q9UBF2,P35606,P53618\nsmall ribosomal subunit\tP60866,P62269,P63244,P62753,P46782\nmitochondrial nucleoid\tP06576,Q9NVI7,Q7L8L6,P40939,P34897,Q96EY1\ncell surface\tP07355,P05556,Q96PE1,Q14524,P08581,P04439,Q9BYF1,P29122,P14735,Q4KMQ2,Q13443,P06576,P00750,P35232,P27487,P15151,P31431,P11021,P15692,Q13224,Q00839,P00747,Q10589,P34741,Q9BS26,Q99623\nCRD-mediated mRNA stability complex\tP67809,O75534,Q00839\nendoplasmic reticulum-Golgi intermediate compartment\tO95070,P11021,Q9Y3A6,P15144,Q9NYU1,Q9BS26,P53618\nmicrotubule\tP40227,Q5SW79,Q96CW5,Q7Z4H7,P17987,Q13561,Q14152,Q99832,P49368,P33176,Q02224,Q9BSJ2,P48643,O00429,O60333,P50402,Q12959\nendocytic vesicle\tQ9UM54,P33176,Q9H270,P27487,P35247,P51148\ncytoplasmic stress granule\tP11940,Q14011,Q13310,Q13283,P67809\nproteasome regulatory particle,lid subcomplex\tP48556,Q9UNM6,O00231\nproteasome core complex,alpha-subunit complex\tP25787,O14818,P60900\nnuclear proteasome complex\tP35998,P62191,P62195\nmitochondrial matrix\tQ96EY1,P34897,O75439,Q07820,Q15303,P09622,Q8IZ52,Q9UDR5,Q07817,P06576,P11310,Q5JRX3,Q9BW92,Q10713,P25705,P13804,Q6L8Q7\ncytoplasmic mRNA processing body\tP35998,Q6P2E9,P25787,Q9Y5A9,P60900,Q9HCE1,Q92900\nlysosomal membrane\tP07355,Q9UM54,Q8IWA5,O75348,Q99519,O00203,P22314,Q96JC1,P51148,B7Z2Y1,P12109,P27487,Q9H270,P38606,P15144\nintracellular membrane-bounded organelle\tP35998,P04406,O14745,Q99519,P39656,Q96EY1,Q96S66,O14964,Q9NV92,P46379,O00429,O95070,Q9Y265,P05023,Q8N0Z8,P25205,P51148,Q9UDR5,Q6P2E9,O60762,Q9H814,P67809,O14980,Q15365,P53618\nmitochondrial outer membrane\tQ07817,Q16548,O94826,Q92843,O00429,Q86UT6,Q07820,Q99623,P10415,Q8N0X7\nintegral component of endoplasmic reticulum membrane\tQ9NZ01,O00124,Q14739,P11021,P57088,Q9P035,Q9NQC3,O76024\ndesmosome\tP15924,Q99569,P22314,Q9H307\ncentrosome\tQ6NXT6,O14745,Q5SW79,Q96CW5,Q8TF76,Q7Z4H7,P17987,Q13561,P06493,P35222,P25205,Q9NQG5,P0DP23,Q07817,Q9H814,Q9BSJ2,P48643,Q8IWR1,Q9P289,P12004\nfascia adherens\tP12814,P15924,P35222\ncytoplasmic ribonucleoprotein granule\tP11940,P62753,Q00839,P19338\ncaveola\tP22460,P37173,Q14524,P09601,P05023,P05026\nendoplasmic reticulum chaperone complex\tQ8IXB1,P11021,Q9Y4L1\nAP-3 adaptor complex\tQ9H270,O00203,Q96JC1\nDNA-directed RNA polymerase II,holoenzyme\tQ9NQG5,Q9UM54,O94762\nnuclear euchromatin\tP16402,P35222,P24928,P38159\nPcG protein complex\tP19784,Q9BSM1,P67870,P68400\nruffle\tP07355,P12814,Q5JWF2,Q9UM54,O14745,P05556,Q14155\nribosome\tP60866,P08708,Q86WX3,Q92552,P62269,P39019,P42677,P61927,P62753,Q9Y3U8\ntransport vesicle\tQ5JWF2,P53621,Q9UBF2,Q9H8W4,P35606,Q15818,P53618\ncell-cell junction\tP07355,Q9NYL9,P12814,Q99569,Q14160,Q9H307,O43707,P61158,P35222,Q12959\nRNA cap binding complex\tQ09161,Q53F19\nScrib-APC-beta-catenin complex\tQ14160,P35222\nproteasome storage granule\tQ13200,Q99460\n', + }, + ], + }, + gallus: { + title: 'Gallus gallus (BioGRID)', + source: 'BioGRID database', + link: null, + files: [ + { + kind: 'network', + name: 'Gallus gallus BioGRID', + fileName: 'Biogrid_no_self_loops.txt', + text: 'Source\tTarget\nP62760\tP60706\nP62764\tP60706\nO15392\tP53352\nQ9IAY5\tP49024\nQ9IAY5\tP49024\nP49024\tQ9IAY5\nQ13625\tP46936\nO93512\tO60542\nP41238\tQ7T2T1\nO42414\tQ9JI92\nO42414\tQ9JI92\nP05556\tQ00944\nP05556\tP49024\nP27986\tQ9DDT2\nP28497\tP23297\nP49024\tP12003\nP49024\tQ00944\nQ4KWZ7\tQ9DEA3\nQ9DEA3\tQ4KWZ7\nP53478\tQ9UUJ1\nP53478\tP32390\nP53478\tP78929\nP12003\tP60010\nP79987\tP56517\nP79987\tP56519\nP79987\tQ9W7I5\nP79987\tQ3C1E9\nQ9W7I5\tP79987\nP56517\tP79987\nP56519\tP79987\nQ5ZJY5\tO93257\nQ5R1T0\tP56517\nQ5R1T0\tP56519\nQ5R1T0\tP56517\nQ5R1T0\tP56519\nQ5R1T0\tP56520\nQ09472\tP17678\nP23204\tP17678\nP23204\tP23824\nP23204\tP23825\nQ92993\tP70082\nQ92993\tP0C1H3\nQ92993\tP84247\nQ92993\tP62801\nP56519\tQ9HAZ2\nO18738\tP31696\nQ8AYS7\tQ1T7B8\nQ8AYS7\tQ1T7C0\nQ8AYS7\tQ1T7B7\nQ8AYS7\tQ1T7C1\nQ8AYS7\tQ1T7B9\nQ90ZF9\tQ1T7B8\nQ90ZF9\tQ1T7C0\nQ90ZF9\tQ1T7B7\nQ90ZF9\tQ1T7C1\nQ90ZF9\tQ1T7B9\nQ76I90\tQ76I89\nQ76I89\tQ76I90\nQ8AYS8\tP09572\nQ8AYS8\tP11501\nQ8AYS8\tQ90593\nQ8AYS8\tP16053\nQ8AYS8\tP08106\nQ8AYS8\tO73885\nQ8AYS8\tQ5ZHY5\nQ8AYS8\tQ5ZL72\nQ8AYS8\tP09207\nQ8AYS8\tQ5ZLC5\nQ8AYS8\tP05094\nQ8AYS8\tP00508\nQ8AYS8\tP54097\nQ8AYS8\tP17153\nQ8AYS8\tQ5ZMT0\nQ8AYS8\tP28497\nQ8AYS8\tP62207\nQ8AYS8\tQ5ZKC9\nQ8AYS8\tQ5F3W6\nQ8AYS8\tP60878\nQ8AYS8\tQ5ZM44\nQ8AYS8\tP08250\nQ8AYS8\tQ5ZMB2\nQ8AYS8\tQ00649\nQ8AYS8\tQ5ZKK4\nQ8AYS8\tQ5ZIV5\nQ8AYS8\tQ5ZMD1\nQ8AYS8\tQ5ZLQ6\nQ8AYS8\tP07090\nQ8AYS8\tP81628\nQ8AYS8\tP42324\nQ8AYS8\tP62758\nQ8AYS8\tP62764\nQ8AYS8\tP08110\nQ8AYS8\tO93510\nQ8AYS8\tP13731\nQ8AYS8\tQ5F425\nQ8AYS8\tP18359\nQ8AYS8\tP02789\nQ8AYS8\tQ5ZL57\nQ8AYS8\tP19121\nQ8AYS8\tO57391\nQ8AYS8\tQ9PTG6\nQ8AYS8\tP63270\nQ8AYS8\tP51913\nQ8AYS8\tP05122\nQ8AYS8\tQ5ZME2\nQ8AYS8\tQ5ZHP5\nQ8AYS8\tP48463\nQ8AYS8\tP04354\nQ8AYS8\tP50890\nQ8AYS8\tP05419\nQ8AYS8\tP80566\nQ8AYS8\tP31395\nQ8AYS8\tP00940\nQ8AYS8\tQ5ZLN1\nQ8AYS8\tQ5ZJF4\nQ8AYS8\tP00340\nQ8AYS8\tP09654\nQ8AYS8\tO42163\nQ8AYS8\tP16580\nQ8AYS8\tP02112\nQ8AYS8\tP80026\nQ8AYS8\tQ5ZLG1\nQ8AYS8\tO57535\nQ8AYS8\tQ07212\nQ8AYS8\tO13268\nQ8AYS8\tP00337\nQ8AYS8\tP00356\nQ8AYS8\tP51903\nQ8AYS8\tP07341\nQ8AYS8\tP00548\nQ8AYS8\tP14732\nQ8AYS8\tP13648\nQ8AYS8\tQ5F3W6\nQ8AYS8\tP42324\nQ8AYS8\tP60878\nQ8AYS8\tP17153\nQ8AYS8\tQ5ZL72\nQ8AYS8\tQ5F425\nQ8AYS8\tP80566\nQ8AYS8\tQ01406\nQ8AYS8\tP08106\nQ8AYS8\tP31395\nQ8AYS8\tQ90593\nQ8AYS8\tQ04619\nQ8AYS8\tP14731\nQ5F3W6\tQ8AYS8\nP42324\tQ8AYS8\nP60878\tQ8AYS8\nP17153\tQ8AYS8\nQ5ZL72\tQ8AYS8\nQ5F425\tQ8AYS8\nP80566\tQ8AYS8\nQ01406\tQ8AYS8\nP08106\tQ8AYS8\nP31395\tQ8AYS8\nQ90593\tQ8AYS8\nQ04619\tQ8AYS8\nP14731\tQ8AYS8\nP84023\tP49140\nP84023\tP83038\nP38531\tP04637\nP38531\tP06876\nP38531\tP01103\nF1N9Y5\tP00548\nF1N9Y5\tP09244\nF1N9Y5\tO73885\nF1N9Y5\tQ5ZL72\nF1N9Y5\tP11501\nF1N9Y5\tQ90705\nF1N9Y5\tQ5ZLC5\nF1N9Y5\tP51913\nF1N9Y5\tQ5ZM98\nF1N9Y5\tP38024\nF1N9Y5\tP47826\nF1N9Y5\tP08070\nF1N9Y5\tQ90593\nF1N9Y5\tP15771\nF1N9Y5\tQ9IAY5\nF1N9Y5\tP50890\nF1N9Y5\tQ5ZJK8\nF1N9Y5\tP51903\nF1N9Y5\tP67883\nF1N9Y5\tP00340\nF1N9Y5\tQ6EE31\nF1N9Y5\tP22451\nF1N9Y5\tQ8UVD9\nF1N9Y5\tQ5ZJU3\nF1N9Y5\tO57535\nF1N9Y5\tQ5ZLP8\nF1N9Y5\tQ04619\nF1N9Y5\tQ5ZIQ3\nF1N9Y5\tP47836\nF1N9Y5\tQ5ZKC9\nF1N9Y5\tQ5ZK03\nF1N9Y5\tQ8AYC9\nF1N9Y5\tP00356\nF1N9Y5\tQ5ZLN4\nF1N9Y5\tQ5ZJ56\nF1N9Y5\tP18660\nF1N9Y5\tQ5ZMT0\nF1N9Y5\tQ8JFP1\nF1N9Y5\tP32429\nF1N9Y5\tP16039\nF1N9Y5\tP00337\nF1N9Y5\tP08106\nF1N9Y5\tQ5ZMS3\nF1N9Y5\tP61355\nF1N9Y5\tQ5ZJ54\nF1N9Y5\tP63247\nF1N9Y5\tQ5ZKA5\nF1N9Y5\tP08629\nF1N9Y5\tP16527\nF1N9Y5\tQ5ZMN2\nF1N9Y5\tQ5ZJZ5\nF1N9Y5\tP18359\nF1N9Y5\tQ5ZKF5\nF1N9Y5\tQ5ZLN1\nF1N9Y5\tQ9YGC1\nF1N9Y5\tQ5ZLE6\nF1N9Y5\tQ8UW59\nF1N9Y5\tQ5ZKB9\nF1N9Y5\tP13863\nF1N9Y5\tQ90WU3\nF1N9Y5\tQ5ZI72\nF1N9Y5\tQ5ZIA5\nF1N9Y5\tP11029\nF1N9Y5\tQ98TF8\nF1N9Y5\tQ5ZL42\nF1N9Y5\tQ5ZKC1\nF1N9Y5\tP17790\nF1N9Y5\tQ5ZMN3\nF1N9Y5\tQ98TF6\nF1N9Y5\tQ5ZLC6\nF1N9Y5\tP24367\nF1N9Y5\tP11799\nF1N9Y5\tQ5ZK62\nF1N9Y5\tP26584\nF1N9Y5\tP14315\nF1N9Y5\tQ5ZKG5\nF1N9Y5\tP42558\nF1N9Y5\tP41239\nF1N9Y5\tP05419\nF1N9Y5\tP43347\nF1N9Y5\tP63283\nF1N9Y5\tQ5ZJN4\nF1N9Y5\tQ5ZL57\nF1N9Y5\tQ8JG64\nF1N9Y5\tO42392\nF1N9Y5\tQ5ZK01\nF1N9Y5\tP19179\nF1N9Y5\tO93256\nF1N9Y5\tQ02391\nP28683\tP62140\nQ6A078\tP00698\nP68400\tO13016\nP12931\tO13016\nP07812\tQ04619\nP07812\tP08106\nP62207\tQ90623\nQ90ZF9\tQ76I89\nQ90ZF9\tQ76I90\nQ90ZF9\tQ76I89\nP00523\tP19785\nP11501\tQ5ZL72\nP11501\tP08106\nQ04619\tQ5ZL72\nQ04619\tP08106\nP68399\tP01109\nP67868\tP01109\nQ12341\tP02263\nQ12341\tP62801\nQ5ZL72\tP08106\nQ5ZL72\tQ04619\nQ04619\tP08106\nQ06592\tP62801\nQ06592\tP84247\nP39979\tP62801\nQ3C1E9\tP79987\nQ3C1E9\tQ5R1S9\nQ3C1E9\tP84247\nQ3C1E9\tP62801\nP07812\tQ15185\nP07812\tP07900\nP07812\tP34932\nP07812\tP25685\nP07812\tP31948\nP25293\tP70082\nP25293\tP0C1H3\nP25293\tP84247\nP25293\tP62801\nQ702N8\tP68139\nA4UGR9\tP68139\nP68139\tP42639\nP42684\tQ04929\nQ92831\tP02263\nQ92831\tP62801\nQ9DEA3\tQ4KWZ7\nQ02842\tQ5ZJL9\nQ15185\tP11501\nP00523\tP51913\nQ04982\tQ5ZIK9\nQ04982\tP48463\nQ04982\tQ5ZM98\nQ04982\tP67869\nQ04982\tO73885\nQ04982\tQ5ZMN3\nQ04982\tP16039\nQ04982\tP42558\nQ04982\tQ6U7I1\nQ04982\tP05625\nQ04982\tQ5ZMD1\nQ04982\tQ5ZLQ6\nQ04982\tQ5ZMT0\nQ04982\tQ5ZKC9\nQ04982\tP50147\nQ04982\tQ5F3W6\nQ04982\tO57476\nQ04982\tP11501\nQ04982\tQ90828\nQ04982\tQ90891\nQ04982\tQ90593\nQ04982\tP08110\nP18652\tO95071\nO42414\tP16092\nP16092\tO42414\nP61088\tQ9DEA3\nQ15819\tQ9DEA3\nQ9W7C5\tQ401C0\nQ401C0\tQ9W7C5\nP11275\tP13387\nQ7T0L4\tQ9PU53\nQ07496\tP32004\n', + }, + { + kind: 'annotation', + name: 'Gallus gallus KEGG pathways', + fileName: 'BioGrid_Chicken_Gallus_Pathways_KEGG_PATHWAY_FILTERED.txt', + text: 'ErbB signaling pathway\tQ00944,Q04929,P00523,P01109,Q90891,P13387,Q9W7C5,P05625,Q04982\nCarbon metabolism\tP00356,O57391,P00548,P07341,P51903,Q5ZLN1,Q5ZME2,P51913,P00940,P00508\nBiosynthesis of antibiotics\tP00356,O57391,P00548,P07341,O57535,P51903,Q5ZLN1,P00337,Q5ZME2,P51913,P00940,P00508,P00340,P38024\nGap junction\tP00523,P09244,Q90891,P08070,P13387,P50147,P09207,P05625,P13863\nRibosome\tQ98TF6,Q98TF8,P67883,P32429,P61355,P47826,Q5ZJ56,P47836,P50890,P18660,P22451\nFocal adhesion\tQ00944,Q04929,P00523,Q90623,P13387,P05094,P60706,P53478,P11799,P49024,P12003,Q04982,P62207,P05625\nProgesterone-mediated oocyte maturation\tQ04619,P07812,P18652,P11501,P50147,P05625,Q04982,P13863\nVascular smooth muscle contraction\tQ8AYS8,P11799,Q90623,P05419,P63270,Q90891,P62207,P05625,Q04982\nProtein processing in endoplasmic reticulum\tQ04619,Q5ZKF5,Q90593,Q8JG64,O73885,P11501,P08110,Q5ZHY5,P81628,Q5ZK03,P08106\nPyruvate metabolism\tP00548,P00337,Q5ZME2,P11029,P00340\nVEGF signaling pathway\tQ00944,P00523,P49024,Q90891,P05625,Q00649\nInsulin signaling pathway\tQ04929,P05419,Q90891,P11029,P62207,P05625,Q04982,O13016\nTight junction\tP00523,P48463,P05094,P60706,P53478,Q01406', + }, + ], + }, + 'docs-tp53': { + title: 'TP53 (NORMA documentation)', + source: 'Example from the NORMA help pages', + link: null, + files: [ + { + kind: 'network', + name: 'TP53 docs network', + fileName: '', + text: 'Source\tTarget\tWeight\nCDKN1A\tTP53\t5\nTP53\tMDM2\t1\nMDM4\tTP53\t3\nBCL2L1\tTP53\t4\nCHEK2\tATM\t2\nTP53\tEP300\t1\nATM\tTP53\t4\nTP53\tCREBBP\t1\nMDM4\tMDM2\t1\nCHEK2\tTP53\t2\nTP53BP2\tTP53\t8\nCDKN2A\tTP53\t3\nCDKN2A\tMDM2\t3\nATM\tMDM2\t1\nEP300\tCREBBP\t2\n', + }, + { + kind: 'annotation', + name: 'TP53 docs groups', + fileName: '', + text: 'Group-2\tBCL2L1,MDM4,MDM2,CHEK2\nGroup-5\tTP53,EP300\nGroup-1\tCDKN2A,ATM,TP53BP2,MDM2\nGroup-4\tCHEK2,CREBBP,MDM2\nGroup-3\tTP53,BCL2L1\nGroup-6\tMDM4,MDM2\n', + }, + { + kind: 'colors', + name: 'TP53 docs expression', + fileName: '', + text: 'CDKN1A\tblue\nTP53\tblue\nMDM4\t#00ff00\nBCL2L1\tred\nCHEK2\tred\nATM\tred\nTP53BP2\tred\nCDKN2A\tblue\nEP300\t#ffff00\nCREBBP\tred\nMDM2\tblue\n', + }, + ], + }, +} + +/* ============================================================ + TABS AND HELP LINKS + ============================================================ */ +export const TAB_VIEWS = { + welcome: 'welcomeView', + network: 'canvas', + network3d: 'canvas', + profiler: 'profilerView', + compare: 'compareView', + help: 'helpView', + about: 'aboutView', + api: 'apiView', +} + +export let currentTab = 'welcome' + +export function switchTab(name, helpAnchor) { + if (!TAB_VIEWS[name]) return + currentTab = name + document.querySelectorAll('#tabs .tab').forEach((btn) => { + const on = btn.dataset.tab === name + btn.setAttribute('aria-selected', on ? 'true' : 'false') + btn.tabIndex = on ? 0 : -1 + }) + const onNetwork = name === 'network' || name === 'network3d' + document.getElementById('canvas').classList.toggle('inactive', !onNetwork) + if (typeof setMode3d === 'function' && (name === 'network3d') !== net3d.active) + setMode3d(name === 'network3d') + document.getElementById('welcomeView').classList.toggle('active', name === 'welcome') + document.getElementById('profilerView').classList.toggle('active', name === 'profiler') + if (name === 'profiler' && typeof refreshEnrichmentChoices === 'function') + refreshEnrichmentChoices() + document.getElementById('compareView').classList.toggle('active', name === 'compare') + document.getElementById('helpView').classList.toggle('active', name === 'help') + document.getElementById('aboutView').classList.toggle('active', name === 'about') + document.getElementById('apiView').classList.toggle('active', name === 'api') + if (name === 'network') { + document.getElementById('cy').style.visibility = '' + cy.resize() + resizeHullCanvas() + drawGroupHulls() + } + if (name === 'profiler') renderProfilerNetworkList() + if (name === 'compare') renderCompareList() + if ((name === 'help' || name === 'about' || name === 'api') && helpAnchor) { + const target = document.getElementById(helpAnchor) + if (target) { + target.scrollIntoView({ block: 'start' }) + target.classList.remove('flash') + void target.offsetWidth + target.classList.add('flash') + } + } +} + +/* ============================================================ + NETWORK PROFILER + Topology statistics following igraph's definitions, computed on a + simple undirected graph (self-loops and parallel edges set aside). + Path-based measures use one BFS per node (Brandes' algorithm for + betweenness), so they are skipped above PROFILE_PATH_LIMIT nodes. + ============================================================ */ +export const PROFILE_PATH_LIMIT = 8000 + +export const PROFILE_STATS = [ + { key: 'n', label: 'Nodes', igraph: 'vcount(g)', desc: 'Number of nodes.' }, + { + key: 'm', + label: 'Edges', + igraph: 'ecount(g)', + desc: 'Connections between distinct node pairs.', + }, + { + key: 'density', + label: 'Density', + igraph: 'edge_density(g)', + desc: 'Share of all possible node pairs that are connected.', + }, + { + key: 'avgDegree', + label: 'Average degree', + igraph: 'mean(degree(g))', + desc: 'Mean number of neighbors per node.', + }, + { + key: 'maxDegree', + label: 'Maximum degree', + igraph: 'max(degree(g))', + desc: 'Most neighbors of any node.', + }, + { + key: 'isolated', + label: 'Isolated nodes', + igraph: 'sum(degree(g) == 0)', + desc: 'Nodes without any connection.', + }, + { + key: 'components', + label: 'Connected components', + igraph: 'count_components(g)', + desc: 'Separate pieces of the network.', + }, + { + key: 'largest', + label: 'Largest component', + igraph: 'max(components(g)$csize)', + desc: 'Nodes in the biggest piece, with its share of all nodes.', + }, + { + key: 'diameter', + label: 'Diameter', + igraph: 'diameter(g)', + desc: 'Longest shortest path between any two connected nodes.', + }, + { + key: 'radius', + label: 'Radius', + igraph: 'radius(g)', + desc: 'Smallest eccentricity of any node.', + }, + { + key: 'avgPath', + label: 'Average path length', + igraph: 'mean_distance(g)', + desc: 'Mean shortest-path length over all connected node pairs.', + }, + { + key: 'avgEcc', + label: 'Average eccentricity', + igraph: 'mean(eccentricity(g))', + desc: 'Mean, over nodes, of the distance to the farthest reachable node.', + }, + { + key: 'transitivity', + label: 'Clustering coefficient', + igraph: 'transitivity(g, "global")', + desc: 'Share of connected triples that close into triangles.', + }, + { + key: 'avgLocal', + label: 'Average local clustering', + igraph: 'transitivity(g, "average")', + desc: "Mean of each node's own clustering coefficient, over nodes with two or more neighbors.", + }, + { + key: 'triangles', + label: 'Triangles', + igraph: 'sum(count_triangles(g)) / 3', + desc: 'Number of three-node cycles.', + }, + { + key: 'assortativity', + label: 'Degree assortativity', + igraph: 'assortativity_degree(g)', + desc: 'Above 0: hubs link to hubs. Below 0: hubs link to sparsely connected nodes.', + }, + { + key: 'centrDegree', + label: 'Degree centralization', + igraph: 'centr_degree(g, loops = FALSE)$centralization', + desc: 'How strongly connections concentrate on one node (1 for a star).', + }, + { + key: 'centrBetw', + label: 'Betweenness centralization', + igraph: 'centr_betw(g)$centralization', + desc: 'How strongly shortest paths run through one node.', + }, + { + key: 'centrClo', + label: 'Closeness centralization', + igraph: 'centr_clo(g)$centralization', + desc: 'How much one node is closer to all others than the rest are.', + }, + { + key: 'modularity', + label: 'Modularity (Louvain)', + igraph: 'modularity(cluster_louvain(g))', + desc: 'How well the network splits into dense communities; above about 0.3 suggests clear structure.', + }, + { + key: 'communities', + label: 'Communities (Louvain)', + igraph: 'length(cluster_louvain(g))', + desc: 'Number of communities Louvain finds, including single nodes.', + }, + { + key: 'setAside', + label: 'Set aside before profiling', + igraph: 'simplify(g)', + desc: 'Self-loops and parallel edges not counted as connections.', + }, +] + +export function formatStat(v) { + if (v === null || v === undefined || (typeof v === 'number' && Number.isNaN(v))) return '—' + if (typeof v === 'string') return v + if (!Number.isFinite(v)) return '—' + if (Number.isInteger(v)) return v.toLocaleString('en-US') + const abs = Math.abs(v) + if (abs !== 0 && abs < 0.001) return v.toExponential(2) + return (+v.toFixed(4)).toString() +} + +// ids: node names; pairs: [source, target] per edge +export function simpleGraph(ids, pairs) { + const index = new Map(ids.map((id, i) => [id, i])) + const n = ids.length + const sets = Array.from({ length: n }, () => new Set()) + let loops = 0, + parallel = 0 + pairs.forEach(([s, t]) => { + const a = index.get(s), + b = index.get(t) + if (a === undefined || b === undefined) return + if (a === b) { + loops++ + return + } + if (sets[a].has(b)) { + parallel++ + return + } + sets[a].add(b) + sets[b].add(a) + }) + const adj = sets.map((x) => Int32Array.from(x)) + let m = 0 + adj.forEach((a) => { + m += a.length + }) + return { ids, n, m: m / 2, adj, loops, parallel } +} + +export function profileGraph(g) { + const { n, m, adj } = g + const deg = adj.map((a) => a.length) + const r = { n, m, degree: deg } + r.density = n > 1 ? (2 * m) / (n * (n - 1)) : NaN + r.avgDegree = n ? (2 * m) / n : NaN + r.maxDegree = n ? Math.max(...deg) : NaN + r.isolated = deg.filter((d) => d === 0).length + r.setAside = + g.loops || g.parallel + ? [ + g.loops ? plural(g.loops, 'self-loop') : '', + g.parallel ? plural(g.parallel, 'parallel edge') : '', + ] + .filter(Boolean) + .join(', ') + : 'none' + + // connected components + const comp = new Int32Array(n).fill(-1) + const queue = new Int32Array(Math.max(1, n)) + const compSizes = [] + for (let s = 0; s < n; s++) { + if (comp[s] >= 0) continue + const c = compSizes.length + let head = 0, + tail = 0 + queue[tail++] = s + comp[s] = c + while (head < tail) { + const v = queue[head++] + const nb = adj[v] + for (let k = 0; k < nb.length; k++) { + const w = nb[k] + if (comp[w] < 0) { + comp[w] = c + queue[tail++] = w + } + } + } + compSizes.push(tail) + } + r.components = compSizes.length + const largest = compSizes.length ? Math.max(...compSizes) : 0 + r.largest = n ? `${largest.toLocaleString('en-US')} (${((100 * largest) / n).toFixed(1)}%)` : '—' + + // triangles and clustering + const tri = new Float64Array(n) + const mark = new Int32Array(n).fill(-1) + for (let u = 0; u < n; u++) { + const nu = adj[u] + for (let k = 0; k < nu.length; k++) mark[nu[k]] = u + for (let k = 0; k < nu.length; k++) { + const v = nu[k] + if (v <= u) continue + const nv = adj[v] + for (let q = 0; q < nv.length; q++) { + const w = nv[q] + if (w > v && mark[w] === u) { + tri[u]++ + tri[v]++ + tri[w]++ + } + } + } + } + let triSum = 0, + triples = 0, + localSum = 0, + localCount = 0 + const localClustering = new Float64Array(n) + for (let v = 0; v < n; v++) { + const d = deg[v] + triSum += tri[v] + const t = (d * (d - 1)) / 2 + triples += t + if (d >= 2) { + localClustering[v] = tri[v] / t + localSum += localClustering[v] + localCount++ + } + } + r.triangles = triSum / 3 + r.transitivity = triples > 0 ? triSum / triples : NaN + r.avgLocal = localCount ? localSum / localCount : NaN + r.localClustering = localClustering + + // degree assortativity (Newman), over each undirected edge once + if (m > 0) { + let sProd = 0, + sMean = 0, + sSq = 0 + for (let u = 0; u < n; u++) { + const nu = adj[u] + for (let k = 0; k < nu.length; k++) { + const v = nu[k] + if (v <= u) continue + const j = deg[u], + q = deg[v] + sProd += j * q + sMean += (j + q) / 2 + sSq += (j * j + q * q) / 2 + } + } + const mean = sMean / m + const denom = sSq / m - mean * mean + r.assortativity = Math.abs(denom) > 1e-12 ? (sProd / m - mean * mean) / denom : NaN + } else { + r.assortativity = NaN + } + + r.centrDegree = n > 2 ? deg.reduce((s, d) => s + (r.maxDegree - d), 0) / ((n - 1) * (n - 2)) : NaN + + // shortest paths, eccentricity, closeness, betweenness + if (n <= PROFILE_PATH_LIMIT) { + const dist = new Int32Array(n).fill(-1) + const sigma = new Float64Array(n) + const delta = new Float64Array(n) + const order = new Int32Array(Math.max(1, n)) + const betw = new Float64Array(n) + const ecc = new Float64Array(n) + const closeness = new Float64Array(n).fill(NaN) + let pathSum = 0, + pathCount = 0, + diameter = 0 + for (let s = 0; s < n; s++) { + let head = 0, + tail = 0 + order[tail++] = s + dist[s] = 0 + sigma[s] = 1 + let far = 0, + sum = 0 + while (head < tail) { + const v = order[head++] + const dv = dist[v] + if (dv > far) far = dv + sum += dv + const nb = adj[v] + for (let k = 0; k < nb.length; k++) { + const w = nb[k] + if (dist[w] < 0) { + dist[w] = dv + 1 + order[tail++] = w + } + if (dist[w] === dv + 1) sigma[w] += sigma[v] + } + } + const reached = tail - 1 + ecc[s] = far + if (far > diameter) diameter = far + if (reached > 0) { + closeness[s] = reached / sum + pathSum += sum + pathCount += reached + } + for (let i = tail - 1; i >= 0; i--) { + const w = order[i] + const nb = adj[w] + for (let k = 0; k < nb.length; k++) { + const v = nb[k] + if (dist[v] === dist[w] - 1) delta[v] += (sigma[v] / sigma[w]) * (1 + delta[w]) + } + if (w !== s) betw[w] += delta[w] + } + for (let i = 0; i < tail; i++) { + const v = order[i] + dist[v] = -1 + sigma[v] = 0 + delta[v] = 0 + } + } + for (let v = 0; v < n; v++) betw[v] /= 2 + r.betweenness = betw + r.closeness = closeness + r.diameter = n ? diameter : NaN + r.radius = n ? Math.min(...ecc) : NaN + r.avgEcc = n ? ecc.reduce((a, b) => a + b, 0) / n : NaN + r.avgPath = pathCount ? pathSum / pathCount : NaN + const maxB = n ? Math.max(...betw) : 0 + r.centrBetw = + n > 2 ? betw.reduce((s, b) => s + (maxB - b), 0) / (((n - 1) * (n - 1) * (n - 2)) / 2) : NaN + const finiteClo = [...closeness].filter(Number.isFinite) + const maxC = finiteClo.length ? Math.max(...finiteClo) : NaN + r.centrClo = + n > 2 && finiteClo.length + ? finiteClo.reduce((s, c) => s + (maxC - c), 0) / (((n - 1) * (n - 2)) / (2 * n - 3)) + : NaN + } else { + r.pathsSkipped = true + ;['diameter', 'radius', 'avgEcc', 'avgPath', 'centrBetw', 'centrClo'].forEach((k) => { + r[k] = NaN + }) + } + + const lv = louvain(g) + r.louvain = lv + r.modularity = lv.modularity + r.communities = lv.count + return r +} + +// Louvain community detection (Blondel et al. 2008), unweighted input, +// nodes visited in a fixed order so results are repeatable. +export function louvain(g, gamma = 1) { + const n = g.n + const twoM = 2 * g.m + if (!twoM) { + return { + membership: Int32Array.from({ length: n }, (_, i) => i), + count: n, + modularity: NaN, + sizes: new Array(n).fill(1), + } + } + let nbr = g.adj.map((a) => { + const mp = new Map() + a.forEach((v) => mp.set(v, 1)) + return mp + }) + let size = n + let membership = Int32Array.from({ length: n }, (_, i) => i) + for (let level = 0; level < 32; level++) { + const k = new Float64Array(size) + nbr.forEach((mp, i) => { + let s = 0 + mp.forEach((w) => { + s += w + }) + k[i] = s + }) + const comm = Int32Array.from({ length: size }, (_, i) => i) + const tot = Float64Array.from(k) + let moved = true, + anyMove = false, + passes = 0 + while (moved && passes < 100) { + moved = false + passes++ + for (let i = 0; i < size; i++) { + const ci = comm[i], + ki = k[i] + const links = new Map() + nbr[i].forEach((w, j) => { + if (j !== i) { + const cj = comm[j] + links.set(cj, (links.get(cj) || 0) + w) + } + }) + tot[ci] -= ki + let best = ci + let bestGain = (links.get(ci) || 0) - (gamma * tot[ci] * ki) / twoM + links.forEach((w, c) => { + const gain = w - (gamma * tot[c] * ki) / twoM + if (gain > bestGain + 1e-12) { + bestGain = gain + best = c + } + }) + tot[best] += ki + if (best !== ci) { + comm[i] = best + moved = true + anyMove = true + } + } + } + if (!anyMove) break + const remap = new Map() + for (let i = 0; i < size; i++) if (!remap.has(comm[i])) remap.set(comm[i], remap.size) + const next = remap.size + const agg = Array.from({ length: next }, () => new Map()) + for (let i = 0; i < size; i++) { + const ci = remap.get(comm[i]) + nbr[i].forEach((w, j) => { + const cj = remap.get(comm[j]) + agg[ci].set(cj, (agg[ci].get(cj) || 0) + w) + }) + } + membership = membership.map((x) => remap.get(comm[x])) + nbr = agg + if (next === size) break + size = next + } + // modularity on the original graph + let count = 0 + membership.forEach((c) => { + if (c + 1 > count) count = c + 1 + }) + const inW = new Float64Array(count), + totW = new Float64Array(count), + sizes = new Array(count).fill(0) + for (let u = 0; u < n; u++) { + const cu = membership[u] + sizes[cu]++ + totW[cu] += g.adj[u].length + g.adj[u].forEach((v) => { + if (membership[v] === cu) inW[cu] += 1 + }) + } + let q = 0 + for (let c = 0; c < count; c++) q += inW[c] / twoM - Math.pow(totW[c] / twoM, 2) + return { membership, count, modularity: q, sizes } +} + +// page wiring, run by main.ts in the original order +export function init() { + /* ---------- wiring ---------- */ + document + .getElementById('btnNormaAdd') + .addEventListener('click', () => document.getElementById('normaFileInput').click()) + + document.getElementById('normaFileInput').addEventListener('change', (e) => { + handleNormaUploads(e.target.files) + e.target.value = '' + }) + + ;(function setupDropZone() { + const zone = document.getElementById('dropZone') + let depth = 0 + zone.addEventListener('dragenter', (e) => { + e.preventDefault() + depth++ + zone.classList.add('dragging') + }) + zone.addEventListener('dragover', (e) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + }) + zone.addEventListener('dragleave', () => { + depth = Math.max(0, depth - 1) + if (!depth) zone.classList.remove('dragging') + }) + zone.addEventListener('drop', (e) => { + e.preventDefault() + depth = 0 + zone.classList.remove('dragging') + if (e.dataTransfer.files && e.dataTransfer.files.length) + handleNormaUploads(e.dataTransfer.files) + }) + })() + + document.getElementById('btnRefreshView').addEventListener('click', () => { + const v = activeView() + refreshLibraryView() + if (v && v.autoName && cy.nodes().length) { + v.name = uniqueViewName(nameForSelection()) + renderViewBar() + } + }) + + document.getElementById('btnOpenNewView').addEventListener('click', () => { + if (!libSelection.networks.size) { + setStatus('normaStatus', [{ level: 'error', text: 'Tick at least one network first.' }]) + return + } + // the ticked files move to the new view; this view keeps what it shows + const pending = { + networks: [...libSelection.networks], + annotation: libSelection.annotation, + colors: libSelection.colors, + } + const cur = activeView() + const shown = selectionFromKey(S.currentLibView) + const name = nameForSelection() + if (cur && cy.nodes().length) { + libSelection.networks = new Set(shown.networks) + libSelection.annotation = shown.annotation + libSelection.colors = shown.colors + } + openInNewView(name, () => { + libSelection.networks = new Set(pending.networks) + libSelection.annotation = pending.annotation + libSelection.colors = pending.colors + refreshLibraryView() + }) + }) + + document.getElementById('btnNormaExportNetwork').addEventListener('click', exportNormaNetwork) + + document + .getElementById('btnNormaExportAnnotation') + .addEventListener('click', exportNormaAnnotation) + + document.getElementById('btnNormaExportColors').addEventListener('click', exportNormaColors) + + // A numeric expression file for the documentation example (simulated values, + // for trying the color scale; not measured data). + NORMA_EXAMPLE_SETS['docs-tp53'].files.push({ + kind: 'colors', + name: 'TP53 docs log2FC and adjusted p-values (simulated)', + fileName: 'TP53_docs_values_simulated.txt', + text: 'Gene\tlog2FC\tpadj\nCDKN1A\t2.4\t0.000001\nTP53\t-0.3\t0.41\nMDM4\t1.1\t0.02\nBCL2L1\t-1.8\t0.0003\nCHEK2\t-1.2\t0.004\nATM\t-0.9\t0.03\nTP53BP2\t-2.2\t0.00005\nCDKN2A\t1.7\t0.0002\nEP300\t0.4\t0.21\nCREBBP\t-0.6\t0.09\nMDM2\t2.9\t0.00000001\n', + }) + + // ---------- NORMA 3.0 examples that showcase the new features ---------- + ;(function addNorma3Examples() { + // 1. The example network from Arena3D's documentation: layers become groups + const arenaRows = [ + ['An', 'Group1', 'Cn', 'Group1', 2], + ['An', 'Group1', 'Bn', 'Group1', 10], + ['Bn', 'Group1', 'Cn', 'Group1', 1], + ['Cn', 'Group1', 'Dn', 'Group1', 3], + ['En', 'Group2', 'Fn', 'Group2', 4], + ['En', 'Group2', 'Hn', 'Group2', 5], + ['Fn', 'Group2', 'Gn', 'Group2', 6], + ['Gn', 'Group2', 'Hn', 'Group2', 7], + ['In', 'Group3', 'Jn', 'Group3', 8], + ['Bn', 'Group1', 'Fn', 'Group2', 9], + ['Dn', 'Group1', 'Hn', 'Group2', 11], + ['Dn', 'Group1', 'In', 'Group3', 1], + ['Cn', 'Group1', 'Jn', 'Group3', 1], + ['Hn', 'Group2', 'In', 'Group3', 1], + ['Hn', 'Group2', 'Kn', 'Group4', 1], + ['Kn', 'Group4', 'Ln', 'Group4', 0.1], + ['Kn', 'Group4', 'Mn', 'Group5', 1], + ['An', 'Group1', 'Nn', 'Group5', 1], + ['Kn', 'Group4', 'On', 'Group5', 1], + ['Kn', 'Group4', 'Pn', 'Group5', 12], + ['Kn', 'Group4', 'Qn', 'Group6', 1], + ['Kn', 'Group4', 'Rn', 'Group6', 1], + ['Kn', 'Group4', 'Sn', 'Group7', 1], + ['Kn', 'Group4', 'Tn', 'Group7', 10], + ] + const arenaText = + [ + 'SourceNode\tSourceLayer\tTargetNode\tTargetLayer\tWeight\tChannel', + ...arenaRows.map((r) => [...r, 1].join('\t')), + ].join('\n') + '\n' + const arenaConv = convertArena3dNetwork(arenaText) + NORMA_EXAMPLE_SETS['arena3d'] = { + title: 'Arena3D example: 7 layers', + source: 'Arena3D documentation', + link: 'https://arena3d.org', + local: true, + files: [ + { + kind: 'network', + name: 'Arena3D example network', + fileName: 'arena3d_example_network.txt', + text: arenaConv.network, + }, + { + kind: 'annotation', + name: 'Arena3D example layers', + fileName: 'arena3d_example_layers.txt', + text: arenaConv.annotation, + }, + { + kind: 'raw', + name: 'Arena3D network file (original format)', + fileName: 'arena3d_example_upload_network.txt', + text: arenaText, + }, + ], + } + + // 2. A directed signalling cascade with simulated fold changes + const cascade = generateDirectedMultiNetwork(40) + const cTexts = demoToNormaTexts(cascade) + const rc = mulberry32(2024) + const gauss = () => { + let u = 0, + v = 0 + while (!u) u = rc() + while (!v) v = rc() + return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v) + } + const layerShift = { receptors: 1.6, kinases: 1.0, 'transcription factors': 0.3, targets: -1.4 } + const cValues = + [ + 'Gene\tlog2FC\tpadj', + ...cascade.nodes.map((n) => { + if (rc() < 0.06) return `${n.id}\tNA\tNA` + const fc = Math.round((layerShift[n.groups[0]] + gauss() * 0.9) * 1000) / 1000 + const p = Math.min(1, Math.pow(10, -Math.abs(fc) * (1.2 + rc() * 1.8))) + return `${n.id}\t${fc}\t${p.toPrecision(3)}` + }), + ].join('\n') + '\n' + NORMA_EXAMPLE_SETS['cascade'] = { + title: 'Signalling cascade with fold changes (simulated)', + source: 'Simulated for NORMA 3.0', + link: null, + local: true, + display: { + edgeDirection: 'data', + edgeCurveStyle: 'bezier', + layoutSelect: 'fr', + legendShow: true, + sizeMetric: 'fixed', + }, + files: [ + { + kind: 'network', + name: 'Signalling cascade (directed, multi-edge)', + fileName: 'cascade_network.txt', + text: cTexts.network, + }, + { + kind: 'annotation', + name: 'Cascade levels', + fileName: 'cascade_levels.txt', + text: cTexts.annotation, + }, + { + kind: 'colors', + name: 'Cascade log2FC and padj (simulated)', + fileName: 'cascade_values_simulated.txt', + text: cValues, + }, + ], + } + + // 3. A healthy and a disease network (simulated) for Network Comparison + const base = buildModuleNetwork({ + sizes: [18, 16, 16, 14], + overlaps: [ + [0, 1], + [1, 2], + [2, 3], + [3, 0], + [1, 2, 3], + ], + pIn: 0.22, + pOut: 0.01, + seed: 64, + }) + const rh = mulberry32(64) + const ids = base.nodes.map((n) => n.id) + const moduleOf = new Map(base.nodes.map((n) => [n.id, n.groups])) + const healthy = base.edges.map((e) => [e.source, e.target]) + const key = (a, b) => (a < b ? a + '\t' + b : b + '\t' + a) + const hSet = new Set(healthy.map(([a, b]) => key(a, b))) + // disease: 20% of the edges are lost, and the third module gains new links + const disease = healthy.filter(() => rh() > 0.2) + const dSet = new Set(disease.map(([a, b]) => key(a, b))) + const third = base.groupOrder + ? base.groupOrder[2] + : [...new Set(base.nodes.flatMap((n) => n.groups))][2] + const inThird = ids.filter((id) => (moduleOf.get(id) || []).includes(third)) + let added = 0 + while (added < Math.round(healthy.length * 0.15)) { + const a = + rh() < 0.7 ? inThird[Math.floor(rh() * inThird.length)] : ids[Math.floor(rh() * ids.length)] + const b = inThird[Math.floor(rh() * inThird.length)] + if (a === b || dSet.has(key(a, b))) continue + dSet.add(key(a, b)) + disease.push([a, b]) + added++ + } + const netText = (pairs) => + ['Source\tTarget', ...pairs.map((p) => p.join('\t'))].join('\n') + '\n' + const annText = demoToNormaTexts(base).annotation + const dValues = + [ + 'Gene\tlog2FC\tpadj', + ...ids.map((id) => { + const boost = (moduleOf.get(id) || []).includes(third) ? 1.8 : 0 + const fc = Math.round((boost + gauss() * 0.7) * 1000) / 1000 + const p = Math.min(1, Math.pow(10, -Math.abs(fc) * (1 + rh() * 2))) + return `${id}\t${fc}\t${p.toPrecision(3)}` + }), + ].join('\n') + '\n' + NORMA_EXAMPLE_SETS['healthy-disease'] = { + title: 'Healthy vs disease networks (simulated)', + source: 'Simulated for NORMA 3.0', + link: null, + local: true, + showAll: true, + display: { edgeCurveStyle: 'bezier', legendShow: true, showGroupHulls: true }, + files: [ + { + kind: 'network', + name: 'Healthy (simulated)', + fileName: 'healthy_network_simulated.txt', + text: netText(healthy), + }, + { + kind: 'network', + name: 'Disease (simulated)', + fileName: 'disease_network_simulated.txt', + text: netText(disease), + }, + { kind: 'annotation', name: 'Modules', fileName: 'modules.txt', text: annText }, + { + kind: 'colors', + name: 'Disease vs healthy log2FC (simulated)', + fileName: 'disease_vs_healthy_values_simulated.txt', + text: dValues, + }, + ], + } + })() + + // links between Help and About (and to the Welcome page) switch pages + document.addEventListener('click', (e) => { + const sample = e.target.closest('a[data-sample-link]') + if (sample) { + e.preventDefault() + const q = new URLSearchParams(sample.getAttribute('href').replace(/^\?/, '')) + const sel = document.getElementById('sampleSelect') + sel.value = q.get('example') + document.getElementById('btnSample').click() + switchTab(q.get('tab') === '3d' ? 'network3d' : 'network') + return + } + const a = e.target.closest( + 'a[href^="#help-"], a[href^="#about-"], a[href^="#api-"], a[href="#welcome"], [data-go-welcome]' + ) + if (!a) return + const href = a.getAttribute('href') || '#welcome' + e.preventDefault() + if (href === '#welcome') { + switchTab('welcome') + return + } + const id = href.slice(1) + const page = id.startsWith('about-') ? 'about' : id.startsWith('api-') ? 'api' : 'help' + if (currentTab !== page) switchTab(page, id) + else { + const t = document.getElementById(id) + if (t) { + t.scrollIntoView({ block: 'start' }) + t.classList.remove('flash') + void t.offsetWidth + t.classList.add('flash') + } + } + }) + + document.querySelectorAll('#tabs .tab').forEach((btn) => { + btn.addEventListener('click', () => switchTab(btn.dataset.tab)) + btn.addEventListener('keydown', (ev) => { + const tabs = [...document.querySelectorAll('#tabs .tab')] + const i = tabs.indexOf(btn) + let next = null + if (ev.key === 'ArrowRight') next = tabs[(i + 1) % tabs.length] + if (ev.key === 'ArrowLeft') next = tabs[(i - 1 + tabs.length) % tabs.length] + if (next) { + ev.preventDefault() + next.focus() + switchTab(next.dataset.tab) + } + }) + }) + + document.querySelectorAll('.helplink').forEach((btn) => { + btn.title = 'Open help for this section' + btn.addEventListener('click', () => switchTab('help', btn.dataset.help)) + }) + + document.querySelectorAll('.help-nav a').forEach((a) => { + a.addEventListener('click', (ev) => { + ev.preventDefault() + const id = a.getAttribute('href').slice(1) + switchTab('help', id) + }) + }) + + document.querySelectorAll('.help-body a[href^="#"]').forEach((a) => { + a.addEventListener('click', (ev) => { + ev.preventDefault() + switchTab('help', a.getAttribute('href').slice(1)) + }) + }) +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..3412153 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "sourceMap": true, + "noEmit": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node", "vite/client"] + }, + "include": ["src", "playwright.config.ts", "vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..148c913 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vitest/config' +import { viteSingleFile } from 'vite-plugin-singlefile' + +const backend = process.env.VITE_PROXY_TARGET ?? 'http://localhost:8000' + +// The page is norma.html (so ?example=, ?session= links keep working) and the +// build inlines everything into dist/norma.html, which opens from disk too. +export default defineConfig({ + base: './', + plugins: [viteSingleFile()], + build: { rollupOptions: { input: 'norma.html' } }, + server: { + // examples/norma_api_client.py (the API template) is imported ?raw from outside frontend/ + fs: { allow: ['..'] }, + proxy: Object.fromEntries( + ['/api', '/string-api', '/db-api', '/arena3d-api', '/norma-config.js'].map((p) => [ + p, + backend, + ]) + ), + }, + test: { + environment: 'jsdom', + setupFiles: ['./vitest.setup.ts'], + exclude: ['e2e/**', 'node_modules/**'], + }, +}) diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts new file mode 100644 index 0000000..5ffca92 --- /dev/null +++ b/frontend/vitest.setup.ts @@ -0,0 +1,15 @@ +// Unit tests import modules that read the page at load (canvases, controls), +// so give jsdom the real markup and a no-op 2D canvas context. +import fs from 'node:fs' +import path from 'node:path' + +const html = fs.readFileSync(path.join(process.cwd(), 'norma.html'), 'utf8') +document.body.innerHTML = html.slice(html.indexOf('')) + +const noop = () => stub +const stub: unknown = new Proxy( + {}, + { get: (_, key) => (key === 'measureText' ? () => ({ width: 0 }) : noop), set: () => true } +) +HTMLCanvasElement.prototype.getContext = (() => + stub) as typeof HTMLCanvasElement.prototype.getContext diff --git a/norma.html b/norma.html deleted file mode 100644 index 826fd15..0000000 --- a/norma.html +++ /dev/null @@ -1,20258 +0,0 @@ - - - - - -NORMA 3.0: The Network Makeup Artist - - - - - - - - - - - - -
    - -
    - -
    3.0The Network Makeup Artist
    -
    - -
    - -
    - -
    - - - - - - -
    - - - -
    -
    - -
    - - -
    - -
    -
    - - - - - -
    - -
    - -
    -
    -
    - 0 nodes - 0 edges - 0 channels - 0 groups - -
    -
    - - - - -
    -

    Delete file

    - -
    -
    - - -
    -
    -
    - - -
    -

    Export image

    -

    Saves the current view as it looks, including group shading, shapes and labels.

    -
    - Format - - - - - -
    -
    - - - - - -

    - -
    - - -
    - -
    .png
    -
    - - - -
    -
    - - -
    -
    -
    - - - -
    - -
    - - - - - -
    Drag nodes · Shift-drag or Shift-click to select several · scroll to zoom · double-click to recenter
    -
    - - - - - 100% -
    - - -
    - -

    -
    - -
    -
    - -
    -
    -
    - -
    -
    Source
    -
    Target
    -
    Direction
    -
    Edge ID
    -
    -
    -
    - -
    -
    -
    -
    - -
    -

    The Network Makeup Artist

    -

    See how annotated groups sit in a network: bubble sets, hulls and fog clouds, pie nodes and shapes, group-aware layouts, numeric values, a 3D view, communities, enrichment and network comparison.

    -
    -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -Bubble-set groups -outlines that hug only the members - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -−2 -0 -2 -log2FC -Numeric values -color scales, sizes, legends, steps - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -Ribosome - -Proteasome - -Spliceosome - -Glycolysis - -−log10 FDR -Communities and enrichment -Leiden, MCL, Walktrap; term tests - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -3D network -rotate, tilt and zoom freely - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -A - -B - -C -shared -edges -Compare networks -UpSet plots, Venn diagrams, statistics -
    -
      -
    1. Load a networkupload files, fetch one from a database, or open an example
    2. -
    3. Add groups and valuesannotations, expression colors or numbers, communities
    4. -
    5. Arrange, analyse, sharelayouts, statistics, images, Arena3D, Cytoscape, Gephi
    6. -
    -
    - - - - -
    - -

    NORMA 3.0 runs in your browser: your files stay on this computer, except what you send to online databases or Arena3D yourself. Networks of up to 10,000 nodes.

    -
    -
    -
    - -
    -
    -
    -

    Network Profiler

    -

    Topology statistics for one or more networks, computed the way igraph computes them. Each network is treated as a simple undirected graph: self-loops and parallel edges are set aside first.

    -
    -
    -
    -
    Networks to profile
    -
    -
    -
    - - - -
    -
    -
    -
    -
    -

    Group analysis (current view)

    -

    Statistics of the ticked groups of the current view, the network of groups, and an enrichment test, all on the shown part of the view (ticked groups and channels).

    -
    - - - -
    -
    -
    -

    Enrichment

    -

    Tests whether groups of an annotation (terms) are over-represented, with a one-sided hypergeometric test and Benjamini–Hochberg FDR per tested set.

    -
    - - - -
    - - - - - - - - -
    -
    -
    - - -
    -
    -
    -
    -
    -

    Layout benchmark

    -

    Runs every layout on the current view (its ticked groups and channels) without changing it, and scores how well each separates the groups: the silhouette width of the node positions (higher is better), the share of nodes inside another group's outline, and how much the outlines overlap (lower is better). Group layouts use the settings of the Layout section.

    -
    - - - -
    -
    -
    -
    -
    -

    Runtime table

    -

    Times the main steps on random networks of growing size (generated from fixed seeds; the table lists their edges), in this browser: reading the network file, building the network, the weighted layout, edge bundling, the profile statistics, Louvain and the group separation score. The view is not changed.

    -
    - - - -
    -
    -
    -
    -
    -
    - -
    -
    -
    -

    Network Comparison

    -

    Compare two to ten networks from your open views or from the network files: which nodes and edges they share, how similar they are, whether hubs stay hubs, and how their topology differs.

    -
    -
    -
    -
    Networks to compare
    -
    -
    -
    - - - - -
    - -
    -
    -
    -
    -
    - -
    -
    -

    NORMA API

    -

    Other applications can open NORMA with their own networks, groups and values, so that a pathway database, an analysis pipeline, a notebook or a web page can show its results in NORMA with one call. There are three ways in, all taking the same payload:

    -
    -
    RESTPOST the payload to /api/external on a server running server.py; open the link it returns. Works from any language.
    -
    LinksA link to norma.html that points to the data (?data=, ?network=) or carries it (#json=). Needs no server.
    -
    postMessageA web page opens NORMA in a window or an iframe and sends it the payload directly. Needs no server.
    -
    - -

    This copy of NORMA answers at …

    -
    - Python template script -

    A ready-to-run client that checks a NORMA server, sends a network with groups, reads the stored payload back and builds a link that needs no server; it parses every answer and uses only Python's standard library. Adapt its build_payload() to your data.

    -

    python3 norma_api_client.py --server http://localhost:8000/ --open

    -
    - -

    The payload

    -

    A JSON object. Give the network in one of three forms, and add groups, values and settings as needed:

    - - - - - - - - - - - - - - - - -
    FieldTypeMeaning
    nametextThe view's name (and the prefix of its files in Files).
    edgeslistThe network as {"source", "target"} objects, each optionally with type (the channel), weight (a number) and directed (true/false).
    nodeslistOptional, with edges: {"id", …} objects whose other fields become node attributes (shown in node details and searchable).
    filesobjectThe network as NORMA files instead: network (text, or a list for several networks), annotation (text or list) and expression (text), in the tab-separated formats. A list item can be {"name", "text"}.
    networkobjectOr a complete NORMA view (nodes, edges, groupOrder, …) as saved by Save view file; see JSON format.
    groupsobject or list{"Group": ["node", …]}, or [{"name", "members", "color", "description"}]. Nodes may be in several groups.
    annotationslistMore groupings, each {"name", "groups"}; the view's Grouping list switches between them.
    expressionobject{"node": "#e11d48"} (colors), {"node": 1.7} (one numeric value) or {"node": {"t0": 0.2, "t1": 1.4}} (several columns, e.g. time points).
    directedtrue/falseRead files.network as directed.
    settingsobjectDisplay settings by their names in a settings file (for example edgeDirection, edgeCurveStyle, showGroupHulls, hullStyle, nodeFillSelect, legendShow), plus theme and layout (fr, kk, stress, cose, circle, …).
    tabtext"3d" opens the 3D page instead of the 2D page.
    format / views—A whole session file ("format": "norma3-session") can be sent as the payload too.
    -

    With edges or files, NORMA adds the network, groupings and values to Files like uploaded files and opens them in a new view, so everything in NORMA works on them. Networks larger than 10,000 nodes are cut to their first 10,000. The example in Try it shows all common fields.

    - -

    REST

    -

    When NORMA runs with server.py (see Running NORMA locally or on a server), the server offers:

    - - - - - - - - -
    Web services (all answer in JSON)
    RequestAnswer
    POST /api/external with the payload as JSON (Content-Type: application/json){"token", "url", "expiresInHours"}. Open url in a browser: NORMA starts with the payload loaded.
    GET /api/session/TOKENThe stored payload (NORMA fetches it when opened with ?session=TOKEN); 404 once it has expired.
    GET /api/health{"status": "ok", "api": "1.0"}, to check that a server offers the API.
    -

    The token is made by the server; you never build the link yourself. Payloads are kept in the server's memory for 24 hours (set NORMA_API_TTL_HOURS), up to 50 MB each; the API accepts calls from any web page (CORS).

    -

    curl

    -
    curl -X POST http://localhost:8000/api/external \
    -  -H "Content-Type: application/json" \
    -  -d '{"name": "My network",
    -       "edges": [{"source": "A", "target": "B"}, {"source": "B", "target": "C"}],
    -       "groups": {"Module 1": ["A", "B"], "Module 2": ["C"]}}'
    -
    -# answer:
    -# {"token": "k3J9…", "url": "http://localhost:8000/norma.html?session=k3J9…",
    -#  "expiresInHours": 24}
    -

    Python

    -
    import requests, webbrowser
    -
    -payload = {
    -    "name": "My network",
    -    "edges": [{"source": s, "target": t} for s, t in [("A", "B"), ("B", "C"), ("C", "A")]],
    -    "groups": {"Module 1": ["A", "B"], "Module 2": ["C"]},
    -    "expression": {"A": 1.5, "B": -0.4, "C": 2.1},
    -    "settings": {"layout": "fr", "showGroupHulls": True},
    -}
    -r = requests.post("http://localhost:8000/api/external", json=payload, timeout=30)
    -r.raise_for_status()
    -webbrowser.open(r.json()["url"])      # opens NORMA with the network
    -

    R

    -
    library(httr)
    -payload <- list(
    -  name   = "My network",
    -  edges  = data.frame(source = c("A", "B"), target = c("B", "C")),
    -  groups = list(`Module 1` = c("A", "B"), `Module 2` = list("C"))
    -)
    -r <- POST("http://localhost:8000/api/external", body = payload, encode = "json")
    -browseURL(content(r)$url)
    -

    JavaScript

    -
    const r = await fetch("http://localhost:8000/api/external", {
    -  method: "POST",
    -  headers: { "Content-Type": "application/json" },
    -  body: JSON.stringify({ name: "My network", edges, groups }),
    -});
    -const { url } = await r.json();
    -window.open(url, "_blank");
    - - -

    Links need no server: any copy of norma.html, on a web server or opened from disk, reads them when it starts.

    -
    http://localhost:8000/norma.html?data=https://example.org/my-payload.json
    -http://localhost:8000/norma.html?network=https://example.org/net.txt&annotation=https://example.org/groups.txt&expression=https://example.org/values.txt&layout=fr
    -http://localhost:8000/norma.html#json=eyJuYW1lIjoiTXkgbmV0d29yayIsImVkZ2VzIjpb…
    -
      -
    • ?data=URL fetches a payload file. ?network=, ?annotation= and ?expression= fetch NORMA files; add &name=, &layout=, &theme= or &tab=3d if you like. The site holding the files must allow cross-site downloads (CORS), as GitHub raw files, Zenodo and most data portals do.
    • -
    • #json= carries the payload itself, as base64url-encoded JSON. It suits small networks (links longer than a few thousand characters may be cut by chat and mail programs); the part after # is never sent to any server. Make a link below builds one.
    • -
    -

    A button on your own site:

    -
    <a href="http://localhost:8000/norma.html?data=https://example.org/pathway-42.json"
    -   target="_blank">Open in NORMA</a>
    - -

    postMessage

    -

    A web page can open NORMA and hand it data directly, with no server and no size limit other than the browser's. NORMA sends {"type": "norma:ready"} to the page that opened or embedded it when it has started (and answers {"type": "norma:ping"} the same way). Send {"type": "norma:load", "payload": …, "requestId": …}; NORMA answers {"type": "norma:loaded", "ok": true, "summary": …} or {"ok": false, "error": …} with the same requestId. Each load opens a new view, so a page can send several networks.

    -

    In a new window

    -
    // open NORMA (any copy: a file, a web server or server.py)
    -const norma = window.open("http://localhost:8000/norma.html", "_blank");
    -
    -window.addEventListener("message", (e) => {
    -  if (e.source !== norma) return;
    -  if (e.data.type === "norma:ready") {           // NORMA has started
    -    norma.postMessage({
    -      type: "norma:load",
    -      requestId: 1,
    -      payload: { name: "My network", edges, groups, expression },
    -    }, "*");
    -  }
    -  if (e.data.type === "norma:loaded") {          // the answer
    -    console.log(e.data.ok ? e.data.summary : e.data.error);
    -  }
    -});
    -

    In an iframe

    -
    <iframe id="norma" src="norma.html" style="width:100%;height:700px;border:0"></iframe>
    -<script>
    -  const frame = document.getElementById("norma");
    -  window.addEventListener("message", (e) => {
    -    if (e.data.type === "norma:ready")
    -      frame.contentWindow.postMessage({ type: "norma:load", payload }, "*");
    -  });
    -</script>
    - -

    Try it

    -

    Edit the payload and choose what to do with it.

    - -
    - - - - -
    -
    - - -
    -
    - -

    Limits and privacy

    -
      -
    • Payloads up to 50 MB; networks up to 10,000 nodes are shown in full.
    • -
    • Data sent with postMessage or #json links stays in the browser. With REST, the payload is kept only in the memory of the server you sent it to, for the time set there, and anyone with the link can open it until then; don't use it for confidential data on a shared server.
    • -
    • NORMA only displays what it receives: payloads cannot run code, and NORMA sends nothing back except the norma:loaded summary to the page that asked.
    • -
    -
    -
    - -
    -
    - -
    -
    - How to cite NORMA -

    If NORMA helps your work, please cite:

    -
      -
    • Karatzas E, Koutrouli M, Baltoumas FA, Papanikolopoulou K, Bouyioukos C, Pavlopoulos GA. The network makeup artist (NORMA-2.0): distinguishing annotated groups in a network using innovative layout strategies. Bioinformatics Advances. 2022;2(1):vbac036. doi:10.1093/bioadv/vbac036
    • -
    • Koutrouli M, Karatzas E, Papanikolopoulou K, Pavlopoulos GA. NORMA: The Network Makeup Artist, a web tool for network annotation visualization. Genomics, Proteomics & Bioinformatics. 2022;20(3):578–586. doi:10.1016/j.gpb.2021.02.005
    • -
    -

    Please also cite the databases and methods you use; see Resources and methods.

    -
    -

    This is NORMA's technical help: how to load data, use each control and page, save your work and run NORMA on a server. For an overview of NORMA, its data sources, what's new and how to cite it, see About.

    - -
    Getting started
    -

    Getting started

    -

    NORMA 3.0 shows networks whose nodes belong to groups (annotations) and whose node pairs can be linked by several kinds of edge (channels); unlike earlier versions of NORMA, it handles these multi-edge networks. The application is a single file, norma.html, that opens in a browser. It starts on the Welcome tab, which shows what NORMA does and offers quick ways to open an example, fetch a network from a database or upload files; the tab stays there, so you can always go back to it. Opening a network moves to 2D Network. It reads the same tab-delimited files as NORMA, plus a richer JSON format.

    -

    The screen has eight pages. Welcome is the start page, 2D Network is the interactive view, 3D Network shows the same view in three dimensions, Network Profiler computes topology statistics and group analyses, Network Comparison sets up to ten networks side by side, API explains how other programs can open NORMA with their data, Help is this page, and About describes NORMA, its data sources and how to cite it. The NORMA logo at the top left always leads back to Welcome. The ? next to a sidebar heading opens the matching section here. Finding your way around explains the rest of the screen.

    -
      -
    1. Upload your files or open an example in the sidebar's Upload Data tab, or fetch a network from a database (STRING, Reactome, OmniPath, NDEx, IntAct or GO-CAM) in the Database importers tab.
    2. -
    3. Tick networks and choose an annotation and an expression file, then choose Open in new view.
    4. -
    5. Style that view in the Display tab: layout, groups, colors, sizes, labels and edges.
    6. -
    7. Repeat with other combinations. Each becomes its own view; switch between them with the View list in the top bar.
    8. -
    9. Save a picture with Export image (camera button, top bar), or the data in other formats with the Export tab.
    10. -
    - -

    Finding your way around

    -
      -
    • Top bar (purple): the View list, which picks the visualization you are working on, with Duplicate, Rename and Delete…, and the Grouping list (amber), which switches the current view to any annotation in Files while keeping node positions. For views opened from a built-in demo or a JSON file, the groups come with the data and the list only shows how many there are. On the right are Export image (the camera button), undo and redo and the theme.
    • -
    • Page tabs: Welcome (the start page, always available), 2D Network, 3D Network, Network Profiler, Network Comparison and Help. In the row below them, three colored labels say what the current view shows: the Network (blue), the Grouping with its number of groups (amber) and where node Colors come from (pink). Hover a label for its full text. At the right end of that row are the numbers of nodes, edges, channels and groups, with a directed or mixed direction tag when edges have a direction; when some groups are unticked, the group count reads, for example, 8/9. The labels and counts stay visible on every page, so the Network Profiler and Network Comparison pages always tell you which view "Current view" means.
    • -
    • Sidebar tabs: Upload Data (blue) holds examples, your files and opening saved work; Database importers (red) fetches networks and groupings from STRING, Reactome, OmniPath, NDEx, IntAct and the Gene Ontology; Export (purple) saves pictures, files for NORMA and other tools, Arena3D networks and sessions; Display (green) holds everything about how the current view looks. The Examples list only chooses what Open example opens next; it doesn't show what is on screen. The View list and the colored labels do.
    • -
    • Opening and closing sections: click a section's title (or focus it and press Enter or Space) to fold it away or open it again; the ? in a title opens its help instead. All sections start folded, so each tab shows a short list of titles; open the ones you need. Inside a section, small colored headings with a line (for example Direction and arrows or Session file (.json)) separate groups of related options.
    • -
    • Section order and colors: every sidebar section has a color of its own, and neighbouring sections always have clearly different ones. Upload Data: Examples (green), Files (blue) and Open saved work (purple). Database importers: STRING (red), Reactome (blue), OmniPath (teal), NDEx (orange), IntAct (green) and Gene Ontology (purple); click an importer's title to open or close it. Export: NORMA files (blue), Image (pink), Other tools (orange), Save your work (slate) and Arena3D (teal). The Display tab starts with the groups and how they are drawn and placed, then the look of nodes and edges, then extras: Node groups (amber), Group highlighting (magenta), Layout (green; 3D layout, teal, on the 3D page), Colors (pink), Nodes (sky blue), Labels (orange), Edges (slate), Edge channels (lime), Legend (crimson), Attributes (cyan) and Performance (brown). The labels below the page tabs use the matching colors: Network blue like Files, Grouping amber like Node groups, and Colors pink like Colors.
    • -
    - -

    Views

    -

    A view is one visualization: a set of networks with an annotation and an expression file, or an example or JSON file, together with how it looks. Each view keeps its own display settings, node positions, zoom, ticked groups and channels, group colors and shapes, channel colors, edge style including bundling, and its 3D positions and camera, so you can prepare several pictures from the same uploads and move between them without losing work. The theme and the search options apply to every view.

    -
      -
    • Duplicate copies the current view, for example to compare two annotations or two layouts of the same network.
    • -
    • Rename names the current view; press Enter to keep the name or Escape to cancel.
    • -
    • Grouping (top bar) regroups the current view by another annotation, or by none, and keeps node positions; the same as ticking an annotation in Files and choosing Show in this view.
    • -
    -

    In the Upload Data tab, Show in this view replaces what the current view shows with the ticked files and keeps its display settings; if only the annotation or expression file changed, node positions are kept. Open in new view shows the ticked files in a new view and leaves the current one as it was. Open example always opens a new view, reusing an empty untitled one if there is one.

    - -

    Undo and redo

    -

    The arrows in the top bar undo and redo changes to the current view: moved nodes, layouts, spread, display settings, ticked groups and channels, group and channel colors, and switching or clearing what the view shows. Keyboard: Ctrl+Z (⌘Z on a Mac) undoes, Ctrl+Shift+Z or Ctrl+Y redoes. A quick series of changes, such as dragging a slider or a layout's animation, counts as one step. Each view has its own history of up to 60 steps, kept while the page is open. Zooming, panning, searching, selecting and the theme are not part of the history.

    - -
    Data
    -

    Examples

    -

    The first block of the list holds NORMA's example datasets. The TP53 documentation example also brings a small file of simulated log2 fold changes and adjusted p-values, for trying the numeric color scale; tick it under Expressions. Opening one adds its network, annotations and expression file to Files, selects them and shows the result in a new view. Their other annotations stay in the list so you can switch between them.

    - - - - - - - - - - - - - - -
    ExampleFilesSource
    STRING: TP53 interactorsNetwork (weighted), groups, expressionSTRING
    STRING: BCAR3 interactorsNetwork; GO biological process, GO molecular function, KEGGSTRING
    Drosophila Tau networkNetwork; KEGG, Louvain communities; expressionPMID 31488613, doi:10.1523/JNEUROSCI.0391-19.2019
    Human gene co-expressionNetwork; GO biological process, molecular function, cellular component; KEGG; MCODE cluster colorsPMID 19081792, doi:10.1371/journal.pone.0003911, bioinfow.dep.usal.es/coexpression
    COVID-19Network; InterPro, SMART, GO (three), KEGGIntAct
    Gallus gallusNetwork; KEGGBioGRID
    Signalling cascade (showcase)Directed multi-edge network with five channels; four cascade levels; simulated log2FC and adjusted p-values. Opens with arrows, curved parallel edges, the numeric color scale and the legend.Simulated for NORMA 3.0
    Healthy vs disease (showcase)Two networks over the same 64 nodes (the disease network loses about a fifth of the edges and gains links in one module); four modules; simulated log2FC. Opens with both networks overlaid as two channels; compare them on the Network Comparison page.Simulated for NORMA 3.0
    Arena3D example (showcase)The seven-layer example network from Arena3D's documentation; its layers become groups. Try Open in Arena3D to send it back.Arena3D
    TP53, NORMA documentationThe small example from NORMA's help pagesNORMA
    -

    The second block holds built-in demos in JSON form: a curated multi-edge operon, two synthetic networks of four overlapping modules with attributes on nodes, edges and groups, and random networks from 20 to 5,000 nodes. The 40-node random directed multi-edge network opens with arrows, curved parallel edges and a hierarchical layout: its four groups form a signalling cascade from receptors to target genes, pairs carry one to three channels, and about a quarter of them also have a feedback edge in the opposite direction. The 20-node random network opens with edge labels: each edge carries an interaction attribute (activates, inhibits, binds, …) shown at its middle. All demos are generated from fixed seeds and can be downloaded under Example files. Clear view empties the current view but keeps your files.

    - -

    Sample data and sample output

    -

    Every example opens with one click, from Open an example on the Welcome page or the Examples list in the Upload Data tab. Its files (the sample data) can be downloaded under Example files, to see the formats or to try uploading them. The links below open a sample result directly; they behave exactly like results from your own data:

    - - - - - - - - - - -
    Sample outputShows
    Drosophila Tau networkA published network with KEGG pathways, Louvain communities and expression colors; try the Network Profiler and Group analysis on it.
    STRING: TP53 interactorsA STRING network with evidence channels, groups and expression.
    Signalling cascadeDirected, multi-channel edges with numeric values, a color scale and a legend.
    Healthy vs diseaseTwo networks to compare on the Network Comparison page.
    Arena3D exampleSeven layers, ready to open in Arena3D.
    Four overlapping modules in 3DPie-chart nodes and group layouts on the 3D page.
    -

    Each link can also be bookmarked or shared: norma.html?example=NAME (add &tab=3d for the 3D page).

    - -

    Example files

    -

    Sample data: every file behind the examples, so you can see the file formats and try NORMA's upload with them. Click a name to download the file; the same examples can be opened directly from the Examples list or with the sample-output links in Sample data and sample output.

    -
    -

    Built-in demos

    -

    The demos are generated from fixed random seeds, so each download matches what Open example shows. Network and Groups are NORMA files: the network has a Weight column when any edge is weighted (edges without one are written with weight 1) and a Type column when there are several channels. JSON keeps everything, including attributes, group descriptions and, for the labelled demo, the settings that turn edge labels on.

    -
    - -

    Files and formats

    -

    Upload one or many files at once with Upload files…, or drop them on the upload box. Detect file type recognizes networks by their header and expression files by their colors; anything else is read as an annotation. Pick a type yourself if detection guesses wrong. Networks with more than 10,000 nodes are cut to their first 10,000 nodes, with a note in the list. Arena3D network files (columns SourceNode, SourceLayer, TargetNode, TargetLayer, Weight, Channel) are recognized too: each becomes a network and an annotation with one group per layer. The list below it chooses whether network rows without a Direction value are read as undirected or directed. The name box names a single upload; with several files, each keeps its file name. Every file appears in one of three lists (Networks, Annotations, Expressions), in A–Z order, and stays there until you delete it (see Deleting files). Files live only in this browser tab.

    -

    Deleting files

    -

    There are four ways to remove things completely:

    -
      -
    • Delete… in the top bar (next to Rename) lists the current view and every file it shows (its network files, grouping and expression file), each with a tick box; untick what you want to keep. Removing the view closes it (the previous view is shown; removing the only view leaves an empty one). For a view opened from an example or a JSON file, only the view itself can be removed, since its data belongs to the view.
    • -
    • The ✕ next to the Grouping menu removes the grouping file the view uses.
    • -
    • The ✕ next to a network, annotation (grouping) or expression file in Upload Data → Files removes that file.
    • -
    • Delete all files… under the Files lists removes every file at once.
    • -
    -

    Each asks Are you sure you want to completely remove …? and names the views that will change; nothing happens until you choose Yes, remove. Deleting a file:

    -
      -
    • removes the file from Files, from the Grouping menu and from every other file list and menu, together with everything NORMA read from it (for STRING and database imports, also the extra node details that came with the network);
    • -
    • updates every view that shows it, including views you are not looking at: a view keeps its remaining networks, node positions, group colors and ticked groups and channels, and loses the deleted network's nodes and edges, or the deleted groups or colors; a view left with no network becomes empty (its display settings stay);
    • -
    • clears the undo history of those views, so the deleted data can't come back, and removes Network Profiler and Network Comparison results that were computed from it;
    • -
    • leaves out the deleted data from sessions saved afterwards;
    • -
    • renames views that were named after their files automatically (views you renamed keep their names).
    • -
    -

    Groupings that were fetched for a deleted network (for example from STRING) are not deleted with it; delete them separately if you don't need them. Deleting never touches the files on your disk, and cannot be undone inside NORMA: to use a file again, upload it again. Views opened from examples or JSON files hold their own data; empty them with Clear view.

    -

    Network file

    -

    Tab-separated, with a header row Source, Target and optionally Weight. Connections are undirected: A–B and B–A are the same connection, so repeats and self-loops are removed and reported.

    -
    Source	Target	Weight
    -CDKN1A	TP53	5
    -TP53	MDM2	1
    -

    An optional Type column (also accepted as Channel, Layer or Interaction) keeps several edges between the same pair, one per type, and makes each type a channel. Only a repeat of the same pair and type is removed. This column is new in NORMA 3.0: earlier versions of NORMA read only Source, Target and Weight and kept a single connection per pair.

    -
    Source	Target	Weight	Type
    -TP53	MDM2	0.9	experiments
    -TP53	MDM2	0.7	textmining
    -

    An optional Direction column (also accepted as Directed, Dir or Arrow) marks each row as directed (also yes, true, 1 or ->) or undirected (also no, false, 0 or -). A directed row points from Source to Target, so A→B and B→A are two connections; for undirected rows they are one. Rows with an empty or unknown value follow the direction chosen when uploading. This column is new in NORMA 3.0; see Directed and undirected networks.

    -
    Source	Target	Type	Direction
    -EGFR	GRB2	binding	undirected
    -MAPK1	ELK1	phosphorylation	directed
    -ELK1	FOS	expression	directed
    -

    Annotation file

    -

    No header. Each line is a group name, a tab, then the group's nodes separated by commas without spaces. Group names may contain spaces and commas; node names may not contain commas. A node can be in any number of groups.

    -
    Group-2	BCL2L1,MDM4,MDM2,CHEK2
    -Group-5	TP53,EP300
    -

    Expression file

    -

    No header. Each line is a node name, a tab, then a color: a name such as red or a hex code such as #ff0000. Nodes without a color are gray.

    -
    CDKN1A	blue
    -MDM4	#00ff00
    -

    Numeric values can be used instead of colors (new in NORMA 3.0): each line is a node name, a tab, then one or more numbers, such as a log2 fold change and an adjusted p-value. An optional first line names the columns; without it they are called value, value 2 and so on. NA, empty cells and similar entries count as missing. Decimal commas are accepted. Detect file type recognizes such files, and the list shows how many value columns they have.

    -
    Gene	log2FC	padj
    -CDKN1A	2.4	0.000001
    -BCL2L1	-1.8	0.0003
    -TP53	NA	NA
    -

    Choosing what to show

    -

    Tick one or more networks, choose one annotation and one expression file (or None), then choose Show in this view or Open in new view. A note under the buttons tells you when the selection differs from what the view shows. If only the annotation or expression changed, Show in this view updates in place and keeps node positions, so you can flip between GO, KEGG and community groupings of the same layout, or open each grouping in its own view to keep them side by side.

    -

    With an expression file selected, nodes are filled with its colors and groups are shaded behind them, as in NORMA. Without one, nodes are filled with their groups' colors, split into a pie for nodes in several groups.

    -

    Annotation names not in the network

    -

    Node names in an annotation must also appear in the network. The check happens twice:

    -
      -
    • When you upload an annotation, its names are compared with the networks uploaded together with it, or, if there are none, with the ticked networks, or else with every network in the list. Names found in none of them are discarded right away, groups left empty are removed, and a warning lists what was dropped. Download corrected annotation saves the cleaned file, which is what NORMA's companion R script produces. If no name matches at all, the file is not kept. If no network has been uploaded yet, the check waits until the annotation is shown.
    • -
    • When you show files, annotation and expression entries for nodes that aren't in the selected networks are left out and listed in the status notes, again with a corrected annotation to download.
    • -
    - -

    Several networks and multi-edges

    -

    New in NORMA 3.0. Previous versions of NORMA did not support multi-edge graphs: every pair of nodes could have only one connection, and repeated connections were removed when a network was loaded. NORMA 3.0 keeps all of them, as parallel edges in separate channels.

    -

    Parallel edges come from three sources: a Type column in a network file, several network files shown together, or JSON files with more than one edge per pair.

    -

    Ticking more than one network overlays them. Nodes with the same name are merged, and each network becomes its own edge channel, so a connection found in two networks is drawn as two parallel edges in two colors. Edges and nodes then carry a network / networks attribute naming their source, which you can search for.

    -

    Ways to work with parallel edges:

    -
      -
    • Edge channels turns each channel on or off and recolors it.
    • -
    • Edges → Parallel edges: merge per node pair draws one edge per pair, thicker the more channels it carries. It's gray when channels disagree and in the channel's color when there's only one. Clicking a merged edge lists all its channels.
    • -
    • Edges → Shape: curved fans parallel edges apart so each stays visible.
    • -
    • Layout → Use checked channels only positions nodes using only the channels you've ticked, so you can lay the graph out by one network and compare the others on top.
    • -
    • Edges → Shape: bundled draws edges running in similar directions together, and Labels → Edge labels can name each edge's channel.
    • -
    • Network Profiler reports, for the current view, how many node pairs are shared between channels and how similar each pair of channels is; Network Comparison sets whole networks side by side.
    • -
    • Export → NORMA files → Keep channels as a Type column saves parallel edges in a NORMA network file instead of merging them.
    • -
    - -

    Directed and undirected networks

    -

    New in NORMA 3.0. Earlier versions of NORMA treated every connection as undirected. Edges can now point from a source to a target, and a network can mix directed and undirected edges.

    -

    Bringing direction in

    -
      -
    • When uploading, the list under the file type sets how network rows are read: undirected (A–B), the default, or directed (Source → Target). Choose directed before uploading if A→B and B→A are different interactions: an undirected upload treats them as the same connection and keeps only one.
    • -
    • A Direction column in the network file overrides that choice row by row (see Network file), so one file can mix both kinds.
    • -
    • In JSON, "directed": true at the top makes every edge directed, and "directed": true or false on an edge sets that edge (see JSON format).
    • -
    • The built-in Random directed multi-edge network shows a signalling cascade with up to three channels per pair and some feedback edges.
    • -
    -

    Handling a network as directed or undirected

    -

    Display → Edges → Direction decides, for the current view:

    -
      -
    • Treat all edges as undirected (default): direction is ignored everywhere, as in earlier versions of NORMA. When the data does mark edges as directed, the top bar shows a direction ignored tag; click it to switch to As in the data.
    • -
    • As in the data: edges marked as directed get arrows and are treated as directed; the rest are undirected.
    • -
    • Treat all edges as directed: every edge points from its source to its target, whatever the file says.
    • -
    -

    With arrows shown, Arrow chooses a triangle, vee, curved triangle or chevron, and Arrow size scales them. The top bar shows a directed or mixed direction tag next to the counts.

    -

    What direction changes

    -
      -
    • Parallel edges: A→B and B→A are separate edges. With Shape: curved they are drawn apart; merge per node pair merges only edges running the same way.
    • -
    • Node size and details: In-degree and Out-degree count incoming and outgoing edges (an undirected edge counts as both). Betweenness and closeness follow edge directions when the shown part has directed edges; clustering ignores direction. A node's details show in- and out-degree and mark each neighbor with → (outgoing) or ← (incoming); an edge's details show its direction.
    • -
    • Layout: Hierarchical places sources above their targets. Force-directed layouts and the group strategies ignore direction.
    • -
    • Network Profiler and Network Comparison: with Use edge direction ticked, they add directed statistics and match edges by direction (see below).
    • -
    • Exports: NORMA network files get a Direction column when any edge is directed, and JSON keeps each edge's direction. Earlier versions of NORMA read neither.
    • -
    -

    Search, group shading, shapes, bundling and edge labels work the same for directed and undirected edges.

    - -

    Database importers

    -

    The Database importers tab fetches networks and groupings from public databases; no account is needed. Every import is added to Files (a network plus one annotation per grouping) and opens in a new view, where the Grouping list switches between its groupings. Nodes keep useful identifiers as attributes, in particular uniprot, which the Gene Ontology importer uses. Networks with directed edges open with arrows. Very large results are cut to 10,000 nodes, and every importer has a Cancel button. When NORMA runs with server.py, all requests go through the server (see Running NORMA locally or on a server); otherwise the browser calls the services directly, which some browsers block. STRING is described in Importing from STRING.

    -

    Reactome

    -

    Curated pathways from Reactome. Type a pathway name (choose the species) or a stable identifier such as R-HSA-69278, choose Search pathways, pick one, and choose Fetch pathway network. NORMA reads the pathway's reactions (up to Most reactions) and their participating molecules: nodes are the proteins (and, if ticked, small molecules), and two nodes are linked when they take part in the same reaction, weighted by how many reactions they share. Reactions with more than the chosen number of molecules are left out, since they would link everything to everything. Groupings: the pathway's sub-pathways (the proteins of the reactions each contains) and, optionally, the 40 largest reactions.

    -

    OmniPath

    -

    Signalling, transcriptional and ligand–receptor interactions from OmniPath for human, mouse or rat. Type gene symbols or UniProt accessions and tick the datasets (OmniPath core, pathway, kinase–substrate and ligand–receptor extras, CollecTRI and DoRothEA). NORMA fetches their interactions with all partners (keeping the Most partners best supported by curation effort) or only those among the given proteins. Edges keep OmniPath's direction; Channels split them by effect (stimulation, inhibition, both, unsigned) or by interaction type, and their weight is the curation effort. Groupings: protein complexes with at least two members in the network, intercellular roles (ligand, receptor, …) and, optionally, an annotation resource such as SignaLink pathways or NetPath.

    -

    NDEx

    -

    Public networks from the Network Data Exchange. Search with words or paste a network UUID, pick a network (the list shows its size and owner) and choose Fetch network. Node names, edge interactions and numeric weights are read from the network, its saved layout is kept, and node attributes become node attributes in NORMA. Groups depend on the network ("limited"): every node attribute with 2 to 60 distinct values covering at least 30% of the nodes (such as a type or module) becomes a grouping, up to six.

    -

    IntAct

    -

    Experimentally detected molecular interactions from IntAct, through its PSICQUIC service. Type gene names or UniProt accessions and choose the organism (both partners must belong to it). Interaction records below the Lowest MI-score are left out; the others are merged per pair and interaction type (physical association, direct interaction, …), which become channels, with the best MI-score as the weight. Most partners keeps the highest-scoring partners, and Most records limits how much is read. IntAct has no groupings of its own, so NORMA adds one that separates the query proteins from their partners; add GO terms or communities for more.

    -

    Gene Ontology

    -
      -
    • GO-CAM network (limited): GO-CAM models from the Gene Ontology link the activities of gene products causally. Choose the organism, List models, filter and pick one (or type a model ID), and Fetch GO-CAM network. Nodes are the gene products that enable the activities; directed edges follow the causal relations (directly positively regulates, provides input for, …), which become channels. Groupings: the biological process, cellular component and molecular function of each activity.
    • -
    • GO groupings add GO terms as groups to the network already shown, from QuickGO annotations of the nodes' UniProt accessions (the node name or its uniprot attribute). Tick the aspects; Leave out electronic annotations skips IEA evidence, NOT annotations are always skipped, and each aspect keeps up to Groups per aspect terms with at least Smallest group members (terms that contain every node are left out). The groupings are added to Files and shown in the current view.
    • -
    - -

    Importing from STRING

    -

    New in NORMA 3.0. STRING, in the Database importers tab, fetches a protein interaction network, and functional groupings for it, directly from the STRING database. It needs an internet connection.

    -

    The network

    -
      -
    • Proteins or genes: one or more names, such as TP53, p53 or a UniProt accession, separated by spaces, commas or new lines. STRING matches each name to its best protein and the status notes say how each was matched and which names weren't found.
    • -
    • Organism: pick one from the list, or choose Other organism and type its NCBI taxon ID (for example 7955 for zebrafish). Any organism in STRING can be used.
    • -
    • Network: functional associations use all of STRING's evidence; physical interactions only keeps proteins that bind each other or form a complex.
    • -
    • Interactors to add (0–500) extends the network with the proteins most confidently connected to yours, best first. With 0 and several names, only the connections among them are fetched; with a single name STRING always adds its 10 best interactors.
    • -
    • Minimum confidence: highest (0.900), high (0.700), medium (0.400), low (0.150) or a custom value; weaker connections are left out.
    • -
    • Evidence channels: gene neighborhood, gene fusion, co-occurrence, co-expression, experiments, curated databases and text mining. Untick channels to leave that evidence out: each connection's confidence is then recomputed from the ticked channels only, the way STRING combines them, and connections that fall below the minimum are dropped (the notes say how many).
    • -
    • Edges: one edge per channel makes a multi-edge network in which each channel has STRING's color and its own score as the weight, so you can tick channels on and off, merge or bundle them later. One edge per pair keeps a single edge with the combined score.
    • -
    -

    Each protein carries its STRING identifier, STRING's description of it, and whether it was one of your query proteins, as attributes: they appear in the node's details and can be searched.

    -

    Groupings from functional terms

    -
      -
    • Terms: enriched in the network (recommended) runs STRING's enrichment analysis on the network's proteins and keeps terms with an FDR at or below the chosen value; all annotations of its proteins takes every term any of them has. STRING doesn't share full KEGG annotations for licensing reasons, so KEGG pathways come only with enriched terms.
    • -
    • Term collections: GO biological process, molecular function and cellular component, KEGG, Reactome and WikiPathways pathways, UniProt keywords, Pfam, InterPro and SMART domains, diseases, tissues, subcellular localization, phenotype ontologies (human, mammalian, fly, worm, zebrafish, fission yeast), STRING's local network clusters and reference publications. Which ones have terms depends on the organism and the STRING version; the notes list what STRING returned. Any other collection STRING offers includes collections not in the list.
    • -
    • Groups per collection keeps the most significant (enrichment) or largest (all annotations) terms, and Smallest group leaves out terms with fewer proteins in the network.
    • -
    -

    Each collection becomes an annotation in Files, named after the network and the collection, and the view opens grouped by the first available of KEGG, Reactome, GO biological process and so on. Switch collections with the Grouping list in the top bar; node positions stay. Every group carries the term's identifier and collection and, for enrichment, its FDR, p-value and number of proteins in the genome, shown in the group's details (its i button). Fetch groupings for the current view adds more collections, or the same ones with other settings, to a STRING network that is already shown.

    -

    STRING server

    -

    By default NORMA asks https://string-db.org, which always answers with the latest STRING version; the notes say which version was used. To get the same answers every time, enter a version address such as https://version-12-0.string-db.org. NORMA identifies itself to STRING and waits a second between requests, as STRING asks, so a full import takes a few seconds. Cancel stops a request that takes too long. Untick Fetch protein descriptions to save one request.

    -

    Networks and groupings fetched from STRING can be saved as NORMA files or JSON like any other; please cite STRING (Szklarczyk et al., Nucleic Acids Research 2023;51(D1):D638–D646, doi:10.1093/nar/gkac1000) when you use them.

    - -

    JSON format

    -

    The only required fields are nodes and edges:

    -
    {
    -  "nodes": [
    -    { "id": "A", "groups": ["g1", "g2"], "size": 40, "color": "#e07a7a", "tissue": "liver" },
    -    { "id": "B", "group": "g1" }
    -  ],
    -  "edges": [
    -    { "source": "A", "target": "B", "type": "experiments", "weight": 0.8, "pmid": 123 },
    -    { "source": "B", "target": "A", "type": "regulation", "directed": true }
    -  ],
    -  "directed": false,
    -  "groupAttrs": { "g1": { "label": "Group one", "description": "…", "curated": true } },
    -  "nodeColors": { "g1": "#e8a15f" },
    -  "edgeColors": { "experiments": "#b464c9" },
    -  "groupShapes": { "g1": "diamond" },
    -  "config": { "showEdgeLabels": true, "edgeLabelContent": "attr:pmid" }
    -}
    -
      -
    • Nodes: id, group or groups, size and color have fixed meanings; any other field is kept as an attribute.
    • -
    • Edges: id, source, target, type (the channel) and weight have fixed meanings; any other field is kept as an attribute. Any number of edges may join the same pair, for example one per channel. directed (true or false) sets an edge's direction.
    • -
    • Direction: a top-level "directed": true makes every edge without its own directed field directed.
    • -
    • Numeric values: "values": { "log2FC": 1.2, "padj": 0.003 } on a node gives it numeric values for the color scale and node size.
    • -
    • Groups: in groupAttrs, label, description and color have fixed meanings; anything else is a group attribute. Attributes can also be nested under an attrs object.
    • -
    • Colors: nodeColors and edgeColors fix group and channel colors.
    • -
    • Shapes: groupShapes gives a group a node shape. Names are Cytoscape's: rectangle, round-rectangle, diamond, triangle, vee, pentagon, hexagon, octagon, star, rhomboid, tag, concave-hexagon, barrel; groups not listed are circles.
    • -
    • Group order: an optional groupOrder list sets the order of groups in the legend and the order palette colors are handed out.
    • -
    • Settings: an optional config object holds display settings in the same shape as a settings file. For example, "edgeLabelContent": "attr:pmid" labels edges with their pmid attribute.
    • -
    - -
    Viewing and styling
    -

    Exploring the network

    -
      -
    • Selecting several nodes: Shift-click (or Ctrl/⌘-click) adds or removes a node, Shift-drag on the background draws a selection box, Ctrl+A selects every visible node and Escape clears the selection. Selected nodes get a thick yellow border and halo, which disappears as soon as they are deselected; a bar at the top right shows how many are selected with Zoom to selection and Clear, and dragging any selected node moves them all.
    • -
    • Drag nodes to move them, scroll to zoom, and drag the background to pan. Zoom has no practical limit in either direction, and the view always keeps the network in frame: a network smaller than the canvas can't be pushed off it, and a larger one can't be panned past its edges. The +, – and Fit buttons at the bottom left zoom around the center; the number under them is the zoom level. Double-click the background to fit everything, or a node to center it.
    • -
    • Click a node for its groups, degree (with in- and out-degree for directed edges), betweenness, closeness, clustering coefficient (computed on the ticked groups and channels), attributes and neighbors; with directed edges, → and ← mark outgoing and incoming connections. Group names in that panel open the group's details.
    • -
    • Neighbourhoods and paths (new in NORMA 3.0): the node details offer Open neighbourhood as a view with 1, 2 or 3 steps. With nodes selected, Neighbourhood in the selection bar opens them with their direct neighbours; with exactly two selected, Shortest paths opens every shortest path from the node selected first to the second, keeping only the edges on those paths, and says how many paths there are. Both follow edge directions where edges are directed, use only the ticked groups and channels, and open as new views that keep the nodes' positions, groups, colors and settings; the starting nodes are selected and carry a role attribute (center, source or target).
    • -
    • Click an edge for its channel, endpoints, direction, weight and attributes.
    • -
    • Click the i next to a group for its description, attributes, member and edge counts, density, and the groups it shares members with.
    • -
    • Click empty space to clear the highlight.
    • -
    • Find nodes, at the top left of the network (the – button at its right minimizes the panel to a small Find nodes button, which still shows the number of matches; click it to open the panel again), highlights matching nodes and shows how many matched. Choose how to match: Contains, Exact name, Starts with, Ends with (suffix) or Regular expression. A regular expression can be typed as is, such as ^Rp[LS]\d+$, or between slashes with flags, such as /kinase$/i. Matching ignores case unless Match case is ticked, and also checks attribute values while Search attributes is ticked. Press Enter to zoom to the matches and Escape to clear.
    • -
    • The numbers at the top right count nodes, edges, edge channels and groups in the view; with groups unticked, the group count shows ticked/all, for example 8/9.
    • -
    - -

    3D Network

    -

    New in NORMA 3.0. The 3D Network page shows the current view in three dimensions. It is the same view as 2D Network: groups, colors, pie slices, node shapes, borders, labels, edge labels, arrows, curved parallel edges, group shading, ticked groups and channels, search results and selected nodes all carry over, and every setting in the Display tab except the 2D Layout section changes both pictures. Node positions and the camera are separate for 3D, and each view keeps its own.

    -

    Moving around

    -
      -
    • Drag the background to rotate, right-drag (or Alt-drag) to pan, and scroll to zoom. Turning and tilting have no limit: drag on, or keep pressing a turn or tilt button, and the network rolls over as often as you like, so it can be seen from any side, including upside down.
    • -
    • Drag a node to move it within the screen plane; with several nodes selected, dragging one moves them all.
    • -
    • Click a node or an edge for its details, exactly as in 2D; click the background to clear. Shift-click selects nodes and Shift-drag draws a selection box. Double-click a node to turn around it, or the background to fit everything.
    • -
    • The buttons at the bottom left zoom (+, –, Fit), turn (↺, ↻), tilt (↑, ↓), reset the view (⟲) and switch automatic rotation on and off (Auto). With the 3D picture focused, the arrow keys turn and tilt, + and – zoom, and F fits.
    • -
    • Find nodes, Zoom to selection and Export image work here too and act on the 3D picture.
    • -
    -

    3D layout

    -

    On this page the Display tab shows a 3D layout section in place of the 2D one. As in 2D, layouts arrange only the nodes of ticked groups and, with Use checked channels only, only the ticked channels count.

    -
      -
    • Force-directed 3D, weighted: the Fruchterman–Reingold idea in three dimensions; heavier edges pull harder. Each connected piece is laid out on its own and smaller pieces are placed around the largest. It runs in the background and is used the first time a view is shown in 3D.
    • -
    • Sphere, Cube grid and Random place nodes on a sphere, in a cube or at random, best-connected nodes first.
    • -
    • Groups as 3D clusters: each ticked group becomes a ball of its nodes, with hubs in the middle. Groups that share many edges are placed close together, and the balls never overlap. Nodes in several groups sit between their groups.
    • -
    • Groups on stacked layers: the 2D layout lies flat and each group gets its own layer, one above the other, so the links between groups run between the layers.
    • -
    • Same as 2D (flat), Height by hierarchy level and Height by degree keep the 2D layout and add height: none, the level from the top of a hierarchy (following edge directions when edges are directed), or the number of connections, so hubs rise above the rest. Height step sets the distance between levels.
    • -
    -

    Spread pushes nodes apart or pulls them together, as in 2D. Double-click the slider to go back to 1×.

    -

    Look

    -
      -
    • View from turns the camera to the front, top, side or a tilted angle and fits the network.
    • -
    • Perspective: normal, strong (wide angle, more depth) or none (parallel projection, where sizes don't change with distance).
    • -
    • Nodes: shaded spheres or flat, as in 2D. Group shapes and pies are always drawn flat, facing you.
    • -
    • Depth fading blends distant nodes and edges into the background, which helps tell near from far.
    • -
    • Floor grid and axes draws a grid under the network and short x, y and z axes at its center.
    • -
    • Rotate automatically turns the network slowly; Rotation speed sets how fast.
    • -
    -

    Group shading follows the nodes as seen from the camera. Edge bundling applies to the 2D picture only; in 3D, edges are straight, and parallel edges curve apart when Shape is curved or bundled. On large networks the picture is simplified while you move it (no labels, pies or arrows) and drawn in full as soon as you stop; at most 500 node labels and 300 edge labels are drawn at a time, the nearest first.

    - -

    Layout

    -

    This section is about the 2D Network page; the 3D page has its own layouts (see 3D Network). Every layout arranges only the shown part of the view: nodes in ticked groups, connected by edges of ticked channels while Use checked channels only is on. Nodes of unticked groups stay where they are, so ticking them again brings them back in place.

    -

    The switch at the top of Layout chooses how nodes are arranged. Only one side is active at a time; the other is shown greyed out and marked frozen, and Run layout runs only the active side. The choice is saved with the view.

    -

    By connections

    -

    Positions come from the edges alone; groups play no part. Force-directed (cose) places connected nodes near each other. Force-directed, weighted is the Fruchterman–Reingold layout NORMA uses: heavier edges pull their nodes closer, and the result is the same every time for the same input. Kamada–Kawai (new in NORMA 3.0) treats every pair of nodes as a spring whose length is the number of steps between them and relaxes the springs one node at a time; stress majorization starts from a pivot-MDS layout and then minimises the same kind of stress (the mismatch between on-screen and network distances) for all nodes together, which is usually faster and keeps distances most faithfully. Both ignore edge weights, lay out each connected part separately, and handle parts of up to 2,500 nodes. They are also available as group strategies and inside groups. Concentric puts high-degree nodes in the middle; hierarchical arranges nodes in levels from a root, following edge directions when the view has directed edges. Networks above 700 nodes load with a grid layout, since a force-directed layout can freeze the page for a while at that size; you can still run it yourself.

    -

    By groups

    -

    Group arrangement decides where each group goes, and Layout inside each group arranges the group's own nodes (circle, force-directed, concentric, hierarchical, grid or random). A node in several groups sits at the average of its positions, between those groups. Only groups ticked under Node groups count.

    -
      -
    • Groups as separate blocks treats each group, plus one block for nodes in no group, as a disc sized to fit its nodes, and places the discs so they never overlap. Force-directed puts groups that share many edges next to each other and packs the rest around them; Circle lines them up on a ring; Grid packs them in rows; Hierarchical stacks them in levels starting from the best-connected group; Concentric puts the largest group in the middle with the others in rings by size. Space between groups sets the gap.
    • -
    • NORMA-2.0 strategies (Karatzas et al., 2022, Bioinformatics Advances 2:vbac036, Figure 1) use the same weights and steps as NORMA's own code, with the algorithm you pick under Algorithm the strategy runs. A small diagram shows how the selected strategy works; links a strategy adds exist only while positions are computed. Weights change the result only with the two force-directed algorithms. -
        -
      • Strategy 1, virtual node per group: each group gets a hidden hub linked to all its members with weight 50. Real edges get weight 5, and every pair of members gets a light link (0.1). The algorithm runs on this larger network and the hubs are then removed.
      • -
      • Strategy 2, group gravity: every pair of nodes in a group gets a link at the network's highest weight. Real edges inside a group are multiplied by Force strength, and edges between groups, or to ungrouped nodes, are divided by it.
      • -
      • Strategy 3, super nodes per group: each group becomes a single node and ungrouped nodes stay as they are. The algorithm lays out this collapsed network and Force strength pushes the result outward.
      • -
      - Strategies 1 and 2 place every node themselves. With them, Layout inside each group can also be Keep the strategy's arrangement; any other choice rearranges each group around where the strategy put it and keeps groups from overlapping.
    • -
    -

    Group size scales each group's local layout. Force strength runs from 1 to 20 (NORMA's default is 10). Use checked channels only applies to both sides. A group with more than 400 nodes gets 60 random partner links per node instead of links to every other member, to keep the layout fast. While By groups is active, it is also used when a network loads and when you switch annotations.

    -

    Fit to screen zooms to show the whole network. Spread changes how strongly nodes repel each other compared with the pull of their links, from a quarter to four times the strength of the current arrangement. To the right, dense clusters open up and loosely linked parts move apart; to the left, linked nodes are drawn together while nodes are kept from overlapping. It starts from the current positions and keeps the overall arrangement, and going back to 1× restores them. Double-click the slider to go back to 1×. Moving nodes by hand starts again from their new positions, and running a layout resets it.

    - -

    Node groups

    -

    Groups are listed A–Z (numbers in numeric order, so Group-2 comes before Group-10). The Order menu under the filter also sorts them by their number of nodes (the count shown on each row), Most nodes first or Fewest nodes first (groups of equal size stay A–Z), or keeps the File order of the annotation file. Nodes in no group always come last. The legend follows the same order, each view remembers the choice, and group colors don't change. Node names in the details panels, and the files in Files and in every file menu (Grouping, Network Profiler, Network Comparison, enrichment) are always listed A–Z.

    -

    Lists the groups of the current annotation with their node counts. Untick a group to hide nodes that are only in inactive groups; a node stays visible while any of its groups is active, and its pie shows only its active groups. Click a swatch to recolor a group. Nodes that no group lists appear under Not in any group.

    -

    Type in the filter box to narrow long lists such as GO terms. While a filter is set, the two buttons activate or deactivate only the matching groups, which makes it quick to show, for example, only terms containing "signaling". Pies show up to 16 slices; a node in more active groups gets a gray slice for the rest.

    -

    Node shapes

    -

    The small shape button on each group row opens a choice of shapes: circle, square, rounded square, diamond, triangle, vee, pentagon, hexagon, octagon, star, rhomboid, tag, concave hexagon and barrel. Use the arrow keys to move through them and Escape to close. The same shapes are offered in a group's details panel (its i button). Assign shapes gives each listed group a different shape at once, and Reset shapes makes them circular again; with a filter set, both apply only to the matching groups.

    -

    A node takes its group's shape only when exactly one of its groups is ticked. Nodes shown with two or more active groups stay circular, so their pie slices stay readable. Unticking groups therefore changes shapes: a node in a square group and a diamond group is a two-color circle, becomes a square when the diamond group is unticked, and a diamond when the square one is. Shapes combine with either fill: with expression colors, the shape shows the group and the color shows expression. Each view keeps its own shapes, and they are saved in JSON.

    - -

    Group highlighting

    -

    Shades the area behind each active group in its color, as NORMA does. Opacity controls how strong the shading is. Three styles are available:

    -
      -
    • Convex hull draws a sharp outline around all of the group's nodes. Groups of one or two nodes get a circle or capsule.
    • -
    • Bubble sets draws contours that hug the group, in the style of Collins, Penn and Carpendale's Bubble Sets (IEEE TVCG 2009): the outline follows the group's nodes closely, links them with narrow bands so each group stays in one piece where possible, and bends around nodes that are not in the group, leaving holes where outsiders sit inside. Interleaved groups therefore stay readable where convex hulls would cover each other. Contours are recomputed when nodes move, groups are ticked or the zoom changes a lot; on networks with more than 1,500 shown nodes they are updated once the movement stops.
    • -
    • Fog cloud draws a soft, blurred area.
    • -
    -

    All three styles appear in image exports (SVG included) and on the 3D Network page, where bubble sets are drawn from the camera's point of view and simplified while the picture moves.

    - -

    Colors

    -
      -
    • Node fill chooses between group colors, expression colors (from an expression file or each node's color field) and numeric values on a color scale, when the view has numeric values.
    • -
    • Numeric values (shown when the view has them): -
        -
      • Column picks which value to show, for example log2FC or padj.
      • -
      • Transform: use the values as they are, their −log10 (so small p-values become large), or their absolute values.
      • -
      • Scale: diverging scales (blue–white–red, purple–white–orange, purple–white–green, brown–white–teal) show values below and above a Center (0 by default), which suits fold changes; sequential scales (viridis, magma, blues, reds) run from low to high, which suits p-values and intensities. Most of these scales stay readable under common forms of color vision deficiency.
      • -
      • Range: symmetric around the center (diverging scales), from the lowest to the highest shown value, or custom; values beyond the range get the end colors.
      • -
      • No value sets the color of nodes without a value. A line under the controls says how many shown nodes have a value and their range.
      • -
      - Steps (time points or conditions) appear when there are several value columns: ◀ and ▶ move one step, Play steps through them repeatedly at the chosen speed, and the slider jumps to any step, while node positions stay put. The current step shows at the top of the network. Same color scale for all steps (on by default) fixes the scale over all columns so colors can be compared between steps. Add the numeric files in Files as steps adds every column of the other numeric files as further steps, named file: column; nodes a file doesn't list get no value for its steps. - A numeric file selected in Files switches the fill to numeric values automatically.
    • -
    • Group palette recolors all groups. The Okabe–Ito, IBM and Viridis palettes stay distinguishable under common forms of color vision deficiency. Past a palette's own colors, further colors are spread evenly around the color wheel, which keeps around 100 groups distinguishable.
    • -
    • Channel palette does the same for edge channels. Classic uses STRING's evidence colors.
    • -
    -

    Themes (top bar) change the interface and canvas background, not the data colors.

    - -

    Node size

    -

    Nodes can keep the size given in the data or be sized by degree, in-degree, out-degree, betweenness, closeness, clustering coefficient or a numeric value (the column and transform chosen under Colors; on a diverging scale the distance from the center, so strongly raised and strongly lowered nodes are both large). With directed edges, betweenness and closeness follow edge directions. Sizes run from Min px for the lowest value to Max px for the highest. Metrics are computed on the nodes of ticked groups; Use checked channels only (on by default) also limits them to the ticked channels. Sizes update as you tick and untick. Node scale multiplies every size, from a tenth to five times.

    - -

    Labels

    -
      -
    • Node labels show node names, centered on the node by default; the position list moves them above, below or to a side. Node label size sets their size, and Grow labels with node size scales each label with its node, so bigger nodes get bigger names.
    • -
    • Edge labels are drawn at the middle of each edge, whether straight, curved or bundled. They can show the channel, the weight, both, or any edge attribute in the network. Merged edges show how many channels they combine and their highest weight. Orientation runs them along the edge or keeps them horizontal, and Edge label size sets their size. With edge labels on, straight edges are drawn with a slightly slower method that supports labels.
    • -
    • Label colors: node and edge labels can follow the theme, use black, dark gray, white, blue, red or green, match their node's color or their edge's channel color, or use any color chosen with Custom…. White labels get a dark outline so they stay readable on a light background.
    • -
    • Hide labels when zoomed out drops labels that would appear smaller than the chosen size on screen, which keeps large networks readable and fast.
    • -
    - -

    Edges

    -
      -
    • Direction: handle edges as in the data, all as directed, or all as undirected; with arrows shown, choose their shape and size (see Directed and undirected networks).
    • -
    • Parallel edges: draw every channel separately, or merge each node pair into one edge (see multi-edges); directed edges merge only with edges running the same way.
    • -
    • Shape: straight lines are fastest. Curved edges fan parallel edges apart, and Fan-out sets how far.
    • -
    • Bundled draws edges that run in similar directions together, like cables in a tray, which untangles dense networks and shows the main routes between groups. It uses force-directed edge bundling (Holten and van Wijk, 2009): edges whose direction, length, position and overlap are similar pull on each other, while springs keep each edge smooth. Bundling strength sets how readily edges join a bundle and how tightly they are drawn together. Bundles are computed in the background, so the page stays usable: about 5 seconds for 3,000 edges and half a minute for 8,000. They are recomputed after a layout, after dragging nodes, after using Spread, and when groups or channels are ticked or unticked. Parallel edges between the same pair follow the same bundle. Only visible edges are bundled, up to 10,000 at a time; merging parallel edges or unticking channels brings larger networks under that limit.
    • -
    • Thickness: a fixed width, or mapped from each edge's weight between Min and Max px. Edges without a weight get the middle width.
    • -
    • Edge opacity: how solid edges are drawn; lower it to let dense networks show their nodes.
    • -
    - -

    Edge channels

    -

    Lists each kind of edge with its count. Untick a channel to hide its edges; click its swatch to recolor it. For files loaded from Files, each network (or each value of its Type column) is a channel.

    - -

    Legend

    -

    Display → Legend → Show a legend on the network places a legend at the bottom right of the 2D and 3D network (it steps aside while a node's or edge's details are open). It can include the color scale of numeric values with its ticks and the "no value" color, the groups with their colors and, with Group shapes, their node shapes, and the edge channels when there are two or more. An optional title goes on top. Groups and channels follow what is ticked, and the legend updates as colors, shapes or settings change.

    -

    Views made by Show as network on the comparison page come with the legend switched on and an extra Networks part that says which letter stands for which network.

    -

    In Export image, Add the legend puts the same legend to the right of the picture, at the picture's scale, in every format; in SVG files it is a separate, editable group. The legend is included whether or not it is shown on screen.

    - -

    Attributes

    -

    Summarizes the extra data carried by nodes, edges and groups: each field, its kind (text, number, yes/no, list), its range or values, and how many items have it. Values appear when you click a node, an edge or a group's i, and search looks through node attribute values.

    - -
    Analysis
    -

    Network Profiler

    -

    Besides the statistics below, the page holds Group analysis, the Layout benchmark and the Runtime table.

    -

    Tick any mix of the current view and networks from Files, then choose Compute statistics. Results appear side by side so networks can be compared, and Download table saves them as tab-separated text. Each network is profiled as a simple undirected graph. The current view is profiled as shown: only nodes of ticked groups and edges of ticked channels count, and parallel edges from different channels count as one connection.

    -

    With Use edge direction ticked (it is off by default), networks with directed edges get a second table, Direction: directed edges, reciprocity (the share of directed edges whose reverse also exists), maximum in- and out-degree, source and sink nodes, strongly connected components, the share of node pairs that can reach each other, and the average directed path length and diameter. Undirected edges can be walked both ways. The main table always treats edges as undirected, as igraph does for these statistics.

    - - - - -
    StatisticMeaning
    -

    Below the table:

    -
      -
    • Degree distribution: how many nodes have each degree. Tick log–log to check for a power law.
    • -
    • Most central nodes: the top ten by the measure you choose. Click a name to find it in the 2D Network page when it's in the current view.
    • -
    • Communities: choose an algorithm and choose Find communities; Louvain is shown by default. Add as annotation saves the communities (without single nodes) to Files as a new annotation you can view like any other; NORMA's Tau example ships such a file. All algorithms work on the simple undirected network, use a fixed random seed so results repeat, and are scored by the same modularity. -
        -
      • Louvain (Blondel et al., 2008): greedy modularity optimisation. Resolution above 1 gives more, smaller communities.
      • -
      • Leiden (Traag et al., 2019): Louvain with a refinement step that guarantees every community is connected; same resolution setting.
      • -
      • Label propagation (Raghavan et al., 2007): each node repeatedly takes the most common label among its neighbours; very fast. Communities that end up in pieces are split into connected parts.
      • -
      • Walktrap (Pons and Latapy, 2006): merges communities that short random walks of the chosen walk length cannot tell apart, and keeps the partition with the highest modularity. Limited to 2,000 nodes.
      • -
      • Markov clustering, MCL (van Dongen, 2000): simulates flow on the network; higher inflation gives more, smaller clusters. Clusters that end up in pieces are split into connected parts.
      • -
    • -
    • Edge channels (current view only, with two or more channels): how many node pairs carry one, two or more channels, and how much each pair of channels overlaps (Jaccard index of their node pairs).
    • -
    -

    Path-based statistics (distances, betweenness, closeness) visit every node from every node, so networks above 8,000 nodes skip them.

    - -

    Group analysis

    -

    The Group analysis part of the Network Profiler page works on the current view: its ticked groups and channels.

    -
      -
    • Group statistics: for each group, its nodes, the edges inside it and leaving it, its density inside (edges inside divided by all possible pairs), the mean degree inside, its conductance (edges leaving divided by the smaller of the group's and the rest's total degree; lower means a better-separated group) and its share of the modularity. Above the table, the modularity of the grouping says how well the groups as a whole follow the network's structure (above about 0.3 is clear structure). For this number, a node in several groups counts for its first group and a node in none counts as a group of its own. Download statistics saves the table.
    • -
    • Open group network opens a new view with one node per group, sized by the group's size, and an edge between two groups weighted (and labelled) by how many of their members are connected; each node also carries its group's edges inside as an attribute. Parallel channels count once. This is the network NORMA-2.0's strategy 3 lays out internally.
    • -
    • Enrichment tests whether the groups of an annotation (the terms, for example KEGG pathways) are over-represented, with a one-sided hypergeometric test: -
        -
      • Test: the nodes selected in the view (for example a cluster you selected by hand, or the nodes a Select button picked), or each group of an annotation (for example Louvain communities or the view's own groups).
      • -
      • Terms from: any annotation in Files, or the current view's groups.
      • -
      • Background: the shown nodes of the current view (the usual choice), or those plus every node of the term annotation.
      • -
      • Only terms and tested sets with at least Smallest overlap nodes count. p-values are adjusted with the Benjamini–Hochberg procedure within each tested set, and terms with an FDR above the limit are left out.
      • -
      - The table lists each enriched term with its overlap, its size in the background, the fold enrichment, the p-value and the FDR; Select selects the overlapping nodes in the view, and Download results saves every row with the node names.
    • -
    - -

    Group separation and layout benchmark

    -

    The Group separation box in the 2D Layout section measures how well the current layout separates the ticked groups. It updates after every layout, after dragging nodes or using Spread, and when groups are ticked or unticked.

    -
      -
    • Silhouette: the mean silhouette width (Rousseeuw, 1987) of the nodes that are in exactly one group, from their positions. For each node, it compares the mean distance to its own group with the mean distance to the nearest other group. It runs from −1 (groups intermixed) through 0 (no separation) to 1 (compact, well-separated groups). On large networks it is computed on a fixed sample of 1,500 nodes.
    • -
    • Inside other outlines: the share of shown nodes that lie inside the convex outline of a group they don't belong to. These are the nodes a hull drawing would misleadingly shade.
    • -
    • Outline overlap: the share of the outlined area that is covered by two or more group outlines. Groups of fewer than three nodes have no outline.
    • -
    -

    The Layout benchmark at the end of the Network Profiler page runs every layout on the current view without changing it: the seven layouts by connections (random as a baseline), the six group arrangements and the three NORMA-2.0 strategies, with the Layout section's settings for group layouts. For each it reports the run time and the three scores, as the mean (and standard deviation) over 1, 3 or 5 runs; the best value in each column is highlighted. Use applies a layout to the view, and Download table saves the results as tab-separated text, ready for a supplementary table.

    - -

    Network Comparison

    -

    The Network Comparison page sets two to ten networks side by side. Tick them in the list: Open views compare what each view shows: its files, and only its ticked groups and channels, and Network files compare uploaded or example networks directly. Choose Compare; each network gets a letter (A to J) and a color used throughout the results. Nodes are matched by name and edges by the pair of nodes they join, ignoring channels. With Use edge direction ticked (off by default), direction counts: A→B, B→A and an undirected A–B are three different edges, and directed statistics are added to the topology table. Untick it to match edges regardless of direction.

    -
      -
    • Networks compared: the size of each network and how many nodes and edges all of them share.
    • -
    • Node overlap and Edge overlap: for two or three networks, a Venn diagram with the count in every region (circle sizes are schematic). For any number, an UpSet plot: each column is a group of nodes or edges found in exactly the networks marked by dots below it, sorted largest first; the bars at the left give each network's total. Hover a bar for its count. With many networks only the 40 largest combinations are drawn; the rest are listed in the downloaded table.
    • -
    • Jaccard similarity: for each pair, shared items divided by items in either network, from 0 (nothing in common) to 1 (identical). Darker cells are more similar; hover for the counts.
    • -
    • Degree agreement on shared nodes: the Spearman correlation of a node's degree in two networks, over the nodes they share. Close to 1 means hubs in one are hubs in the other; negative values (red) mean roles swap. A dash means fewer than three shared nodes.
    • -
    • Degree distributions: for each network, the share of nodes with at least a given degree, on log–log axes.
    • -
    • Topology side by side: every Network Profiler statistic for each network, with the highest value in each row in bold.
    • -
    -

    Download table saves the node and edge membership (1 or 0 per network) and the topology table as tab-separated text.

    -

    Show as network (new in NORMA 3.0) opens the compared networks as one view, with a legend that explains its colors: which letter stands for which network, which node colors mean "in which networks", and which edge colors mean "A only", "B only" or "Shared". Every edge becomes a channel that says which networks contain it: A only and B only in the networks' colors and Shared in gray (for three or more networks, combinations such as A + C and In all). Nodes are grouped the same way. Tick channels in the Display tab to see, for example, only what the disease network gained, and use edge direction as set for the comparison.

    - -
    Saving and sharing
    -

    Interpreting the results

    -

    NORMA's results are pictures and numbers about how groups sit in a network. How to read them:

    -

    The network picture

    -
      -
    • Groups: a shaded area (hull, bubble set or fog cloud) shows which nodes belong to a group; a pie-chart node belongs to several groups (one slice each), and a node shape stands for one group. Overlapping shading means shared members or nodes placed close together, not necessarily shared biology.
    • -
    • Positions: in layouts by connections, nodes that are close are well connected, so groups that come out compact and separate really are separated in the network. Layouts by groups place groups apart on purpose, which makes them easy to see but says nothing about how separated they are; use the group separation score on a connection layout to judge that.
    • -
    • Colors, sizes and edges: node colors show group membership, expression colors or numeric values (read them against the legend; with Same color scale for all steps, colors can be compared between time points). Node size follows the chosen measure (for example degree: bigger nodes have more connections). Edge colors are channels (evidence types, interaction types or networks), thickness can follow weight, and arrows show direction.
    • -
    • Edge weights from databases: STRING's confidence score (0–1), the number of shared reactions (Reactome), curation effort (OmniPath) and the IntAct MI-score (0–1). Higher means better supported.
    • -
    -

    Network Profiler

    -
      -
    • Size and density: density is the share of possible connections present; biological networks are usually sparse (well below 0.1).
    • -
    • Connectivity: the number of connected components and the size of the largest; the average path length and diameter say how far apart nodes are (small values mean a "small-world" network).
    • -
    • Clustering coefficient (0–1): how often a node's neighbours are connected to each other; high values mean tightly knit neighbourhoods.
    • -
    • Degree distribution: a straight line on the log–log plot suggests a few highly connected hubs and many poorly connected nodes.
    • -
    • Centrality: nodes with high betweenness lie on many shortest paths (bridges between modules); high closeness means close to all others; high degree means many partners. The top-ten lists point to candidate key nodes.
    • -
    • Communities and modularity: each community is a set of nodes more connected among themselves than to the rest. Modularity above about 0.3 indicates clear community structure; compare the communities with your annotation using Group analysis or enrichment.
    • -
    -

    Group analysis and enrichment

    -
      -
    • Density inside higher than the network's density, and conductance close to 0, mean a group is a real module of the network; conductance near 1 means its members mostly connect outside the group. The modularity of the grouping summarises this for all groups (above about 0.3: the grouping follows the network's structure).
    • -
    • Enrichment: fold enrichment above 1 means a term is more common in the tested set than in the background; the FDR (Benjamini–Hochberg adjusted p-value) says how likely that is by chance, and terms with FDR ≤ 0.05 are usually reported. Small overlaps (2–3 nodes) and the choice of background strongly affect the result; the background should be the nodes you could have picked.
    • -
    -

    Group separation and layout benchmark

    -
      -
    • Silhouette (−1 to 1): above about 0.5, groups are well separated in the picture; 0.25–0.5 moderately; below 0.25 they overlap; negative values mean nodes sit closer to other groups than to their own.
    • -
    • Outline intrusion and overlap (0–100%): the share of nodes inside another group's outline and of shaded area covered twice; lower is clearer. The benchmark compares layouts on these scores, averaged over repeated runs.
    • -
    -

    Network Comparison

    -
      -
    • Venn and UpSet plots count the nodes and edges found in exactly each combination of networks; the tallest UpSet bars show the largest shared or unique parts.
    • -
    • Jaccard similarity (0–1): shared items divided by items in either network; 1 means identical. Degree agreement (Spearman, −1 to 1) says whether shared nodes are similarly connected in both networks.
    • -
    • Show as network colors each edge by the networks that contain it (A only, B only, shared), so gained and lost connections can be seen directly.
    • -
    - -

    Export and saved work

    -

    The Export tab saves and sends: NORMA files, Image, Other tools, Save your work and, at the bottom, Arena3D. Saved work is opened again under Upload Data → Open saved work. Three kinds of NORMA JSON file keep work for later, and each is saved and opened with its own button:

    - - - - - - - -
    FileHoldsSave / open
    View file (.json)One view: its network, groups, colors, attributes and node positions (see JSON format)Save view file / Open view file… or Paste JSON…
    Session file (.json)Every open view and every file in FilesSave session file / Open session file…
    Settings file (.json)Display settings only, to apply the same look to other viewsSave settings file / Open settings file…
    -

    The other JSON files NORMA writes are for other programs: Arena3D (.json) for Arena3D and Cytoscape (.cyjs) for Cytoscape.

    -
      -
    • NORMA files save the current view as a network, annotation or expression file that NORMA can read. -
        -
      • Parallel edges are merged into one connection and keep their highest weight, unless you tick Keep channels as a Type column. When any edge is directed, a Direction column is added and A→B and B→A stay separate rows. Files with Type or Direction columns open in NORMA 3.0 as multi-edge or directed networks; earlier versions of NORMA expect only Source, Target and Weight and may not read them, so leave the box unticked for files meant for those versions.
      • -
      • Nodes without connections are left out, since NORMA can't list them.
      • -
      • Commas in node names become semicolons.
      • -
      • With no expression colors loaded, each node gets its first group's color.
      • -
      -
    • -
    • Only ticked groups and channels (on by default) makes every export below save just the shown part of the view; untick it to save the whole network.
    • -
    • Export image… (also the camera button in the top bar) saves the current view as a picture: -
        -
      • Formats: PNG (supports a transparent background), JPEG (smaller files), WebP (small, sharp web images), SVG (vector: stays sharp at any size and can be edited in Illustrator, Inkscape or PowerPoint, with nodes, edges, labels and group shading in separate layers) and PDF (one page with a high-resolution image, sized so one screen pixel is one point).
      • -
      • Area: the whole network (the ticked groups and channels, with a margin for group shading) or exactly what is on screen.
      • -
      • Resolution: 1× to 8× the on-screen size. The screen counts as 96 dpi, so each step adds 96 dpi at the size the network has on screen: 1× is 96 dpi, 2× 192 dpi, 3× 288 dpi (about the 300 dpi journals ask for), 4× 384 dpi, 6× 576 dpi and 8× 768 dpi. Below the options, the dialog shows the image's exact size in pixels, the dpi and print size (inches and centimeters) at the on-screen size, and the print size at 300 dpi. PNG and JPEG files store this dpi, so layout and print programs open them at the right size. For a figure of a given width, divide the pixel width by the dpi you need: a 3,000-pixel-wide image prints 10 inches (25.4 cm) wide at 300 dpi. SVG is vector graphics, sharp at any size, so its "resolution" only sets the picture's size in the file; PDF pages have the on-screen size. Very large images are reduced to the biggest size the browser can draw, and the dialog says so.
      • -
      • Background: the theme color, white, transparent (PNG, WebP and SVG) or any color.
      • -
      • Add the legend puts the legend set under Display → Legend to the right of the picture.
      • -
      • Include group shading adds the shaded group areas, and Leave out selection and search highlighting (on by default) draws the network without yellow selection borders or dimmed nodes; the selection is restored afterwards.
      • -
      - The picture shows the view as it looks: layout, colors, shapes, pies, labels, edge labels and bundled edges. On the 3D Network page the dialog saves the 3D picture from the current angle, in any of the same formats (SVG included); Whole network fits the network into the picture without changing the angle.
    • -
    • Other tools: GraphML (yEd, Cytoscape, igraph, NetworkX), GEXF 1.3 with node colors, sizes and positions (Gephi), SIF (Cytoscape's simple interaction format: source, channel, target) and Cytoscape (.cyjs) (with positions, for Cytoscape desktop and Cytoscape.js). Nodes carry their groups (separated by semicolons), color, size, numeric values and attributes; edges their channel, color, weight, direction and attributes. Like the other exports, they include only ticked groups and channels unless that option is off.
    • -
    • Session: Save session file saves every open view (network, positions, settings, 3D camera) and every file in Files, including groupings fetched from STRING, in one JSON file; Open session file… brings it all back, replacing the current views and files.
    • -
    • Arena3D: open the view in Arena3D, or save it in Arena3D's formats, with each group as a layer (see Arena3D).
    • -
    • Save view file saves everything in the current view, including attributes, colors and group descriptions. Open view file… and Paste JSON… load that format into a new view.
    • -
    • Save settings file stores the current view's display options (palettes, layout, sizing, labels, shading, edges) and the theme in a small file that Open settings file… applies to the current view.
    • -
    - -

    Arena3D

    -

    Arena3D shows multilayer networks in 3D. Under Export → Arena3D, NORMA turns the current view into an Arena3D network in which each ticked group is a layer:

    -
      -
    • Layers appear in the order of the group list, side by side, each floor in its group's color; nodes in no group get a layer of their own.
    • -
    • Inside a layer, nodes keep their 2D positions from NORMA, scaled to fit the layer, and their size.
    • -
    • Edges between members of the same group stay in that group's layer; edges between groups run between the layers. A node in several groups appears in each of its layers, and with Link the copies of a node that is in several layers (on by default) its copies are joined by white edges.
    • -
    • Edge colors are the channel colors; with weights, stronger edges are more opaque. Several channels are kept as Arena3D channels, and directed edges switch on Arena3D's direction display.
    • -
    • Node colors: as shown in NORMA (group, expression or numeric colors; pie nodes take the color of the layer they are drawn in), or the color of each node's layer.
    • -
    • Only the ticked groups and channels are exported, like everywhere else in NORMA. Arena3D takes up to 20 layers: with more ticked groups (counting the layer for nodes in no group), only the first 20 in the group list are exported, and the status note says how many groups and nodes were left out. Untick groups you don't need to choose which 20 go.
    • -
    -

    Comparing networks in Arena3D: after Compare, the Network Comparison page offers the same three ways out for the comparison, with one layer per compared network (A, B, … in the networks' colors). Each layer holds that network's nodes and edges; nodes and edges found only in that network take its color, and common ones are gray. All networks are laid out together, so a node sits at the same place in every layer. Between layers chooses what links the layers: Common edges (an edge found in networks A and B also runs from its source in layer A to its target in layer B, in white), Common nodes (the copies of a node found in several networks are joined, in yellow), or both. Up to 20 layers fit, so all ten comparable networks can go.

    -

    Three ways out (for the current view, under Export → Arena3D):

    -
      -
    • Open in Arena3D sends the network to Arena3D (/api/external) and opens the returned link in a new tab. Some browsers block direct calls to other sites; running NORMA with server.py avoids that, because the server passes the request on (see Running NORMA locally or on a server). The address can be changed under Arena3D options.
    • -
    • Arena3D (.json) saves the same network in Arena3D's JSON format, to load in Arena3D yourself.
    • -
    • Table (.txt) saves Arena3D's tab-separated upload format: SourceNode, SourceLayer, TargetNode, TargetLayer, Weight, Channel. NORMA can read such files back (see Files and formats).
    • -
    - -
    Running, API and troubleshooting
    -

    API for other applications

    -

    Other programs can open NORMA with their own networks, groups and values, through the API tab's three routes (full reference, code for curl, Python, R and JavaScript, and a tester are on that tab):

    -
      -
    • REST (needs server.py): POST /api/external with a JSON payload returns {"token", "url"}; opening url (norma.html?session=TOKEN) shows the data. Payloads are kept in the server's memory for 24 hours (NORMA_API_TTL_HOURS). GET /api/health tells whether a server offers the API.
    • -
    • Links (no server needed): norma.html?data=URL loads a payload file, ?network=URL&annotation=URL&expression=URL loads NORMA files (the other site must allow cross-site downloads), and #json=… carries a small payload inside the link.
    • -
    • postMessage (no server needed): a page that opens NORMA in a window or iframe receives norma:ready, sends {"type": "norma:load", "payload": …} and gets norma:loaded back with a summary or an error.
    • -
    -

    The payload is a JSON object: name; the network as edges (source, target, optional type, weight, directed), as NORMA files or as a complete network view; optional nodes with attributes, groups (and more groupings in annotations), expression (colors or numbers per node), settings (display settings, theme, layout) and tab. A whole session file is accepted too. Each call opens a new view; the network, groupings and values also appear in Files. If a link's data cannot be read, NORMA opens the API tab and says why.

    - -

    Running NORMA locally or on a server

    -

    NORMA is one web page (norma.html) plus a small server program (server.py, Python 3.8 or newer, no extra packages). The same package runs NORMA on your own computer or as a public web server; everything that differs is a setting.

    -

    On your own computer

    -
      -
    • Double-click run_local.sh (macOS, Linux) or run_local.bat (Windows), or run python3 server.py: NORMA opens in your browser at http://localhost:8000, reachable only from this computer.
    • -
    • Without Python: open norma.html directly. Everything works except the REST API and the relays; database importers then call the services directly, which some browsers block.
    • -
    -

    As a public web server

    -
      -
    • Run python3 server.py --mode hosted --config norma.config.json (start from norma.config.hosted.json), behind a web server that provides HTTPS on port 443 (deploy/nginx.conf), as a system service (deploy/norma.service) or in a container (Dockerfile, docker-compose.yml). The server can also serve HTTPS itself (--tls-cert, --tls-key, optional --http-redirect-port 80).
    • -
    • Hosted mode listens on all addresses, trusts the proxy's X-Forwarded-* headers, logs requests without IP addresses, sends security headers (HSTS over HTTPS) and never opens a browser.
    • -
    • Any static web server (nginx, Apache, GitHub Pages) can also serve the folder; settings then come from norma-config.js, and the REST API and relays are not available.
    • -
    -

    Settings

    -

    Settings come from, in increasing priority: built-in defaults, the settings file (norma.config.json, or --config / NORMA_CONFIG), environment variables and command-line options; python3 server.py --print-config shows the result, and --help lists every option. The server hands the page its part of the settings as /norma-config.js.

    - - - - - - - - - - - - - - -
    SettingOption / variableMeaning
    mode--mode, NORMA_MODElocal (default) or hosted; sets sensible defaults for the rest
    server.host, server.port--host, --port, NORMA_HOST, NORMA_PORTWhere the server listens (local 127.0.0.1, hosted 0.0.0.0; port 8000)
    server.publicUrl--public-url, NORMA_PUBLIC_URLThe address users see (e.g. https://norma.example.org/); used in API links
    server.tlsCert, tlsKey, httpRedirectPort, hsts--tls-cert, --tls-key, --http-redirect-portServe HTTPS directly, redirect HTTP to it, send Strict-Transport-Security
    server.trustProxyNORMA_TRUST_PROXYRead the reverse proxy's forwarded headers (hosted: on)
    server.accessLog--access-log, NORMA_ACCESS_LOGoff (local), anonymous (hosted: no IP addresses) or full
    api.enabled, ttlHours, maxMB, maxSessions--no-api, NORMA_API, NORMA_API_TTL_HOURS, NORMA_API_MAX_MBThe REST API and how long, how large and how many payloads it keeps
    relays.string, arena3d, databases--no-relays, NORMA_RELAYSTurn the relays on or off; without them the page calls the services directly
    site.*NORMA_CONTACT_EMAIL, NORMA_INSTITUTION, NORMA_NOTICEName, institution, contact e-mail and page, source code, licence name and link, privacy policy and imprint links, maintenance statement, tested browsers, and a notice shown at the top of the page (e.g. planned maintenance)
    app.maxNodes, theme, startTab, cdnFallbackNORMA_MAX_NODESLargest network shown (default 10,000), starting theme and page, and whether Cytoscape.js may be downloaded if the bundled copy is missing
    -

    What the server offers

    -
      -
    • API: NORMA's REST API (/api/external, /api/session/TOKEN, /api/health); see API for other applications.
    • -
    • STRING relay: /string-api/ passes read-only STRING calls to string-db.org addresses, spaced a second apart. With Database importers → STRING → STRING server → Connect set to Automatically, NORMA uses the relay when it is available and otherwise calls STRING directly.
    • -
    • Database relay: /db-api/fetch passes the importers' calls to Reactome, OmniPath, NDEx, IntAct, QuickGO and the GO API, and only to those services' API addresses.
    • -
    • Arena3D relay: /arena3d-api/external passes Open in Arena3D to arena3d.org.
    • -
    • Package contents: norma.html, index.html (forwards static web servers' root address to norma.html), norma-config.js, server.py, norma.config.json and norma.config.hosted.json, norma_api_client.py (Python template), run_local.sh / run_local.bat, Dockerfile, docker-compose.yml, deploy/ (nginx, systemd), vendor/ (Cytoscape.js), assets/, README.md and NAR_CHECKLIST.md.
    • -
    • STRING logo: to show STRING's official logo in the importer, save it as assets/string-logo.png.
    • -
    - -

    Performance

    -
      -
    • Display → Performance → Draw with WebGL uses Cytoscape.js's WebGL renderer, which draws large networks much faster. It is experimental: the choice is made when the page starts, so NORMA asks to reload the page (save the session first to keep your views). Adding ?webgl=1 or ?webgl=0 to the address sets it for one visit. Exported images, group shading, the 3D page and everything else work the same.
    • -
    • The Runtime table at the end of the Network Profiler page times the main steps on random networks of 100 to 10,000 nodes in your browser: reading the network file, building the network, the weighted layout, edge bundling, the profile statistics, Louvain and the group separation score. Drawing time depends on the screen and is not included. Download table saves the times with the browser and processor count, for a supplementary table.
    • -
    • Network size: NORMA shows networks of up to 10,000 nodes. A network file, JSON file, session or combination of networks with more nodes is cut to its first 10,000 nodes (in the order they first appear) and the edges among them, and a message says how much was left out. To study part of a bigger network, filter it first (for example to a pathway, a STRING neighbourhood or a community).
    • -
    • Networks of a few thousand nodes are comfortable; beyond that, turn on WebGL drawing, turn off labels, curved edges and edge bundling, and use the grid or weighted force-directed layout.
    • -
    - -

    Keyboard shortcuts

    - - - - - - - - - - - - - - - - - -
    KeysAction
    Ctrl+Z (⌘Z)Undo the last change to the view
    Ctrl+Shift+Z or Ctrl+YRedo
    Ctrl+A (⌘A)Select every shown node (2D and 3D Network pages)
    EscapeClear the selection; in the search box, clear the search; in a dialog or shape chooser, close it
    Enter in the search boxZoom to the matching nodes
    Shift-drag on the backgroundDraw a selection box
    Shift-click or Ctrl/⌘-clickAdd a node to the selection or remove it
    Double-clickOn the background: fit the network; on a node: center it
    Left/Right arrows on a tabMove between pages or between the Data and Display tabs
    Arrow keys in the shape chooserMove between shapes; Enter picks one
    Arrow keys on the 3D networkTurn and tilt the view (with Shift: in bigger steps)
    + / – on the 3D networkZoom in and out
    F or 0 on the 3D networkFit the network to the screen
    - -

    Troubleshooting

    -
      -
    • Only part of my network appears: NORMA shows up to 10,000 nodes and keeps the first 10,000 of a larger network (see Performance).
    • -
    • A network file is rejected: its first line must be the header Source, Target (optionally Weight and Type), separated by tabs.
    • -
    • Groups are missing: node names in the annotation must match the network exactly, including capitalization. Names that don't match are discarded when the annotation is uploaded and when it is shown; the status notes list them, and Download corrected annotation saves the cleaned file.
    • -
    • An annotation was not kept: none of its names matched the networks it was checked against. Upload it together with its network, or tick the right network before uploading it.
    • -
    • A node doesn't take its group's shape: it is shown with more than one active group, so it stays a circle. Untick the other groups to see the shape.
    • -
    • Arena3D doesn't open: if the browser blocked the new tab, use the Open Arena3D link in the status note. If Arena3D can't be reached, run NORMA with server.py, or save the Arena3D (.json) and load it in Arena3D.
    • -
    • STRING can't be reached: check the internet connection, and run NORMA with server.py (see Running NORMA locally or on a server) so that requests go through its relay. If the server is running but requests fail, set Connect to Through this server to see its error. If a request takes too long, lower Interactors to add or try again later.
    • -
    • STRING found none of the names: check the spelling and the organism; a gene name from one species often isn't found in another.
    • -
    • A STRING grouping is missing: that collection had no terms passing the FDR and group-size limits, or isn't available for the organism. The notes list what STRING returned; raise the FDR limit or lower Smallest group.
    • -
    • A file was read as the wrong type: remove it, pick its type in the upload box and upload it again.
    • -
    • The view is slow: try straight edges instead of bundled or curved ones, turn off group shading or use the hull style instead of fog, merge parallel edges, or deactivate groups you don't need.
    • -
    • Colors look alike: switch to a colorblind-safe palette, or click a swatch to set a color yourself.
    • -
    • No arrows are shown: the edges aren't marked as directed, or Edges → Direction is set to undirected. Choose Treat all edges as directed, or upload the file again as directed or with a Direction column.
    • -
    • B→A disappeared after uploading: the file was read as undirected, where A→B and B→A are the same connection. Delete it (its ✕ in Files) and upload it again with Networks: directed.
    • -
    • My parallel edges became one edge: the network file has no Type column, or Edges → Parallel edges is set to merge. Add a Type column, show the networks together as separate files, or switch back to drawing each channel.
    • -
    • A function ignores some nodes or edges: layouts, sizes, statistics, Network Comparison and exports work on the ticked groups and channels only. Tick them again, or untick Only ticked groups and channels before exporting.
    • -
    • An exported image is smaller than chosen: the browser can't draw images beyond about 16,000 pixels per side; the dialog says when the size was reduced. Choose a lower resolution or export only what is on screen, or use SVG, which has no size limit.
    • -
    • The 3D picture is slow: turn off group shading or shaded spheres, lower Depth fading, hide labels, or untick groups and channels you don't need. Large networks are drawn simplified while they move.
    • -
    • The 3D network looks flat: the layout is Same as 2D (flat), or the camera is facing it from the front. Run another 3D layout or choose View from → Tilted.
    • -
    • A few edges are missing: edges between two nodes that sit on top of each other can't be drawn. Run a layout again or use Spread.
    • -
    - -
    -
    -
    - -
    -
    -
    - -
    -

    NORMA 3.0 The Network Makeup Artist

    -

    A browser application for visualising and analysing biological networks together with their functional annotations: how genes, proteins or other entities that share a pathway, complex, GO term or community sit in a network, and how several networks compare.

    -
    -
    - -
    -

    How to cite

    -

    If NORMA helps your work, please cite:

    -
      -
    • Karatzas E, Koutrouli M, Baltoumas FA, Papanikolopoulou K, Bouyioukos C, Pavlopoulos GA. The network makeup artist (NORMA-2.0): distinguishing annotated groups in a network using innovative layout strategies. Bioinformatics Advances. 2022;2(1):vbac036. PMID: 36699373, doi:10.1093/bioadv/vbac036 (NORMA-2.0, group-aware layout strategies)
    • -
    • Koutrouli M, Karatzas E, Papanikolopoulou K, Pavlopoulos GA. NORMA: The Network Makeup Artist, a web tool for network annotation visualization. Genomics, Proteomics & Bioinformatics. 2022;20(3):578–586. doi:10.1016/j.gpb.2021.02.005 (the original NORMA application)
    • -
    -

    Please also cite the databases and methods you use; see Resources and methods.

    -
    - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -Bubble-set groups -outlines that hug only the members - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -−2 -0 -2 -log2FC -Numeric values -color scales, sizes, legends, steps - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -Ribosome - -Proteasome - -Spliceosome - -Glycolysis - -−log10 FDR -Communities and enrichment -Leiden, MCL, Walktrap; term tests - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -3D network -rotate, tilt and zoom freely - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -A - -B - -C -shared -edges -Compare networks -UpSet plots, Venn diagrams, statistics -
    - -

    What NORMA does

    -

    Networks of interacting molecules are easier to understand when you can see which nodes belong together. NORMA takes a network and one or more annotations (groupings of nodes, such as pathways, complexes, GO terms, tissues or communities) and shows them together: groups are shaded with hulls, bubble sets or fog clouds, nodes in several groups become pie charts, and layouts can arrange the network by its connections or by its groups, so that groups separate cleanly.

    -

    On top of the picture, NORMA adds node colors from expression data or numeric values, node sizes from topology, several edge channels between the same nodes (evidence types, interaction types or whole networks), directed edges, a 3D view, network statistics, community detection, group enrichment and a side-by-side comparison of up to ten networks. Networks can be uploaded as simple tab-separated files, fetched from six public databases, and exported as images, as files for Cytoscape, Gephi and yEd, or straight into Arena3D.

    -

    Everything runs in your browser, from a single file (norma.html); a small optional server (server.py) relays database requests. NORMA 3.0 continues NORMA and NORMA-2.0, which introduced group-aware layout strategies.

    - -

    Highlights

    -
    -
    Groups made visibleConvex hulls, bubble sets and fog clouds; pie-chart nodes; one shape per group; group colors and descriptions.
    -
    Group-aware layoutsThe NORMA-2.0 strategies, groups as blocks, clusters or layers, plus force-directed, Kamada–Kawai and stress majorization; a separation score and benchmark.
    -
    Multi-edge and directedSeveral channels per node pair, merged, fanned, bundled or labelled; arrows and direction-aware statistics.
    -
    Values and legendsExpression colors, numeric color scales, time-series steps, node sizes and exportable legends.
    -
    AnalysisNetwork profiles, five community detection methods, group statistics, enrichment, neighbourhoods and shortest paths.
    -
    ComparisonUp to ten networks with Venn and UpSet plots, similarity and degree statistics, drawn as one network or sent to Arena3D.
    -
    Database importersSTRING, Reactome, OmniPath, NDEx, IntAct and the Gene Ontology, with their groupings.
    -
    3D and exportA 3D page, high-resolution PNG, SVG and PDF, NORMA files, GraphML, GEXF, SIF, Cytoscape, Arena3D and sessions.
    -
    - -

    Data sources

    -

    NORMA reads your own files and the resources below. Nothing is sent to a resource until you use its importer; see Database importers in Help for how each one works.

    - - - - - - - - - - - -
    ResourceNetworkGroupingsWhat NORMA fetches
    STRINGYesYesProtein association networks with evidence channels; enriched GO, KEGG, Reactome, UniProt, domain, disease and tissue terms
    ReactomeYesYesThe molecules of a pathway's reactions, linked when they share a reaction; sub-pathways and reactions as groups
    OmniPathYesYesSigned, directed signalling, TF–target and ligand–receptor interactions; complexes, intercellular roles, pathway annotations
    NDExYesLimitedPublic networks with their layout; groups from node attributes
    IntActYesNoExperimentally detected interactions with MI-scores and interaction types
    Gene OntologyLimited (GO-CAM)YesCausal GO-CAM models as networks; GO terms (QuickGO) as groups for any network
    Arena3DExportViews and comparisons as multilayer networks, one layer per group or network
    -

    None of these services needs an account for public data. Please follow each resource's terms of use and cite it when you use its data (see Resources and methods).

    - -

    What's new in NORMA 3.0

    -
    -

    Everything works on the groups and channels you tick

    -

    This is the main difference from earlier versions of NORMA. Untick groups under Node groups or channels under Edge channels, and every function works on the part that stays ticked: nodes in at least one ticked group, and edges of ticked channels between those nodes. Nothing has to be re-uploaded or filtered by hand.

    -
      -
    • Layouts, both by connections and by groups (including the NORMA-2.0 strategies), arrange only the shown nodes using only the ticked channels; hidden nodes keep their places.
    • -
    • Node sizes by degree, betweenness, closeness or clustering, and the statistics in a node's details panel, are computed on the shown part.
    • -
    • Group shading, node shapes and pie slices follow the ticked groups: a node in two groups turns into a single-group shape when the other group is unticked.
    • -
    • Edge bundling, edge merging and edge labels use only the ticked channels.
    • -
    • Search finds only shown nodes, and Spread and Fit to screen work on what you see.
    • -
    • The Network Profiler and Network Comparison pages analyse the shown part of each view.
    • -
    • Exports (NORMA files and JSON) save the shown part, unless you untick Only ticked groups and channels.
    • -
    -

    So you can, for example, tick two KEGG pathways and one evidence channel, lay them out, size nodes by betweenness within that sub-network, profile it, compare it with another view, and export exactly that.

    -
    -
    -

    Multi-edge networks are supported

    -

    Previous versions of NORMA did not support multi-edge graphs. A network could hold only one connection per pair of nodes: repeated connections were removed automatically when a file was loaded, so a pair supported by, say, both experiments and text mining ended up as one plain edge.

    -

    NORMA 3.0 keeps every connection. The same pair of nodes can be linked by several edges, each belonging to a channel (an interaction type, an evidence source or a whole network):

    -
      -
    • An optional Type column in a network file keeps one edge per pair and type; only exact repeats of the same pair and type are removed (see Network file).
    • -
    • Ticking several network files overlays them, with each network as its own channel (see Several networks and multi-edges).
    • -
    • JSON files and the built-in demos can carry any number of edges per pair, each with its own type, weight, direction and attributes.
    • -
    • Channels can be colored, ticked and unticked one by one; parallel edges can be fanned apart, merged per pair, bundled or labelled; and layouts, node sizes, the Network Profiler and Network Comparison can be limited to the ticked channels.
    • -
    -
    - -
    -

    Directed networks

    -

    Edges can point from a source to a target, and each view can handle its network as directed or undirected. Arrows show the direction; a Direction column in network files, a directed/undirected choice when uploading, and a directed field in JSON bring direction in; in- and out-degree, direction-following betweenness and closeness, reciprocity, strongly connected components and directed path lengths analyse it. See Directed and undirected networks.

    -
    - -
    -

    A 3D Network page

    -

    The new 3D Network tab shows the current view in three dimensions: the same groups, colors, pies, shapes, labels, arrows and group shading, with its own 3D layouts (weighted force-directed, groups as 3D clusters, groups on stacked layers, height by hierarchy or degree), rotate, pan and zoom controls, automatic rotation and 3D image export. See 3D Network.

    -
    -
    -

    Import networks and groupings from STRING

    -

    STRING in the Database importers tab fetches the interaction network of one or more proteins for any organism, with control over the number of interactors, confidence, network type and evidence channels, and turns enriched GO terms, KEGG and Reactome pathways, UniProt keywords, protein domains, diseases, tissues and more into groupings you can switch between. See Importing from STRING.

    -
    - -

    Also new

    -
      -
    • Deleting files and views: views, networks, groupings and expression files can be removed completely with Delete… next to Rename, the ✕ next to the Grouping menu, or the ✕ in Files.
    • -
    • API: other applications can open NORMA with their networks and groups by REST, links or postMessage; see the API tab.
    • -
    • Welcome tab and tidier menus: a permanent Welcome tab; every sidebar section folds open and closed from its title; separators group related options; the three NORMA JSON files are named view, session and settings files.
    • -
    • Database importers: besides STRING, Reactome, OmniPath, NDEx, IntAct and the Gene Ontology (GO-CAM networks and GO term groupings), in a tab of their own; exports moved to an Export tab.
    • -
    • Welcome page: NORMA starts on a page that shows what it does, with quick ways to open an example, fetch from STRING or upload files.
    • -
    • Classic layouts: Kamada–Kawai and stress majorization, for connections, as group strategies and inside groups.
    • -
    • Group analysis: group statistics and the modularity of a grouping, a network of groups, and enrichment of annotation terms in selected nodes or in each group of another annotation.
    • -
    • Comparison as a network: Show as network merges compared networks into one view whose edges say which networks contain them.
    • -
    • Local exploration: a node's neighbourhood or the shortest paths between two nodes, opened as a new view.
    • -
    • Time series and conditions: step through value columns or several numeric files, by hand or as an animation, with the layout fixed.
    • -
    • More export formats and sessions: GraphML, GEXF, SIF and Cytoscape (.cyjs) for Cytoscape, Gephi and yEd, and whole sessions (all views and files) in one file.
    • -
    • Performance: optional WebGL drawing for large networks, and a runtime table of the main steps.
    • -
    • Export to Arena3D: open the current view in Arena3D with one click, each group as its own layer, or save it as an Arena3D file (.json) or table (.txt); Arena3D tables can also be uploaded.
    • -
    • Showcase examples: a directed signalling cascade with simulated fold changes, a simulated healthy-vs-disease pair for Network Comparison, and Arena3D's seven-layer example (see Examples).
    • -
    • Numeric expression values: log2 fold changes, p-values or any numbers in expression files, shown on diverging or sequential color scales and as node size, with an optional legend that can also go into exported images.
    • -
    • More community detection: Leiden, label propagation, Walktrap and Markov clustering next to Louvain in the Network Profiler, each able to become a grouping.
    • -
    • A number for group separation: silhouette width, nodes inside other groups' outlines and outline overlap after every layout, and a layout benchmark that compares all layouts, including the NORMA-2.0 strategies, on the current view.
    • -
    • STRING import: fetch a protein's interaction network from the STRING database for any organism, with control over the number of interactors, confidence and evidence channels, and turn its enriched GO terms, KEGG and Reactome pathways, keywords, domains, diseases, tissues and more into groupings.
    • -
    • A 3D network: the 3D Network page shows the current view in three dimensions, with the same groups, colors, shapes, labels, arrows and shading, its own 3D layouts, and rotate, pan and zoom controls.
    • -
    • Working with several visualizations: views, each with its own settings; a Grouping list in the top bar to regroup a view while keeping positions; undo and redo for every change to a view.
    • -
    • A clearer screen: the View list in the top bar, colored labels saying which network, grouping and colors are shown, node, edge, channel and group counts, and color-coded sidebar sections split into Upload Data, Database importers, Display and Export (see Finding your way around).
    • -
    • Layout: arrangement by connections or by groups, NORMA-2.0's three group strategies, groups as non-overlapping blocks, a weighted Fruchterman–Reingold layout, and a Spread slider.
    • -
    • Groups: Bubble Sets–style contours that hug each group, node shapes per group alongside group colors, a filter for long group lists, pies of up to 16 slices, and group details with shared members.
    • -
    • Labels: adjustable sizes and colors for node and edge labels, edge labels showing channels, weights or any edge attribute, and hiding labels when zoomed out.
    • -
    • Edges: edge bundling, merging of parallel edges, curved fan-out, weight-based thickness and edge opacity.
    • -
    • Exploring: search by name, prefix, suffix or regular expression in a panel that can be minimized; selecting several nodes (shown in yellow); zoom without limits while the network stays in frame.
    • -
    • Data: attributes on nodes, edges and groups (Attributes, JSON format); annotation checks on upload with a corrected annotation to download (Files and formats).
    • -
    • Direction: networks are handled as undirected by default, as in earlier versions of NORMA; directed data can be shown with arrows in one click (see Directed and undirected networks).
    • -
    • Analysis: a Network Profiler with a short description of each statistic, Louvain communities that can be added as an annotation, and Network Comparison of up to ten networks with Venn diagrams, UpSet plots, similarity matrices, degree agreement and topology side by side.
    • -
    • Output: image export in high resolution as PNG, JPEG, WebP, vector SVG or PDF; NORMA files and JSON limited to the ticked groups and channels if you like.
    • -
    • Examples: NORMA's example datasets and built-in demos, with every file downloadable.
    • -
    • A white theme by default, and keyboard shortcuts.
    • -
    - - -

    Resources and methods

    -

    NORMA builds on the following software, data resources and published methods. When you use their data or results, please cite them as well.

    -

    Software

    -
      -
    • Cytoscape.js draws the 2D network; statistic definitions follow igraph; color scales follow ColorBrewer and viridis.
    • -
    -

    Data resources

    -
      -
    • STRING: Szklarczyk et al., Nucleic Acids Research 2023;51(D1):D638–D646.
    • -
    • Reactome: Milacic et al., Nucleic Acids Research 2024;52(D1):D672–D678.
    • -
    • OmniPath: Türei et al., Molecular Systems Biology 2021;17:e9923.
    • -
    • NDEx: Pratt et al., Cell Systems 2015;1(4):302–305.
    • -
    • IntAct: del Toro et al., Nucleic Acids Research 2022;50(D1):D648–D653.
    • -
    • Gene Ontology: Gene Ontology Consortium, Genetics 2023;224(1):iyad031.
    • -
    • Arena3D: Karatzas et al., Nucleic Acids Research 2021;49(W1):W36–W45, doi:10.1093/nar/gkab278.
    • -
    -

    Methods

    -
      -
    • Community detection: Louvain (Blondel et al., J Stat Mech 2008:P10008), Leiden (Traag et al., Sci Rep 2019;9:5233), label propagation (Raghavan et al., Phys Rev E 2007;76:036106), Walktrap (Pons and Latapy, J Graph Algorithms Appl 2006;10(2):191–218) and MCL (van Dongen, PhD thesis, University of Utrecht, 2000).
    • -
    • Layouts: Kamada and Kawai (Inf Process Lett 1989;31(1):7–15); stress majorization (Gansner, Koren and North, Graph Drawing 2004, LNCS 3383:239–250) with a pivot-MDS start (Brandes and Pich, Graph Drawing 2006, LNCS 4372:42–53).
    • -
    • Edge bundling: Holten and van Wijk's force-directed method (Computer Graphics Forum 2009;28(3):983–990, doi:10.1111/j.1467-8659.2009.01450.x).
    • -
    • Statistics: enrichment p-values adjusted as in Benjamini and Hochberg (J R Stat Soc B 1995;57(1):289–300); group separation by the silhouette width of Rousseeuw (J Comput Appl Math 1987;20:53–65).
    • -
    - -

    Team, access and maintenance

    -
      -
    • Team: NORMA is developed by the Pavlopoulos Lab (GitHub). Source code: github.com/PavlopoulosLab/NORMA.
    • -
    • Access: NORMA is free and open to all users. It needs no login, registration or e-mail address, and no installation: it runs in the web browser.
    • -
    • Licence: NORMA is released under the licence shown on the Welcome page (MIT by default), which allows free use, including non-commercial and commercial use. The data resources have their own terms of use.
    • -
    -
      - -

      Privacy, data and cookies

      -
        -
      • Your data stays private. Files you upload, the networks you build, views and sessions are processed in your own browser and are not uploaded anywhere. Nobody else can see them. Deleting a file removes it and everything read from it; closing the browser tab removes everything.
      • -
      • What leaves your browser: only what you send yourself: the names you search for with a database importer (to that database), a network you open in Arena3D (to Arena3D), and a payload you send to the NORMA REST API (to that server). On a NORMA server, these requests pass through the server, which does not store them; API payloads are kept in the server's memory only for the time shown in the API tab, and only someone with the link can open them.
      • -
      • No cookies, no tracking. NORMA sets no cookies and uses no analytics, advertising or third-party tracking. The only thing it stores is the WebGL drawing preference, in your browser's local storage, when you change it. Public NORMA servers log requests without IP addresses by default.
      • -
      • Third-party content: NORMA loads no fonts, scripts or images from other sites (a copy of Cytoscape.js is bundled).
      • -
      -
      -
      - - -
      - - - - - diff --git a/run_local.bat b/run_local.bat index 2feab4f..b1115b0 100644 --- a/run_local.bat +++ b/run_local.bat @@ -1,5 +1,9 @@ -@echo off -REM Start NORMA on this computer and open it in the browser (Windows). -cd /d "%~dp0" -python server.py --mode local %* -pause +@echo off +REM Start NORMA on this computer and open it in the browser (Windows). +cd /d "%~dp0" +if not exist frontend\dist\norma.html ( + echo Building the page (needs Node 22 and npm)... + cd frontend && call npm install && call npm run build && cd .. +) +python backend\server.py --mode local %* +pause diff --git a/run_local.sh b/run_local.sh index d017e5a..902c461 100755 --- a/run_local.sh +++ b/run_local.sh @@ -1,4 +1,8 @@ #!/bin/sh # Start NORMA on this computer and open it in the browser (macOS, Linux). cd "$(dirname "$0")" -exec python3 server.py --mode local "$@" +if [ ! -f frontend/dist/norma.html ]; then + echo "Building the page (needs Node 22 and npm)..." + (cd frontend && npm install && npm run build) || exit 1 +fi +exec python3 backend/server.py --mode local "$@" diff --git a/server.py b/server.py deleted file mode 100755 index d8ccd07..0000000 --- a/server.py +++ /dev/null @@ -1,715 +0,0 @@ -#!/usr/bin/env python3 -""" -NORMA 3.0 server -================ - -Serves the NORMA web application (norma.html), offers NORMA's REST API and -relays requests for STRING, the database importers and Arena3D, so the browser -only ever talks to this server. The same program runs NORMA on a laptop -("local" mode) or as a public web server ("hosted" mode). - - python3 server.py # local: http://127.0.0.1:8000, opens the browser - python3 server.py --mode hosted --config norma.config.json - python3 server.py --help # every option - -Settings come from, in increasing priority: built-in defaults, a JSON file -(--config, NORMA_CONFIG, or norma.config.json next to this file), NORMA_* -environment variables, and command-line options. `--print-config` shows the -result. The settings meant for the page are served as /norma-config.js. - -Only the Python 3 standard library is used (Python 3.8 or newer). - -The relay ---------- - GET /string-api/ping -> {"norma_proxy": true, ...} - POST /string-api//?upstream= - -The request body (form-encoded) is passed on unchanged to - /api// -Only STRING addresses (string-db.org and its version-specific sub-domains) -and only read-only API methods are relayed. As STRING asks, requests are -spaced at least one second apart and carry a caller identity. - - POST /arena3d-api/external?upstream= - -The JSON body (a network in Arena3D's format) is passed on to - /api/external -which answers with { "token": ..., "url": ... }. Only arena3d.org -addresses are allowed. - - GET or POST /db-api/fetch?url=
      - -relays the database importers (Reactome, OmniPath, NDEx, IntAct, QuickGO -and the GO API); only those services' API addresses are allowed. - -NORMA API (for other applications): - - POST /api/external JSON payload -> {"token", "url", "expiresInHours"} - GET /api/session/TOKEN the stored payload (NORMA opens ?session=TOKEN) - GET /api/health {"status": "ok", "api": "1.0"} - -Payloads are kept in memory for NORMA_API_TTL_HOURS (default 24) hours, up to -50 MB each and 500 at a time. The API allows calls from any web page (CORS). -""" -import argparse -import copy -import hashlib -import ssl -import http.server -import json -import os -import re -import secrets -import socketserver -import sys -import threading -import time -import urllib.error -import urllib.parse -import urllib.request - -APP_DIR = os.path.dirname(os.path.abspath(__file__)) -DEFAULT_UPSTREAM = "https://string-db.org" -ALLOWED_UPSTREAM = re.compile(r"^https://([a-z0-9-]+\.)*string-db\.org$", re.I) -ALLOWED_FORMATS = {"json", "tsv", "tsv-no-header", "xml"} -ALLOWED_METHODS = { - "version", "get_string_ids", "network", "interaction_partners", - "enrichment", "functional_annotation", "ppi_enrichment", - "homology", "homology_best", "functional_terms", "geneset_description", -} -MAX_BODY = 5 * 1024 * 1024 # 5 MB of identifiers is plenty -TIMEOUT = 120 # seconds to wait for STRING -MIN_GAP = 1.0 # seconds between STRING requests -USER_AGENT = "NORMA3-local-server" - -ARENA_DEFAULT = "https://arena3d.org" -ALLOWED_ARENA = re.compile(r"^https://([a-z0-9-]+\.)*arena3d\.org$", re.I) -MAX_ARENA_BODY = 50 * 1024 * 1024 # networks can be large - -ALLOWED_DB = [re.compile(p) for p in ( - r"^https://reactome\.org/ContentService/", - r"^https://omnipathdb\.org/", - r"^https://(www|public)\.ndexbio\.org/v[23]/", - r"^https://www\.ebi\.ac\.uk/Tools/webservices/psicquic/", - r"^https://www\.ebi\.ac\.uk/QuickGO/services/", - r"^https://api\.geneontology\.org/api/", -)] -MAX_DB_BODY = 1024 * 1024 - -# for automated tests only: allow local mocks of STRING and Arena3D -TEST_UPSTREAM = os.environ.get("NORMA_STRING_TEST_UPSTREAM", "") -TEST_ARENA = os.environ.get("NORMA_ARENA_TEST_UPSTREAM", "") -TEST_DB = os.environ.get("NORMA_DB_TEST_PREFIX", "") - -_gap_lock = threading.Lock() -_last_call = [0.0] - - -_short_lock = threading.Lock() -_short_last = [0.0] - - -def _wait_turn_short(): - # a light spacing for the database relay (several requests run at once) - with _short_lock: - wait = _short_last[0] + 0.05 - time.monotonic() - if wait > 0: - time.sleep(wait) - _short_last[0] = time.monotonic() - - -def _wait_turn(): - with _gap_lock: - wait = _last_call[0] + MIN_GAP - time.monotonic() - if wait > 0: - time.sleep(wait) - _last_call[0] = time.monotonic() - - -API_VERSION = "1.0" -APP_VERSION = "3.0" - -# ---------- settings ---------- -DEFAULTS = { - "mode": "local", # "local" or "hosted" - "server": { - "host": None, # local: 127.0.0.1, hosted: 0.0.0.0 - "port": 8000, - "publicUrl": "", # e.g. https://norma.example.org/ (used in API links) - "trustProxy": None, # read X-Forwarded-* headers (hosted: true) - "tlsCert": "", # PEM files to serve HTTPS directly - "tlsKey": "", - "httpRedirectPort": 0, # also listen here and redirect to HTTPS (e.g. 80) - "hsts": True, # Strict-Transport-Security when served over HTTPS - "accessLog": None, # "full", "anonymous" (no IP addresses) or "off" - "openBrowser": None, # local: true - }, - "api": {"enabled": True, "ttlHours": 24, "maxMB": 50, "maxSessions": 500}, - "relays": {"string": True, "arena3d": True, "databases": True}, - "site": { - "name": "NORMA", - "institution": "", - "contactName": "Pavlopoulos Lab", - "contactEmail": "", - "contactUrl": "https://github.com/PavlopoulosLab/NORMA/issues", - "sourceUrl": "https://github.com/PavlopoulosLab/NORMA", - "licenceName": "MIT License", - "licenceUrl": "https://opensource.org/licenses/MIT", - "privacyUrl": "", - "imprintUrl": "", - "maintainedUntil": "", - "notice": "", - "testedBrowsers": [], - }, - "app": {"maxNodes": 10000, "theme": "white", "startTab": "welcome", "cdnFallback": True}, -} -ENV_MAP = { - "NORMA_MODE": ("mode",), "NORMA_HOST": ("server", "host"), "NORMA_PORT": ("server", "port"), - "NORMA_PUBLIC_URL": ("server", "publicUrl"), "NORMA_TLS_CERT": ("server", "tlsCert"), - "NORMA_TLS_KEY": ("server", "tlsKey"), "NORMA_ACCESS_LOG": ("server", "accessLog"), - "NORMA_TRUST_PROXY": ("server", "trustProxy"), "NORMA_API": ("api", "enabled"), - "NORMA_API_TTL_HOURS": ("api", "ttlHours"), "NORMA_API_MAX_MB": ("api", "maxMB"), - "NORMA_RELAYS": ("relays", "*"), "NORMA_CONTACT_EMAIL": ("site", "contactEmail"), - "NORMA_NOTICE": ("site", "notice"), "NORMA_INSTITUTION": ("site", "institution"), - "NORMA_MAX_NODES": ("app", "maxNodes"), -} - - -def _merge(base, extra): - for k, v in (extra or {}).items(): - if isinstance(v, dict) and isinstance(base.get(k), dict): - _merge(base[k], v) - else: - base[k] = v - return base - - -def _typed(template, value): - if isinstance(template, bool) or template is None and value.lower() in ("true", "false", "1", "0", "yes", "no", "on", "off"): - return value.lower() in ("1", "true", "yes", "on") - if isinstance(template, int) and not isinstance(template, bool): - return int(value) - if isinstance(template, float): - return float(value) - return value - - -def _set(cfg, path, value): - if path[-1] == "*": - for k in cfg[path[0]]: - cfg[path[0]][k] = _typed(True, value) - return - node = cfg - for k in path[:-1]: - node = node[k] - node[path[-1]] = _typed(node.get(path[-1]), value) - - -def load_config(args): - cfg = copy.deepcopy(DEFAULTS) - path = args.config or os.environ.get("NORMA_CONFIG") or os.path.join(APP_DIR, "norma.config.json") - if os.path.isfile(path): - with open(path, encoding="utf-8") as fh: - _merge(cfg, json.load(fh)) - cfg["_file"] = path - elif args.config: - sys.exit("Settings file not found: %s" % args.config) - for env, target in ENV_MAP.items(): - if os.environ.get(env, "") != "": - _set(cfg, target, os.environ[env]) - cli = { - "mode": args.mode, ("server", "host"): args.host, ("server", "port"): args.port, - ("server", "publicUrl"): args.public_url, ("server", "tlsCert"): args.tls_cert, - ("server", "tlsKey"): args.tls_key, ("server", "httpRedirectPort"): args.http_redirect_port, - ("server", "accessLog"): args.access_log, - } - for key, value in cli.items(): - if value is None: - continue - if key == "mode": - cfg["mode"] = value - else: - cfg[key[0]][key[1]] = value - if args.no_api: - cfg["api"]["enabled"] = False - if args.no_relays: - cfg["relays"] = {k: False for k in cfg["relays"]} - if args.open: - cfg["server"]["openBrowser"] = True - if args.no_open: - cfg["server"]["openBrowser"] = False - if cfg["mode"] not in ("local", "hosted"): - sys.exit('"mode" must be "local" or "hosted".') - hosted = cfg["mode"] == "hosted" - srv = cfg["server"] - if srv["host"] is None: - srv["host"] = "0.0.0.0" if hosted else "127.0.0.1" - if srv["trustProxy"] is None: - srv["trustProxy"] = hosted - if srv["accessLog"] is None: - srv["accessLog"] = "anonymous" if hosted else "off" - if srv["openBrowser"] is None: - srv["openBrowser"] = not hosted - if srv["publicUrl"] and not srv["publicUrl"].endswith("/"): - srv["publicUrl"] += "/" - return cfg - - -def client_config(cfg): - """The settings the page needs; nothing secret.""" - return { - "mode": cfg["mode"], - "version": APP_VERSION, - "publicUrl": cfg["server"]["publicUrl"], - "features": { - "restApi": bool(cfg["api"]["enabled"]), - "relays": dict(cfg["relays"]), - "apiTtlHours": cfg["api"]["ttlHours"], - }, - "site": cfg["site"], - "app": cfg["app"], - } - - -CONFIG = copy.deepcopy(DEFAULTS) # replaced in main() -API_MAX_BYTES = 50 * 1024 * 1024 -API_MAX_SESSIONS = 500 -API_TTL_HOURS = float(os.environ.get("NORMA_API_TTL_HOURS", "24") or 24) -_api_store = {} # token -> (created, bytes) -_api_lock = threading.Lock() - - -def api_put(data): - token = secrets.token_urlsafe(18) - now = time.time() - with _api_lock: - _api_expire(now) - while len(_api_store) >= API_MAX_SESSIONS: - oldest = min(_api_store, key=lambda k: _api_store[k][0]) - del _api_store[oldest] - _api_store[token] = (now, data) - return token - - -def api_get(token): - with _api_lock: - _api_expire(time.time()) - item = _api_store.get(token) - return item[1] if item else None - - -def _api_expire(now): - limit = API_TTL_HOURS * 3600 - for k in [k for k, (t, _) in _api_store.items() if now - t > limit]: - del _api_store[k] - - -# Static files are served straight out of APP_DIR (see NormaHandler.__init__), -# which is also where deployment/VCS files live (.git, Dockerfile, deploy/, ...). -# Keep those from being handed out over HTTP regardless of what a given -# deployment happens to have sitting next to server.py. -_BLOCKED_STATIC_NAMES = {"dockerfile", "docker-compose.yml", "docker-compose.yaml"} -_BLOCKED_STATIC_DIRS = {"deploy"} - - -def _static_blocked(path): - parts = [p for p in path.split("?", 1)[0].split("/") if p] - if any(p.startswith(".") for p in parts): - return True - if parts and parts[0].lower() in _BLOCKED_STATIC_DIRS: - return True - if parts and (parts[-1].lower() in _BLOCKED_STATIC_NAMES or parts[-1].endswith(".service")): - return True - return False - - -class NormaHandler(http.server.SimpleHTTPRequestHandler): - server_version = "NORMA3" - - def __init__(self, *args, **kwargs): - super().__init__(*args, directory=APP_DIR, **kwargs) - - # one line per request; hosted servers log without IP addresses by default - def log_message(self, fmt, *args): - mode = CONFIG["server"]["accessLog"] - if mode == "off": - return - who = self.client_address[0] if mode == "full" else "-" - sys.stderr.write("%s %s %s\n" % (self.log_date_time_string(), who, fmt % args)) - - def log_error(self, fmt, *args): - sys.stderr.write("%s error %s\n" % (self.log_date_time_string(), fmt % args)) - - def _https(self): - if isinstance(self.connection, ssl.SSLSocket): - return True - return CONFIG["server"]["trustProxy"] and self.headers.get("X-Forwarded-Proto", "").lower() == "https" - - def end_headers(self): - # the app is a single page; always serve the latest copy - if not self.path.startswith(("/string-api/", "/arena3d-api/", "/db-api/", "/api/")): - self.send_header("Cache-Control", "no-cache") - if self.path.startswith("/api/"): - # the NORMA API may be called from any web page - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - self.send_header("Access-Control-Allow-Headers", "Content-Type") - self.send_header("Access-Control-Max-Age", "86400") - self.send_header("X-Content-Type-Options", "nosniff") - self.send_header("Referrer-Policy", "strict-origin-when-cross-origin") - self.send_header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()") - if CONFIG["server"]["hsts"] and self._https(): - self.send_header("Strict-Transport-Security", "max-age=31536000") - super().end_headers() - - # ---------- routing ---------- - def do_GET(self): - # the application is norma.html; the site root opens it - bare = self.path.split("?")[0] - if bare in ("/", "/index.html"): - query = self.path[len(bare):] - self.path = "/norma.html" + query - bare = self.path.split("?")[0] - if bare in ("/norma-config.js", "/norma-config.json"): - return self._client_config(bare.endswith(".js")) - relays = CONFIG["relays"] - if bare.startswith("/string-api/") and not relays["string"]: - return self._json(404, {"message": "The STRING relay is turned off on this server."}) - if bare.startswith("/db-api/") and not relays["databases"]: - return self._json(404, {"message": "The database relay is turned off on this server."}) - if bare.startswith("/api/") and not CONFIG["api"]["enabled"]: - return self._json(404, {"message": "The NORMA API is turned off on this server."}) - if self.path.split("?")[0] == "/string-api/ping": - return self._json(200, {"norma_proxy": True, "upstream": DEFAULT_UPSTREAM}) - if self.path.startswith("/string-api/"): - return self._relay("GET") - if self.path.split("?")[0] == "/db-api/fetch": - return self._relay_db("GET") - if self.path.startswith("/api/"): - return self._api_get() - if _static_blocked(self.path): - return self._json(404, {"message": "Not found."}) - return super().do_GET() - - def do_HEAD(self): - if self.path.startswith("/string-api/"): - return self._json(405, {"message": "Use GET or POST."}) - if _static_blocked(self.path): - return self._json(404, {"message": "Not found."}) - return super().do_HEAD() - - def do_POST(self): - bare = self.path.split("?")[0] - relays = CONFIG["relays"] - off = ((bare.startswith("/string-api/") and not relays["string"]) - or (bare.startswith("/db-api/") and not relays["databases"]) - or (bare.startswith("/arena3d-api/") and not relays["arena3d"]) - or (bare.startswith("/api/") and not CONFIG["api"]["enabled"])) - if off: - return self._json(404, {"message": "This service is turned off on this server."}) - if self.path.startswith("/string-api/"): - return self._relay("POST") - if self.path.split("?")[0] == "/arena3d-api/external": - return self._relay_arena() - if self.path.split("?")[0] == "/db-api/fetch": - return self._relay_db("POST") - if self.path.split("?")[0] == "/api/external": - return self._api_post() - return self._json(405, {"message": "Only the NORMA API and the STRING, Arena3D and database relays accept POST requests."}) - - def do_OPTIONS(self): - self.send_response(204) - self.send_header("Allow", "GET, POST, OPTIONS") - self.end_headers() - - def _client_config(self, as_js): - body = json.dumps(client_config(CONFIG), indent=1) - if as_js: - body = "// generated by server.py from its settings\nwindow.NORMA_CONFIG = %s;\n" % body - data = body.encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "text/javascript; charset=utf-8" if as_js else "application/json; charset=utf-8") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - - # ---------- NORMA API ---------- - def _base_url(self): - if CONFIG["server"]["publicUrl"]: - return CONFIG["server"]["publicUrl"] - trust = CONFIG["server"]["trustProxy"] - host = (trust and self.headers.get("X-Forwarded-Host")) or self.headers.get("Host") or "localhost" - proto = (trust and self.headers.get("X-Forwarded-Proto")) or ("https" if isinstance(self.connection, ssl.SSLSocket) else "http") - prefix = ((trust and self.headers.get("X-Forwarded-Prefix")) or "").rstrip("/") - return "%s://%s%s/" % (proto, host, prefix) - - def _api_post(self): - length = int(self.headers.get("Content-Length") or 0) - if length <= 0: - return self._json(400, {"message": "Send the payload as a JSON body."}) - if length > API_MAX_BYTES: - return self._json(413, {"message": "The payload is larger than %d MB." % (API_MAX_BYTES // (1024 * 1024))}) - body = self.rfile.read(length) - try: - obj = json.loads(body.decode("utf-8")) - except (ValueError, UnicodeDecodeError): - return self._json(400, {"message": "The payload is not valid JSON."}) - if not isinstance(obj, dict): - return self._json(400, {"message": "The payload must be a JSON object."}) - if not any(k in obj for k in ("edges", "files", "network", "nodes", "session", "views")): - return self._json(400, {"message": 'Nothing to show: give "edges", "files", "network" or a session.'}) - token = api_put(body) - url = self._base_url() + "norma.html?session=" + token - return self._json(200, {"token": token, "url": url, "expiresInHours": API_TTL_HOURS}) - - def _api_get(self): - bare = self.path.split("?")[0] - if bare == "/api/health": - with _api_lock: - count = len(_api_store) - return self._json(200, {"status": "ok", "api": API_VERSION, "version": APP_VERSION, "mode": CONFIG["mode"], - "stored": count, "expiresInHours": API_TTL_HOURS, - "relays": CONFIG["relays"]}) - m = re.match(r"^/api/session/([A-Za-z0-9_-]{8,64})$", bare) - if not m: - return self._json(404, {"message": "Unknown API address."}) - data = api_get(m.group(1)) - if data is None: - return self._json(404, {"message": "This link has expired or does not exist."}) - self.send_response(200) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", str(len(data))) - self.send_header("Cache-Control", "no-store") - self.end_headers() - self.wfile.write(data) - - # ---------- helpers ---------- - def _json(self, status, obj, relay=False): - data = json.dumps(obj).encode("utf-8") - self.send_response(status) - if relay: - self.send_header("X-Norma-Relay", "1") - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - - def _text(self, status, text, relay=False): - data = text.encode("utf-8") - self.send_response(status) - if relay: - self.send_header("X-Norma-Relay", "1") - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - - def _relay_db(self, verb): - query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query) - target = (query.get("url") or [""])[0] - allowed = any(p.match(target) for p in ALLOWED_DB) or (TEST_DB and target.startswith(TEST_DB)) - if not allowed: - return self._json(400, {"message": "This address is not one of the database importers' services.", "norma_proxy": True}, relay=True) - body = None - headers = {"User-Agent": USER_AGENT, "Accept": self.headers.get("Accept", "application/json")} - if verb == "POST": - length = int(self.headers.get("Content-Length") or 0) - if length > MAX_DB_BODY: - return self._json(413, {"message": "The request is too large.", "norma_proxy": True}, relay=True) - body = self.rfile.read(length) - headers["Content-Type"] = self.headers.get("Content-Type", "application/json") - req = urllib.request.Request(target, data=body, method=verb, headers=headers) - _wait_turn_short() - try: - with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: - data = resp.read() - status = resp.status - ctype = resp.headers.get("Content-Type", "application/json") - except urllib.error.HTTPError as err: - data = err.read() - status = err.code - ctype = err.headers.get("Content-Type", "text/plain") - except (urllib.error.URLError, TimeoutError, OSError) as err: - reason = getattr(err, "reason", err) - return self._text(502, "The database could not be reached from this server (%s)." % reason, relay=True) - self.send_response(status) - self.send_header("Content-Type", ctype) - self.send_header("Content-Length", str(len(data))) - self.send_header("Cache-Control", "no-store") - self.send_header("X-Norma-Relay", "1") - self.end_headers() - self.wfile.write(data) - - def _relay_arena(self): - parsed = urllib.parse.urlsplit(self.path) - query = urllib.parse.parse_qs(parsed.query) - upstream = (query.get("upstream", [ARENA_DEFAULT])[0] or ARENA_DEFAULT).rstrip("/") - if not (ALLOWED_ARENA.match(upstream) or (TEST_ARENA and upstream == TEST_ARENA)): - return self._json(400, {"message": "Only arena3d.org addresses can be used.", "norma_proxy": True}) - length = int(self.headers.get("Content-Length") or 0) - if length > MAX_ARENA_BODY: - return self._json(413, {"message": "The network is too large to send.", "norma_proxy": True}) - body = self.rfile.read(length) - try: - json.loads(body.decode("utf-8")) - except ValueError: - return self._json(400, {"message": "The request body is not JSON.", "norma_proxy": True}) - req = urllib.request.Request(upstream + "/api/external", data=body, method="POST", headers={ - "Content-Type": "application/json", - "User-Agent": USER_AGENT, - "Accept": "application/json", - }) - try: - with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: - data = resp.read() - status = resp.status - ctype = resp.headers.get("Content-Type", "application/json") - except urllib.error.HTTPError as err: - data = err.read() - status = err.code - ctype = err.headers.get("Content-Type", "text/plain") - except (urllib.error.URLError, TimeoutError, OSError) as err: - reason = getattr(err, "reason", err) - return self._text(502, "Arena3D could not be reached from this server (%s)." % reason) - self.send_response(status) - self.send_header("Content-Type", ctype) - self.send_header("Content-Length", str(len(data))) - self.send_header("Cache-Control", "no-store") - self.end_headers() - self.wfile.write(data) - - def _relay(self, verb): - parsed = urllib.parse.urlsplit(self.path) - parts = parsed.path.split("/") # ['', 'string-api', fmt, method] - if len(parts) != 4 or parts[2] not in ALLOWED_FORMATS or parts[3] not in ALLOWED_METHODS: - return self._json(404, {"message": "Unknown STRING API call.", "norma_proxy": True}) - fmt, method = parts[2], parts[3] - query = urllib.parse.parse_qs(parsed.query) - upstream = (query.pop("upstream", [DEFAULT_UPSTREAM])[0] or DEFAULT_UPSTREAM).rstrip("/") - if not (ALLOWED_UPSTREAM.match(upstream) or (TEST_UPSTREAM and upstream == TEST_UPSTREAM)): - return self._json(400, {"message": "Only string-db.org addresses can be used."}) - - body = b"" - if verb == "POST": - length = int(self.headers.get("Content-Length") or 0) - if length > MAX_BODY: - return self._json(413, {"message": "The request is too large."}) - body = self.rfile.read(length) - # make sure STRING knows who is calling - form = urllib.parse.parse_qs(body.decode("utf-8"), keep_blank_values=True) - for k, v in query.items(): - form.setdefault(k, v) - form.setdefault("caller_identity", ["NORMA3"]) - payload = urllib.parse.urlencode(form, doseq=True).encode("utf-8") - - target = "%s/api/%s/%s" % (upstream, fmt, method) - req = urllib.request.Request(target, data=payload, method="POST", headers={ - "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", - "User-Agent": USER_AGENT, - "Accept": "application/json, text/plain, */*", - }) - _wait_turn() - try: - with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: - data = resp.read() - status = resp.status - ctype = resp.headers.get("Content-Type", "application/json") - except urllib.error.HTTPError as err: - data = err.read() - status = err.code - ctype = err.headers.get("Content-Type", "text/plain") - except (urllib.error.URLError, TimeoutError, OSError) as err: - reason = getattr(err, "reason", err) - return self._text(502, "STRING could not be reached from this server (%s)." % reason) - self.send_response(status) - self.send_header("Content-Type", ctype) - self.send_header("Content-Length", str(len(data))) - self.send_header("Cache-Control", "no-store") - self.end_headers() - self.wfile.write(data) - - -class ThreadingServer(socketserver.ThreadingMixIn, http.server.HTTPServer): - daemon_threads = True - allow_reuse_address = True - - -class RedirectHandler(http.server.BaseHTTPRequestHandler): - """Sends plain-HTTP visitors to the HTTPS address.""" - target = "" - - def do_GET(self): - self.send_response(301) - self.send_header("Location", self.target.rstrip("/") + self.path) - self.end_headers() - - do_HEAD = do_GET - do_POST = do_GET - - def log_message(self, fmt, *args): - pass - - -def main(): - ap = argparse.ArgumentParser(description="Serve NORMA 3.0 locally or as a public web server.") - ap.add_argument("--config", help="settings file (JSON); default norma.config.json next to server.py") - ap.add_argument("--mode", choices=("local", "hosted"), help="local (this computer) or hosted (public web server)") - ap.add_argument("--host", help="address to listen on (local default 127.0.0.1, hosted default 0.0.0.0)") - ap.add_argument("--port", type=int, help="port (default 8000; 443 for HTTPS without a proxy)") - ap.add_argument("--public-url", help="the address users see, e.g. https://norma.example.org/ (used in API links)") - ap.add_argument("--tls-cert", help="certificate (PEM) to serve HTTPS directly") - ap.add_argument("--tls-key", help="private key (PEM) for --tls-cert") - ap.add_argument("--http-redirect-port", type=int, help="also listen on this port and redirect HTTP to HTTPS (e.g. 80)") - ap.add_argument("--access-log", choices=("full", "anonymous", "off"), help="request log (hosted default: anonymous, without IP addresses)") - ap.add_argument("--no-api", action="store_true", help="turn off the REST API") - ap.add_argument("--no-relays", action="store_true", help="turn off the STRING, database and Arena3D relays") - ap.add_argument("--open", action="store_true", help="open NORMA in the browser") - ap.add_argument("--no-open", action="store_true", help="don't open the browser (local mode opens it by default)") - ap.add_argument("--print-config", action="store_true", help="print the effective settings and exit") - args = ap.parse_args() - - global CONFIG, API_TTL_HOURS, API_MAX_BYTES, API_MAX_SESSIONS - CONFIG = load_config(args) - API_TTL_HOURS = float(CONFIG["api"]["ttlHours"]) - API_MAX_BYTES = int(float(CONFIG["api"]["maxMB"]) * 1024 * 1024) - API_MAX_SESSIONS = int(CONFIG["api"]["maxSessions"]) - if args.print_config: - print(json.dumps(CONFIG, indent=2)) - return - srv = CONFIG["server"] - NormaHandler.extensions_map.update({".js": "text/javascript", ".mjs": "text/javascript", ".svg": "image/svg+xml", ".json": "application/json"}) - tls = bool(srv["tlsCert"] and srv["tlsKey"]) - with ThreadingServer((srv["host"], int(srv["port"])), NormaHandler) as httpd: - if tls: - ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - ctx.minimum_version = ssl.TLSVersion.TLSv1_2 - ctx.load_cert_chain(srv["tlsCert"], srv["tlsKey"]) - httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True) - scheme = "https" if tls else "http" - shown = "localhost" if srv["host"] in ("127.0.0.1", "0.0.0.0", "::") else srv["host"] - port = int(srv["port"]) - default_port = (tls and port == 443) or (not tls and port == 80) - url = srv["publicUrl"] or "%s://%s%s/" % (scheme, shown, "" if default_port else ":%d" % port) - if srv["httpRedirectPort"]: - RedirectHandler.target = url - redirect = ThreadingServer((srv["host"], int(srv["httpRedirectPort"])), RedirectHandler) - threading.Thread(target=redirect.serve_forever, daemon=True).start() - on = [k for k, v in CONFIG["relays"].items() if v] - print("NORMA %s (%s mode) is running at %s" % (APP_VERSION, CONFIG["mode"], url)) - print(" REST API: %s" % ("%sapi/external" % url if CONFIG["api"]["enabled"] else "off")) - print(" relays: %s" % (", ".join(on) if on else "off")) - print(" settings: %s" % CONFIG.get("_file", "built-in defaults")) - if CONFIG["mode"] == "hosted" and not tls and not srv["trustProxy"]: - print(" note: public servers should use HTTPS (TLS options or a reverse proxy); see README.") - print("Press Ctrl+C to stop.") - if srv["openBrowser"]: - import webbrowser - webbrowser.open(url) - try: - httpd.serve_forever() - except KeyboardInterrupt: - print("\nStopped.") - - -if __name__ == "__main__": - main() diff --git a/vendor/cytoscape.min.js b/vendor/cytoscape.min.js deleted file mode 100644 index a1a9134..0000000 --- a/vendor/cytoscape.min.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Copyright (c) 2016-2026, The Cytoscape Consortium. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the “Software”), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies - * of the Software, and to permit persons to whom the Software is furnished to do - * so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).cytoscape=t()}(this,(function(){"use strict";function e(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=Array(t);n=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:a}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var i,o=!0,s=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return o=e.done,e},e:function(e){s=!0,i=e},f:function(){try{o||null==n.return||n.return()}finally{if(s)throw i}}}}function a(e,t,n){return(t=s(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function i(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=n){var r,a,i,o,s=[],l=!0,u=!1;try{if(i=(n=n.call(e)).next,0===t){if(Object(n)!==n)return;l=!1}else for(;!(l=(r=i.call(n)).done)&&(s.push(r.value),s.length!==t);l=!0);}catch(e){u=!0,a=e}finally{try{if(!l&&null!=n.return&&(o=n.return(),Object(o)!==o))return}finally{if(u)throw a}}return s}}(e,t)||u(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function o(t){return function(t){if(Array.isArray(t))return e(t)}(t)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(t)||u(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function s(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t);if("object"!=typeof r)return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e,"string");return"symbol"==typeof t?t:t+""}function l(e){return l="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},l(e)}function u(t,n){if(t){if("string"==typeof t)return e(t,n);var r={}.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?e(t,n):void 0}}var c="undefined"==typeof window?null:window,d=c?c.navigator:null;c&&c.document;var h,f,p,g,v,y,m,b,x,w,E,k,T,C,P,S,B,D,_,A,M,R,I,N,L,z,O,V,F=l(""),X=l({}),j=l((function(){})),Y="undefined"==typeof HTMLElement?"undefined":l(HTMLElement),q=function(e){return e&&e.instanceString&&U(e.instanceString)?e.instanceString():null},W=function(e){return null!=e&&l(e)==F},U=function(e){return null!=e&&l(e)===j},H=function(e){return!Q(e)&&(Array.isArray?Array.isArray(e):null!=e&&e instanceof Array)},K=function(e){return null!=e&&l(e)===X&&!H(e)&&e.constructor===Object},G=function(e){return null!=e&&l(e)===l(1)&&!isNaN(e)},Z=function(e){return G(e)&&Math.floor(e)===e},$=function(e){return"undefined"===Y?void 0:null!=e&&e instanceof HTMLElement},Q=function(e){return J(e)||ee(e)},J=function(e){return"collection"===q(e)&&e._private.single},ee=function(e){return"collection"===q(e)&&!e._private.single},te=function(e){return"core"===q(e)},ne=function(e){return"stylesheet"===q(e)},re=function(e){return null==e||!(""!==e&&!e.match(/^\s+$/))},ae=function(e){return function(e){return null!=e&&l(e)===X}(e)&&U(e.then)},ie=function(e,t){t||(t=function(){if(1===arguments.length)return arguments[0];if(0===arguments.length)return"undefined";for(var e=[],t=0;tt?1:0},ye=null!=Object.assign?Object.assign.bind(Object):function(e){for(var t=arguments,n=1;n255)return;t.push(Math.floor(i))}var o=r[1]||r[2]||r[3],s=r[1]&&r[2]&&r[3];if(o&&!s)return;var l=n[4];if(void 0!==l){if((l=parseFloat(l))<0||l>1)return;t.push(l)}}return t}(e)||function(e){var t,n,r,a,i,o,s,l;function u(e,t,n){return n<0&&(n+=1),n>1&&(n-=1),n<1/6?e+6*(t-e)*n:n<.5?t:n<2/3?e+(t-e)*(2/3-n)*6:e}var c=new RegExp("^"+pe+"$").exec(e);if(c){if((n=parseInt(c[1]))<0?n=(360- -1*n%360)%360:n>360&&(n%=360),n/=360,(r=parseFloat(c[2]))<0||r>100)return;if(r/=100,(a=parseFloat(c[3]))<0||a>100)return;if(a/=100,void 0!==(i=c[4])&&((i=parseFloat(i))<0||i>1))return;if(0===r)o=s=l=Math.round(255*a);else{var d=a<.5?a*(1+r):a+r-a*r,h=2*a-d;o=Math.round(255*u(h,d,n+1/3)),s=Math.round(255*u(h,d,n)),l=Math.round(255*u(h,d,n-1/3))}t=[o,s,l,i]}return t}(e)},be={transparent:[0,0,0,0],aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],grey:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]},xe=function(e){for(var t=e.map,n=e.keys,r=n.length,a=0;a=o||t<0||v&&e-p>=c}function x(){var e=t();if(b(e))return w(e);h=setTimeout(x,function(e){var t=o-(e-f);return v?a(t,c-(e-p)):t}(e))}function w(e){return h=void 0,y&&l?m(e):(l=u=void 0,d)}function E(){var e=t(),n=b(e);if(l=arguments,u=this,f=e,n){if(void 0===h)return function(e){return p=e,h=setTimeout(x,o),g?m(e):d}(f);if(v)return clearTimeout(h),h=setTimeout(x,o),m(f)}return void 0===h&&(h=setTimeout(x,o)),d}return o=n(o)||0,e(s)&&(g=!!s.leading,c=(v="maxWait"in s)?r(n(s.maxWait)||0,o):c,y="trailing"in s?!!s.trailing:y),E.cancel=function(){void 0!==h&&clearTimeout(h),p=0,l=f=u=h=void 0},E.flush=function(){return void 0===h?d:w(t())},E},O}(),Ae=ke(_e),Me=c?c.performance:null,Re=Me&&Me.now?function(){return Me.now()}:function(){return Date.now()},Ie=function(){if(c){if(c.requestAnimationFrame)return function(e){c.requestAnimationFrame(e)};if(c.mozRequestAnimationFrame)return function(e){c.mozRequestAnimationFrame(e)};if(c.webkitRequestAnimationFrame)return function(e){c.webkitRequestAnimationFrame(e)};if(c.msRequestAnimationFrame)return function(e){c.msRequestAnimationFrame(e)}}return function(e){e&&setTimeout((function(){e(Re())}),1e3/60)}}(),Ne=function(e){return Ie(e)},Le=Re,ze=9261,Oe=5381,Ve=function(e){for(var t,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:ze;!(t=e.next()).done;)n=65599*n+t.value|0;return n},Fe=function(e){return 65599*(arguments.length>1&&void 0!==arguments[1]?arguments[1]:ze)+e|0},Xe=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:Oe;return(t<<5)+t+e|0},je=function(e){return 2097152*e[0]+e[1]},Ye=function(e,t){return[Fe(e[0],t[0]),Xe(e[1],t[1])]},qe=function(e,t){var n={value:0,done:!1},r=0,a=e.length;return Ve({next:function(){return r=0;r--)e[r]===t&&e.splice(r,1)},ht=function(e){e.splice(0,e.length)},ft=function(e,t,n){return n&&(t=le(n,t)),e[t]},pt=function(e,t,n,r){n&&(t=le(n,t)),e[t]=r},gt="undefined"!=typeof Map?Map:function(){return n((function e(){t(this,e),this._obj={}}),[{key:"set",value:function(e,t){return this._obj[e]=t,this}},{key:"delete",value:function(e){return this._obj[e]=void 0,this}},{key:"clear",value:function(){this._obj={}}},{key:"has",value:function(e){return void 0!==this._obj[e]}},{key:"get",value:function(e){return this._obj[e]}}])}(),vt=function(){return n((function e(n){if(t(this,e),this._obj=Object.create(null),this.size=0,null!=n){var r;r=null!=n.instanceString&&n.instanceString()===this.instanceString()?n.toArray():n;for(var a=0;a2&&void 0!==arguments[2])||arguments[2];if(void 0!==e&&void 0!==t&&te(e)){var r=t.group;if(null==r&&(r=t.data&&null!=t.data.source&&null!=t.data.target?"edges":"nodes"),"nodes"===r||"edges"===r){this.length=1,this[0]=this;var a=this._private={cy:e,single:!0,data:t.data||{},position:t.position||{x:0,y:0},autoWidth:void 0,autoHeight:void 0,autoPadding:void 0,compoundBoundsClean:!1,listeners:[],group:r,style:{},rstyle:{},styleCxts:[],styleKeys:{},removed:!0,selected:!!t.selected,selectable:void 0===t.selectable||!!t.selectable,locked:!!t.locked,grabbed:!1,grabbable:void 0===t.grabbable||!!t.grabbable,pannable:void 0===t.pannable?"edges"===r:!!t.pannable,active:!1,classes:new yt,animation:{current:[],queue:[]},rscratch:{},scratch:t.scratch||{},edges:[],children:[],parent:t.parent&&t.parent.isNode()?t.parent:null,traversalCache:{},backgrounding:!1,bbCache:null,bbCacheShift:{x:0,y:0},bodyBounds:null,overlayBounds:null,labelBounds:{all:null,source:null,target:null,main:null},arrowBounds:{source:null,target:null,"mid-source":null,"mid-target":null}};if(null==a.position.x&&(a.position.x=0),null==a.position.y&&(a.position.y=0),t.renderedPosition){var i=t.renderedPosition,o=e.pan(),s=e.zoom();a.position={x:(i.x-o.x)/s,y:(i.y-o.y)/s}}var l=[];H(t.classes)?l=t.classes:W(t.classes)&&(l=t.classes.split(/\s+/));for(var u=0,c=l.length;ut?1:0},u=function(e,t,a,i,o){var s;if(null==a&&(a=0),null==o&&(o=n),a<0)throw new Error("lo must be non-negative");for(null==i&&(i=e.length);an;0<=n?t++:t--)u.push(t);return u}.apply(this).reverse()).length;ig;0<=g?++h:--h)v.push(i(e,r));return v},p=function(e,t,r,a){var i,o,s;for(null==a&&(a=n),i=e[r];r>t&&a(i,o=e[s=r-1>>1])<0;)e[r]=o,r=s;return e[r]=i},g=function(e,t,r){var a,i,o,s,l;for(null==r&&(r=n),i=e.length,l=t,o=e[t],a=2*t+1;a0;){var w=y.pop(),E=g(w),k=w.id();if(d[k]=E,E!==1/0)for(var T=w.neighborhood().intersect(f),C=0;C0)for(n.unshift(t);c[a];){var i=c[a];n.unshift(i.edge),n.unshift(i.node),a=(r=i.node).id()}return o.spawn(n)}}}},At={kruskal:function(e){e=e||function(e){return 1};for(var t=this.byGroup(),n=t.nodes,r=t.edges,a=n.length,i=new Array(a),o=n,s=function(e){for(var t=0;t0;){if(l=g.pop(),u=l.id(),v.delete(u),w++,u===d){for(var E=[],k=a,T=d,C=m[T];E.unshift(k),null!=C&&E.unshift(C),null!=(k=y[T]);)C=m[T=k.id()];return{found:!0,distance:h[u],path:this.spawn(E),steps:w}}p[u]=!0;for(var P=l._private.edges,S=0;SP&&(f[C]=P,y[C]=T,m[C]=x),!a){var S=T*u+k;!a&&f[S]>P&&(f[S]=P,y[S]=k,m[S]=x)}}}for(var B=0;B1&&void 0!==arguments[1]?arguments[1]:i,r=[],a=m(e);;){if(null==a)return t.spawn();var o=y(a),l=o.edge,u=o.pred;if(r.unshift(a[0]),a.same(n)&&r.length>0)break;null!=l&&r.unshift(l),a=u}return s.spawn(r)},hasNegativeWeightCycle:p,negativeWeightCycles:g}}},Ot=Math.sqrt(2),Vt=function(e,t,n){0===n.length&&rt("Karger-Stein must be run on a connected (sub)graph");for(var r=n[e],a=r[1],i=r[2],o=t[a],s=t[i],l=n,u=l.length-1;u>=0;u--){var c=l[u],d=c[1],h=c[2];(t[d]===o&&t[h]===s||t[d]===s&&t[h]===o)&&l.splice(u,1)}for(var f=0;fr;){var a=Math.floor(Math.random()*t.length);t=Vt(a,e,t),n--}return t},Xt={kargerStein:function(){var e=this,t=this.byGroup(),n=t.nodes,r=t.edges;r.unmergeBy((function(e){return e.isLoop()}));var a=n.length,i=r.length,o=Math.ceil(Math.pow(Math.log(a)/Math.LN2,2)),s=Math.floor(a/Ot);if(!(a<2)){for(var l=[],u=0;u0?1:e<0?-1:0},Gt=function(e,t){return Math.sqrt(Zt(e,t))},Zt=function(e,t){var n=t.x-e.x,r=t.y-e.y;return n*n+r*r},$t=function(e){for(var t=e.length,n=0,r=0;r=e.x1&&e.y2>=e.y1)return{x1:e.x1,y1:e.y1,x2:e.x2,y2:e.y2,w:e.x2-e.x1,h:e.y2-e.y1};if(null!=e.w&&null!=e.h&&e.w>=0&&e.h>=0)return{x1:e.x1,y1:e.y1,x2:e.x1+e.w,y2:e.y1+e.h,w:e.w,h:e.h}}},nn=function(e,t,n){e.x1=Math.min(e.x1,t),e.x2=Math.max(e.x2,t),e.w=e.x2-e.x1,e.y1=Math.min(e.y1,n),e.y2=Math.max(e.y2,n),e.h=e.y2-e.y1},rn=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0;return e.x1-=t,e.x2+=t,e.y1-=t,e.y2+=t,e.w=e.x2-e.x1,e.h=e.y2-e.y1,e},an=function(e){var t,n,r,a,o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[0];if(1===o.length)t=n=r=a=o[0];else if(2===o.length)t=r=o[0],a=n=o[1];else if(4===o.length){var s=i(o,4);t=s[0],n=s[1],r=s[2],a=s[3]}return e.x1-=a,e.x2+=n,e.y1-=t,e.y2+=r,e.w=e.x2-e.x1,e.h=e.y2-e.y1,e},on=function(e,t){e.x1=t.x1,e.y1=t.y1,e.x2=t.x2,e.y2=t.y2,e.w=e.x2-e.x1,e.h=e.y2-e.y1},sn=function(e,t){return!(e.x1>t.x2)&&(!(t.x1>e.x2)&&(!(e.x2t.y2)&&!(t.y1>e.y2)))))))},ln=function(e,t,n){return e.x1<=t&&t<=e.x2&&e.y1<=n&&n<=e.y2},un=function(e,t){return ln(e,t.x,t.y)},cn=function(e,t){return ln(e,t.x1,t.y1)&&ln(e,t.x2,t.y2)},dn=null!==(St=Math.hypot)&&void 0!==St?St:function(e,t){return Math.sqrt(e*e+t*t)};function hn(e,t,n,r,a,i){var o=function(e,t){if(e.length<3)throw new Error("Need at least 3 vertices");var n=function(e,t){return{x:e.x+t.x,y:e.y+t.y}},r=function(e,t){return{x:e.x-t.x,y:e.y-t.y}},a=function(e,t){return{x:e.x*t,y:e.y*t}},i=function(e,t){return e.x*t.y-e.y*t.x},o=function(e,t,o,s){var l=r(t,e),u=r(s,o),c=i(l,u);if(Math.abs(c)<1e-9)return n(e,a(l,.5));var d=i(r(o,e),u)/c;return n(e,a(l,d))},s=e.map((function(e){return{x:e.x,y:e.y}}));(function(e){for(var t=0,n=0;n7&&void 0!==arguments[7]?arguments[7]:"auto",c="auto"===u?Mn(a,i):u,d=a/2,h=i/2,f=(c=Math.min(c,d,h))!==d,p=c!==h;if(f){var g=r-h-o;if((s=Cn(e,t,n,r,n-d+c-o,g,n+d-c+o,g,!1)).length>0)return s}if(p){var v=n+d+o;if((s=Cn(e,t,n,r,v,r-h+c-o,v,r+h-c+o,!1)).length>0)return s}if(f){var y=r+h+o;if((s=Cn(e,t,n,r,n-d+c-o,y,n+d-c+o,y,!1)).length>0)return s}if(p){var m=n-d-o;if((s=Cn(e,t,n,r,m,r-h+c-o,m,r+h-c+o,!1)).length>0)return s}var b=n-d+c,x=r-h+c;if((l=kn(e,t,n,r,b,x,c+o)).length>0&&l[0]<=b&&l[1]<=x)return[l[0],l[1]];var w=n+d-c,E=r-h+c;if((l=kn(e,t,n,r,w,E,c+o)).length>0&&l[0]>=w&&l[1]<=E)return[l[0],l[1]];var k=n+d-c,T=r+h-c;if((l=kn(e,t,n,r,k,T,c+o)).length>0&&l[0]>=k&&l[1]>=T)return[l[0],l[1]];var C=n-d+c,P=r+h-c;return(l=kn(e,t,n,r,C,P,c+o)).length>0&&l[0]<=C&&l[1]>=P?[l[0],l[1]]:[]},pn=function(e,t,n,r,a,i,o){var s=o,l=Math.min(n,a),u=Math.max(n,a),c=Math.min(r,i),d=Math.max(r,i);return l-s<=e&&e<=u+s&&c-s<=t&&t<=d+s},gn=function(e,t,n,r,a,i,o,s,l){var u=Math.min(n,o,a)-l,c=Math.max(n,o,a)+l,d=Math.min(r,s,i)-l,h=Math.max(r,s,i)+l;return!(ec||th)},vn=function(e,t,n,r,a,i,o,s){var l=[];!function(e,t,n,r,a){var i,o,s,l,u,c,d,h;0===e&&(e=1e-5),s=-27*(r/=e)+(t/=e)*(9*(n/=e)-t*t*2),i=(o=(3*n-t*t)/9)*o*o+(s/=54)*s,a[1]=0,d=t/3,i>0?(u=(u=s+Math.sqrt(i))<0?-Math.pow(-u,1/3):Math.pow(u,1/3),c=(c=s-Math.sqrt(i))<0?-Math.pow(-c,1/3):Math.pow(c,1/3),a[0]=-d+u+c,d+=(u+c)/2,a[4]=a[2]=-d,d=Math.sqrt(3)*(-c+u)/2,a[3]=d,a[5]=-d):(a[5]=a[3]=0,0===i?(h=s<0?-Math.pow(-s,1/3):Math.pow(s,1/3),a[0]=2*h-d,a[4]=a[2]=-(h+d)):(l=(o=-o)*o*o,l=Math.acos(s/Math.sqrt(l)),h=2*Math.sqrt(o),a[0]=-d+h*Math.cos(l/3),a[2]=-d+h*Math.cos((l+2*Math.PI)/3),a[4]=-d+h*Math.cos((l+4*Math.PI)/3)))}(1*n*n-4*n*a+2*n*o+4*a*a-4*a*o+o*o+r*r-4*r*i+2*r*s+4*i*i-4*i*s+s*s,9*n*a-3*n*n-3*n*o-6*a*a+3*a*o+9*r*i-3*r*r-3*r*s-6*i*i+3*i*s,3*n*n-6*n*a+n*o-n*e+2*a*a+2*a*e-o*e+3*r*r-6*r*i+r*s-r*t+2*i*i+2*i*t-s*t,1*n*a-n*n+n*e-a*e+r*i-r*r+r*t-i*t,l);for(var u=[],c=0;c<6;c+=2)Math.abs(l[c+1])<1e-7&&l[c]>=0&&l[c]<=1&&u.push(l[c]);u.push(1),u.push(0);for(var d,h,f,p=-1,g=0;g=0?fl?(e-a)*(e-a)+(t-i)*(t-i):u-d},mn=function(e,t,n){for(var r,a,i,o,s=0,l=0;l=e&&e>=i||r<=e&&e<=i))continue;(e-r)/(i-r)*(o-a)+a>t&&s++}return s%2!=0},bn=function(e,t,n,r,a,i,o,s,l){var u,c=new Array(n.length);null!=s[0]?(u=Math.atan(s[1]/s[0]),s[0]<0?u+=Math.PI/2:u=-u-Math.PI/2):u=s;for(var d,h=Math.cos(-u),f=Math.sin(-u),p=0;p0){var g=wn(c,-l);d=xn(g)}else d=c;return mn(e,t,d)},xn=function(e){for(var t,n,r,a,i,o,s,l,u=new Array(e.length/2),c=0;c=0&&p<=1&&v.push(p),g>=0&&g<=1&&v.push(g),0===v.length)return[];var y=v[0]*s[0]+e,m=v[0]*s[1]+t;return v.length>1?v[0]==v[1]?[y,m]:[y,m,v[1]*s[0]+e,v[1]*s[1]+t]:[y,m]},Tn=function(e,t,n){return t<=e&&e<=n||n<=e&&e<=t?e:e<=t&&t<=n||n<=t&&t<=e?t:n},Cn=function(e,t,n,r,a,i,o,s,l){var u=e-a,c=n-e,d=o-a,h=t-i,f=r-t,p=s-i,g=d*h-p*u,v=c*h-f*u,y=p*c-d*f;if(0!==y){var m=g/y,b=v/y,x=-.001;return x<=m&&m<=1.001&&x<=b&&b<=1.001||l?[e+m*c,t+m*f]:[]}return 0===g||0===v?Tn(e,n,o)===o?[o,s]:Tn(e,n,a)===a?[a,i]:Tn(a,o,n)===n?[n,r]:[]:[]},Pn=function(e,t,n,r,a){var i=[],o=r/2,s=a/2,l=t,u=n;i.push({x:l+o*e[0],y:u+s*e[1]});for(var c=1;c0){var m=wn(g,-s);u=xn(m)}else u=g}else u=n;for(var b=0;bu&&(u=t)},d=function(e){return l[e]},h=0;h0?b.edgesTo(m)[0]:m.edgesTo(b)[0];var w=r(x);m=m.id(),u[m]>u[g]+w&&(u[m]=u[g]+w,h.nodes.indexOf(m)<0?h.push(m):h.updateItem(m),l[m]=0,n[m]=[]),u[m]==u[g]+w&&(l[m]=l[m]+l[g],n[m].push(g))}else for(var E=0;E0;){for(var P=t.pop(),S=0;S0&&o.push(n[s]);0!==o.length&&a.push(r.collection(o))}return a}(c,l,t,r);return b=function(e){for(var t=0;t5&&void 0!==arguments[5]?arguments[5]:er,o=r,s=0;s=2?or(e,t,n,0,rr,ar):or(e,t,n,0,nr)},squaredEuclidean:function(e,t,n){return or(e,t,n,0,rr)},manhattan:function(e,t,n){return or(e,t,n,0,nr)},max:function(e,t,n){return or(e,t,n,-1/0,ir)}};function lr(e,t,n,r,a,i){var o;return o=U(e)?e:sr[e]||sr.euclidean,0===t&&U(e)?o(a,i):o(t,n,r,a,i)}sr["squared-euclidean"]=sr.squaredEuclidean,sr.squaredeuclidean=sr.squaredEuclidean;var ur=ct({k:2,m:2,sensitivityThreshold:1e-4,distance:"euclidean",maxIterations:10,attributes:[],testMode:!1,testCentroids:null}),cr=function(e){return ur(e)},dr=function(e,t,n,r,a){var i="kMedoids"!==a?function(e){return n[e]}:function(e){return r[e](n)},o=n,s=t;return lr(e,r.length,i,(function(e){return r[e](t)}),o,s)},hr=function(e,t,n){for(var r=n.length,a=new Array(r),i=new Array(r),o=new Array(t),s=null,l=0;ln)return!1}return!0},yr=function(e,t,n){for(var r=0;ra&&(a=t[l][u],i=u);o[i].push(e[l])}for(var c=0;c=a.threshold||"dendrogram"===a.mode&&1===e.length)return!1;var f,p=t[o],g=t[r[o]];f="dendrogram"===a.mode?{left:p,right:g,key:p.key}:{value:p.value.concat(g.value),key:p.key},e[p.index]=f,e.splice(g.index,1),t[p.key]=f;for(var v=0;vn[g.key][y.key]&&(i=n[g.key][y.key])):"max"===a.linkage?(i=n[p.key][y.key],n[p.key][y.key]1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e.length,r=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],a=!(arguments.length>5&&void 0!==arguments[5])||arguments[5];arguments.length>3&&void 0!==arguments[3]&&!arguments[3]?(n0&&e.splice(0,t)):e=e.slice(t,n);for(var i=0,o=e.length-1;o>=0;o--){var s=e[o];a?isFinite(s)||(e[o]=-1/0,i++):e.splice(o,1)}r&&e.sort((function(e,t){return e-t}));var l=e.length,u=Math.floor(l/2);return l%2!=0?e[u+1+i]:(e[u-1+i]+e[u+i])/2}(e):"mean"===t?function(e){for(var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e.length,r=0,a=0,i=t;i1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e.length,r=1/0,a=t;a1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e.length,r=-1/0,a=t;ao&&(i=l,o=t[a*e+l])}i>0&&r.push(i)}for(var u=0;u=P?(S=P,P=D,B=_):D>S&&(S=D);for(var A=0;A0?1:0;k[E%u.minIterations*t+z]=O,L+=O}if(L>0&&(E>=u.minIterations-1||E==u.maxIterations-1)){for(var V=0,F=0;F0&&r.push(a);return r}(t,i,o),Y=function(e,t,n){for(var r=Nr(e,t,n),a=0;al&&(s=u,l=c)}n[a]=i[s]}return Nr(e,t,n)}(t,r,j),q={},W=0;W1||o>1)&&(u=!0),c[t]=[],e.outgoers().forEach((function(e){e.isEdge()&&c[t].push(e.id())}))}else d[t]=[void 0,e.target().id()]})):l.forEach((function(e){var t=e.id();e.isNode()?(e.degree(!0)%2&&(n?r?u=!0:r=t:n=t),c[t]=[],e.connectedEdges().forEach((function(e){return c[t].push(e.id())}))):d[t]=[e.source().id(),e.target().id()]}));var h={found:!1,trail:void 0};if(u)return h;if(r&&n)if(s){if(a&&r!=a)return h;a=r}else{if(a&&r!=a&&n!=a)return h;a||(a=r)}else a||(a=l[0].id());var f=function(e){for(var t,n,r,a=e,i=[e];c[a].length;)t=c[a].shift(),n=d[t][0],a!=(r=d[t][1])?(c[r]=c[r].filter((function(e){return e!=t})),a=r):s||a==n||(c[n]=c[n].filter((function(e){return e!=t})),a=n),i.unshift(t),i.unshift(a);return i},p=[],g=[];for(g=f(a);1!=g.length;)0==c[g[0]].length?(p.unshift(l.getElementById(g.shift())),p.unshift(l.getElementById(g.shift()))):g=f(g.shift()).concat(g);for(var v in p.unshift(l.getElementById(g.shift())),c)if(c[v].length)return h;return h.found=!0,h.trail=this.spawn(p,!0),h}},Fr=function(){var e=this,t={},n=0,r=0,a=[],i=[],o={},s=function(l,u,c){l===c&&(r+=1),t[u]={id:n,low:n++,cutVertex:!1};var d,h,f,p,g=e.getElementById(u).connectedEdges().intersection(e);0===g.size()?a.push(e.spawn(e.getElementById(u))):g.forEach((function(n){d=n.source().id(),h=n.target().id(),(f=d===u?h:d)!==c&&(p=n.id(),o[p]||(o[p]=!0,i.push({x:u,y:f,edge:n})),f in t?t[u].low=Math.min(t[u].low,t[f].id):(s(l,f,u),t[u].low=Math.min(t[u].low,t[f].low),t[u].id<=t[f].low&&(t[u].cutVertex=!0,function(n,r){for(var o=i.length-1,s=[],l=e.spawn();i[o].x!=n||i[o].y!=r;)s.push(i.pop().edge),o--;s.push(i.pop().edge),s.forEach((function(n){var r=n.connectedNodes().intersection(e);l.merge(n),r.forEach((function(n){var r=n.id(),a=n.connectedEdges().intersection(e);l.merge(n),t[r].cutVertex?l.merge(a.filter((function(e){return e.isLoop()}))):l.merge(a)}))})),a.push(l)}(u,f))))}))};e.forEach((function(e){if(e.isNode()){var n=e.id();n in t||(r=0,s(n,n),t[n].cutVertex=r>1)}}));var l=Object.keys(t).filter((function(e){return t[e].cutVertex})).map((function(t){return e.getElementById(t)}));return{cut:e.spawn(l),components:a}},Xr=function(){var e=this,t={},n=0,r=[],a=[],i=e.spawn(e),o=function(s){if(a.push(s),t[s]={index:n,low:n++,explored:!1},e.getElementById(s).connectedEdges().intersection(e).forEach((function(e){var n=e.target().id();n!==s&&(n in t||o(n),t[n].explored||(t[s].low=Math.min(t[s].low,t[n].low)))})),t[s].index===t[s].low){for(var l=e.spawn();;){var u=a.pop();if(l.merge(e.getElementById(u)),t[u].low=t[s].index,t[u].explored=!0,u===s)break}var c=l.edgesWith(l),d=l.merge(c);r.push(d),i=i.difference(d)}};return e.forEach((function(e){if(e.isNode()){var n=e.id();n in t||o(n)}})),{cut:i,components:r}},jr={};[xt,_t,At,Rt,Nt,zt,Xt,zn,Vn,Xn,Yn,Jn,kr,Ar,zr,Vr,{hopcroftTarjanBiconnected:Fr,htbc:Fr,htb:Fr,hopcroftTarjanBiconnectedComponents:Fr},{tarjanStronglyConnected:Xr,tsc:Xr,tscc:Xr,tarjanStronglyConnectedComponents:Xr}].forEach((function(e){ye(jr,e)})); -/*! - Embeddable Minimum Strictly-Compliant Promises/A+ 1.1.1 Thenable - Copyright (c) 2013-2014 Ralf S. Engelschall (http://engelschall.com) - Licensed under The MIT License (http://opensource.org/licenses/MIT) - */ -var Yr=function(e){if(!(this instanceof Yr))return new Yr(e);this.id="Thenable/1.0.7",this.state=0,this.fulfillValue=void 0,this.rejectReason=void 0,this.onFulfilled=[],this.onRejected=[],this.proxy={then:this.then.bind(this)},"function"==typeof e&&e.call(this,this.fulfill.bind(this),this.reject.bind(this))};Yr.prototype={fulfill:function(e){return qr(this,1,"fulfillValue",e)},reject:function(e){return qr(this,2,"rejectReason",e)},then:function(e,t){var n=this,r=new Yr;return n.onFulfilled.push(Hr(e,r,"fulfill")),n.onRejected.push(Hr(t,r,"reject")),Wr(n),r.proxy}};var qr=function(e,t,n,r){return 0===e.state&&(e.state=t,e[n]=r,Wr(e)),e},Wr=function(e){1===e.state?Ur(e,"onFulfilled",e.fulfillValue):2===e.state&&Ur(e,"onRejected",e.rejectReason)},Ur=function(e,t,n){if(0!==e[t].length){var r=e[t];e[t]=[];var a=function(){for(var e=0;e0:void 0}},clearQueue:function(){return function(){var e=this,t=void 0!==e.length?e:[e];if(!(this._private.cy||this).styleEnabled())return this;for(var n=0;n-1}}(),a=function(){if(Ya)return ja;Ya=1;var e=Oi();return ja=function(t,n){var r=this.__data__,a=e(r,t);return a<0?(++this.size,r.push([t,n])):r[a][1]=n,this},ja}();function i(e){var t=-1,n=null==e?0:e.length;for(this.clear();++t-1&&t%1==0&&t0&&this.spawn(r).updateStyle().emit("class"),t},addClass:function(e){return this.toggleClass(e,!0)},hasClass:function(e){var t=this[0];return null!=t&&t._private.classes.has(e)},toggleClass:function(e,t){H(e)||(e=e.match(/\S+/g)||[]);for(var n=this,r=void 0===t,a=[],i=0,o=n.length;i0&&this.spawn(a).updateStyle().emit("class"),n},removeClass:function(e){return this.toggleClass(e,!1)},flashClass:function(e,t){var n=this;if(null==t)t=250;else if(0===t)return n;return n.addClass(e),setTimeout((function(){n.removeClass(e)}),t),n}};To.className=To.classNames=To.classes;var Co={metaChar:"[\\!\\\"\\#\\$\\%\\&\\'\\(\\)\\*\\+\\,\\.\\/\\:\\;\\<\\=\\>\\?\\@\\[\\]\\^\\`\\{\\|\\}\\~]",comparatorOp:"=|\\!=|>|>=|<|<=|\\$=|\\^=|\\*=",boolOp:"\\?|\\!|\\^",string:"\"(?:\\\\\"|[^\"])*\"|'(?:\\\\'|[^'])*'",number:de,meta:"degree|indegree|outdegree",separator:"\\s*,\\s*",descendant:"\\s+",child:"\\s+>\\s+",subject:"\\$",group:"node|edge|\\*",directedEdge:"\\s+->\\s+",undirectedEdge:"\\s+<->\\s+"};Co.variable="(?:[\\w-.]|(?:\\\\"+Co.metaChar+"))+",Co.className="(?:[\\w-]|(?:\\\\"+Co.metaChar+"))+",Co.value=Co.string+"|"+Co.number,Co.id=Co.variable,function(){var e,t,n;for(e=Co.comparatorOp.split("|"),n=0;n=0||"="!==t&&(Co.comparatorOp+="|\\!"+t)}();var Po=0,So=1,Bo=2,Do=3,_o=4,Ao=5,Mo=6,Ro=7,Io=8,No=9,Lo=10,zo=11,Oo=12,Vo=13,Fo=14,Xo=15,jo=16,Yo=17,qo=18,Wo=19,Uo=20,Ho=[{selector:":selected",matches:function(e){return e.selected()}},{selector:":unselected",matches:function(e){return!e.selected()}},{selector:":selectable",matches:function(e){return e.selectable()}},{selector:":unselectable",matches:function(e){return!e.selectable()}},{selector:":locked",matches:function(e){return e.locked()}},{selector:":unlocked",matches:function(e){return!e.locked()}},{selector:":visible",matches:function(e){return e.visible()}},{selector:":hidden",matches:function(e){return!e.visible()}},{selector:":transparent",matches:function(e){return e.transparent()}},{selector:":grabbed",matches:function(e){return e.grabbed()}},{selector:":free",matches:function(e){return!e.grabbed()}},{selector:":removed",matches:function(e){return e.removed()}},{selector:":inside",matches:function(e){return!e.removed()}},{selector:":grabbable",matches:function(e){return e.grabbable()}},{selector:":ungrabbable",matches:function(e){return!e.grabbable()}},{selector:":animated",matches:function(e){return e.animated()}},{selector:":unanimated",matches:function(e){return!e.animated()}},{selector:":parent",matches:function(e){return e.isParent()}},{selector:":childless",matches:function(e){return e.isChildless()}},{selector:":child",matches:function(e){return e.isChild()}},{selector:":orphan",matches:function(e){return e.isOrphan()}},{selector:":nonorphan",matches:function(e){return e.isChild()}},{selector:":compound",matches:function(e){return e.isNode()?e.isParent():e.source().isParent()||e.target().isParent()}},{selector:":loop",matches:function(e){return e.isLoop()}},{selector:":simple",matches:function(e){return e.isSimple()}},{selector:":active",matches:function(e){return e.active()}},{selector:":inactive",matches:function(e){return!e.active()}},{selector:":backgrounding",matches:function(e){return e.backgrounding()}},{selector:":nonbackgrounding",matches:function(e){return!e.backgrounding()}}].sort((function(e,t){return function(e,t){return-1*ve(e,t)}(e.selector,t.selector)})),Ko=function(){for(var e,t={},n=0;n0&&u.edgeCount>0)return it("The selector `"+e+"` is invalid because it uses both a compound selector and an edge selector"),!1;if(u.edgeCount>1)return it("The selector `"+e+"` is invalid because it uses multiple edge selectors"),!1;1===u.edgeCount&&it("The selector `"+e+"` is deprecated. Edge selectors do not take effect on changes to source and target nodes after an edge is added, for performance reasons. Use a class or data selector on edges instead, updating the class or data of an edge when your app detects a change in source or target nodes.")}return!0},toString:function(){if(null!=this.toStringCache)return this.toStringCache;for(var e=function(e){return null==e?"":e},t=function(t){return W(t)?'"'+t+'"':e(t)},n=function(e){return" "+e+" "},r=function(r,i){var o=r.type,s=r.value;switch(o){case Po:var l=e(s);return l.substring(0,l.length-1);case Do:var u=r.field,c=r.operator;return"["+u+n(e(c))+t(s)+"]";case Ao:var d=r.operator,h=r.field;return"["+e(d)+h+"]";case _o:return"["+r.field+"]";case Mo:var f=r.operator;return"[["+r.field+n(e(f))+t(s)+"]]";case Ro:return s;case Io:return"#"+s;case No:return"."+s;case Yo:case Xo:return a(r.parent,i)+n(">")+a(r.child,i);case qo:case jo:return a(r.ancestor,i)+" "+a(r.descendant,i);case Wo:var p=a(r.left,i),g=a(r.subject,i),v=a(r.right,i);return p+(p.length>0?" ":"")+g+v;case Uo:return""}},a=function(e,t){return e.checks.reduce((function(n,a,i){return n+(t===e&&0===i?"$":"")+r(a,t)}),"")},i="",o=0;o1&&o=0&&(t=t.replace("!",""),c=!0),t.indexOf("@")>=0&&(t=t.replace("@",""),u=!0),(o||l||u)&&(a=o||s?""+e:"",i=""+n),u&&(e=a=a.toLowerCase(),n=i=i.toLowerCase()),t){case"*=":r=a.indexOf(i)>=0;break;case"$=":r=a.indexOf(i,a.length-i.length)>=0;break;case"^=":r=0===a.indexOf(i);break;case"=":r=e===n;break;case">":d=!0,r=e>n;break;case">=":d=!0,r=e>=n;break;case"<":d=!0,r=e0;){var u=a.shift();t(u),i.add(u.id()),o&&r(a,i,u)}return e}function ps(e,t,n){if(n.isParent())for(var r=n._private.children,a=0;a1&&void 0!==arguments[1])||arguments[1],ps)},hs.forEachUp=function(e){return fs(this,e,!(arguments.length>1&&void 0!==arguments[1])||arguments[1],gs)},hs.forEachUpAndDown=function(e){return fs(this,e,!(arguments.length>1&&void 0!==arguments[1])||arguments[1],vs)},hs.ancestors=hs.parents,(us=cs={data:Eo.data({field:"data",bindingEvent:"data",allowBinding:!0,allowSetting:!0,settingEvent:"data",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,immutableKeys:{id:!0,source:!0,target:!0,parent:!0},updateStyle:!0}),removeData:Eo.removeData({field:"data",event:"data",triggerFnName:"trigger",triggerEvent:!0,immutableKeys:{id:!0,source:!0,target:!0,parent:!0},updateStyle:!0}),scratch:Eo.data({field:"scratch",bindingEvent:"scratch",allowBinding:!0,allowSetting:!0,settingEvent:"scratch",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeScratch:Eo.removeData({field:"scratch",event:"scratch",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0}),rscratch:Eo.data({field:"rscratch",allowBinding:!1,allowSetting:!0,settingTriggersEvent:!1,allowGetting:!0}),removeRscratch:Eo.removeData({field:"rscratch",triggerEvent:!1}),id:function(){var e=this[0];if(e)return e._private.data.id}}).attr=us.data,us.removeAttr=us.removeData;var ys,ms,bs=cs,xs={};function ws(e){return function(t){var n=this;if(void 0===t&&(t=!0),0!==n.length&&n.isNode()&&!n.removed()){for(var r=0,a=n[0],i=a._private.edges,o=0;ot})),minIndegree:Es("indegree",(function(e,t){return et})),minOutdegree:Es("outdegree",(function(e,t){return et}))}),ye(xs,{totalDegree:function(e){for(var t=0,n=this.nodes(),r=0;r0,c=u;u&&(l=l[0]);var d=c?l.position():{x:0,y:0};return a={x:s.x-d.x,y:s.y-d.y},void 0===e?a:a[e]}for(var h=0;h0,v=g;g&&(p=p[0]);var y=v?p.position():{x:0,y:0};void 0!==t?f.position(e,t+y[e]):void 0!==a&&f.position({x:a.x+y.x,y:a.y+y.y})}}else if(!i)return;return this}},ys.modelPosition=ys.point=ys.position,ys.modelPositions=ys.points=ys.positions,ys.renderedPoint=ys.renderedPosition,ys.relativePoint=ys.relativePosition;var Cs,Ps,Ss=ms,Bs=function(e){switch(e){case"left":case"right-inside":return"left";case"right":case"left-inside":return"right";default:return"center"}},Ds=function(e){switch(e){case"top":case"bottom-inside":return"top";case"bottom":case"top-inside":return"bottom";default:return"center"}};Cs=Ps={},Ps.renderedBoundingBox=function(e){var t=this.boundingBox(e),n=this.cy(),r=n.zoom(),a=n.pan(),i=t.x1*r+a.x,o=t.x2*r+a.x,s=t.y1*r+a.y,l=t.y2*r+a.y;return{x1:i,x2:o,y1:s,y2:l,w:o-i,h:l-s}},Ps.dirtyCompoundBoundsCache=function(){var e=arguments.length>0&&void 0!==arguments[0]&&arguments[0],t=this.cy();return t.styleEnabled()&&t.hasCompoundNodes()?(this.forEachUp((function(t){if(t.isParent()){var n=t._private;n.compoundBoundsClean=!1,n.bbCache=null,e||t.emitAndNotify("bounds")}})),this):this},Ps.updateCompoundBounds=function(){var e=arguments.length>0&&void 0!==arguments[0]&&arguments[0],t=this.cy();if(!t.styleEnabled()||!t.hasCompoundNodes())return this;if(!e&&t.batching())return this;function n(e){if(e.isParent()){var t=e._private,n=e.children(),r="include"===e.pstyle("compound-sizing-wrt-labels").value,a={width:{val:e.pstyle("min-width").pfValue,left:e.pstyle("min-width-bias-left"),right:e.pstyle("min-width-bias-right")},height:{val:e.pstyle("min-height").pfValue,top:e.pstyle("min-height-bias-top"),bottom:e.pstyle("min-height-bias-bottom")}},i=n.boundingBox({includeLabels:r,includeOverlays:!1,useCache:!1}),o=t.position;0!==i.w&&0!==i.h||((i={w:e.pstyle("width").pfValue,h:e.pstyle("height").pfValue}).x1=o.x-i.w/2,i.x2=o.x+i.w/2,i.y1=o.y-i.h/2,i.y2=o.y+i.h/2);var s=a.width.left.value;"px"===a.width.left.units&&a.width.val>0&&(s=100*s/a.width.val);var l=a.width.right.value;"px"===a.width.right.units&&a.width.val>0&&(l=100*l/a.width.val);var u=a.height.top.value;"px"===a.height.top.units&&a.height.val>0&&(u=100*u/a.height.val);var c=a.height.bottom.value;"px"===a.height.bottom.units&&a.height.val>0&&(c=100*c/a.height.val);var d=y(a.width.val-i.w,s,l),h=d.biasDiff,f=d.biasComplementDiff,p=y(a.height.val-i.h,u,c),g=p.biasDiff,v=p.biasComplementDiff;t.autoPadding=function(e,t,n,r){if("%"!==n.units)return"px"===n.units?n.pfValue:0;switch(r){case"width":return e>0?n.pfValue*e:0;case"height":return t>0?n.pfValue*t:0;case"average":return e>0&&t>0?n.pfValue*(e+t)/2:0;case"min":return e>0&&t>0?e>t?n.pfValue*t:n.pfValue*e:0;case"max":return e>0&&t>0?e>t?n.pfValue*e:n.pfValue*t:0;default:return 0}}(i.w,i.h,e.pstyle("padding"),e.pstyle("padding-relative-to").value),t.autoWidth=Math.max(i.w,a.width.val),o.x=(-h+i.x1+i.x2+f)/2,t.autoHeight=Math.max(i.h,a.height.val),o.y=(-g+i.y1+i.y2+v)/2}function y(e,t,n){var r=0,a=0,i=t+n;return e>0&&i>0&&(r=t/i*e,a=n/i*e),{biasDiff:r,biasComplementDiff:a}}}for(var r=0;re.x2?r:e.x2,e.y1=ne.y2?a:e.y2,e.w=e.x2-e.x1,e.h=e.y2-e.y1)},Ms=function(e,t){return null==t?e:As(e,t.x1,t.y1,t.x2,t.y2)},Rs=function(e,t,n){return ft(e,t,n)},Is=function(e,t,n){if(!t.cy().headless()){var r,a,i=t._private,o=i.rstyle,s=o.arrowWidth/2;if("none"!==t.pstyle(n+"-arrow-shape").value){"source"===n?(r=o.srcX,a=o.srcY):"target"===n?(r=o.tgtX,a=o.tgtY):(r=o.midX,a=o.midY);var l=i.arrowBounds=i.arrowBounds||{},u=l[n]=l[n]||{};u.x1=r-s,u.y1=a-s,u.x2=r+s,u.y2=a+s,u.w=u.x2-u.x1,u.h=u.y2-u.y1,rn(u,1),As(e,u.x1,u.y1,u.x2,u.y2)}}},Ns=function(e,t,n){if(!t.cy().headless()){var r;r=n?n+"-":"";var a=t._private,i=a.rstyle;if(t.pstyle(r+"label").strValue){var o,s,l,u,c=t.pstyle("text-halign"),d=t.pstyle("text-valign"),h=Rs(i,"labelWidth",n),f=Rs(i,"labelHeight",n),p=Rs(i,"labelX",n),g=Rs(i,"labelY",n),v=t.pstyle(r+"text-margin-x").pfValue,y=t.pstyle(r+"text-margin-y").pfValue,m=t.isEdge(),b=t.pstyle(r+"text-rotation"),x=t.pstyle("text-outline-width").pfValue,w=t.pstyle("text-border-width").pfValue/2,E=t.pstyle("text-background-padding").pfValue,k=f,T=h,C=T/2,P=k/2;if(m)o=p-C,s=p+C,l=g-P,u=g+P;else{switch(Bs(c.value)){case"left":o=p-T,s=p;break;case"center":o=p-C,s=p+C;break;case"right":o=p,s=p+T}switch(Ds(d.value)){case"top":l=g-k,u=g;break;case"center":l=g-P,u=g+P;break;case"bottom":l=g,u=g+k}}var S=v-Math.max(x,w)-E-2,B=v+Math.max(x,w)+E+2,D=y-Math.max(x,w)-E-2,_=y+Math.max(x,w)+E+2;o+=S,s+=B,l+=D,u+=_;var A=n||"main",M=a.labelBounds,R=M[A]=M[A]||{};R.x1=o,R.y1=l,R.x2=s,R.y2=u,R.w=s-o,R.h=u-l,R.leftPad=S,R.rightPad=B,R.topPad=D,R.botPad=_;var I=m&&"autorotate"===b.strValue,N=null!=b.pfValue&&0!==b.pfValue;if(I||N){var L=I?Rs(a.rstyle,"labelAngle",n):b.pfValue,z=Math.cos(L),O=Math.sin(L),V=(o+s)/2,F=(l+u)/2;if(!m){switch(Bs(c.value)){case"left":V=s;break;case"right":V=o}switch(Ds(d.value)){case"top":F=u;break;case"bottom":F=l}}var X=function(e,t){return{x:(e-=V)*z-(t-=F)*O+V,y:e*O+t*z+F}},j=X(o,l),Y=X(o,u),q=X(s,l),W=X(s,u);o=Math.min(j.x,Y.x,q.x,W.x),s=Math.max(j.x,Y.x,q.x,W.x),l=Math.min(j.y,Y.y,q.y,W.y),u=Math.max(j.y,Y.y,q.y,W.y)}var U=A+"Rot",H=M[U]=M[U]||{};H.x1=o,H.y1=l,H.x2=s,H.y2=u,H.w=s-o,H.h=u-l,As(e,o,l,s,u),As(a.labelBounds.all,o,l,s,u)}return e}},Ls=function(e,t){if(!t.cy().headless()){var n=t.pstyle("outline-opacity").value,r=t.pstyle("outline-width").value+t.pstyle("outline-offset").value;zs(e,t,n,r,"outside",r/2)}},zs=function(e,t,n,r,a,i){if(!(0===n||r<=0||"inside"===a)){var o=t.cy().renderer(),s=o.nodeShapes[o.getNodeShape(t)];if(s){var l=t.position(),u=l.x,c=l.y,d=t.width(),h=t.height();if(s.hasMiterBounds){"center"===a&&(r/=2);var f=s.miterBounds(u,c,d,h,r);Ms(e,f)}else null!=i&&i>0&&an(e,[i,i,i,i])}}},Os=function(e,t){var n,r,a,i,o,s,l,u=e._private.cy,c=u.styleEnabled(),d=u.headless(),h=tn(),f=e._private,p=e.isNode(),g=e.isEdge(),v=f.rstyle,y=p&&c?e.pstyle("bounds-expansion").pfValue:[0],m=function(e){return"none"!==e.pstyle("display").value},b=!c||m(e)&&(!g||m(e.source())&&m(e.target()));if(b){var x=0;c&&t.includeOverlays&&0!==e.pstyle("overlay-opacity").value&&(x=e.pstyle("overlay-padding").value);var w=0;c&&t.includeUnderlays&&0!==e.pstyle("underlay-opacity").value&&(w=e.pstyle("underlay-padding").value);var E=Math.max(x,w),k=0;if(c&&(k=e.pstyle("width").pfValue/2),p&&t.includeNodes){var T=e.position();o=T.x,s=T.y;var C=e.outerWidth()/2,P=e.outerHeight()/2;As(h,n=o-C,a=s-P,r=o+C,i=s+P),c&&Ls(h,e),c&&t.includeOutlines&&!d&&Ls(h,e),c&&function(e,t){if(!t.cy().headless()){var n=t.pstyle("border-opacity").value,r=t.pstyle("border-width").pfValue,a=t.pstyle("border-position").value;zs(e,t,n,r,a)}}(h,e)}else if(g&&t.includeEdges)if(c&&!d){var S=e.pstyle("curve-style").strValue;if(n=Math.min(v.srcX,v.midX,v.tgtX),r=Math.max(v.srcX,v.midX,v.tgtX),a=Math.min(v.srcY,v.midY,v.tgtY),i=Math.max(v.srcY,v.midY,v.tgtY),As(h,n-=k,a-=k,r+=k,i+=k),"haystack"===S){var B=v.haystackPts;if(B&&2===B.length){if(n=B[0].x,a=B[0].y,n>(r=B[1].x)){var D=n;n=r,r=D}if(a>(i=B[1].y)){var _=a;a=i,i=_}As(h,n-k,a-k,r+k,i+k)}}else if("bezier"===S||"unbundled-bezier"===S||ce(S,"segments")||ce(S,"taxi")){var A;switch(S){case"bezier":case"unbundled-bezier":A=v.bezierPts;break;case"segments":case"taxi":case"round-segments":case"round-taxi":A=v.linePts}if(null!=A)for(var M=0;M(r=N.x)){var L=n;n=r,r=L}if((a=I.y)>(i=N.y)){var z=a;a=i,i=z}As(h,n-=k,a-=k,r+=k,i+=k)}if(c&&t.includeEdges&&g&&(Is(h,e,"mid-source"),Is(h,e,"mid-target"),Is(h,e,"source"),Is(h,e,"target")),c)if("yes"===e.pstyle("ghost").value){var O=e.pstyle("ghost-offset-x").pfValue,V=e.pstyle("ghost-offset-y").pfValue;As(h,h.x1+O,h.y1+V,h.x2+O,h.y2+V)}var F=f.bodyBounds=f.bodyBounds||{};on(F,h),an(F,y),rn(F,1),c&&(n=h.x1,r=h.x2,a=h.y1,i=h.y2,As(h,n-E,a-E,r+E,i+E));var X=f.overlayBounds=f.overlayBounds||{};on(X,h),an(X,y),rn(X,1);var j=f.labelBounds=f.labelBounds||{};null!=j.all?((l=j.all).x1=1/0,l.y1=1/0,l.x2=-1/0,l.y2=-1/0,l.w=0,l.h=0):j.all=tn(),c&&t.includeLabels&&(t.includeMainLabels&&Ns(h,e,null),g&&(t.includeSourceLabels&&Ns(h,e,"source"),t.includeTargetLabels&&Ns(h,e,"target")))}return h.x1=_s(h.x1),h.y1=_s(h.y1),h.x2=_s(h.x2),h.y2=_s(h.y2),h.w=_s(h.x2-h.x1),h.h=_s(h.y2-h.y1),h.w>0&&h.h>0&&b&&(an(h,y),rn(h,1)),h},Vs=function(e){var t=0,n=function(e){return(e?1:0)<0&&void 0!==arguments[0]?arguments[0]:il,t=arguments.length>1?arguments[1]:void 0,n=0;n=0;s--)o(s);return this},sl.removeAllListeners=function(){return this.removeListener("*")},sl.emit=sl.trigger=function(e,t,n){var r=this.listeners,a=r.length;return this.emitting++,H(t)||(t=[t]),cl(this,(function(e,i){null!=n&&(r=[{event:i.event,type:i.type,namespace:i.namespace,callback:n}],a=r.length);for(var o=function(){var n=r[s];if(n.type===i.type&&(!n.namespace||n.namespace===i.namespace||".*"===n.namespace)&&e.eventMatches(e.context,n,i)){var a=[i];null!=t&&function(e,t){for(var n=0;n1&&!r){var a=this.length-1,i=this[a],o=i._private.data.id;this[a]=void 0,this[e]=i,n.set(o,{ele:i,index:e})}return this.length--,this},unmergeOne:function(e){e=e[0];var t=this._private,n=e._private.data.id,r=t.map.get(n);if(!r)return this;var a=r.index;return this.unmergeAt(a),this},unmerge:function(e){var t=this._private.cy;if(!e)return this;if(e&&W(e)){var n=e;e=t.mutableElements().filter(n)}for(var r=0;r=0;t--){e(this[t])&&this.unmergeAt(t)}return this},map:function(e,t){for(var n=[],r=this,a=0;ar&&(r=s,n=o)}return{value:r,ele:n}},min:function(e,t){for(var n,r=1/0,a=this,i=0;i=0&&a1&&void 0!==arguments[1])||arguments[1],n=this[0],r=n.cy();if(r.styleEnabled()&&n){n._private.styleDirty&&(n._private.styleDirty=!1,r.style().apply(n));var a=n._private.style[e];return null!=a?a:t?r.style().getDefaultProperty(e):null}},numericStyle:function(e){var t=this[0];if(t.cy().styleEnabled()&&t){var n=t.pstyle(e);return void 0!==n.pfValue?n.pfValue:n.value}},numericStyleUnits:function(e){var t=this[0];if(t.cy().styleEnabled())return t?t.pstyle(e).units:void 0},renderedStyle:function(e){var t=this.cy();if(!t.styleEnabled())return this;var n=this[0];return n?t.style().getRenderedStyle(n,e):void 0},style:function(e,t){var n=this.cy();if(!n.styleEnabled())return this;var r=!1,a=n.style();if(K(e)){var i=e;a.applyBypass(this,i,r),this.emitAndNotify("style")}else if(W(e)){if(void 0===t){var o=this[0];return o?a.getStylePropertyValue(o,e):void 0}a.applyBypass(this,e,t,r),this.emitAndNotify("style")}else if(void 0===e){var s=this[0];return s?a.getRawStyle(s):void 0}return this},removeStyle:function(e){var t=this.cy();if(!t.styleEnabled())return this;var n=!1,r=t.style(),a=this;if(void 0===e)for(var i=0;i0&&t.push(c[0]),t.push(s[0])}return this.spawn(t,!0).filter(e)}),"neighborhood"),closedNeighborhood:function(e){return this.neighborhood().add(this).filter(e)},openNeighborhood:function(e){return this.neighborhood(e)}}),Nl.neighbourhood=Nl.neighborhood,Nl.closedNeighbourhood=Nl.closedNeighborhood,Nl.openNeighbourhood=Nl.openNeighborhood,ye(Nl,{source:ds((function(e){var t,n=this[0];return n&&(t=n._private.source||n.cy().collection()),t&&e?t.filter(e):t}),"source"),target:ds((function(e){var t,n=this[0];return n&&(t=n._private.target||n.cy().collection()),t&&e?t.filter(e):t}),"target"),sources:Vl({attr:"source"}),targets:Vl({attr:"target"})}),ye(Nl,{edgesWith:ds(Fl(),"edgesWith"),edgesTo:ds(Fl({thisIsSrc:!0}),"edgesTo")}),ye(Nl,{connectedEdges:ds((function(e){for(var t=[],n=0;n0);return i},component:function(){var e=this[0];return e.cy().mutableElements().components(e)[0]}}),Nl.componentsOf=Nl.components;var jl=function(e,t){var n=arguments.length>2&&void 0!==arguments[2]&&arguments[2],r=arguments.length>3&&void 0!==arguments[3]&&arguments[3];if(void 0!==e){var a=new gt,i=!1;if(t){if(t.length>0&&K(t[0])&&!J(t[0])){i=!0;for(var o=[],s=new yt,l=0,u=t.length;l0&&void 0!==arguments[0])||arguments[0],r=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],a=this,i=a.cy(),o=i._private,s=[],l=[],u=0,c=a.length;u0){for(var I=e.length===a.length?a:new jl(i,e),N=0;N0&&void 0!==arguments[0])||arguments[0],t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],n=this,r=[],a={},i=n._private.cy;function o(e){var n=a[e.id()];t&&e.removed()||n||(a[e.id()]=!0,e.isNode()?(r.push(e),function(e){for(var t=e._private.edges,n=0;n0&&(e?k.emitAndNotify("remove"):t&&k.emit("remove"));for(var T=0;T=.001?function(t,r){for(var a=0;a<4;++a){var i=h(r,e,n);if(0===i)return r;r-=(d(r,e,n)-t)/i}return r}(t,o):0===l?o:function(t,r,a){var i,o,s=0;do{(i=d(o=r+(a-r)/2,e,n)-t)>0?a=o:r=o}while(Math.abs(i)>1e-7&&++s<10);return o}(t,r,r+a)}var p=!1;function g(){p=!0,e===t&&n===r||function(){for(var t=0;t<11;++t)s[t]=d(t*a,e,n)}()}var v=function(a){return p||g(),e===t&&n===r?a:0===a?0:1===a?1:d(f(a),t,r)};v.getControlPoints=function(){return[{x:e,y:t},{x:n,y:r}]};var y="generateBezier("+[e,t,n,r]+")";return v.toString=function(){return y},v} -/*! Runge-Kutta spring physics function generator. Adapted from Framer.js, copyright Koen Bok. MIT License: http://en.wikipedia.org/wiki/MIT_License */var Ul=function(){function e(e){return-e.tension*e.x-e.friction*e.v}function t(t,n,r){var a={x:t.x+r.dx*n,v:t.v+r.dv*n,tension:t.tension,friction:t.friction};return{dx:a.v,dv:e(a)}}function n(n,r){var a={dx:n.v,dv:e(n)},i=t(n,.5*r,a),o=t(n,.5*r,i),s=t(n,r,o),l=1/6*(a.dx+2*(i.dx+o.dx)+s.dx),u=1/6*(a.dv+2*(i.dv+o.dv)+s.dv);return n.x=n.x+l*r,n.v=n.v+u*r,n}return function e(t,r,a){var i,o,s,l={x:-1,v:0,tension:null,friction:null},u=[0],c=0,d=1e-4;for(t=parseFloat(t)||500,r=parseFloat(r)||20,a=a||null,l.tension=t,l.friction=r,o=(i=null!==a)?(c=e(t,r))/a*.016:.016;s=n(s||l,o),u.push(1+s.x),c+=16,Math.abs(s.x)>d&&Math.abs(s.v)>d;);return i?function(e){return u[e*(u.length-1)|0]}:c}}(),Hl=function(e,t,n,r){var a=Wl(e,t,n,r);return function(e,t,n){return e+(t-e)*a(n)}},Kl={linear:function(e,t,n){return e+(t-e)*n},ease:Hl(.25,.1,.25,1),"ease-in":Hl(.42,0,1,1),"ease-out":Hl(0,0,.58,1),"ease-in-out":Hl(.42,0,.58,1),"ease-in-sine":Hl(.47,0,.745,.715),"ease-out-sine":Hl(.39,.575,.565,1),"ease-in-out-sine":Hl(.445,.05,.55,.95),"ease-in-quad":Hl(.55,.085,.68,.53),"ease-out-quad":Hl(.25,.46,.45,.94),"ease-in-out-quad":Hl(.455,.03,.515,.955),"ease-in-cubic":Hl(.55,.055,.675,.19),"ease-out-cubic":Hl(.215,.61,.355,1),"ease-in-out-cubic":Hl(.645,.045,.355,1),"ease-in-quart":Hl(.895,.03,.685,.22),"ease-out-quart":Hl(.165,.84,.44,1),"ease-in-out-quart":Hl(.77,0,.175,1),"ease-in-quint":Hl(.755,.05,.855,.06),"ease-out-quint":Hl(.23,1,.32,1),"ease-in-out-quint":Hl(.86,0,.07,1),"ease-in-expo":Hl(.95,.05,.795,.035),"ease-out-expo":Hl(.19,1,.22,1),"ease-in-out-expo":Hl(1,0,0,1),"ease-in-circ":Hl(.6,.04,.98,.335),"ease-out-circ":Hl(.075,.82,.165,1),"ease-in-out-circ":Hl(.785,.135,.15,.86),spring:function(e,t,n){if(0===n)return Kl.linear;var r=Ul(e,t,n);return function(e,t,n){return e+(t-e)*r(n)}},"cubic-bezier":Hl};function Gl(e,t,n,r,a){if(1===r)return n;if(t===n)return n;var i=a(t,n,r);return null==e||((e.roundValue||e.color)&&(i=Math.round(i)),void 0!==e.min&&(i=Math.max(i,e.min)),void 0!==e.max&&(i=Math.min(i,e.max))),i}function Zl(e,t){return null!=e.pfValue||null!=e.value?null==e.pfValue||null!=t&&"%"===t.type.units?e.value:e.pfValue:e}function $l(e,t,n,r,a){var i=null!=a?a.type:null;n<0?n=0:n>1&&(n=1);var o=Zl(e,a),s=Zl(t,a);if(G(o)&&G(s))return Gl(i,o,s,n,r);if(H(o)&&H(s)){for(var l=[],u=0;u0?("spring"===d&&h.push(o.duration),o.easingImpl=Kl[d].apply(null,h)):o.easingImpl=Kl[d]}var f,p=o.easingImpl;if(f=0===o.duration?1:(n-l)/o.duration,o.applying&&(f=o.progress),f<0?f=0:f>1&&(f=1),null==o.delay){var g=o.startPosition,v=o.position;if(v&&a&&!e.locked()){var y={};Jl(g.x,v.x)&&(y.x=$l(g.x,v.x,f,p)),Jl(g.y,v.y)&&(y.y=$l(g.y,v.y,f,p)),e.position(y)}var m=o.startPan,b=o.pan,x=i.pan,w=null!=b&&r;w&&(Jl(m.x,b.x)&&(x.x=$l(m.x,b.x,f,p)),Jl(m.y,b.y)&&(x.y=$l(m.y,b.y,f,p)),e.emit("pan"));var E=o.startZoom,k=o.zoom,T=null!=k&&r;T&&(Jl(E,k)&&(i.zoom=en(i.minZoom,$l(E,k,f,p),i.maxZoom)),e.emit("zoom")),(w||T)&&e.emit("viewport");var C=o.style;if(C&&C.length>0&&a){for(var P=0;P=0;t--){(0,e[t])()}e.splice(0,e.length)},c=i.length-1;c>=0;c--){var d=i[c],h=d._private;h.stopped?(i.splice(c,1),h.hooked=!1,h.playing=!1,h.started=!1,u(h.frames)):(h.playing||h.applying)&&(h.playing&&h.applying&&(h.applying=!1),h.started||eu(0,d,e),Ql(t,d,e,n),h.applying&&(h.applying=!1),u(h.frames),null!=h.step&&h.step(e),d.completed()&&(i.splice(c,1),h.hooked=!1,h.playing=!1,h.started=!1,u(h.completes)),s=!0)}return n||0!==i.length||0!==o.length||r.push(t),s}for(var i=!1,o=0;o0?t.notify("draw",n):t.notify("draw")),n.unmerge(r),t.emit("step")}var nu={animate:Eo.animate(),animation:Eo.animation(),animated:Eo.animated(),clearQueue:Eo.clearQueue(),delay:Eo.delay(),delayAnimation:Eo.delayAnimation(),stop:Eo.stop(),addToAnimationPool:function(e){this.styleEnabled()&&this._private.aniEles.merge(e)},stopAnimationLoop:function(){this._private.animationsRunning=!1},startAnimationLoop:function(){var e=this;if(e._private.animationsRunning=!0,e.styleEnabled()){var t=e.renderer();t&&t.beforeRender?t.beforeRender((function(t,n){tu(n,e)}),t.beforeRenderPriorities.animations):function t(){e._private.animationsRunning&&Ne((function(n){tu(n,e),t()}))}()}}},ru={qualifierCompare:function(e,t){return null==e||null==t?null==e&&null==t:e.sameText(t)},eventMatches:function(e,t,n){var r=t.qualifier;return null==r||e!==n.target&&J(n.target)&&r.matches(n.target)},addEventFields:function(e,t){t.cy=e,t.target=e},callbackContext:function(e,t,n){return null!=t.qualifier?n.target:e}},au=function(e){return W(e)?new os(e):e},iu={createEmitter:function(){var e=this._private;return e.emitter||(e.emitter=new ol(ru,this)),this},emitter:function(){return this._private.emitter},on:function(e,t,n){return this.emitter().on(e,au(t),n),this},removeListener:function(e,t,n){return this.emitter().removeListener(e,au(t),n),this},removeAllListeners:function(){return this.emitter().removeAllListeners(),this},one:function(e,t,n){return this.emitter().one(e,au(t),n),this},once:function(e,t,n){return this.emitter().one(e,au(t),n),this},emit:function(e,t){return this.emitter().emit(e,t),this},emitAndNotify:function(e,t){return this.emit(e),this.notify(e,t),this}};Eo.eventAliasesOn(iu);var ou={png:function(e){return e=e||{},this._private.renderer.png(e)},jpg:function(e){var t=this._private.renderer;return(e=e||{}).bg=e.bg||"#fff",t.jpg(e)}};ou.jpeg=ou.jpg;var su={layout:function(e){var t=this;if(null!=e)if(null!=e.name){var n=e.name,r=t.extension("layout",n);if(null!=r){var a;a=W(e.eles)?t.$(e.eles):null!=e.eles?e.eles:t.$();var i=new r(ye({},e,{cy:t,eles:a}));return i}rt("No such layout `"+n+"` found. Did you forget to import it and `cytoscape.use()` it?")}else rt("A `name` must be specified to make a layout");else rt("Layout options must be specified to make a layout")}};su.createLayout=su.makeLayout=su.layout;var lu={notify:function(e,t){var n=this._private;if(this.batching()){n.batchNotifications=n.batchNotifications||{};var r=n.batchNotifications[e]=n.batchNotifications[e]||this.collection();null!=t&&r.merge(t)}else if(n.notificationsEnabled){var a=this.renderer();!this.destroyed()&&a&&a.notify(e,t)}},notifications:function(e){var t=this._private;return void 0===e?t.notificationsEnabled:(t.notificationsEnabled=!!e,this)},noNotifications:function(e){this.notifications(!1),e(),this.notifications(!0)},batching:function(){return this._private.batchCount>0},startBatch:function(){var e=this._private;return null==e.batchCount&&(e.batchCount=0),0===e.batchCount&&(e.batchStyleEles=this.collection(),e.batchNotifications={}),e.batchCount++,this},endBatch:function(){var e=this._private;if(0===e.batchCount)return this;if(e.batchCount--,0===e.batchCount){e.batchStyleEles.updateStyle();var t=this.renderer();Object.keys(e.batchNotifications).forEach((function(n){var r=e.batchNotifications[n];r.empty()?t.notify(n):t.notify(n,r)}))}return this},batch:function(e){return this.startBatch(),e(),this.endBatch(),this},batchData:function(e){var t=this;return this.batch((function(){for(var n=Object.keys(e),r=0;r0;)t.removeChild(t.childNodes[0]);e._private.renderer=null,e.mutableElements().forEach((function(e){var t=e._private;t.rscratch={},t.rstyle={},t.animation.current=[],t.animation.queue=[]}))},onRender:function(e){return this.on("render",e)},offRender:function(e){return this.off("render",e)}};cu.invalidateDimensions=cu.resize;var du={collection:function(e,t){return W(e)?this.$(e):Q(e)?e.collection():H(e)?(t||(t={}),new jl(this,e,t.unique,t.removed)):new jl(this)},nodes:function(e){var t=this.$((function(e){return e.isNode()}));return e?t.filter(e):t},edges:function(e){var t=this.$((function(e){return e.isEdge()}));return e?t.filter(e):t},$:function(e){var t=this._private.elements;return e?t.filter(e):t.spawnSelf()},mutableElements:function(){return this._private.elements}};du.elements=du.filter=du.$;var hu={},fu="t";hu.apply=function(e){for(var t=this,n=t._private.cy.collection(),r=0;r0;if(h||d&&f){var p=void 0;h&&f||h?p=u.properties:f&&(p=u.mappedProperties);for(var g=0;g1&&(v=1),s.color){var w=a.valueMin[0],E=a.valueMax[0],k=a.valueMin[1],T=a.valueMax[1],C=a.valueMin[2],P=a.valueMax[2],S=null==a.valueMin[3]?1:a.valueMin[3],B=null==a.valueMax[3]?1:a.valueMax[3],D=[Math.round(w+(E-w)*v),Math.round(k+(T-k)*v),Math.round(C+(P-C)*v),Math.round(S+(B-S)*v)];n={bypass:a.bypass,name:a.name,value:D,strValue:"rgb("+D[0]+", "+D[1]+", "+D[2]+")"}}else{if(!s.number)return!1;var _=a.valueMin+(a.valueMax-a.valueMin)*v;n=this.parse(a.name,_,a.bypass,h)}if(!n)return g(),!1;n.mapping=a,a=n;break;case o.data:for(var A=a.field.split("."),M=d.data,R=0;R0&&i>0){for(var s={},l=!1,u=0;u0?e.delayAnimation(o).play().promise().then(t):t()})).then((function(){return e.animation({style:s,duration:i,easing:e.pstyle("transition-timing-function").value,queue:!1}).play().promise()})).then((function(){n.removeBypasses(e,a),e.emitAndNotify("style"),r.transitioning=!1}))}else r.transitioning&&(this.removeBypasses(e,a),e.emitAndNotify("style"),r.transitioning=!1)},hu.checkTrigger=function(e,t,n,r,a,i){var o=this.properties[t],s=a(o);e.removed()||null!=s&&s(n,r,e)&&i(o)},hu.checkZOrderTrigger=function(e,t,n,r){var a=this;this.checkTrigger(e,t,n,r,(function(e){return e.triggersZOrder}),(function(){a._private.cy.notify("zorder",e)}))},hu.checkBoundsTrigger=function(e,t,n,r){this.checkTrigger(e,t,n,r,(function(e){return e.triggersBounds}),(function(t){e.dirtyCompoundBoundsCache(),e.dirtyBoundingBoxCache()}))},hu.checkConnectedEdgesBoundsTrigger=function(e,t,n,r){this.checkTrigger(e,t,n,r,(function(e){return e.triggersBoundsOfConnectedEdges}),(function(t){e.connectedEdges().forEach((function(e){e.dirtyBoundingBoxCache()}))}))},hu.checkParallelEdgesBoundsTrigger=function(e,t,n,r){this.checkTrigger(e,t,n,r,(function(e){return e.triggersBoundsOfParallelEdges}),(function(t){e.parallelEdges().forEach((function(e){e.dirtyBoundingBoxCache()}))}))},hu.checkTriggers=function(e,t,n,r){e.dirtyStyleCache(),this.checkZOrderTrigger(e,t,n,r),this.checkBoundsTrigger(e,t,n,r),this.checkConnectedEdgesBoundsTrigger(e,t,n,r),this.checkParallelEdgesBoundsTrigger(e,t,n,r)};var pu={applyBypass:function(e,t,n,r){var a=[];if("*"===t||"**"===t){if(void 0!==n)for(var i=0;it.length?i.substr(t.length):""}function s(){n=n.length>r.length?n.substr(r.length):""}for(i=i.replace(/[/][*](\s|.)+?[*][/]/g,"");;){if(i.match(/^\s*$/))break;var l=i.match(/^\s*((?:.|\s)+?)\s*\{((?:.|\s)+?)\}/);if(!l){it("Halting stylesheet parsing: String stylesheet contains more to parse but no selector and block found in: "+i);break}t=l[0];var u=l[1];if("core"!==u)if(new os(u).invalid){it("Skipping parsing of block: Invalid selector found in string stylesheet: "+u),o();continue}var c=l[2],d=!1;n=c;for(var h=[];;){if(n.match(/^\s*$/))break;var f=n.match(/^\s*(.+?)\s*:\s*(.+?)(?:\s*;|\s*$)/);if(!f){it("Skipping parsing of block: Invalid formatting of style property and value definitions found in:"+c),d=!0;break}r=f[0];var p=f[1],g=f[2];if(this.properties[p])a.parse(p,g)?(h.push({name:p,val:g}),s()):(it("Skipping property: Invalid property definition in: "+r),s());else it("Skipping property: Invalid property name in: "+r),s()}if(d){o();break}a.selector(u);for(var v=0;v=7&&"d"===t[0]&&(u=new RegExp(s.data.regex).exec(t))){if(n)return!1;var h=s.data;return{name:e,value:u,strValue:""+t,mapped:h,field:u[1],bypass:n}}if(t.length>=10&&"m"===t[0]&&(c=new RegExp(s.mapData.regex).exec(t))){if(n)return!1;if(d.multiple)return!1;var f=s.mapData;if(!d.color&&!d.number)return!1;var p=this.parse(e,c[4]);if(!p||p.mapped)return!1;var g=this.parse(e,c[5]);if(!g||g.mapped)return!1;if(p.pfValue===g.pfValue||p.strValue===g.strValue)return it("`"+e+": "+t+"` is not a valid mapper because the output range is zero; converting to `"+e+": "+p.strValue+"`"),this.parse(e,p.strValue);if(d.color){var v=p.value,y=g.value;if(!(v[0]!==y[0]||v[1]!==y[1]||v[2]!==y[2]||v[3]!==y[3]&&(null!=v[3]&&1!==v[3]||null!=y[3]&&1!==y[3])))return!1}return{name:e,value:c,strValue:""+t,mapped:f,field:c[1],fieldMin:parseFloat(c[2]),fieldMax:parseFloat(c[3]),valueMin:p.value,valueMax:g.value,bypass:n}}}if(d.multiple&&"multiple"!==r){var m;if(m=l?t.split(/\s+/):H(t)?t:[t],d.evenMultiple&&m.length%2!=0)return null;for(var b=[],x=[],w=[],E="",k=!1,T=0;T0?" ":"")+C.strValue}return d.validate&&!d.validate(b,x)?null:d.singleEnum&&k?1===b.length&&W(b[0])?{name:e,value:b[0],strValue:b[0],bypass:n}:null:{name:e,value:b,pfValue:w,strValue:E,bypass:n,units:x}}var P,S=function(){for(var r=0;rd.max||d.strictMax&&t===d.max))return null;var M={name:e,value:t,strValue:""+t+(B||""),units:B,bypass:n};return d.unitless||"px"!==B&&"em"!==B?M.pfValue=t:M.pfValue="px"!==B&&B?this.getEmSizeInPixels()*t:t,"ms"!==B&&"s"!==B||(M.pfValue="ms"===B?t:1e3*t),"deg"!==B&&"rad"!==B||(M.pfValue="rad"===B?t:(P=t,Math.PI*P/180)),"%"===B&&(M.pfValue=t/100),M}if(d.propList){var R=[],I=""+t;if("none"===I);else{for(var N=I.split(/\s*,\s*|\s+/),L=0;L0&&l>0&&!isNaN(n.w)&&!isNaN(n.h)&&n.w>0&&n.h>0)return{zoom:o=(o=(o=Math.min((s-2*t)/n.w,(l-2*t)/n.h))>this._private.maxZoom?this._private.maxZoom:o)=n.minZoom&&(n.maxZoom=t),this},minZoom:function(e){return void 0===e?this._private.minZoom:this.zoomRange({min:e})},maxZoom:function(e){return void 0===e?this._private.maxZoom:this.zoomRange({max:e})},getZoomedViewport:function(e){var t,n,r=this._private,a=r.pan,i=r.zoom,o=!1;if(r.zoomingEnabled||(o=!0),G(e)?n=e:K(e)&&(n=e.level,null!=e.position?t=jt(e.position,i,a):null!=e.renderedPosition&&(t=e.renderedPosition),null==t||r.panningEnabled||(o=!0)),n=(n=n>r.maxZoom?r.maxZoom:n)t.maxZoom||!t.zoomingEnabled?i=!0:(t.zoom=s,a.push("zoom"))}if(r&&(!i||!e.cancelOnFailedZoom)&&t.panningEnabled){var l=e.pan;G(l.x)&&(t.pan.x=l.x,o=!1),G(l.y)&&(t.pan.y=l.y,o=!1),o||a.push("pan")}return a.length>0&&(a.push("viewport"),this.emit(a.join(" ")),this.notify("viewport")),this},center:function(e){var t=this.getCenterPan(e);return t&&(this._private.pan=t,this.emit("pan viewport"),this.notify("viewport")),this},getCenterPan:function(e,t){if(this._private.panningEnabled){if(W(e)){var n=e;e=this.mutableElements().filter(n)}else Q(e)||(e=this.mutableElements());if(0!==e.length){var r=e.boundingBox(),a=this.width(),i=this.height();return{x:(a-(t=void 0===t?this._private.zoom:t)*(r.x1+r.x2))/2,y:(i-t*(r.y1+r.y2))/2}}}},reset:function(){return this._private.panningEnabled&&this._private.zoomingEnabled?(this.viewport({pan:{x:0,y:0},zoom:1}),this):this},invalidateSize:function(){this._private.sizeCache=null},size:function(){var e,t,n=this._private,r=n.container,a=this;return n.sizeCache=n.sizeCache||(r?(e=a.window().getComputedStyle(r),t=function(t){return parseFloat(e.getPropertyValue(t))},{width:r.clientWidth-t("padding-left")-t("padding-right"),height:r.clientHeight-t("padding-top")-t("padding-bottom")}):{width:1,height:1})},width:function(){return this.size().width},height:function(){return this.size().height},extent:function(){var e=this._private.pan,t=this._private.zoom,n=this.renderedExtent(),r={x1:(n.x1-e.x)/t,x2:(n.x2-e.x)/t,y1:(n.y1-e.y)/t,y2:(n.y2-e.y)/t};return r.w=r.x2-r.x1,r.h=r.y2-r.y1,r},renderedExtent:function(){var e=this.width(),t=this.height();return{x1:0,y1:0,x2:e,y2:t,w:e,h:t}},multiClickDebounceTime:function(e){return e?(this._private.multiClickDebounceTime=e,this):this._private.multiClickDebounceTime}};Tu.centre=Tu.center,Tu.autolockNodes=Tu.autolock,Tu.autoungrabifyNodes=Tu.autoungrabify;var Cu={data:Eo.data({field:"data",bindingEvent:"data",allowBinding:!0,allowSetting:!0,settingEvent:"data",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeData:Eo.removeData({field:"data",event:"data",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0}),scratch:Eo.data({field:"scratch",bindingEvent:"scratch",allowBinding:!0,allowSetting:!0,settingEvent:"scratch",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeScratch:Eo.removeData({field:"scratch",event:"scratch",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0})};Cu.attr=Cu.data,Cu.removeAttr=Cu.removeData;var Pu=function(e){var t=this,n=(e=ye({},e)).container;n&&!$(n)&&$(n[0])&&(n=n[0]);var r=n?n._cyreg:null;(r=r||{})&&r.cy&&(r.cy.destroy(),r={});var a=r.readies=r.readies||[];n&&(n._cyreg=r),r.cy=t;var i=void 0!==c&&void 0!==n&&!e.headless,o=e;o.layout=ye({name:i?"grid":"null"},o.layout),o.renderer=ye({name:i?"canvas":"null"},o.renderer);var s=function(e,t,n){return void 0!==t?t:void 0!==n?n:e},l=this._private={container:n,ready:!1,options:o,elements:new jl(this),listeners:[],aniEles:new jl(this),data:o.data||{},scratch:{},layout:null,renderer:null,destroyed:!1,notificationsEnabled:!0,minZoom:1e-50,maxZoom:1e50,zoomingEnabled:s(!0,o.zoomingEnabled),userZoomingEnabled:s(!0,o.userZoomingEnabled),panningEnabled:s(!0,o.panningEnabled),userPanningEnabled:s(!0,o.userPanningEnabled),boxSelectionEnabled:s(!0,o.boxSelectionEnabled),autolock:s(!1,o.autolock,o.autolockNodes),autoungrabify:s(!1,o.autoungrabify,o.autoungrabifyNodes),autounselectify:s(!1,o.autounselectify),styleEnabled:void 0===o.styleEnabled?i:o.styleEnabled,zoom:G(o.zoom)?o.zoom:1,pan:{x:K(o.pan)&&G(o.pan.x)?o.pan.x:0,y:K(o.pan)&&G(o.pan.y)?o.pan.y:0},animation:{current:[],queue:[]},hasCompoundNodes:!1,multiClickDebounceTime:s(250,o.multiClickDebounceTime)};this.createEmitter(),this.selectionType(o.selectionType),this.zoomRange({min:o.minZoom,max:o.maxZoom});l.styleEnabled&&t.setStyle([]);var u=ye({},o,o.renderer);t.initRenderer(u);!function(e,t){if(e.some(ae))return Gr.all(e).then(t);t(e)}([o.style,o.elements],(function(e){var n=e[0],i=e[1];l.styleEnabled&&t.style().append(n),function(e,n,r){t.notifications(!1);var a=t.mutableElements();a.length>0&&a.remove(),null!=e&&(K(e)||H(e))&&t.add(e),t.one("layoutready",(function(e){t.notifications(!0),t.emit(e),t.one("load",n),t.emitAndNotify("load")})).one("layoutstop",(function(){t.one("done",r),t.emit("done")}));var i=ye({},t._private.options.layout);i.eles=t.elements(),t.layout(i).run()}(i,(function(){t.startAnimationLoop(),l.ready=!0,U(o.ready)&&t.on("ready",o.ready);for(var e=0;e0,l=!!t.boundingBox,u=tn(l?t.boundingBox:structuredClone(n.extent()));if(Q(t.roots))e=t.roots;else if(H(t.roots)){for(var c=[],d=0;d0;){var B=C.shift(),D=T(B,P);if(D)B.outgoers().filter((function(e){return e.isNode()&&r.has(e)})).forEach(S);else if(null===D){it("Detected double maximal shift for node `"+B.id()+"`. Bailing maximal adjustment due to cycle. Use `options.maximal: true` only on DAGs.");break}}}var _=0;if(t.avoidOverlap)for(var A=0;A0&&y[0].length<=3?i/2:0),s=2*Math.PI/y[r].length*a;return 0===r&&1===y[0].length&&(o=1),{x:q+o*Math.cos(s),y:U+o*Math.sin(s)}}var c=y[r].length,d=Math.max(1===c?0:l?(u.w-2*t.padding-K.w)/((t.grid?Z:c)-1):(u.w-2*t.padding-K.w)/((t.grid?Z:c)+1),_);return{x:q+(a+1-(c+1)/2)*d,y:U+(r+1-(O+1)/2)*G}}(e),u,$[t.direction])})),this};var Ru={fit:!0,padding:30,boundingBox:void 0,avoidOverlap:!0,nodeDimensionsIncludeLabels:!1,spacingFactor:void 0,radius:void 0,startAngle:1.5*Math.PI,sweep:void 0,clockwise:!0,sort:void 0,animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:function(e,t){return!0},ready:void 0,stop:void 0,transform:function(e,t){return t}};function Iu(e){this.options=ye({},Ru,e)}Iu.prototype.run=function(){var e=this.options,t=e,n=e.cy,r=t.eles,a=void 0!==t.counterclockwise?!t.counterclockwise:t.clockwise,i=r.nodes().not(":parent");t.sort&&(i=i.sort(t.sort));for(var o,s=tn(t.boundingBox?t.boundingBox:{x1:0,y1:0,w:n.width(),h:n.height()}),l=s.x1+s.w/2,u=s.y1+s.h/2,c=(void 0===t.sweep?2*Math.PI-2*Math.PI/i.length:t.sweep)/Math.max(1,i.length-1),d=0,h=0;h1&&t.avoidOverlap){d*=1.75;var v=Math.cos(c)-Math.cos(0),y=Math.sin(c)-Math.sin(0),m=Math.sqrt(d*d/(v*v+y*y));o=Math.max(m,o)}return r.nodes().layoutPositions(this,t,(function(e,n){var r=t.startAngle+n*c*(a?1:-1),i=o*Math.cos(r),s=o*Math.sin(r);return{x:l+i,y:u+s}})),this};var Nu,Lu={fit:!0,padding:30,startAngle:1.5*Math.PI,sweep:void 0,clockwise:!0,equidistant:!1,minNodeSpacing:10,boundingBox:void 0,avoidOverlap:!0,nodeDimensionsIncludeLabels:!1,height:void 0,width:void 0,spacingFactor:void 0,concentric:function(e){return e.degree()},levelWidth:function(e){return e.maxDegree()/4},animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:function(e,t){return!0},ready:void 0,stop:void 0,transform:function(e,t){return t}};function zu(e){this.options=ye({},Lu,e)}zu.prototype.run=function(){for(var e=this.options,t=e,n=void 0!==t.counterclockwise?!t.counterclockwise:t.clockwise,r=e.cy,a=t.eles,i=a.nodes().not(":parent"),o=tn(t.boundingBox?t.boundingBox:{x1:0,y1:0,w:r.width(),h:r.height()}),s=o.x1+o.w/2,l=o.y1+o.h/2,u=[],c=0,d=0;d0)Math.abs(m[0].value-x.value)>=v&&(m=[],y.push(m));m.push(x)}var w=c+t.minNodeSpacing;if(!t.avoidOverlap){var E=y.length>0&&y[0].length>1,k=(Math.min(o.w,o.h)/2-w)/(y.length+E?1:0);w=Math.min(w,k)}for(var T=0,C=0;C1&&t.avoidOverlap){var D=Math.cos(B)-Math.cos(0),_=Math.sin(B)-Math.sin(0),A=Math.sqrt(w*w/(D*D+_*_));T=Math.max(A,T)}P.r=T,T+=w}if(t.equidistant){for(var M=0,R=0,I=0;I=e.numIter)&&(Uu(r,e),r.temperature=r.temperature*e.coolingFactor,!(r.temperature=e.animationThreshold&&i(),Ne(c)):(ac(r,e),s())};c()}else{for(;u;)u=o(l),l++;ac(r,e),s()}return this},Vu.prototype.stop=function(){return this.stopped=!0,this.thread&&this.thread.stop(),this.emit("layoutstop"),this},Vu.prototype.destroy=function(){return this.thread&&this.thread.stop(),this};var Fu=function(e,t,n){for(var r=n.eles.edges(),a=n.eles.nodes(),i=tn(n.boundingBox?n.boundingBox:{x1:0,y1:0,w:e.width(),h:e.height()}),o={isCompound:e.hasCompoundNodes(),layoutNodes:[],idToIndex:{},nodeSize:a.size(),graphSet:[],indexToGraph:[],layoutEdges:[],edgeSize:r.size(),temperature:n.initialTemp,clientWidth:i.w,clientHeight:i.h,boundingBox:i},s=n.eles.components(),l={},u=0;u0){o.graphSet.push(w);for(u=0;ur.count?0:r.graph},ju=function(e,t,n,r){var a=r.graphSet[n];if(-10)var s=(u=r.nodeOverlap*o)*a/(g=Math.sqrt(a*a+i*i)),l=u*i/g;else{var u,c=$u(e,a,i),d=$u(t,-1*a,-1*i),h=d.x-c.x,f=d.y-c.y,p=h*h+f*f,g=Math.sqrt(p);s=(u=(e.nodeRepulsion+t.nodeRepulsion)/p)*h/g,l=u*f/g}e.isLocked||(e.offsetX-=s,e.offsetY-=l),t.isLocked||(t.offsetX+=s,t.offsetY+=l)}},Zu=function(e,t,n,r){if(n>0)var a=e.maxX-t.minX;else a=t.maxX-e.minX;if(r>0)var i=e.maxY-t.minY;else i=t.maxY-e.minY;return a>=0&&i>=0?Math.sqrt(a*a+i*i):0},$u=function(e,t,n){var r=e.positionX,a=e.positionY,i=e.height||1,o=e.width||1,s=n/t,l=i/o,u={};return 0===t&&0n?(u.x=r,u.y=a+i/2,u):0t&&-1*l<=s&&s<=l?(u.x=r-o/2,u.y=a-o*n/2/t,u):0=l)?(u.x=r+i*t/2/n,u.y=a+i/2,u):0>n&&(s<=-1*l||s>=l)?(u.x=r-i*t/2/n,u.y=a-i/2,u):u},Qu=function(e,t){for(var n=0;n1){var p=t.gravity*d/f,g=t.gravity*h/f;c.offsetX+=p,c.offsetY+=g}}}}},ec=function(e,t){var n=[],r=0,a=-1;for(n.push.apply(n,e.graphSet[0]),a+=e.graphSet[0].length;r<=a;){var i=n[r++],o=e.idToIndex[i],s=e.layoutNodes[o],l=s.children;if(0n)var a={x:n*e/r,y:n*t/r};else a={x:e,y:t};return a},rc=function(e,t){var n=e.parentId;if(null!=n){var r=t.layoutNodes[t.idToIndex[n]],a=!1;return(null==r.maxX||e.maxX+r.padRight>r.maxX)&&(r.maxX=e.maxX+r.padRight,a=!0),(null==r.minX||e.minX-r.padLeftr.maxY)&&(r.maxY=e.maxY+r.padBottom,a=!0),(null==r.minY||e.minY-r.padTopp&&(d+=f+t.componentSpacing,c=0,h=0,f=0)}}},ic={fit:!0,padding:30,boundingBox:void 0,avoidOverlap:!0,avoidOverlapPadding:10,nodeDimensionsIncludeLabels:!1,spacingFactor:void 0,condense:!1,rows:void 0,cols:void 0,position:function(e){},sort:void 0,animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:function(e,t){return!0},ready:void 0,stop:void 0,transform:function(e,t){return t}};function oc(e){this.options=ye({},ic,e)}oc.prototype.run=function(){var e=this.options,t=e,n=e.cy,r=t.eles,a=r.nodes().not(":parent");t.sort&&(a=a.sort(t.sort));var i=tn(t.boundingBox?t.boundingBox:{x1:0,y1:0,w:n.width(),h:n.height()});if(0===i.h||0===i.w)r.nodes().layoutPositions(this,t,(function(e){return{x:i.x1,y:i.y1}}));else{var o=a.size(),s=Math.sqrt(o*i.h/i.w),l=Math.round(s),u=Math.round(i.w/i.h*s),c=function(e){if(null==e)return Math.min(l,u);Math.min(l,u)==l?l=e:u=e},d=function(e){if(null==e)return Math.max(l,u);Math.max(l,u)==l?l=e:u=e},h=t.rows,f=null!=t.cols?t.cols:t.columns;if(null!=h&&null!=f)l=h,u=f;else if(null!=h&&null==f)l=h,u=Math.ceil(o/l);else if(null==h&&null!=f)u=f,l=Math.ceil(o/u);else if(u*l>o){var p=c(),g=d();(p-1)*g>=o?c(p-1):(g-1)*p>=o&&d(g-1)}else for(;u*l=o?d(y+1):c(v+1)}var m=i.w/u,b=i.h/l;if(t.condense&&(m=0,b=0),t.avoidOverlap)for(var x=0;x=u&&(A=0,_++)},R={},I=0;I(r=yn(e,t,x[w],x[w+1],x[w+2],x[w+3])))return v(n,r),!0}else if("bezier"===i.edgeType||"multibezier"===i.edgeType||"self"===i.edgeType||"compound"===i.edgeType)for(x=i.allpts,w=0;w+5(r=vn(e,t,x[w],x[w+1],x[w+2],x[w+3],x[w+4],x[w+5])))return v(n,r),!0;m=m||a.source,b=b||a.target;var E=o.getArrowWidth(l,c),k=[{name:"source",x:i.arrowStartX,y:i.arrowStartY,angle:i.srcArrowAngle},{name:"target",x:i.arrowEndX,y:i.arrowEndY,angle:i.tgtArrowAngle},{name:"mid-source",x:i.midX,y:i.midY,angle:i.midsrcArrowAngle},{name:"mid-target",x:i.midX,y:i.midY,angle:i.midtgtArrowAngle}];for(w=0;w0&&(y(m),y(b))}function b(e,t,n){return ft(e,t,n)}function x(n,r){var a,i=n._private,o=p;a=r?r+"-":"",n.boundingBox();var s=i.labelBounds[r||"main"],l=n.pstyle(a+"label").value;if("yes"===n.pstyle("text-events").strValue&&l){var u=b(i.rscratch,"labelX",r),c=b(i.rscratch,"labelY",r),d=b(i.rscratch,"labelAngle",r),h=n.pstyle(a+"text-margin-x").pfValue,f=n.pstyle(a+"text-margin-y").pfValue,g=s.x1-o-h,y=s.x2+o-h,m=s.y1-o-f,x=s.y2+o-f;if(d){var w=Math.cos(d),E=Math.sin(d),k=function(e,t){return{x:(e-=u)*w-(t-=c)*E+u,y:e*E+t*w+c}},T=k(g,m),C=k(g,x),P=k(y,m),S=k(y,x),B=[T.x+h,T.y+f,P.x+h,P.y+f,S.x+h,S.y+f,C.x+h,C.y+f];if(mn(e,t,B))return v(n),!0}else if(ln(s,e,t))return v(n),!0}}n&&(l=l.interactive);for(var w=l.length-1;w>=0;w--){var E=l[w];E.isNode()?y(E)||x(E):m(E)||x(E)||x(E,"source")||x(E,"target")}return u},getAllInBox:function(e,t,n,r){var a=this.getCachedZSortedEles().interactive,o=2/this.cy.zoom(),s=[],l=Math.min(e,n),u=Math.max(e,n),c=Math.min(t,r),d=Math.max(t,r),h=tn({x1:e=l,y1:t=c,x2:n=u,y2:r=d}),f=[{x:h.x1,y:h.y1},{x:h.x2,y:h.y1},{x:h.x2,y:h.y2},{x:h.x1,y:h.y2}],p=[[f[0],f[1]],[f[1],f[2]],[f[2],f[3]],[f[3],f[0]]];function g(e,t,n){return ft(e,t,n)}function v(e,t){var n=e._private,r=o;e.boundingBox();var a=n.labelBounds.main;if(!a)return null;var i=g(n.rscratch,"labelX",t),s=g(n.rscratch,"labelY",t),l=g(n.rscratch,"labelAngle",t),u=e.pstyle("text-margin-x").pfValue,c=e.pstyle("text-margin-y").pfValue,d=a.x1-r-u,h=a.x2+r-u,f=a.y1-r-c,p=a.y2+r-c;if(l){var v=Math.cos(l),y=Math.sin(l),m=function(e,t){return{x:(e-=i)*v-(t-=s)*y+i,y:e*y+t*v+s}};return[m(d,f),m(h,f),m(h,p),m(d,p)]}return[{x:d,y:f},{x:h,y:f},{x:h,y:p},{x:d,y:p}]}function y(e,t,n,r){function a(e,t,n){return(n.y-e.y)*(t.x-e.x)>(t.y-e.y)*(n.x-e.x)}return a(e,n,r)!==a(t,n,r)&&a(e,t,n)!==a(e,t,r)}for(var m=0;m0?-(Math.PI-i.ang):Math.PI+i.ang),Vc(t,n,Oc),Ec=zc.nx*Oc.ny-zc.ny*Oc.nx,kc=zc.nx*Oc.nx-zc.ny*-Oc.ny,Pc=Math.asin(Math.max(-1,Math.min(1,Ec))),Math.abs(Pc)<1e-6)return xc=t.x,wc=t.y,void(Bc=_c=0);Tc=1,Cc=!1,kc<0?Pc<0?Pc=Math.PI+Pc:(Pc=Math.PI-Pc,Tc=-1,Cc=!0):Pc>0&&(Tc=-1,Cc=!0),_c=void 0!==t.radius?t.radius:r,Sc=Pc/2,Ac=Math.min(zc.len/2,Oc.len/2),a?(Dc=Math.abs(Math.cos(Sc)*_c/Math.sin(Sc)))>Ac?(Dc=Ac,Bc=Math.abs(Dc*Math.sin(Sc)/Math.cos(Sc))):Bc=_c:(Dc=Math.min(Ac,_c),Bc=Math.abs(Dc*Math.sin(Sc)/Math.cos(Sc))),Ic=t.x+Oc.nx*Dc,Nc=t.y+Oc.ny*Dc,xc=Ic-Oc.ny*Bc*Tc,wc=Nc+Oc.nx*Bc*Tc,Mc=t.x+zc.nx*Dc,Rc=t.y+zc.ny*Dc,Lc=t};function Xc(e,t){0===t.radius?e.lineTo(t.cx,t.cy):e.arc(t.cx,t.cy,t.radius,t.startAngle,t.endAngle,t.counterClockwise)}function jc(e,t,n,r){var a=!(arguments.length>4&&void 0!==arguments[4])||arguments[4];return 0===r||0===t.radius?{cx:t.x,cy:t.y,radius:0,startX:t.x,startY:t.y,stopX:t.x,stopY:t.y,startAngle:void 0,endAngle:void 0,counterClockwise:void 0}:(Fc(e,t,n,r,a),{cx:xc,cy:wc,radius:Bc,startX:Mc,startY:Rc,stopX:Ic,stopY:Nc,startAngle:zc.ang+Math.PI/2*Tc,endAngle:Oc.ang-Math.PI/2*Tc,counterClockwise:Cc})}var Yc=.01,qc=Math.sqrt(.02),Wc={};function Uc(e){var t=[];if(null!=e){for(var n=0;n0?Math.max(e-t,0):Math.min(e+t,0)},S=P(T,E),B=P(C,k),D=!1;"auto"===v?g=Math.abs(S)>Math.abs(B)?a:r:v===l||v===s?(g=r,D=!0):v!==i&&v!==o||(g=a,D=!0);var _,A=g===r,M=A?B:S,R=A?C:T,I=Kt(R),N=!1;(D&&(m||x)||!(v===s&&R<0||v===l&&R>0||v===i&&R>0||v===o&&R<0)||(M=(I*=-1)*Math.abs(M),N=!0),m)?_=(b<0?1+b:b)*M:_=(b<0?M:0)+b*I;var L=function(e){return Math.abs(e)=Math.abs(M)},z=L(_),O=L(Math.abs(M)-Math.abs(_));if((z||O)&&!N)if(A){var V=Math.abs(R)<=d/2,F=Math.abs(T)<=h/2;if(V){var X=(u.x1+u.x2)/2,j=u.y1,Y=u.y2;n.segpts=[X,j,X,Y]}else if(F){var q=(u.y1+u.y2)/2,W=u.x1,U=u.x2;n.segpts=[W,q,U,q]}else n.segpts=[u.x1,u.y2]}else{var H=Math.abs(R)<=c/2,K=Math.abs(C)<=f/2;if(H){var G=(u.y1+u.y2)/2,Z=u.x1,$=u.x2;n.segpts=[Z,G,$,G]}else if(K){var Q=(u.x1+u.x2)/2,J=u.y1,ee=u.y2;n.segpts=[Q,J,Q,ee]}else n.segpts=[u.x2,u.y1]}else if(A){var te=u.y1+_+(p?d/2*I:0),ne=u.x1,re=u.x2;n.segpts=[ne,te,re,te]}else{var ae=u.x1+_+(p?c/2*I:0),ie=u.y1,oe=u.y2;n.segpts=[ae,ie,ae,oe]}if(n.isRound){var se=e.pstyle("taxi-radius").value,le="arc-radius"===e.pstyle("radius-type").value[0];n.radii=new Array(n.segpts.length/2).fill(se),n.isArcRadius=new Array(n.segpts.length/2).fill(le)}},Wc.tryToCorrectInvalidPoints=function(e,t){var n=e._private.rscratch;if("bezier"===n.edgeType){var r=t.srcPos,a=t.tgtPos,i=t.srcW,o=t.srcH,s=t.tgtW,l=t.tgtH,u=t.srcShape,c=t.tgtShape,d=t.srcCornerRadius,h=t.tgtCornerRadius,f=t.srcRs,p=t.tgtRs,g=!G(n.startX)||!G(n.startY),v=!G(n.arrowStartX)||!G(n.arrowStartY),y=!G(n.endX)||!G(n.endY),m=!G(n.arrowEndX)||!G(n.arrowEndY),b=3*(this.getArrowWidth(e.pstyle("width").pfValue,e.pstyle("arrow-scale").value)*this.arrowShapeWidth),x=Gt({x:n.ctrlpts[0],y:n.ctrlpts[1]},{x:n.startX,y:n.startY}),w=xg.poolIndex()){var v=p;p=g,g=v}var y=d.srcPos=p.position(),m=d.tgtPos=g.position(),b=d.srcW=p.outerWidth(),x=d.srcH=p.outerHeight(),E=d.tgtW=g.outerWidth(),k=d.tgtH=g.outerHeight(),T=d.srcShape=n.nodeShapes[t.getNodeShape(p)],C=d.tgtShape=n.nodeShapes[t.getNodeShape(g)],P=d.srcCornerRadius="auto"===p.pstyle("corner-radius").value?"auto":p.pstyle("corner-radius").pfValue,S=d.tgtCornerRadius="auto"===g.pstyle("corner-radius").value?"auto":g.pstyle("corner-radius").pfValue,B=d.tgtRs=g._private.rscratch,D=d.srcRs=p._private.rscratch;d.dirCounts={north:0,west:0,south:0,east:0,northwest:0,southwest:0,northeast:0,southeast:0};for(var _=0;_=qc||(q=Math.sqrt(Math.max(Y*Y,Yc)+Math.max(j*j,Yc)));var W=d.vector={x:Y,y:j},U=d.vectorNorm={x:W.x/q,y:W.y/q},H={x:-U.y,y:U.x};d.nodesOverlap=!G(q)||C.checkPoint(L[0],L[1],0,E,k,m.x,m.y,S,B)||T.checkPoint(O[0],O[1],0,b,x,y.x,y.y,P,D),d.vectorNormInverse=H,e={nodesOverlap:d.nodesOverlap,dirCounts:d.dirCounts,calculatedIntersection:!0,hasBezier:d.hasBezier,hasUnbundled:d.hasUnbundled,eles:d.eles,srcPos:m,srcRs:B,tgtPos:y,tgtRs:D,srcW:E,srcH:k,tgtW:b,tgtH:x,srcIntn:V,tgtIntn:z,srcShape:C,tgtShape:T,posPts:{x1:X.x2,y1:X.y2,x2:X.x1,y2:X.y1},intersectionPts:{x1:F.x2,y1:F.y2,x2:F.x1,y2:F.y1},vector:{x:-W.x,y:-W.y},vectorNorm:{x:-U.x,y:-U.y},vectorNormInverse:{x:-H.x,y:-H.y}}}var K=N?e:d;M.nodesOverlap=K.nodesOverlap,M.srcIntn=K.srcIntn,M.tgtIntn=K.tgtIntn,M.isRound=R.startsWith("round"),r&&(p.isParent()||p.isChild()||g.isParent()||g.isChild())&&(p.parents().anySame(g)||g.parents().anySame(p)||p.same(g)&&p.isParent())?t.findCompoundLoopPoints(A,K,_,I):p===g?t.findLoopPoints(A,K,_,I):R.endsWith("segments")?t.findSegmentsPoints(A,K):R.endsWith("taxi")?t.findTaxiPoints(A,K):"straight"===R||!I&&d.eles.length%2==1&&_===Math.floor(d.eles.length/2)?t.findStraightEdgePoints(A):t.findBezierPoints(A,K,_,I,N),t.findEndpoints(A),t.tryToCorrectInvalidPoints(A,K),t.checkForInvalidEdgeWarning(A),t.storeAllpts(A),t.storeEdgeProjections(A),t.calculateArrowAngles(A),t.recalculateEdgeLabelProjections(A),t.calculateLabelAngles(A)}},w=0;w0){var J=f,ee=Zt(J,qt(i)),te=Zt(J,qt(Q)),ne=ee;if(te2)Zt(J,{x:Q[2],y:Q[3]})0){var ve=p,ye=Zt(ve,qt(i)),me=Zt(ve,qt(ge)),be=ye;if(me2)Zt(ve,{x:ge[2],y:ge[3]})=u||m){c={cp:g,segment:y};break}}if(c)break}var b=c.cp,x=c.segment,w=(u-h)/x.length,E=x.t1-x.t0,k=s?x.t0+E*w:x.t1-E*w;k=en(0,k,1),t=Jt(b.p0,b.p1,b.p2,k),a=function(e,t,n,r){var a=en(0,r-.001,1),i=en(0,r+.001,1),o=Jt(e,t,n,a),s=Jt(e,t,n,i);return Qc(o,s)}(b.p0,b.p1,b.p2,k);break;case"straight":case"segments":case"haystack":for(var T,C,P,S,B=0,D=r.allpts.length,_=0;_+3=u));_+=2);var A=(u-C)/T;A=en(0,A,1),t=function(e,t,n,r){var a=t.x-e.x,i=t.y-e.y,o=Gt(e,t),s=a/o,l=i/o;return n=null==n?0:n,r=null!=r?r:n*o,{x:e.x+s*r,y:e.y+l*r}}(P,S,A),a=Qc(P,S)}o("labelX",n,t.x),o("labelY",n,t.y),o("labelAutoAngle",n,a)}};u("source"),u("target"),this.applyLabelDimensions(e)}},Zc.applyLabelDimensions=function(e){this.applyPrefixedLabelDimensions(e),e.isEdge()&&(this.applyPrefixedLabelDimensions(e,"source"),this.applyPrefixedLabelDimensions(e,"target"))},Zc.applyPrefixedLabelDimensions=function(e,t){var n=e._private,r=this.getLabelText(e,t),a=We(r,e._private.labelDimsKey);if(ft(n.rscratch,"prefixedLabelDimsKey",t)!==a){pt(n.rscratch,"prefixedLabelDimsKey",t,a);var i=this.calculateLabelDimensions(e,r),o=e.pstyle("line-height").pfValue,s=e.pstyle("font-size").pfValue,l=e.pstyle("text-wrap").strValue,u=ft(n.rscratch,"labelWrapCachedLines",t)||[],c="wrap"!==l?1:Math.max(u.length,1),d=s*o,h=i.width,f=i.height+(c-1)*(o-1)*s;pt(n.rstyle,"labelWidth",t,h),pt(n.rscratch,"labelWidth",t,h),pt(n.rstyle,"labelHeight",t,f),pt(n.rscratch,"labelHeight",t,f),pt(n.rscratch,"labelLineHeight",t,d),pt(n.rscratch,"labelActualDescent",t,i.labelActualDescent)}},Zc.getLabelText=function(e,t){var n=e._private,a=t?t+"-":"",i=e.pstyle(a+"label").strValue,o=e.pstyle("text-transform").value,s=function(e,r){return r?(pt(n.rscratch,e,t,r),r):ft(n.rscratch,e,t)};if(!i)return"";"none"==o||("uppercase"==o?i=i.toUpperCase():"lowercase"==o&&(i=i.toLowerCase()));var l=e.pstyle("text-wrap").value;if("wrap"===l){var u=s("labelKey");if(null!=u&&s("labelWrapKey")===u)return s("labelWrapCachedText");for(var c=i.split("\n"),d=e.pstyle("text-max-width").pfValue,h="anywhere"===e.pstyle("text-overflow-wrap").value,f=[],p=/[\s\u200b]+|$/g,g=0;gd){var b,x="",w=0,E=r(v.matchAll(p));try{for(E.s();!(b=E.n()).done;){var k=b.value,T=k[0],C=v.substring(w,k.index);w=k.index+T.length;var P=0===x.length?C:x+C+T;this.calculateLabelDimensions(e,P).width<=d?x+=C+T:(x&&f.push(x),x=C+T)}}catch(e){E.e(e)}finally{E.f()}x.match(/^[\s\u200b]+$/)||f.push(x)}else f.push(v)}s("labelWrapCachedLines",f),i=s("labelWrapCachedText",f.join("\n")),s("labelWrapKey",u)}else if("ellipsis"===l){var S=e.pstyle("text-max-width").pfValue,B="",D=!1;if(this.calculateLabelDimensions(e,i).widthS)break;B+=i[_],_===i.length-1&&(D=!0)}return D||(B+="…"),B}return i},Zc.getLabelJustification=function(e){var t=e.pstyle("text-justification").strValue,n=e.pstyle("text-halign").strValue;return"auto"===t?e.isNode()?function(e){switch(e){case"left":case"right-inside":return"right";case"right":case"left-inside":return"left";default:return"center"}}(n):"center":t},Zc.calculateLabelDimensions=function(e,t){var n=this.cy.window().document,r=e.pstyle("font-style").strValue,a=e.pstyle("font-size").pfValue,i=e.pstyle("font-family").strValue,o=e.pstyle("font-weight").strValue,s=e.pstyle("text-metrics").strValue||"font",l=this.labelCalcCanvas,u=this.labelCalcCanvasContext;if(!l){l=this.labelCalcCanvas=n.createElement("canvas"),u=this.labelCalcCanvasContext=l.getContext("2d");var c=l.style;c.position="absolute",c.left="-9999px",c.top="-9999px",c.zIndex="-1",c.visibility="hidden",c.pointerEvents="none"}u.font="".concat(r," ").concat(o," ").concat(a,"px ").concat(i);for(var d=0,h=0,f=t.split("\n"),p=f.length,g=0,v=0,y=0;y1&&void 0!==arguments[1])||arguments[1];if(t.merge(e),n)for(var r=0;r=e.desktopTapThreshold2}var P=a(t);v&&(e.hoverData.tapholdCancelled=!0);n=!0,r(g,["mousemove","vmousemove","tapdrag"],t,{x:c[0],y:c[1]});var S=function(e){return{originalEvent:t,type:e,position:{x:c[0],y:c[1]}}},B=function(){e.data.bgActivePosistion=void 0,e.hoverData.selecting||o.emit(S("boxstart")),p[4]=1,e.hoverData.selecting=!0,e.redrawHint("select",!0),e.redraw()};if(3===e.hoverData.which){if(v){var D=S("cxtdrag");b?b.emit(D):o.emit(D),e.hoverData.cxtDragged=!0,e.hoverData.cxtOver&&g===e.hoverData.cxtOver||(e.hoverData.cxtOver&&e.hoverData.cxtOver.emit(S("cxtdragout")),e.hoverData.cxtOver=g,g&&g.emit(S("cxtdragover")))}}else if(e.hoverData.dragging){if(n=!0,o.panningEnabled()&&o.userPanningEnabled()){var _;if(e.hoverData.justStartedPan){var A=e.hoverData.mdownPos;_={x:(c[0]-A[0])*s,y:(c[1]-A[1])*s},e.hoverData.justStartedPan=!1}else _={x:x[0]*s,y:x[1]*s};o.panBy(_),o.emit(S("dragpan")),e.hoverData.dragged=!0}c=e.projectIntoViewport(t.clientX,t.clientY)}else if(1!=p[4]||null!=b&&!b.pannable()){if(b&&b.pannable()&&b.active()&&b.unactivate(),b&&b.grabbed()||g==y||(y&&r(y,["mouseout","tapdragout"],t,{x:c[0],y:c[1]}),g&&r(g,["mouseover","tapdragover"],t,{x:c[0],y:c[1]}),e.hoverData.last=g),b)if(v){if(o.boxSelectionEnabled()&&P)b&&b.grabbed()&&(d(w),b.emit(S("freeon")),w.emit(S("free")),e.dragData.didDrag&&(b.emit(S("dragfreeon")),w.emit(S("dragfree")))),B();else if(b&&b.grabbed()&&e.nodeIsDraggable(b)){var M=!e.dragData.didDrag;M&&e.redrawHint("eles",!0),e.dragData.didDrag=!0,e.hoverData.draggingEles||u(w,{inDragLayer:!0});var R={x:0,y:0};if(G(x[0])&&G(x[1])&&(R.x+=x[0],R.y+=x[1],M)){var I=e.hoverData.dragDelta;I&&G(I[0])&&G(I[1])&&(R.x+=I[0],R.y+=I[1])}e.hoverData.draggingEles=!0,w.silentShift(R).emit(S("position")).emit(S("drag")),e.redrawHint("drag",!0),e.redraw()}}else!function(){var t=e.hoverData.dragDelta=e.hoverData.dragDelta||[];0===t.length?(t.push(x[0]),t.push(x[1])):(t[0]+=x[0],t[1]+=x[1])}();n=!0}else if(v){if(e.hoverData.dragging||!o.boxSelectionEnabled()||!P&&o.panningEnabled()&&o.userPanningEnabled()){if(!e.hoverData.selecting&&o.panningEnabled()&&o.userPanningEnabled()){i(b,e.hoverData.downs)&&(e.hoverData.dragging=!0,e.hoverData.justStartedPan=!0,p[4]=0,e.data.bgActivePosistion=qt(h),e.redrawHint("select",!0),e.redraw())}}else B();b&&b.pannable()&&b.active()&&b.unactivate()}return p[2]=c[0],p[3]=c[1],n?(t.stopPropagation&&t.stopPropagation(),t.preventDefault&&t.preventDefault(),!1):void 0}}),!1),e.registerBinding(t,"mouseup",(function(t){if((1!==e.hoverData.which||1===t.which||!e.hoverData.capture)&&e.hoverData.capture){e.hoverData.capture=!1;var i=e.cy,o=e.projectIntoViewport(t.clientX,t.clientY),s=e.selection,l=e.findNearestElement(o[0],o[1],!0,!1),u=e.dragData.possibleDragElements,c=e.hoverData.down,h=a(t);e.data.bgActivePosistion&&(e.redrawHint("select",!0),e.redraw()),e.hoverData.tapholdCancelled=!0,e.data.bgActivePosistion=void 0,c&&c.unactivate();var f=function(e){return{originalEvent:t,type:e,position:{x:o[0],y:o[1]}}};if(3===e.hoverData.which){var p=f("cxttapend");if(c?c.emit(p):i.emit(p),!e.hoverData.cxtDragged){var g=f("cxttap");c?c.emit(g):i.emit(g)}e.hoverData.cxtDragged=!1,e.hoverData.which=null}else if(1===e.hoverData.which){if(r(l,["mouseup","tapend","vmouseup"],t,{x:o[0],y:o[1]}),e.dragData.didDrag||e.hoverData.dragged||e.hoverData.selecting||e.hoverData.isOverThresholdDrag||(r(c,["click","tap","vclick"],t,{x:o[0],y:o[1]}),x=!1,t.timeStamp-w<=i.multiClickDebounceTime()?(b&&clearTimeout(b),x=!0,w=null,r(c,["dblclick","dbltap","vdblclick"],t,{x:o[0],y:o[1]})):(b=setTimeout((function(){x||r(c,["oneclick","onetap","voneclick"],t,{x:o[0],y:o[1]})}),i.multiClickDebounceTime()),w=t.timeStamp)),null!=c||e.dragData.didDrag||e.hoverData.selecting||e.hoverData.dragged||a(t)||(i.$(n).unselect(["tapunselect"]),u.length>0&&e.redrawHint("eles",!0),e.dragData.possibleDragElements=u=i.collection()),l!=c||e.dragData.didDrag||e.hoverData.selecting||null!=l&&l._private.selectable&&(e.hoverData.dragging||("additive"===i.selectionType()||h?l.selected()?l.unselect(["tapunselect"]):l.select(["tapselect"]):h||(i.$(n).unmerge(l).unselect(["tapunselect"]),l.select(["tapselect"]))),e.redrawHint("eles",!0)),e.hoverData.selecting){var v=i.collection(e.getAllInBox(s[0],s[1],s[2],s[3]));e.redrawHint("select",!0),v.length>0&&e.redrawHint("eles",!0),i.emit(f("boxend"));var y=function(e){return e.selectable()&&!e.selected()};"additive"===i.selectionType()||h||i.$(n).unmerge(v).unselect(),v.emit(f("box")).stdFilter(y).select().emit(f("boxselect")),e.redraw()}if(e.hoverData.dragging&&(e.hoverData.dragging=!1,e.redrawHint("select",!0),e.redrawHint("eles",!0),e.redraw()),!s[4]){e.redrawHint("drag",!0),e.redrawHint("eles",!0);var m=c&&c.grabbed();d(u),m&&(c.emit(f("freeon")),u.emit(f("free")),e.dragData.didDrag&&(c.emit(f("dragfreeon")),u.emit(f("dragfree"))))}}s[4]=0,e.hoverData.down=null,e.hoverData.cxtStarted=!1,e.hoverData.draggingEles=!1,e.hoverData.selecting=!1,e.hoverData.isOverThresholdDrag=!1,e.dragData.didDrag=!1,e.hoverData.dragged=!1,e.hoverData.dragDelta=[],e.hoverData.mdownPos=null,e.hoverData.mdownGPos=null,e.hoverData.which=null}}),!1);var k,T,C,P,S,B,D,_,A,M,R,I,N,L,z=[],O=1e5,V=function(t){var n=!1,r=t.deltaY;if(null==r&&(null!=t.wheelDeltaY?r=t.wheelDeltaY/4:null!=t.wheelDelta&&(r=t.wheelDelta/4)),0!==r){if(null==k)if(z.length>=4){k=!1;var a,i=z;if(i[0]>=5)a=function(e){for(var t=Math.abs(e[0]),n=1;n0&&(t=Wt(t,e[n]))}return t}(i),a>1&&(k=!0,O=a)}else z.push(Math.abs(r)),n=!0;else k&&(O=Math.min(Math.abs(r),O));if(!e.scrollingPage){var o=e.cy,s=o.zoom(),l=o.pan(),u=e.projectIntoViewport(t.clientX,t.clientY),c=[u[0]*s+l.x,u[1]*s+l.y];if(e.hoverData.draggingEles||e.hoverData.dragging||e.hoverData.cxtStarted||0!==e.selection[4])t.preventDefault();else if(o.panningEnabled()&&o.userPanningEnabled()&&o.zoomingEnabled()&&o.userZoomingEnabled()){var d;t.preventDefault(),e.data.wheelZooming=!0,clearTimeout(e.data.wheelTimeout),e.data.wheelTimeout=setTimeout((function(){e.data.wheelZooming=!1,e.redrawHint("eles",!0),e.redraw()}),150),n&&Math.abs(r)>5&&(r=5*Kt(r)),d=r/-250,k&&(d/=O,d*=3),d*=e.wheelSensitivity,1===t.deltaMode&&(d*=33);var h=o.zoom()*Math.pow(10,d);"gesturechange"===t.type&&(h=e.gestureStartZoom*t.scale),o.zoom({level:h,renderedPosition:{x:c[0],y:c[1]}}),o.emit({type:"gesturechange"===t.type?"pinchzoom":"scrollzoom",originalEvent:t,position:{x:u[0],y:u[1]}})}}}};e.registerBinding(e.container,"wheel",V,!0),e.registerBinding(t,"scroll",(function(t){e.scrollingPage=!0,clearTimeout(e.scrollingPageTimeout),e.scrollingPageTimeout=setTimeout((function(){e.scrollingPage=!1}),250)}),!0),e.registerBinding(e.container,"gesturestart",(function(t){e.gestureStartZoom=e.cy.zoom(),e.hasTouchStarted||t.preventDefault()}),!0),e.registerBinding(e.container,"gesturechange",(function(t){e.hasTouchStarted||V(t)}),!0),e.registerBinding(e.container,"mouseout",(function(t){var n=e.projectIntoViewport(t.clientX,t.clientY);e.cy.emit({originalEvent:t,type:"mouseout",position:{x:n[0],y:n[1]}})}),!1),e.registerBinding(e.container,"mouseover",(function(t){var n=e.projectIntoViewport(t.clientX,t.clientY);e.cy.emit({originalEvent:t,type:"mouseover",position:{x:n[0],y:n[1]}})}),!1);var F,X,j,Y,q,W,U,H=function(e,t,n,r){return Math.sqrt((n-e)*(n-e)+(r-t)*(r-t))},K=function(e,t,n,r){return(n-e)*(n-e)+(r-t)*(r-t)};if(e.registerBinding(e.container,"touchstart",F=function(t){if(e.hasTouchStarted=!0,m(t)){f(),e.touchData.capture=!0,e.data.bgActivePosistion=void 0;var n=e.cy,a=e.touchData.now,i=e.touchData.earlier;if(t.touches[0]){var o=e.projectIntoViewport(t.touches[0].clientX,t.touches[0].clientY);a[0]=o[0],a[1]=o[1]}if(t.touches[1]){o=e.projectIntoViewport(t.touches[1].clientX,t.touches[1].clientY);a[2]=o[0],a[3]=o[1]}if(t.touches[2]){o=e.projectIntoViewport(t.touches[2].clientX,t.touches[2].clientY);a[4]=o[0],a[5]=o[1]}var l=function(e){return{originalEvent:t,type:e,position:{x:a[0],y:a[1]}}};if(t.touches[1]){e.touchData.singleTouchMoved=!0,d(e.dragData.touchDragEles);var h=e.findContainerClientCoords();M=h[0],R=h[1],I=h[2],N=h[3],T=t.touches[0].clientX-M,C=t.touches[0].clientY-R,P=t.touches[1].clientX-M,S=t.touches[1].clientY-R,L=0<=T&&T<=I&&0<=P&&P<=I&&0<=C&&C<=N&&0<=S&&S<=N;var p=n.pan(),g=n.zoom();B=H(T,C,P,S),D=K(T,C,P,S),A=[((_=[(T+P)/2,(C+S)/2])[0]-p.x)/g,(_[1]-p.y)/g];if(D<4e4&&!t.touches[2]){var v=e.findNearestElement(a[0],a[1],!0,!0),y=e.findNearestElement(a[2],a[3],!0,!0);return v&&v.isNode()?(v.activate().emit(l("cxttapstart")),e.touchData.start=v):y&&y.isNode()?(y.activate().emit(l("cxttapstart")),e.touchData.start=y):n.emit(l("cxttapstart")),e.touchData.start&&(e.touchData.start._private.grabbed=!1),e.touchData.cxt=!0,e.touchData.cxtDragged=!1,e.data.bgActivePosistion=void 0,void e.redraw()}}if(t.touches[2])n.boxSelectionEnabled()&&t.preventDefault();else if(t.touches[1]);else if(t.touches[0]){var b=e.findNearestElements(a[0],a[1],!0,!0),x=b[0];if(null!=x&&(x.activate(),e.touchData.start=x,e.touchData.starts=b,e.nodeIsGrabbable(x))){var w=e.dragData.touchDragEles=n.collection(),E=null;e.redrawHint("eles",!0),e.redrawHint("drag",!0),x.selected()?(E=n.$((function(t){return t.selected()&&e.nodeIsGrabbable(t)})),u(E,{addToList:w})):c(x,{addToList:w}),s(x),x.emit(l("grabon")),E?E.forEach((function(e){e.emit(l("grab"))})):x.emit(l("grab"))}r(x,["touchstart","tapstart","vmousedown"],t,{x:a[0],y:a[1]}),null==x&&(e.data.bgActivePosistion={x:o[0],y:o[1]},e.redrawHint("select",!0),e.redraw()),e.touchData.singleTouchMoved=!1,e.touchData.singleTouchStartTime=+new Date,clearTimeout(e.touchData.tapholdTimeout),e.touchData.tapholdTimeout=setTimeout((function(){!1!==e.touchData.singleTouchMoved||e.pinching||e.touchData.selecting||r(e.touchData.start,["taphold"],t,{x:a[0],y:a[1]})}),e.tapholdDuration)}if(t.touches.length>=1){for(var k=e.touchData.startPosition=[null,null,null,null,null,null],z=0;z=e.touchTapThreshold2}if(n&&e.touchData.cxt){t.preventDefault();var E=t.touches[0].clientX-M,k=t.touches[0].clientY-R,_=t.touches[1].clientX-M,I=t.touches[1].clientY-R,N=K(E,k,_,I);if(N/D>=2.25||N>=22500){e.touchData.cxt=!1,e.data.bgActivePosistion=void 0,e.redrawHint("select",!0);var z=p("cxttapend");e.touchData.start?(e.touchData.start.unactivate().emit(z),e.touchData.start=null):o.emit(z)}}if(n&&e.touchData.cxt){z=p("cxtdrag");e.data.bgActivePosistion=void 0,e.redrawHint("select",!0),e.touchData.start?e.touchData.start.emit(z):o.emit(z),e.touchData.start&&(e.touchData.start._private.grabbed=!1),e.touchData.cxtDragged=!0;var O=e.findNearestElement(s[0],s[1],!0,!0);e.touchData.cxtOver&&O===e.touchData.cxtOver||(e.touchData.cxtOver&&e.touchData.cxtOver.emit(p("cxtdragout")),e.touchData.cxtOver=O,O&&O.emit(p("cxtdragover")))}else if(n&&t.touches[2]&&o.boxSelectionEnabled())t.preventDefault(),e.data.bgActivePosistion=void 0,this.lastThreeTouch=+new Date,e.touchData.selecting||o.emit(p("boxstart")),e.touchData.selecting=!0,e.touchData.didSelect=!0,a[4]=1,a&&0!==a.length&&void 0!==a[0]?(a[2]=(s[0]+s[2]+s[4])/3,a[3]=(s[1]+s[3]+s[5])/3):(a[0]=(s[0]+s[2]+s[4])/3,a[1]=(s[1]+s[3]+s[5])/3,a[2]=(s[0]+s[2]+s[4])/3+1,a[3]=(s[1]+s[3]+s[5])/3+1),e.redrawHint("select",!0),e.redraw();else if(n&&t.touches[1]&&!e.touchData.didSelect&&o.zoomingEnabled()&&o.panningEnabled()&&o.userZoomingEnabled()&&o.userPanningEnabled()){if(t.preventDefault(),e.data.bgActivePosistion=void 0,e.redrawHint("select",!0),te=e.dragData.touchDragEles){e.redrawHint("drag",!0);for(var V=0;V0&&!e.hoverData.draggingEles&&!e.swipePanning&&null!=e.data.bgActivePosistion&&(e.data.bgActivePosistion=void 0,e.redrawHint("select",!0),e.redraw())}},!1),e.registerBinding(t,"touchcancel",j=function(t){var n=e.touchData.start;e.touchData.capture=!1,n&&n.unactivate()}),e.registerBinding(t,"touchend",Y=function(t){var a=e.touchData.start;if(e.touchData.capture){0===t.touches.length&&(e.touchData.capture=!1),t.preventDefault();var i=e.selection;e.swipePanning=!1,e.hoverData.draggingEles=!1;var o=e.cy,s=o.zoom(),l=e.touchData.now,u=e.touchData.earlier;if(t.touches[0]){var c=e.projectIntoViewport(t.touches[0].clientX,t.touches[0].clientY);l[0]=c[0],l[1]=c[1]}if(t.touches[1]){c=e.projectIntoViewport(t.touches[1].clientX,t.touches[1].clientY);l[2]=c[0],l[3]=c[1]}if(t.touches[2]){c=e.projectIntoViewport(t.touches[2].clientX,t.touches[2].clientY);l[4]=c[0],l[5]=c[1]}var h,f=function(e){return{originalEvent:t,type:e,position:{x:l[0],y:l[1]}}};if(a&&a.unactivate(),e.touchData.cxt){if(h=f("cxttapend"),a?a.emit(h):o.emit(h),!e.touchData.cxtDragged){var p=f("cxttap");a?a.emit(p):o.emit(p)}return e.touchData.start&&(e.touchData.start._private.grabbed=!1),e.touchData.cxt=!1,e.touchData.start=null,void e.redraw()}if(!t.touches[2]&&o.boxSelectionEnabled()&&e.touchData.selecting){e.touchData.selecting=!1;var g=o.collection(e.getAllInBox(i[0],i[1],i[2],i[3]));i[0]=void 0,i[1]=void 0,i[2]=void 0,i[3]=void 0,i[4]=0,e.redrawHint("select",!0),o.emit(f("boxend"));g.emit(f("box")).stdFilter((function(e){return e.selectable()&&!e.selected()})).select().emit(f("boxselect")),g.nonempty()&&e.redrawHint("eles",!0),e.redraw()}if(null!=a&&a.unactivate(),t.touches[2])e.data.bgActivePosistion=void 0,e.redrawHint("select",!0);else if(t.touches[1]);else if(t.touches[0]);else if(!t.touches[0]){e.data.bgActivePosistion=void 0,e.redrawHint("select",!0);var v=e.dragData.touchDragEles;if(null!=a){var y=a._private.grabbed;d(v),e.redrawHint("drag",!0),e.redrawHint("eles",!0),y&&(a.emit(f("freeon")),v.emit(f("free")),e.dragData.didDrag&&(a.emit(f("dragfreeon")),v.emit(f("dragfree")))),r(a,["touchend","tapend","vmouseup","tapdragout"],t,{x:l[0],y:l[1]}),a.unactivate(),e.touchData.start=null}else{var m=e.findNearestElement(l[0],l[1],!0,!0);r(m,["touchend","tapend","vmouseup","tapdragout"],t,{x:l[0],y:l[1]})}var b=e.touchData.startPosition[0]-l[0],x=b*b,w=e.touchData.startPosition[1]-l[1],E=(x+w*w)*s*s;e.touchData.singleTouchMoved||(a||o.$(":selected").unselect(["tapunselect"]),r(a,["tap","vclick"],t,{x:l[0],y:l[1]}),q=!1,t.timeStamp-U<=o.multiClickDebounceTime()?(W&&clearTimeout(W),q=!0,U=null,r(a,["dbltap","vdblclick"],t,{x:l[0],y:l[1]})):(W=setTimeout((function(){q||r(a,["onetap","voneclick"],t,{x:l[0],y:l[1]})}),o.multiClickDebounceTime()),U=t.timeStamp)),null!=a&&!e.dragData.didDrag&&a._private.selectable&&E2){for(var f=[c[0],c[1]],p=Math.pow(f[0]-e,2)+Math.pow(f[1]-t,2),g=1;g0)return g[0]}return null},f=Object.keys(d),p=0;p0?u:fn(a,i,e,t,n,r,o,s)},checkPoint:function(e,t,n,r,a,i,o,s){var l=2*(s="auto"===s?Mn(r,a):s);if(bn(e,t,this.points,i,o,r,a-l,[0,-1],n))return!0;if(bn(e,t,this.points,i,o,r-l,a,[0,-1],n))return!0;var u=r/2+2*n,c=a/2+2*n;return!!mn(e,t,[i-u,o-c,i-u,o,i+u,o,i+u,o-c])||(!!En(e,t,l,l,i+r/2-s,o+a/2-s,n)||!!En(e,t,l,l,i-r/2+s,o+a/2-s,n))}}},sd.registerNodeShapes=function(){var e=this.nodeShapes={},t=this;this.generateEllipse(),this.generatePolygon("triangle",Dn(3,0)),this.generateRoundPolygon("round-triangle",Dn(3,0)),this.generatePolygon("rectangle",Dn(4,0)),e.square=e.rectangle,this.generateRoundRectangle(),this.generateCutRectangle(),this.generateBarrel(),this.generateBottomRoundrectangle();var n=[0,1,1,0,0,-1,-1,0];this.generatePolygon("diamond",n),this.generateRoundPolygon("round-diamond",n),this.generatePolygon("pentagon",Dn(5,0)),this.generateRoundPolygon("round-pentagon",Dn(5,0)),this.generatePolygon("hexagon",Dn(6,0)),this.generateRoundPolygon("round-hexagon",Dn(6,0)),this.generatePolygon("heptagon",Dn(7,0)),this.generateRoundPolygon("round-heptagon",Dn(7,0)),this.generatePolygon("octagon",Dn(8,0)),this.generateRoundPolygon("round-octagon",Dn(8,0));var r=new Array(20),a=An(5,0),i=An(5,Math.PI/5),o=.5*(3-Math.sqrt(5));o*=1.57;for(var s=0;s=e.deqFastCost*g)break}else if(a){if(f>=e.deqCost*l||f>=e.deqAvgCost*s)break}else if(p>=e.deqNoDrawCost*hd)break;var v=e.deq(t,d,c);if(!(v.length>0))break;for(var y=0;y0&&(e.onDeqd(t,u),!a&&e.shouldRedraw(t,u,d,c)&&r())}),a(t))}}},pd=function(){return n((function e(n){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:et;t(this,e),this.idsByKey=new gt,this.keyForId=new gt,this.cachesByLvl=new gt,this.lvls=[],this.getKey=n,this.doesEleInvalidateKey=r}),[{key:"getIdsFor",value:function(e){null==e&&rt("Can not get id list for null key");var t=this.idsByKey,n=this.idsByKey.get(e);return n||(n=new yt,t.set(e,n)),n}},{key:"addIdForKey",value:function(e,t){null!=e&&this.getIdsFor(e).add(t)}},{key:"deleteIdForKey",value:function(e,t){null!=e&&this.getIdsFor(e).delete(t)}},{key:"getNumberOfIdsForKey",value:function(e){return null==e?0:this.getIdsFor(e).size}},{key:"updateKeyMappingFor",value:function(e){var t=e.id(),n=this.keyForId.get(t),r=this.getKey(e);this.deleteIdForKey(n,t),this.addIdForKey(r,t),this.keyForId.set(t,r)}},{key:"deleteKeyMappingFor",value:function(e){var t=e.id(),n=this.keyForId.get(t);this.deleteIdForKey(n,t),this.keyForId.delete(t)}},{key:"keyHasChangedFor",value:function(e){var t=e.id();return this.keyForId.get(t)!==this.getKey(e)}},{key:"isInvalid",value:function(e){return this.keyHasChangedFor(e)||this.doesEleInvalidateKey(e)}},{key:"getCachesAt",value:function(e){var t=this.cachesByLvl,n=this.lvls,r=t.get(e);return r||(r=new gt,t.set(e,r),n.push(e)),r}},{key:"getCache",value:function(e,t){return this.getCachesAt(t).get(e)}},{key:"get",value:function(e,t){var n=this.getKey(e),r=this.getCache(n,t);return null!=r&&this.updateKeyMappingFor(e),r}},{key:"getForCachedKey",value:function(e,t){var n=this.keyForId.get(e.id());return this.getCache(n,t)}},{key:"hasCache",value:function(e,t){return this.getCachesAt(t).has(e)}},{key:"has",value:function(e,t){var n=this.getKey(e);return this.hasCache(n,t)}},{key:"setCache",value:function(e,t,n){n.key=e,this.getCachesAt(t).set(e,n)}},{key:"set",value:function(e,t,n){var r=this.getKey(e);this.setCache(r,t,n),this.updateKeyMappingFor(e)}},{key:"deleteCache",value:function(e,t){this.getCachesAt(t).delete(e)}},{key:"delete",value:function(e,t){var n=this.getKey(e);this.deleteCache(n,t)}},{key:"invalidateKey",value:function(e){var t=this;this.lvls.forEach((function(n){return t.deleteCache(e,n)}))}},{key:"invalidate",value:function(e){var t=e.id(),n=this.keyForId.get(t);this.deleteKeyMappingFor(e);var r=this.doesEleInvalidateKey(e);return r&&this.invalidateKey(n),r||0===this.getNumberOfIdsForKey(n)}}])}(),gd=7.99,vd={dequeue:"dequeue",downscale:"downscale",highQuality:"highQuality"},yd=ct({getKey:null,doesEleInvalidateKey:et,drawElement:null,getBoundingBox:null,getRotationPoint:null,getRotationOffset:null,isVisible:Je,allowEdgeTxrCaching:!0,allowParentTxrCaching:!0}),md=function(e,t){var n=this;n.renderer=e,n.onDequeues=[];var r=yd(t);ye(n,r),n.lookup=new pd(r.getKey,r.doesEleInvalidateKey),n.setupDequeueing()},bd=md.prototype;bd.reasons=vd,bd.getTextureQueue=function(e){var t=this;return t.eleImgCaches=t.eleImgCaches||{},t.eleImgCaches[e]=t.eleImgCaches[e]||[]},bd.getRetiredTextureQueue=function(e){var t=this.eleImgCaches.retired=this.eleImgCaches.retired||{};return t[e]=t[e]||[]},bd.getElementQueue=function(){return this.eleCacheQueue=this.eleCacheQueue||new Bt((function(e,t){return t.reqs-e.reqs}))},bd.getElementKeyToQueue=function(){return this.eleKeyToCacheQueue=this.eleKeyToCacheQueue||{}},bd.getElement=function(e,t,n,r,a){var i=this,o=this.renderer,s=o.cy.zoom(),l=this.lookup;if(!t||0===t.w||0===t.h||isNaN(t.w)||isNaN(t.h)||!e.visible()||e.removed())return null;if(!i.allowEdgeTxrCaching&&e.isEdge()||!i.allowParentTxrCaching&&e.isParent())return null;if(null==r&&(r=Math.ceil(Ht(s*n))),r<-4)r=-4;else if(s>=7.99||r>3)return null;var u=Math.pow(2,r),c=t.h*u,d=t.w*u,h=o.eleTextBiggerThanMin(e,u);if(!this.isVisible(e,h))return null;var f,p=l.get(e,r);if(p&&p.invalidated&&(p.invalidated=!1,p.texture.invalidatedWidth-=p.width),p)return p;if(f=c<=25?25:c<=50?50:50*Math.ceil(c/50),c>1024||d>1024)return null;var g=i.getTextureQueue(f),v=g[g.length-2],y=function(){return i.recycleTexture(f,d)||i.addTexture(f,d)};v||(v=g[g.length-1]),v||(v=y()),v.width-v.usedWidthr;S--)C=i.getElement(e,t,n,S,vd.downscale);P()}else{var B;if(!x&&!w&&!E)for(var D=r-1;D>=-4;D--){var _=l.get(e,D);if(_){B=_;break}}if(b(B))return i.queueElement(e,r),B;v.context.translate(v.usedWidth,0),v.context.scale(u,u),this.drawElement(v.context,e,t,h,!1),v.context.scale(1/u,1/u),v.context.translate(-v.usedWidth,0)}return p={x:v.usedWidth,texture:v,level:r,scale:u,width:d,height:c,scaledLabelShown:h},v.usedWidth+=Math.ceil(d+8),v.eleCaches.push(p),l.set(e,r,p),i.checkTextureFullness(v),p},bd.invalidateElements=function(e){for(var t=0;t=.2*e.width&&this.retireTexture(e)},bd.checkTextureFullness=function(e){var t=this.getTextureQueue(e.height);e.usedWidth/e.width>.8&&e.fullnessChecks>=10?dt(t,e):e.fullnessChecks++},bd.retireTexture=function(e){var t=e.height,n=this.getTextureQueue(t),r=this.lookup;dt(n,e),e.retired=!0;for(var a=e.eleCaches,i=0;i=t)return i.retired=!1,i.usedWidth=0,i.invalidatedWidth=0,i.fullnessChecks=0,ht(i.eleCaches),i.context.setTransform(1,0,0,1,0,0),i.context.clearRect(0,0,i.width,i.height),dt(r,i),n.push(i),i}},bd.queueElement=function(e,t){var n=this.getElementQueue(),r=this.getElementKeyToQueue(),a=this.getKey(e),i=r[a];if(i)i.level=Math.max(i.level,t),i.eles.merge(e),i.reqs++,n.updateItem(i);else{var o={eles:e.spawn().merge(e),level:t,reqs:1,key:a};n.push(o),r[a]=o}},bd.dequeue=function(e){for(var t=this,n=t.getElementQueue(),r=t.getElementKeyToQueue(),a=[],i=t.lookup,o=0;o<1&&n.size()>0;o++){var s=n.pop(),l=s.key,u=s.eles[0],c=i.hasCache(u,s.level);if(r[l]=null,!c){a.push(s);var d=t.getBoundingBox(u);t.getElement(u,d,e,s.level,vd.dequeue)}}return a},bd.removeFromQueue=function(e){var t=this.getElementQueue(),n=this.getElementKeyToQueue(),r=this.getKey(e),a=n[r];null!=a&&(1===a.eles.length?(a.reqs=Qe,t.updateItem(a),t.pop(),n[r]=null):a.eles.unmerge(e))},bd.onDequeue=function(e){this.onDequeues.push(e)},bd.offDequeue=function(e){dt(this.onDequeues,e)},bd.setupDequeueing=fd({deqRedrawThreshold:100,deqCost:.15,deqAvgCost:.1,deqNoDrawCost:.9,deqFastCost:.9,deq:function(e,t,n){return e.dequeue(t,n)},onDeqd:function(e,t){for(var n=0;n=3.99||n>2)return null;r.validateLayersElesOrdering(n,e);var o,s,l=r.layersByLevel,u=Math.pow(2,n),c=l[n]=l[n]||[];if(r.levelIsComplete(n,e))return c;!function(){var t=function(t){if(r.validateLayersElesOrdering(t,e),r.levelIsComplete(t,e))return s=l[t],!0},a=function(e){if(!s)for(var r=n+e;-4<=r&&r<=2&&!t(r);r+=e);};a(1),a(-1);for(var i=c.length-1;i>=0;i--){var o=c[i];o.invalid&&dt(c,o)}}();var d=function(t){var a=(t=t||{}).after;!function(){if(!o){o=tn();for(var t=0;t32767||s>32767)return null;if(i*s>16e6)return null;var l=r.makeLayer(o,n);if(null!=a){var d=c.indexOf(a)+1;c.splice(d,0,l)}else(void 0===t.insert||t.insert)&&c.unshift(l);return l};if(r.skipping&&!i)return null;for(var h=null,f=e.length/1,p=!i,g=0;g=f||!cn(h.bb,v.boundingBox()))&&!(h=d({insert:!0,after:h})))return null;s||p?r.queueLayer(h,v):r.drawEleInLayer(h,v,n,t),h.eles.push(v),m[n]=h}}return s||(p?null:c)},wd.getEleLevelForLayerLevel=function(e,t){return e},wd.drawEleInLayer=function(e,t,n,r){var a=this.renderer,i=e.context,o=t.boundingBox();0!==o.w&&0!==o.h&&t.visible()&&(n=this.getEleLevelForLayerLevel(n,r),a.setImgSmoothing(i,!1),a.drawCachedElement(i,t,null,null,n,true),a.setImgSmoothing(i,!0))},wd.levelIsComplete=function(e,t){var n=this.layersByLevel[e];if(!n||0===n.length)return!1;for(var r=0,a=0;a0)return!1;if(i.invalid)return!1;r+=i.eles.length}return r===t.length},wd.validateLayersElesOrdering=function(e,t){var n=this.layersByLevel[e];if(n)for(var r=0;r0){e=!0;break}}return e},wd.invalidateElements=function(e){var t=this;0!==e.length&&(t.lastInvalidationTime=Le(),0!==e.length&&t.haveLayers()&&t.updateElementsInLayers(e,(function(e,n,r){t.invalidateLayer(e)})))},wd.invalidateLayer=function(e){if(this.lastInvalidationTime=Le(),!e.invalid){var t=e.level,n=e.eles,r=this.layersByLevel[t];dt(r,e),e.elesQueue=[],e.invalid=!0,e.replacement&&(e.replacement.invalid=!0);for(var a=0;a3&&void 0!==arguments[3])||arguments[3],a=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],i=!(arguments.length>5&&void 0!==arguments[5])||arguments[5],o=this,s=t._private.rscratch;if((!i||t.visible())&&!s.badLine&&null!=s.allpts&&!isNaN(s.allpts[0])){var l;n&&(l=n,e.translate(-l.x1,-l.y1));var u=i?t.pstyle("opacity").value:1,c=i?t.pstyle("line-opacity").value:1,d=t.pstyle("curve-style").value,h=t.pstyle("line-style").value,f=t.pstyle("width").pfValue,p=t.pstyle("line-cap").value,g=t.pstyle("line-outline-width").value,v=t.pstyle("line-outline-color").value,y=u*c,m=u*c,b=function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:y;"straight-triangle"===d?(o.eleStrokeStyle(e,t,n),o.drawEdgeTrianglePath(t,e,s.allpts)):(e.lineWidth=f,e.lineCap=p,o.eleStrokeStyle(e,t,n),o.drawEdgePath(t,e,s.allpts,h),e.lineCap="butt")},x=function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:m;o.drawArrowheads(e,t,n)};if(e.lineJoin="round","yes"===t.pstyle("ghost").value){var w=t.pstyle("ghost-offset-x").pfValue,E=t.pstyle("ghost-offset-y").pfValue,k=t.pstyle("ghost-opacity").value,T=y*k;e.translate(w,E),b(T),x(T),e.translate(-w,-E)}else!function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:y;e.lineWidth=f+g,e.lineCap=p,g>0?(o.colorStrokeStyle(e,v[0],v[1],v[2],n),"straight-triangle"===d?o.drawEdgeTrianglePath(t,e,s.allpts):(o.drawEdgePath(t,e,s.allpts,h),e.lineCap="butt")):e.lineCap="butt"}();a&&o.drawEdgeUnderlay(e,t),b(),x(),a&&o.drawEdgeOverlay(e,t),o.drawElementText(e,t,null,r),n&&e.translate(l.x1,l.y1)}}},Vd=function(e){if(!["overlay","underlay"].includes(e))throw new Error("Invalid state");return function(t,n){if(n.visible()){var r=n.pstyle("".concat(e,"-opacity")).value;if(0!==r){var a=this,i=a.usePaths(),o=n._private.rscratch,s=2*n.pstyle("".concat(e,"-padding")).pfValue,l=n.pstyle("".concat(e,"-color")).value;t.lineWidth=s,"self"!==o.edgeType||i?t.lineCap="round":t.lineCap="butt",a.colorStrokeStyle(t,l[0],l[1],l[2],r),a.drawEdgePath(n,t,o.allpts,"solid")}}}};Od.drawEdgeOverlay=Vd("overlay"),Od.drawEdgeUnderlay=Vd("underlay"),Od.drawEdgePath=function(e,t,n,a){var i,o=e._private.rscratch,s=t,l=!1,u=this.usePaths(),c=e.pstyle("line-dash-pattern").pfValue,d=e.pstyle("line-dash-offset").pfValue;if(u){var h=n.join("$");o.pathCacheKey&&o.pathCacheKey===h?(i=t=o.pathCache,l=!0):(i=t=new Path2D,o.pathCacheKey=h,o.pathCache=i)}if(s.setLineDash)switch(a){case"dotted":s.setLineDash([1,1]);break;case"dashed":s.setLineDash(c),s.lineDashOffset=d;break;case"solid":s.setLineDash([])}if(!l&&!o.badLine)switch(t.beginPath&&t.beginPath(),t.moveTo(n[0],n[1]),o.edgeType){case"bezier":case"self":case"compound":case"multibezier":for(var f=2;f+35&&void 0!==arguments[5]?arguments[5]:5,o=Math.min(i,r/2,a/2);e.beginPath(),e.moveTo(t+o,n),e.lineTo(t+r-o,n),e.quadraticCurveTo(t+r,n,t+r,n+o),e.lineTo(t+r,n+a-o),e.quadraticCurveTo(t+r,n+a,t+r-o,n+a),e.lineTo(t+o,n+a),e.quadraticCurveTo(t,n+a,t,n+a-o),e.lineTo(t,n+o),e.quadraticCurveTo(t,n,t+o,n),e.closePath()}Xd.eleTextBiggerThanMin=function(e,t){if(!t){var n=e.cy().zoom(),r=this.getPixelRatio(),a=Math.ceil(Ht(n*r));t=Math.pow(2,a)}return!(e.pstyle("font-size").pfValue*t5&&void 0!==arguments[5])||arguments[5],o=this;if(null==r){if(i&&!o.eleTextBiggerThanMin(t))return}else if(!1===r)return;if(t.isNode()){var s=t.pstyle("label");if(!s||!s.value)return;var l=o.getLabelJustification(t),u="glyph"===t.pstyle("text-metrics").strValue;e.textAlign=l,e.textBaseline=u?"alphabetic":"bottom"}else{var c=t.element()._private.rscratch.badLine,d=t.pstyle("label"),h=t.pstyle("source-label"),f=t.pstyle("target-label");if(c||(!d||!d.value)&&(!h||!h.value)&&(!f||!f.value))return;e.textAlign="center",e.textBaseline="bottom"}var p,g=!n;n&&(p=n,e.translate(-p.x1,-p.y1)),null==a?(o.drawText(e,t,null,g,i),t.isEdge()&&(o.drawText(e,t,"source",g,i),o.drawText(e,t,"target",g,i))):o.drawText(e,t,a,g,i),n&&e.translate(p.x1,p.y1)},Xd.getFontCache=function(e){var t;this.fontCaches=this.fontCaches||[];for(var n=0;n2&&void 0!==arguments[2])||arguments[2],r=t.pstyle("font-style").strValue,a=t.pstyle("font-size").pfValue+"px",i=t.pstyle("font-family").strValue,o=t.pstyle("font-weight").strValue,s=n?t.effectiveOpacity()*t.pstyle("text-opacity").value:1,l=t.pstyle("text-outline-opacity").value*s,u=t.pstyle("color").value,c=t.pstyle("text-outline-color").value;e.font=r+" "+o+" "+a+" "+i,e.lineJoin="round",this.colorFillStyle(e,u[0],u[1],u[2],s),this.colorStrokeStyle(e,c[0],c[1],c[2],l)},Xd.getTextAngle=function(e,t){var n,r=e._private.rscratch,a=t?t+"-":"",i=e.pstyle(a+"text-rotation");if("autorotate"===i.strValue){var o=ft(r,"labelAngle",t);n=e.isEdge()?o:0}else n="none"===i.strValue?0:i.pfValue;return n},Xd.drawText=function(e,t,n){var r=!(arguments.length>3&&void 0!==arguments[3])||arguments[3],a=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],i=t._private.rscratch,o=a?t.effectiveOpacity():1;if(!a||0!==o&&0!==t.pstyle("text-opacity").value){"main"===n&&(n=null);var s,l,u=ft(i,"labelX",n),c=ft(i,"labelY",n),d=this.getLabelText(t,n);if(null!=d&&""!==d&&!isNaN(u)&&!isNaN(c)){this.setupTextStyle(e,t,a);var h,f=n?n+"-":"",p=ft(i,"labelWidth",n),g=ft(i,"labelHeight",n),v=ft(i,"labelActualDescent",n),y=t.pstyle(f+"text-margin-x").pfValue,m=t.pstyle(f+"text-margin-y").pfValue,b=t.isEdge(),x=t.pstyle("text-halign").value,w=t.pstyle("text-valign").value;b&&(x="center",w="center"),u+=y,c+=m,0!==(h=r?this.getTextAngle(t,n):0)&&(s=u,l=c,e.translate(s,l),e.rotate(h),u=0,c=0);var E=Bs(x),k=Ds(w);switch(k){case"top":break;case"center":c+=g/2;break;case"bottom":c+=g}var T=t.pstyle("text-background-opacity").value,C=t.pstyle("text-border-opacity").value,P=t.pstyle("text-border-width").pfValue,S=t.pstyle("text-background-padding").pfValue,B=t.pstyle("text-background-shape").strValue,D="round-rectangle"===B||"roundrectangle"===B,_="circle"===B;if(T>0||P>0&&C>0){var A=e.fillStyle,M=e.strokeStyle,R=e.lineWidth,I=t.pstyle("text-background-color").value,N=t.pstyle("text-border-color").value,L=t.pstyle("text-border-style").value,z=T>0,O=P>0&&C>0,V=u-S;switch(E){case"left":V-=p;break;case"center":V-=p/2}var F=c-g-S,X=p+2*S,j=g+2*S;if(z&&(e.fillStyle="rgba(".concat(I[0],",").concat(I[1],",").concat(I[2],",").concat(T*o,")")),O&&(e.strokeStyle="rgba(".concat(N[0],",").concat(N[1],",").concat(N[2],",").concat(C*o,")"),e.lineWidth=P,e.setLineDash))switch(L){case"dotted":e.setLineDash([1,1]);break;case"dashed":e.setLineDash([4,2]);break;case"double":e.lineWidth=P/4,e.setLineDash([]);break;default:e.setLineDash([])}if(D?(e.beginPath(),jd(e,V,F,X,j,2)):_?(e.beginPath(),function(e,t,n,r,a){var i=Math.min(r,a)/2,o=t+r/2,s=n+a/2;e.beginPath(),e.arc(o,s,i,0,2*Math.PI),e.closePath()}(e,V,F,X,j)):(e.beginPath(),e.rect(V,F,X,j)),z&&e.fill(),O&&e.stroke(),O&&"double"===L){var Y=P/2;e.beginPath(),D?jd(e,V+Y,F+Y,X-2*Y,j-2*Y,2):e.rect(V+Y,F+Y,X-2*Y,j-2*Y),e.stroke()}e.fillStyle=A,e.strokeStyle=M,e.lineWidth=R,e.setLineDash&&e.setLineDash([])}var q=2*t.pstyle("text-outline-width").pfValue;if(q>0&&(e.lineWidth=q),c-=v,"wrap"===t.pstyle("text-wrap").value){var W=ft(i,"labelWrapCachedLines",n),U=ft(i,"labelLineHeight",n),H=p/2,K=this.getLabelJustification(t);switch("auto"===K||("left"===E?"left"===K?u+=-p:"center"===K&&(u+=-H):"center"===E?"left"===K?u+=-H:"right"===K&&(u+=H):"right"===E&&("center"===K?u+=H:"right"===K&&(u+=p))),k){case"top":case"center":case"bottom":c-=(W.length-1)*U}for(var G=0;G0&&e.strokeText(W[G],u,c),e.fillText(W[G],u,c),c+=U}else q>0&&e.strokeText(d,u,c),e.fillText(d,u,c);0!==h&&(e.rotate(-h),e.translate(-s,-l))}}};var Yd={drawNode:function(e,t,n){var r,a,i=!(arguments.length>3&&void 0!==arguments[3])||arguments[3],o=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],s=!(arguments.length>5&&void 0!==arguments[5])||arguments[5],l=this,u=t._private,c=u.rscratch,d=t.position();if(G(d.x)&&G(d.y)&&(!s||t.visible())){var h,f,p=s?t.effectiveOpacity():1,g=l.usePaths(),v=!1,y=t.padding();r=t.width()+2*y,a=t.height()+2*y,n&&(f=n,e.translate(-f.x1,-f.y1));for(var m=t.pstyle("background-image").value,b=new Array(m.length),x=new Array(m.length),w=0,E=0;E0&&void 0!==arguments[0]?arguments[0]:S;l.eleFillStyle(e,t,n)},Y=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:N;l.colorStrokeStyle(e,B[0],B[1],B[2],t)},q=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:V;l.colorStrokeStyle(e,z[0],z[1],z[2],t)},W=function(e,t,n,r){var a,i=l.nodePathCache=l.nodePathCache||[],o=Ue("polygon"===n?n+","+r.join(","):n,""+t,""+e,""+X),s=i[o],u=!1;return null!=s?(a=s,u=!0,c.pathCache=a):(a=new Path2D,i[o]=c.pathCache=a),{path:a,cacheHit:u}},U=t.pstyle("shape").strValue,H=t.pstyle("shape-polygon-points").pfValue;if(g){e.translate(d.x,d.y);var K=W(r,a,U,H);h=K.path,v=K.cacheHit}var Z=function(){if(!v){var n=d;g&&(n={x:0,y:0}),l.nodeShapes[l.getNodeShape(t)].draw(h||e,n.x,n.y,r,a,X,c)}g?e.fill(h):e.fill()},$=function(){for(var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:p,r=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],a=u.backgrounding,i=0,o=0;o0&&void 0!==arguments[0]&&arguments[0],i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:p;l.hasPie(t)&&(l.drawPie(e,t,i),n&&(g||l.nodeShapes[l.getNodeShape(t)].draw(e,d.x,d.y,r,a,X,c)))},J=function(){var n=arguments.length>0&&void 0!==arguments[0]&&arguments[0],i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:p;l.hasStripe(t)&&(e.save(),g?e.clip(c.pathCache):(l.nodeShapes[l.getNodeShape(t)].draw(e,d.x,d.y,r,a,X,c),e.clip()),l.drawStripe(e,t,i),e.restore(),n&&(g||l.nodeShapes[l.getNodeShape(t)].draw(e,d.x,d.y,r,a,X,c)))},ee=function(){var t=(C>0?C:-C)*(arguments.length>0&&void 0!==arguments[0]?arguments[0]:p),n=C>0?0:255;0!==C&&(l.colorFillStyle(e,n,n,n,t),g?e.fill(h):e.fill())},te=function(){if(P>0){if(e.lineWidth=P,e.lineCap=A,e.lineJoin=_,e.setLineDash)switch(D){case"dotted":e.setLineDash([1,1]);break;case"dashed":e.setLineDash(R),e.lineDashOffset=I;break;case"solid":case"double":e.setLineDash([])}if("center"!==M){if(e.save(),e.lineWidth*=2,"inside"===M)g?e.clip(h):e.clip();else{var t=new Path2D;t.rect(-r/2-P,-a/2-P,r+2*P,a+2*P),t.addPath(h),e.clip(t,"evenodd")}g?e.stroke(h):e.stroke(),e.restore()}else g?e.stroke(h):e.stroke();if("double"===D){e.lineWidth=P/3;var n=e.globalCompositeOperation;e.globalCompositeOperation="destination-out",g?e.stroke(h):e.stroke(),e.globalCompositeOperation=n}e.setLineDash&&e.setLineDash([])}},ne=function(){if(L>0){if(e.lineWidth=L,e.lineCap="butt",e.setLineDash)switch(O){case"dotted":e.setLineDash([1,1]);break;case"dashed":e.setLineDash([4,2]);break;case"solid":case"double":e.setLineDash([])}var n=d;g&&(n={x:0,y:0});var i=l.getNodeShape(t),o=P;"inside"===M&&(o=0),"outside"===M&&(o*=2);var s,u=(r+o+(L+F))/r,c=(a+o+(L+F))/a,h=r*u,f=a*c,p=l.nodeShapes[i].points;if(g)s=W(h,f,i,p).path;if("ellipse"===i)l.drawEllipsePath(s||e,n.x,n.y,h,f);else if(["round-diamond","round-heptagon","round-hexagon","round-octagon","round-pentagon","round-polygon","round-triangle","round-tag"].includes(i)){var v=0,y=0,m=0;"round-diamond"===i?v=1.4*(o+F+L):"round-heptagon"===i?(v=1.075*(o+F+L),m=-(o/2+F+L)/35):"round-hexagon"===i?v=1.12*(o+F+L):"round-pentagon"===i?(v=1.13*(o+F+L),m=-(o/2+F+L)/15):"round-tag"===i?(v=1.12*(o+F+L),y=.07*(o/2+L+F)):"round-triangle"===i&&(v=(o+F+L)*(Math.PI/2),m=-(o+F/2+L)/Math.PI),0!==v&&(h=r*(u=(r+v)/r),["round-hexagon","round-tag"].includes(i)||(f=a*(c=(a+v)/a)));for(var b=h/2,x=f/2,w=(X="auto"===X?Rn(h,f):X)+(o+L+F)/2,E=new Array(p.length/2),k=new Array(p.length/2),T=0;T0){if(r=r||n.position(),null==a||null==i){var d=n.padding();a=n.width()+2*d,i=n.height()+2*d}this.colorFillStyle(t,l[0],l[1],l[2],s),this.nodeShapes[u].draw(t,r.x,r.y,a+2*o,i+2*o,c),t.fill()}}}};Yd.drawNodeOverlay=qd("overlay"),Yd.drawNodeUnderlay=qd("underlay"),Yd.hasPie=function(e){return(e=e[0])._private.hasPie},Yd.hasStripe=function(e){return(e=e[0])._private.hasStripe},Yd.drawPie=function(e,t,n,r){t=t[0],r=r||t.position();var a,i=t.cy().style(),o=t.pstyle("pie-size"),s=t.pstyle("pie-hole"),l=t.pstyle("pie-start-angle").pfValue,u=r.x,c=r.y,d=t.width(),h=t.height(),f=Math.min(d,h)/2,p=0;if(this.usePaths()&&(u=0,c=0),"%"===o.units?f*=o.pfValue:void 0!==o.pfValue&&(f=o.pfValue/2),"%"===s.units?a=f*s.pfValue:void 0!==s.pfValue&&(a=s.pfValue/2),!(a>=f))for(var g=1;g<=i.pieBackgroundN;g++){var v=t.pstyle("pie-"+g+"-background-size").value,y=t.pstyle("pie-"+g+"-background-color").value,m=t.pstyle("pie-"+g+"-background-opacity").value*n,b=v/100;b+p>1&&(b=1-p);var x=1.5*Math.PI+2*Math.PI*p,w=(x+=l)+2*Math.PI*b;0===v||p>=1||p+b>1||(0===a?(e.beginPath(),e.moveTo(u,c),e.arc(u,c,f,x,w),e.closePath()):(e.beginPath(),e.arc(u,c,f,x,w),e.arc(u,c,a,w,x,!0),e.closePath()),this.colorFillStyle(e,y[0],y[1],y[2],m),e.fill(),p+=b)}},Yd.drawStripe=function(e,t,n,r){t=t[0],r=r||t.position();var a=t.cy().style(),i=r.x,o=r.y,s=t.width(),l=t.height(),u=0,c=this.usePaths();e.save();var d=t.pstyle("stripe-direction").value,h=t.pstyle("stripe-size");switch(d){case"vertical":break;case"righward":e.rotate(-Math.PI/2)}var f=s,p=l;"%"===h.units?(f*=h.pfValue,p*=h.pfValue):void 0!==h.pfValue&&(f=h.pfValue,p=h.pfValue),c&&(i=0,o=0),o-=f/2,i-=p/2;for(var g=1;g<=a.stripeBackgroundN;g++){var v=t.pstyle("stripe-"+g+"-background-size").value,y=t.pstyle("stripe-"+g+"-background-color").value,m=t.pstyle("stripe-"+g+"-background-opacity").value*n,b=v/100;b+u>1&&(b=1-u),0===v||u>=1||u+b>1||(e.beginPath(),e.rect(i,o+p*u,f,p*b),e.closePath(),this.colorFillStyle(e,y[0],y[1],y[2],m),e.fill(),u+=b)}e.restore()};var Wd,Ud={};function Hd(e,t,n){var r=e.createShader(t);if(e.shaderSource(r,n),e.compileShader(r),!e.getShaderParameter(r,e.COMPILE_STATUS))throw new Error(e.getShaderInfoLog(r));return r}function Kd(e,t,n){void 0===n&&(n=t);var r=e.makeOffscreenCanvas(t,n),a=r.context=r.getContext("2d");return r.clear=function(){return a.clearRect(0,0,r.width,r.height)},r.clear(),r}function Gd(e){var t=e.pixelRatio,n=e.cy.zoom(),r=e.cy.pan();return{zoom:n*t,pan:{x:r.x*t,y:r.y*t}}}function Zd(e,t){return!!t.picking||"solid"===e.pstyle("background-fill").value&&("none"===e.pstyle("background-image").strValue&&(0===e.pstyle("border-width").value||(0===e.pstyle("border-opacity").value||"solid"===e.pstyle("border-style").value)))}function $d(e,t){if(e.length!==t.length)return!1;for(var n=0;n>8&255)/255,n[2]=(e>>16&255)/255,n[3]=(e>>24&255)/255,n}function eh(e){return e[0]+(e[1]<<8)+(e[2]<<16)+(e[3]<<24)}function th(e,t){switch(t){case"float":return[1,e.FLOAT,4];case"vec2":return[2,e.FLOAT,4];case"vec3":return[3,e.FLOAT,4];case"vec4":return[4,e.FLOAT,4];case"int":return[1,e.INT,4];case"ivec2":return[2,e.INT,4]}}function nh(e,t,n){switch(t){case e.FLOAT:return new Float32Array(n);case e.INT:return new Int32Array(n)}}function rh(e,t,n,r,a,i){switch(t){case e.FLOAT:return new Float32Array(n.buffer,i*r,a);case e.INT:return new Int32Array(n.buffer,i*r,a)}}function ah(e,t,n,r){var a=i(th(e,n),3),o=a[0],s=a[1],l=a[2],u=nh(e,s,t*o),c=o*l,d=e.createBuffer();e.bindBuffer(e.ARRAY_BUFFER,d),e.bufferData(e.ARRAY_BUFFER,t*c,e.DYNAMIC_DRAW),e.enableVertexAttribArray(r),s===e.FLOAT?e.vertexAttribPointer(r,o,s,!1,c,0):s===e.INT&&e.vertexAttribIPointer(r,o,s,c,0),e.vertexAttribDivisor(r,1),e.bindBuffer(e.ARRAY_BUFFER,null);for(var h=new Array(t),f=0;ft.minMbLowQualFrames&&(t.motionBlurPxRatio=t.mbPxRBlurry)),t.clearingMotionBlur&&(t.motionBlurPxRatio=1),t.textureDrawLastFrame&&!d&&(c[t.NODE]=!0,c[t.SELECT_BOX]=!0);var m=n.style(),b=n.zoom(),x=void 0!==o?o:b,w=n.pan(),E={x:w.x,y:w.y},k={zoom:b,pan:{x:w.x,y:w.y}},T=t.prevViewport;void 0===T||k.zoom!==T.zoom||k.pan.x!==T.pan.x||k.pan.y!==T.pan.y||g&&!p||(t.motionBlurPxRatio=1),s&&(E=s),x*=l,E.x*=l,E.y*=l;var C=t.getCachedZSortedEles();function P(e,n,r,a,i){var o=e.globalCompositeOperation;e.globalCompositeOperation="destination-out",t.colorFillStyle(e,255,255,255,t.motionBlurTransparency),e.fillRect(n,r,a,i),e.globalCompositeOperation=o}function S(e,n){var i,l,c,d;t.clearingMotionBlur||e!==u.bufferContexts[t.MOTIONBLUR_BUFFER_NODE]&&e!==u.bufferContexts[t.MOTIONBLUR_BUFFER_DRAG]?(i=E,l=x,c=t.canvasWidth,d=t.canvasHeight):(i={x:w.x*f,y:w.y*f},l=b*f,c=t.canvasWidth*f,d=t.canvasHeight*f),e.setTransform(1,0,0,1,0,0),"motionBlur"===n?P(e,0,0,c,d):r||void 0!==n&&!n||e.clearRect(0,0,c,d),a||(e.translate(i.x,i.y),e.scale(l,l)),s&&e.translate(s.x,s.y),o&&e.scale(o,o)}if(d||(t.textureDrawLastFrame=!1),d){if(t.textureDrawLastFrame=!0,!t.textureCache){t.textureCache={},t.textureCache.bb=n.mutableElements().boundingBox(),t.textureCache.texture=t.data.bufferCanvases[t.TEXTURE_BUFFER];var B=t.data.bufferContexts[t.TEXTURE_BUFFER];B.setTransform(1,0,0,1,0,0),B.clearRect(0,0,t.canvasWidth*t.textureMult,t.canvasHeight*t.textureMult),t.render({forcedContext:B,drawOnlyNodeLayer:!0,forcedPxRatio:l*t.textureMult}),(k=t.textureCache.viewport={zoom:n.zoom(),pan:n.pan(),width:t.canvasWidth,height:t.canvasHeight}).mpan={x:(0-k.pan.x)/k.zoom,y:(0-k.pan.y)/k.zoom}}c[t.DRAG]=!1,c[t.NODE]=!1;var D=u.contexts[t.NODE],_=t.textureCache.texture;k=t.textureCache.viewport;D.setTransform(1,0,0,1,0,0),h?P(D,0,0,k.width,k.height):D.clearRect(0,0,k.width,k.height);var A=m.core("outside-texture-bg-color").value,M=m.core("outside-texture-bg-opacity").value;t.colorFillStyle(D,A[0],A[1],A[2],M),D.fillRect(0,0,k.width,k.height);b=n.zoom();S(D,!1),D.clearRect(k.mpan.x,k.mpan.y,k.width/k.zoom/l,k.height/k.zoom/l),D.drawImage(_,k.mpan.x,k.mpan.y,k.width/k.zoom/l,k.height/k.zoom/l)}else t.textureOnViewport&&!r&&(t.textureCache=null);var R=n.extent(),I=t.pinching||t.hoverData.dragging||t.swipePanning||t.data.wheelZooming||t.hoverData.draggingEles||t.cy.animated(),N=t.hideEdgesOnViewport&&I,L=[];if(L[t.NODE]=!c[t.NODE]&&h&&!t.clearedForMotionBlur[t.NODE]||t.clearingMotionBlur,L[t.NODE]&&(t.clearedForMotionBlur[t.NODE]=!0),L[t.DRAG]=!c[t.DRAG]&&h&&!t.clearedForMotionBlur[t.DRAG]||t.clearingMotionBlur,L[t.DRAG]&&(t.clearedForMotionBlur[t.DRAG]=!0),c[t.NODE]||a||i||L[t.NODE]){var z=h&&!L[t.NODE]&&1!==f;S(D=r||(z?t.data.bufferContexts[t.MOTIONBLUR_BUFFER_NODE]:u.contexts[t.NODE]),h&&!z?"motionBlur":void 0),N?t.drawCachedNodes(D,C.nondrag,l,R):t.drawLayeredElements(D,C.nondrag,l,R),t.debug&&t.drawDebugPoints(D,C.nondrag),a||h||(c[t.NODE]=!1)}if(!i&&(c[t.DRAG]||a||L[t.DRAG])){z=h&&!L[t.DRAG]&&1!==f;S(D=r||(z?t.data.bufferContexts[t.MOTIONBLUR_BUFFER_DRAG]:u.contexts[t.DRAG]),h&&!z?"motionBlur":void 0),N?t.drawCachedNodes(D,C.drag,l,R):t.drawCachedElements(D,C.drag,l,R),t.debug&&t.drawDebugPoints(D,C.drag),a||h||(c[t.DRAG]=!1)}if(this.drawSelectionRectangle(e,S),h&&1!==f){var O=u.contexts[t.NODE],V=t.data.bufferCanvases[t.MOTIONBLUR_BUFFER_NODE],F=u.contexts[t.DRAG],X=t.data.bufferCanvases[t.MOTIONBLUR_BUFFER_DRAG],j=function(e,n,r){e.setTransform(1,0,0,1,0,0),r||!y?e.clearRect(0,0,t.canvasWidth,t.canvasHeight):P(e,0,0,t.canvasWidth,t.canvasHeight);var a=f;e.drawImage(n,0,0,t.canvasWidth*a,t.canvasHeight*a,0,0,t.canvasWidth,t.canvasHeight)};(c[t.NODE]||L[t.NODE])&&(j(O,V,L[t.NODE]),c[t.NODE]=!1),(c[t.DRAG]||L[t.DRAG])&&(j(F,X,L[t.DRAG]),c[t.DRAG]=!1)}t.prevViewport=k,t.clearingMotionBlur&&(t.clearingMotionBlur=!1,t.motionBlurCleared=!0,t.motionBlur=!0),h&&(t.motionBlurTimeout=setTimeout((function(){t.motionBlurTimeout=null,t.clearedForMotionBlur[t.NODE]=!1,t.clearedForMotionBlur[t.DRAG]=!1,t.motionBlur=!1,t.clearingMotionBlur=!d,t.mbFrames=0,c[t.NODE]=!0,c[t.DRAG]=!0,t.redraw()}),100)),r||n.emit("render")},Ud.drawSelectionRectangle=function(e,t){var n=this,r=n.cy,a=n.data,i=r.style(),o=e.drawOnlyNodeLayer,s=e.drawAllLayers,l=a.canvasNeedsRedraw,u=e.forcedContext;if(n.showFps||!o&&l[n.SELECT_BOX]&&!s){var c=u||a.contexts[n.SELECT_BOX];if(t(c),1==n.selection[4]&&(n.hoverData.selecting||n.touchData.selecting)){var d=n.cy.zoom(),h=i.core("selection-box-border-width").value/d;c.lineWidth=h,c.fillStyle="rgba("+i.core("selection-box-color").value[0]+","+i.core("selection-box-color").value[1]+","+i.core("selection-box-color").value[2]+","+i.core("selection-box-opacity").value+")",c.fillRect(n.selection[0],n.selection[1],n.selection[2]-n.selection[0],n.selection[3]-n.selection[1]),h>0&&(c.strokeStyle="rgba("+i.core("selection-box-border-color").value[0]+","+i.core("selection-box-border-color").value[1]+","+i.core("selection-box-border-color").value[2]+","+i.core("selection-box-opacity").value+")",c.strokeRect(n.selection[0],n.selection[1],n.selection[2]-n.selection[0],n.selection[3]-n.selection[1]))}if(a.bgActivePosistion&&!n.hoverData.selecting){d=n.cy.zoom();var f=a.bgActivePosistion;c.fillStyle="rgba("+i.core("active-bg-color").value[0]+","+i.core("active-bg-color").value[1]+","+i.core("active-bg-color").value[2]+","+i.core("active-bg-opacity").value+")",c.beginPath(),c.arc(f.x,f.y,i.core("active-bg-size").pfValue/d,0,2*Math.PI),c.fill()}var p=n.lastRedrawTime;if(n.showFps&&p){p=Math.round(p);var g=Math.round(1e3/p),v="1 frame = "+p+" ms = "+g+" fps";if(c.setTransform(1,0,0,1,0,0),c.fillStyle="rgba(255, 0, 0, 0.75)",c.strokeStyle="rgba(255, 0, 0, 0.75)",c.font="30px Arial",!Wd){var y=c.measureText(v);Wd=y.actualBoundingBoxAscent}c.fillText(v,0,Wd);c.strokeRect(0,Wd+10,250,20),c.fillRect(0,Wd+10,250*Math.min(g/60,1),20)}s||(l[n.SELECT_BOX]=!1)}};var ih="undefined"!=typeof Float32Array?Float32Array:Array;function oh(){var e=new ih(9);return ih!=Float32Array&&(e[1]=0,e[2]=0,e[3]=0,e[5]=0,e[6]=0,e[7]=0),e[0]=1,e[4]=1,e[8]=1,e}function sh(e){return e[0]=1,e[1]=0,e[2]=0,e[3]=0,e[4]=1,e[5]=0,e[6]=0,e[7]=0,e[8]=1,e}function lh(e,t,n){var r=t[0],a=t[1],i=t[2],o=t[3],s=t[4],l=t[5],u=t[6],c=t[7],d=t[8],h=n[0],f=n[1];return e[0]=r,e[1]=a,e[2]=i,e[3]=o,e[4]=s,e[5]=l,e[6]=h*r+f*o+u,e[7]=h*a+f*s+c,e[8]=h*i+f*l+d,e}function uh(e,t,n){var r=t[0],a=t[1],i=t[2],o=t[3],s=t[4],l=t[5],u=t[6],c=t[7],d=t[8],h=Math.sin(n),f=Math.cos(n);return e[0]=f*r+h*o,e[1]=f*a+h*s,e[2]=f*i+h*l,e[3]=f*o-h*r,e[4]=f*s-h*a,e[5]=f*l-h*i,e[6]=u,e[7]=c,e[8]=d,e}function ch(e,t,n){var r=n[0],a=n[1];return e[0]=r*t[0],e[1]=r*t[1],e[2]=r*t[2],e[3]=a*t[3],e[4]=a*t[4],e[5]=a*t[5],e[6]=t[6],e[7]=t[7],e[8]=t[8],e}Math.hypot||(Math.hypot=function(){for(var e=0,t=arguments.length;t--;)e+=arguments[t]*arguments[t];return Math.sqrt(e)});var dh=function(){return n((function e(n,r,a,i){t(this,e),this.debugID=Math.floor(1e4*Math.random()),this.r=n,this.texSize=r,this.texRows=a,this.texHeight=Math.floor(r/a),this.enableWrapping=!0,this.locked=!1,this.texture=null,this.needsBuffer=!0,this.freePointer={x:0,row:0},this.keyToLocation=new Map,this.canvas=i(n,r,r),this.scratch=i(n,r,this.texHeight,"scratch")}),[{key:"lock",value:function(){this.locked=!0}},{key:"getKeys",value:function(){return new Set(this.keyToLocation.keys())}},{key:"getScale",value:function(e){var t=e.w,n=e.h,r=this.texHeight,a=this.texSize,i=r/n,o=t*i,s=n*i;return o>a&&(o=t*(i=a/t),s=n*i),{scale:i,texW:o,texH:s}}},{key:"draw",value:function(e,t,n){var r=this;if(this.locked)throw new Error("can't draw, atlas is locked");var a=this.texSize,i=this.texRows,o=this.texHeight,s=this.getScale(t),l=s.scale,u=s.texW,c=s.texH,d=function(e,r){if(n&&r){var a=r.context,i=e.x,s=e.row,u=i,c=o*s;a.save(),a.translate(u,c),a.scale(l,l),n(a,t),a.restore()}},h=[null,null],f=function(){d(r.freePointer,r.canvas),h[0]={x:r.freePointer.x,y:r.freePointer.row*o,w:u,h:c},h[1]={x:r.freePointer.x+u,y:r.freePointer.row*o,w:0,h:c},r.freePointer.x+=u,r.freePointer.x==a&&(r.freePointer.x=0,r.freePointer.row++)},p=function(){r.freePointer.x=0,r.freePointer.row++};if(this.freePointer.x+u<=a)f();else{if(this.freePointer.row>=i-1)return!1;this.freePointer.x===a?(p(),f()):this.enableWrapping?function(){var e=r.scratch,t=r.canvas;e.clear(),d({x:0,row:0},e);var n=a-r.freePointer.x,i=u-n,s=o,l=r.freePointer.x,f=r.freePointer.row*o,p=n;t.context.drawImage(e,0,0,p,s,l,f,p,s),h[0]={x:l,y:f,w:p,h:c};var g=n,v=(r.freePointer.row+1)*o,y=i;t&&t.context.drawImage(e,g,0,y,s,0,v,y,s),h[1]={x:0,y:v,w:y,h:c},r.freePointer.x=i,r.freePointer.row++}():(p(),f())}return this.keyToLocation.set(e,h),this.needsBuffer=!0,h}},{key:"getOffsets",value:function(e){return this.keyToLocation.get(e)}},{key:"isEmpty",value:function(){return 0===this.freePointer.x&&0===this.freePointer.row}},{key:"canFit",value:function(e){if(this.locked)return!1;var t=this.texSize,n=this.texRows,r=this.getScale(e).texW;return!(this.freePointer.x+r>t)||this.freePointer.row1&&void 0!==arguments[1]?arguments[1]:{},i=a.forceRedraw,o=void 0!==i&&i,s=a.filterEle,l=void 0===s?function(){return!0}:s,u=a.filterType,c=void 0===u?function(){return!0}:u,d=!1,h=!1,f=r(e);try{for(f.s();!(t=f.n()).done;){var p=t.value;if(l(p)){var g,v=r(this.renderTypes.values());try{var y=function(){var e=g.value,t=e.type;if(c(t)){var r=n.collections.get(e.collection),a=e.getKey(p),i=Array.isArray(a)?a:[a];if(o)i.forEach((function(e){return r.markKeyForGC(e)})),h=!0;else{var s=e.getID?e.getID(p):p.id(),l=n._key(t,s),u=n.typeAndIdToKey.get(l);void 0===u||$d(i,u)||(d=!0,n.typeAndIdToKey.delete(l),u.forEach((function(e){return r.markKeyForGC(e)})))}}};for(v.s();!(g=v.n()).done;)y()}catch(e){v.e(e)}finally{v.f()}}}}catch(e){f.e(e)}finally{f.f()}return h&&(this.gc(),d=!1),d}},{key:"gc",value:function(){var e,t=r(this.collections.values());try{for(t.s();!(e=t.n()).done;){e.value.gc()}}catch(e){t.e(e)}finally{t.f()}}},{key:"getOrCreateAtlas",value:function(e,t,n,r){var a=this.renderTypes.get(t),i=this.collections.get(a.collection),o=!1,s=i.draw(r,n,(function(t){a.drawClipped?(t.save(),t.beginPath(),t.rect(0,0,n.w,n.h),t.clip(),a.drawElement(t,e,n,!0,!0),t.restore()):a.drawElement(t,e,n,!0,!0),o=!0}));if(o){var l=a.getID?a.getID(e):e.id(),u=this._key(t,l);this.typeAndIdToKey.has(u)?this.typeAndIdToKey.get(u).push(r):this.typeAndIdToKey.set(u,[r])}return s}},{key:"getAtlasInfo",value:function(e,t){var n=this,r=this.renderTypes.get(t),a=r.getKey(e);return(Array.isArray(a)?a:[a]).map((function(a){var o=r.getBoundingBox(e,a),s=n.getOrCreateAtlas(e,t,o,a),l=i(s.getOffsets(a),2),u=l[0];return{atlas:s,tex:u,tex1:u,tex2:l[1],bb:o}}))}},{key:"getDebugInfo",value:function(){var e,t=[],n=r(this.collections);try{for(n.s();!(e=n.n()).done;){var a=i(e.value,2),o=a[0],s=a[1].getCounts(),l=s.keyCount,u=s.atlasCount;t.push({type:o,keyCount:l,atlasCount:u})}}catch(e){n.e(e)}finally{n.f()}return t}}])}(),ph=function(){return n((function e(n){t(this,e),this.globalOptions=n,this.atlasSize=n.webglTexSize,this.maxAtlasesPerBatch=n.webglTexPerBatch,this.batchAtlases=[]}),[{key:"getMaxAtlasesPerBatch",value:function(){return this.maxAtlasesPerBatch}},{key:"getAtlasSize",value:function(){return this.atlasSize}},{key:"getIndexArray",value:function(){return Array.from({length:this.maxAtlasesPerBatch},(function(e,t){return t}))}},{key:"startBatch",value:function(){this.batchAtlases=[]}},{key:"getAtlasCount",value:function(){return this.batchAtlases.length}},{key:"getAtlases",value:function(){return this.batchAtlases}},{key:"canAddToCurrentBatch",value:function(e){return this.batchAtlases.length!==this.maxAtlasesPerBatch||this.batchAtlases.includes(e)}},{key:"getAtlasIndexForBatch",value:function(e){var t=this.batchAtlases.indexOf(e);if(t<0){if(this.batchAtlases.length===this.maxAtlasesPerBatch)throw new Error("cannot add more atlases to batch");this.batchAtlases.push(e),t=this.batchAtlases.length-1}return t}}])}(),gh={SCREEN:{name:"screen",screen:!0},PICKING:{name:"picking",picking:!0}},vh=1,yh=2,mh=function(){return n((function e(n,r,a){t(this,e),this.r=n,this.gl=r,this.maxInstances=a.webglBatchSize,this.atlasSize=a.webglTexSize,this.bgColor=a.bgColor,this.debug=a.webglDebug,this.batchDebugInfo=[],a.enableWrapping=!0,a.createTextureCanvas=Kd,this.atlasManager=new fh(n,a),this.batchManager=new ph(a),this.simpleShapeOptions=new Map,this.program=this._createShaderProgram(gh.SCREEN),this.pickingProgram=this._createShaderProgram(gh.PICKING),this.vao=this._createVAO()}),[{key:"addAtlasCollection",value:function(e,t){this.atlasManager.addAtlasCollection(e,t)}},{key:"addTextureAtlasRenderType",value:function(e,t){this.atlasManager.addRenderType(e,t)}},{key:"addSimpleShapeRenderType",value:function(e,t){this.simpleShapeOptions.set(e,t)}},{key:"invalidate",value:function(e){var t=(arguments.length>1&&void 0!==arguments[1]?arguments[1]:{}).type,n=this.atlasManager;return t?n.invalidate(e,{filterType:function(e){return e===t},forceRedraw:!0}):n.invalidate(e)}},{key:"gc",value:function(){this.atlasManager.gc()}},{key:"_createShaderProgram",value:function(e){var t=this.gl,n="#version 300 es\n precision highp float;\n\n uniform mat3 uPanZoomMatrix;\n uniform int uAtlasSize;\n \n // instanced\n in vec2 aPosition; // a vertex from the unit square\n \n in mat3 aTransform; // used to transform verticies, eg into a bounding box\n in int aVertType; // the type of thing we are rendering\n\n // the z-index that is output when using picking mode\n in vec4 aIndex;\n \n // For textures\n in int aAtlasId; // which shader unit/atlas to use\n in vec4 aTex; // x/y/w/h of texture in atlas\n\n // for edges\n in vec4 aPointAPointB;\n in vec4 aPointCPointD;\n in vec2 aLineWidth; // also used for node border width\n\n // simple shapes\n in vec4 aCornerRadius; // for round-rectangle [top-right, bottom-right, top-left, bottom-left]\n in vec4 aColor; // also used for edges\n in vec4 aBorderColor; // aLineWidth is used for border width\n\n // output values passed to the fragment shader\n out vec2 vTexCoord;\n out vec4 vColor;\n out vec2 vPosition;\n // flat values are not interpolated\n flat out int vAtlasId; \n flat out int vVertType;\n flat out vec2 vTopRight;\n flat out vec2 vBotLeft;\n flat out vec4 vCornerRadius;\n flat out vec4 vBorderColor;\n flat out vec2 vBorderWidth;\n flat out vec4 vIndex;\n \n void main(void) {\n int vid = gl_VertexID;\n vec2 position = aPosition; // TODO make this a vec3, simplifies some code below\n\n if(aVertType == ".concat(0,") {\n float texX = aTex.x; // texture coordinates\n float texY = aTex.y;\n float texW = aTex.z;\n float texH = aTex.w;\n\n if(vid == 1 || vid == 2 || vid == 4) {\n texX += texW;\n }\n if(vid == 2 || vid == 4 || vid == 5) {\n texY += texH;\n }\n\n float d = float(uAtlasSize);\n vTexCoord = vec2(texX / d, texY / d); // tex coords must be between 0 and 1\n\n gl_Position = vec4(uPanZoomMatrix * aTransform * vec3(position, 1.0), 1.0);\n }\n else if(aVertType == ").concat(4," || aVertType == ").concat(7," \n || aVertType == ").concat(5," || aVertType == ").concat(6,") { // simple shapes\n\n // the bounding box is needed by the fragment shader\n vBotLeft = (aTransform * vec3(0, 0, 1)).xy; // flat\n vTopRight = (aTransform * vec3(1, 1, 1)).xy; // flat\n vPosition = (aTransform * vec3(position, 1)).xy; // will be interpolated\n\n // calculations are done in the fragment shader, just pass these along\n vColor = aColor;\n vCornerRadius = aCornerRadius;\n vBorderColor = aBorderColor;\n vBorderWidth = aLineWidth;\n\n gl_Position = vec4(uPanZoomMatrix * aTransform * vec3(position, 1.0), 1.0);\n }\n else if(aVertType == ").concat(1,") {\n vec2 source = aPointAPointB.xy;\n vec2 target = aPointAPointB.zw;\n\n // adjust the geometry so that the line is centered on the edge\n position.y = position.y - 0.5;\n\n // stretch the unit square into a long skinny rectangle\n vec2 xBasis = target - source;\n vec2 yBasis = normalize(vec2(-xBasis.y, xBasis.x));\n vec2 point = source + xBasis * position.x + yBasis * aLineWidth[0] * position.y;\n\n gl_Position = vec4(uPanZoomMatrix * vec3(point, 1.0), 1.0);\n vColor = aColor;\n } \n else if(aVertType == ").concat(2,") {\n vec2 pointA = aPointAPointB.xy;\n vec2 pointB = aPointAPointB.zw;\n vec2 pointC = aPointCPointD.xy;\n vec2 pointD = aPointCPointD.zw;\n\n // adjust the geometry so that the line is centered on the edge\n position.y = position.y - 0.5;\n\n vec2 p0, p1, p2, pos;\n if(position.x == 0.0) { // The left side of the unit square\n p0 = pointA;\n p1 = pointB;\n p2 = pointC;\n pos = position;\n } else { // The right side of the unit square, use same approach but flip the geometry upside down\n p0 = pointD;\n p1 = pointC;\n p2 = pointB;\n pos = vec2(0.0, -position.y);\n }\n\n vec2 p01 = p1 - p0;\n vec2 p12 = p2 - p1;\n vec2 p21 = p1 - p2;\n\n // Find the normal vector.\n vec2 tangent = normalize(normalize(p12) + normalize(p01));\n vec2 normal = vec2(-tangent.y, tangent.x);\n\n // Find the vector perpendicular to p0 -> p1.\n vec2 p01Norm = normalize(vec2(-p01.y, p01.x));\n\n // Determine the bend direction.\n float sigma = sign(dot(p01 + p21, normal));\n float width = aLineWidth[0];\n\n if(sign(pos.y) == -sigma) {\n // This is an intersecting vertex. Adjust the position so that there's no overlap.\n vec2 point = 0.5 * width * normal * -sigma / dot(normal, p01Norm);\n gl_Position = vec4(uPanZoomMatrix * vec3(p1 + point, 1.0), 1.0);\n } else {\n // This is a non-intersecting vertex. Treat it like a mitre join.\n vec2 point = 0.5 * width * normal * sigma * dot(normal, p01Norm);\n gl_Position = vec4(uPanZoomMatrix * vec3(p1 + point, 1.0), 1.0);\n }\n\n vColor = aColor;\n } \n else if(aVertType == ").concat(3," && vid < 3) {\n // massage the first triangle into an edge arrow\n if(vid == 0)\n position = vec2(-0.15, -0.3);\n if(vid == 1)\n position = vec2( 0.0, 0.0);\n if(vid == 2)\n position = vec2( 0.15, -0.3);\n\n gl_Position = vec4(uPanZoomMatrix * aTransform * vec3(position, 1.0), 1.0);\n vColor = aColor;\n }\n else {\n gl_Position = vec4(2.0, 0.0, 0.0, 1.0); // discard vertex by putting it outside webgl clip space\n }\n\n vAtlasId = aAtlasId;\n vVertType = aVertType;\n vIndex = aIndex;\n }\n "),r=this.batchManager.getIndexArray(),a="#version 300 es\n precision highp float;\n\n // declare texture unit for each texture atlas in the batch\n ".concat(r.map((function(e){return"uniform sampler2D uTexture".concat(e,";")})).join("\n\t"),"\n\n uniform vec4 uBGColor;\n uniform float uZoom;\n\n in vec2 vTexCoord;\n in vec4 vColor;\n in vec2 vPosition; // model coordinates\n\n flat in int vAtlasId;\n flat in vec4 vIndex;\n flat in int vVertType;\n flat in vec2 vTopRight;\n flat in vec2 vBotLeft;\n flat in vec4 vCornerRadius;\n flat in vec4 vBorderColor;\n flat in vec2 vBorderWidth;\n\n out vec4 outColor;\n\n ").concat("\n float circleSD(vec2 p, float r) {\n return distance(vec2(0), p) - r; // signed distance\n }\n","\n ").concat("\n float rectangleSD(vec2 p, vec2 b) {\n vec2 d = abs(p)-b;\n return distance(vec2(0),max(d,0.0)) + min(max(d.x,d.y),0.0);\n }\n","\n ").concat("\n float roundRectangleSD(vec2 p, vec2 b, vec4 cr) {\n cr.xy = (p.x > 0.0) ? cr.xy : cr.zw;\n cr.x = (p.y > 0.0) ? cr.x : cr.y;\n vec2 q = abs(p) - b + cr.x;\n return min(max(q.x, q.y), 0.0) + distance(vec2(0), max(q, 0.0)) - cr.x;\n }\n","\n ").concat("\n float ellipseSD(vec2 p, vec2 ab) {\n p = abs( p ); // symmetry\n\n // find root with Newton solver\n vec2 q = ab*(p-ab);\n float w = (q.x1.0) ? d : -d;\n }\n","\n\n vec4 blend(vec4 top, vec4 bot) { // blend colors with premultiplied alpha\n return vec4( \n top.rgb + (bot.rgb * (1.0 - top.a)),\n top.a + (bot.a * (1.0 - top.a)) \n );\n }\n\n vec4 distInterp(vec4 cA, vec4 cB, float d) { // interpolate color using Signed Distance\n // scale to the zoom level so that borders don't look blurry when zoomed in\n // note 1.5 is an aribitrary value chosen because it looks good\n return mix(cA, cB, 1.0 - smoothstep(0.0, 1.5 / uZoom, abs(d))); \n }\n\n void main(void) {\n if(vVertType == ").concat(0,") {\n // look up the texel from the texture unit\n ").concat(r.map((function(e){return"if(vAtlasId == ".concat(e,") outColor = texture(uTexture").concat(e,", vTexCoord);")})).join("\n\telse "),"\n } \n else if(vVertType == ").concat(3,") {\n // mimics how canvas renderer uses context.globalCompositeOperation = 'destination-out';\n outColor = blend(vColor, uBGColor);\n outColor.a = 1.0; // make opaque, masks out line under arrow\n }\n else if(vVertType == ").concat(4," && vBorderWidth == vec2(0.0)) { // simple rectangle with no border\n outColor = vColor; // unit square is already transformed to the rectangle, nothing else needs to be done\n }\n else if(vVertType == ").concat(4," || vVertType == ").concat(7," \n || vVertType == ").concat(5," || vVertType == ").concat(6,") { // use SDF\n\n float outerBorder = vBorderWidth[0];\n float innerBorder = vBorderWidth[1];\n float borderPadding = outerBorder * 2.0;\n float w = vTopRight.x - vBotLeft.x - borderPadding;\n float h = vTopRight.y - vBotLeft.y - borderPadding;\n vec2 b = vec2(w/2.0, h/2.0); // half width, half height\n vec2 p = vPosition - vec2(vTopRight.x - b[0] - outerBorder, vTopRight.y - b[1] - outerBorder); // translate to center\n\n float d; // signed distance\n if(vVertType == ").concat(4,") {\n d = rectangleSD(p, b);\n } else if(vVertType == ").concat(7," && w == h) {\n d = circleSD(p, b.x); // faster than ellipse\n } else if(vVertType == ").concat(7,") {\n d = ellipseSD(p, b);\n } else {\n d = roundRectangleSD(p, b, vCornerRadius.wzyx);\n }\n\n // use the distance to interpolate a color to smooth the edges of the shape, doesn't need multisampling\n // we must smooth colors inwards, because we can't change pixels outside the shape's bounding box\n if(d > 0.0) {\n if(d > outerBorder) {\n discard;\n } else {\n outColor = distInterp(vBorderColor, vec4(0), d - outerBorder);\n }\n } else {\n if(d > innerBorder) {\n vec4 outerColor = outerBorder == 0.0 ? vec4(0) : vBorderColor;\n vec4 innerBorderColor = blend(vBorderColor, vColor);\n outColor = distInterp(innerBorderColor, outerColor, d);\n } \n else {\n vec4 outerColor;\n if(innerBorder == 0.0 && outerBorder == 0.0) {\n outerColor = vec4(0);\n } else if(innerBorder == 0.0) {\n outerColor = vBorderColor;\n } else {\n outerColor = blend(vBorderColor, vColor);\n }\n outColor = distInterp(vColor, outerColor, d - innerBorder);\n }\n }\n }\n else {\n outColor = vColor;\n }\n\n ").concat(e.picking?"if(outColor.a == 0.0) discard;\n else outColor = vIndex;":"","\n }\n "),i=function(e,t,n){var r=Hd(e,e.VERTEX_SHADER,t),a=Hd(e,e.FRAGMENT_SHADER,n),i=e.createProgram();if(e.attachShader(i,r),e.attachShader(i,a),e.linkProgram(i),!e.getProgramParameter(i,e.LINK_STATUS))throw new Error("Could not initialize shaders");return i}(t,n,a);i.aPosition=t.getAttribLocation(i,"aPosition"),i.aIndex=t.getAttribLocation(i,"aIndex"),i.aVertType=t.getAttribLocation(i,"aVertType"),i.aTransform=t.getAttribLocation(i,"aTransform"),i.aAtlasId=t.getAttribLocation(i,"aAtlasId"),i.aTex=t.getAttribLocation(i,"aTex"),i.aPointAPointB=t.getAttribLocation(i,"aPointAPointB"),i.aPointCPointD=t.getAttribLocation(i,"aPointCPointD"),i.aLineWidth=t.getAttribLocation(i,"aLineWidth"),i.aColor=t.getAttribLocation(i,"aColor"),i.aCornerRadius=t.getAttribLocation(i,"aCornerRadius"),i.aBorderColor=t.getAttribLocation(i,"aBorderColor"),i.uPanZoomMatrix=t.getUniformLocation(i,"uPanZoomMatrix"),i.uAtlasSize=t.getUniformLocation(i,"uAtlasSize"),i.uBGColor=t.getUniformLocation(i,"uBGColor"),i.uZoom=t.getUniformLocation(i,"uZoom"),i.uTextures=[];for(var o=0;o1&&void 0!==arguments[1]?arguments[1]:gh.SCREEN;this.panZoomMatrix=e,this.renderTarget=t,this.batchDebugInfo=[],this.wrappedCount=0,this.simpleCount=0,this.startBatch()}},{key:"startBatch",value:function(){this.instanceCount=0,this.batchManager.startBatch()}},{key:"endFrame",value:function(){this.endBatch()}},{key:"_isVisible",value:function(e,t){return!!e.visible()&&(!t||!t.isVisible||t.isVisible(e))}},{key:"drawTexture",value:function(e,t,n){var a=this.atlasManager,o=this.batchManager,s=a.getRenderTypeOpts(n);if(this._isVisible(e,s)&&(!e.isEdge()||this._isValidEdge(e))){if(this.renderTarget.picking&&s.getTexPickingMode){var l=s.getTexPickingMode(e);if(l===vh)return;if(l==yh)return void this.drawPickingRectangle(e,t,n)}var u,c=r(a.getAtlasInfo(e,n));try{for(c.s();!(u=c.n()).done;){var d=u.value,h=d.atlas,f=d.tex1,p=d.tex2;o.canAddToCurrentBatch(h)||this.endBatch();for(var g=o.getAtlasIndexForBatch(h),v=0,y=[[f,!0],[p,!1]];v=this.maxInstances&&this.endBatch()}}}}catch(e){c.e(e)}finally{c.f()}}}},{key:"setTransformMatrix",value:function(e,t,n,r){var a=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],i=0;if(n.shapeProps&&n.shapeProps.padding&&(i=e.pstyle(n.shapeProps.padding).pfValue),r){var o=r.bb,s=r.tex1,l=r.tex2,u=s.w/(s.w+l.w);a||(u=1-u);var c=this._getAdjustedBB(o,i,a,u);this._applyTransformMatrix(t,c,n,e)}else{var d=n.getBoundingBox(e),h=this._getAdjustedBB(d,i,!0,1);this._applyTransformMatrix(t,h,n,e)}}},{key:"_applyTransformMatrix",value:function(e,t,n,r){var a,i;sh(e);var o=n.getRotation?n.getRotation(r):0;if(0!==o){var s=n.getRotationPoint(r);lh(e,e,[s.x,s.y]),uh(e,e,o);var l=n.getRotationOffset(r);a=l.x+(t.xOffset||0),i=l.y+(t.yOffset||0)}else a=t.x1,i=t.y1;lh(e,e,[a,i]),ch(e,e,[t.w,t.h])}},{key:"_getAdjustedBB",value:function(e,t,n,r){var a=e.x1,i=e.y1,o=e.w,s=e.h;t&&(a-=t,i-=t,o+=2*t,s+=2*t);var l=0,u=o*r;return n&&r<1?o=u:!n&&r<1&&(a+=l=o-u,o=u),{x1:a,y1:i,w:o,h:s,xOffset:l,yOffset:e.yOffset}}},{key:"drawPickingRectangle",value:function(e,t,n){var r=this.atlasManager.getRenderTypeOpts(n),a=this.instanceCount;this.vertTypeBuffer.getView(a)[0]=4,Jd(t,this.indexBuffer.getView(a)),Qd([0,0,0],1,this.colorBuffer.getView(a));var i=this.transformBuffer.getMatrixView(a);this.setTransformMatrix(e,i,r),this.simpleCount++,this.instanceCount++,this.instanceCount>=this.maxInstances&&this.endBatch()}},{key:"drawNode",value:function(e,t,n){var r=this.simpleShapeOptions.get(n);if(this._isVisible(e,r)){var a=r.shapeProps,i=this._getVertTypeForShape(e,a.shape);if(void 0===i||r.isSimple&&!r.isSimple(e,this.renderTarget))this.drawTexture(e,t,n);else{var o=this.instanceCount;if(this.vertTypeBuffer.getView(o)[0]=i,5===i||6===i){var s=r.getBoundingBox(e),l=this._getCornerRadius(e,a.radius,s),u=this.cornerRadiusBuffer.getView(o);u[0]=l,u[1]=l,u[2]=l,u[3]=l,6===i&&(u[0]=0,u[2]=0)}Jd(t,this.indexBuffer.getView(o));var c=this.renderTarget.picking?1:"node-body"===n?e.effectiveOpacity():1,d=this.renderTarget.picking?1:e.pstyle(a.opacity).value*c;Qd(e.pstyle(a.color).value,d,this.colorBuffer.getView(o));var h=this.lineWidthBuffer.getView(o);if(h[0]=0,h[1]=0,a.border){var f=e.pstyle("border-width").value;if(f>0){Qd(e.pstyle("border-color").value,c*e.pstyle("border-opacity").value,this.borderColorBuffer.getView(o));var p=e.pstyle("border-position").value;if("inside"===p)h[0]=0,h[1]=-f;else if("outside"===p)h[0]=f,h[1]=0;else{var g=f/2;h[0]=g,h[1]=-g}}}var v=this.transformBuffer.getMatrixView(o);this.setTransformMatrix(e,v,r),this.simpleCount++,this.instanceCount++,this.instanceCount>=this.maxInstances&&this.endBatch()}}}},{key:"_getVertTypeForShape",value:function(e,t){switch(e.pstyle(t).value){case"rectangle":return 4;case"ellipse":return 7;case"roundrectangle":case"round-rectangle":return 5;case"bottom-round-rectangle":return 6;default:return}}},{key:"_getCornerRadius",value:function(e,t,n){var r=n.w,a=n.h;if("auto"===e.pstyle(t).value)return Mn(r,a);var i=e.pstyle(t).pfValue,o=r/2,s=a/2;return Math.min(i,s,o)}},{key:"drawEdgeArrow",value:function(e,t,n){if(e.visible()){var r,a,i,o=e._private.rscratch;if("source"===n?(r=o.arrowStartX,a=o.arrowStartY,i=o.srcArrowAngle):(r=o.arrowEndX,a=o.arrowEndY,i=o.tgtArrowAngle),!(isNaN(r)||null==r||isNaN(a)||null==a||isNaN(i)||null==i))if("none"!==e.pstyle(n+"-arrow-shape").value){var s=e.pstyle(n+"-arrow-color").value,l=e.pstyle("opacity").value*e.pstyle("line-opacity").value,u=e.pstyle("width").pfValue,c=e.pstyle("arrow-scale").value,d=this.r.getArrowWidth(u,c),h=this.instanceCount,f=this.transformBuffer.getMatrixView(h);sh(f),lh(f,f,[r,a]),ch(f,f,[d,d]),uh(f,f,i),this.vertTypeBuffer.getView(h)[0]=3,Jd(t,this.indexBuffer.getView(h)),Qd(s,l,this.colorBuffer.getView(h)),this.instanceCount++,this.instanceCount>=this.maxInstances&&this.endBatch()}}}},{key:"drawEdgeLine",value:function(e,t){if(e.visible()){var n=this._getEdgePoints(e);if(n){var r=e.pstyle("opacity").value,a=e.pstyle("line-opacity").value,i=e.pstyle("width").pfValue,o=e.pstyle("line-color").value,s=r*a;if(n.length/2+this.instanceCount>this.maxInstances&&this.endBatch(),4==n.length){var l=this.instanceCount;this.vertTypeBuffer.getView(l)[0]=1,Jd(t,this.indexBuffer.getView(l)),Qd(o,s,this.colorBuffer.getView(l)),this.lineWidthBuffer.getView(l)[0]=i;var u=this.pointAPointBBuffer.getView(l);u[0]=n[0],u[1]=n[1],u[2]=n[2],u[3]=n[3],this.instanceCount++,this.instanceCount>=this.maxInstances&&this.endBatch()}else for(var c=0;c=this.maxInstances&&this.endBatch()}}}}},{key:"_isValidEdge",value:function(e){var t=e._private.rscratch;return!t.badLine&&null!=t.allpts&&!isNaN(t.allpts[0])}},{key:"_getEdgePoints",value:function(e){var t=e._private.rscratch;if(this._isValidEdge(e)){var n=t.allpts;if(4==n.length)return n;var r=this._getNumSegments(e);return this._getCurveSegmentPoints(n,r)}}},{key:"_getNumSegments",value:function(e){return Math.min(Math.max(15,5),this.maxInstances)}},{key:"_getCurveSegmentPoints",value:function(e,t){if(4==e.length)return e;for(var n=Array(2*(t+1)),r=0;r<=t;r++)if(0==r)n[0]=e[0],n[1]=e[1];else if(r==t)n[2*r]=e[e.length-2],n[2*r+1]=e[e.length-1];else{var a=r/t;this._setCurvePoint(e,a,n,2*r)}return n}},{key:"_setCurvePoint",value:function(e,t,n,r){if(!(e.length<=2)){for(var a=Array(e.length-2),i=0;i0}},u=function(e){return"yes"===e.pstyle("text-events").strValue?yh:vh},c=function(e){var t=e.position(),n=t.x,r=t.y,a=e.outerWidth(),i=e.outerHeight();return{w:a,h:i,x1:n-a/2,y1:r-i/2}};n.drawing.addAtlasCollection("node",{texRows:e.webglTexRowsNodes}),n.drawing.addAtlasCollection("label",{texRows:e.webglTexRows}),n.drawing.addTextureAtlasRenderType("node-body",{collection:"node",getKey:t.getStyleKey,getBoundingBox:t.getElementBox,drawElement:t.drawElement}),n.drawing.addSimpleShapeRenderType("node-body",{getBoundingBox:c,isSimple:Zd,shapeProps:{shape:"shape",color:"background-color",opacity:"background-opacity",radius:"corner-radius",border:!0}}),n.drawing.addSimpleShapeRenderType("node-overlay",{getBoundingBox:c,isVisible:l("overlay"),shapeProps:{shape:"overlay-shape",color:"overlay-color",opacity:"overlay-opacity",padding:"overlay-padding",radius:"overlay-corner-radius"}}),n.drawing.addSimpleShapeRenderType("node-underlay",{getBoundingBox:c,isVisible:l("underlay"),shapeProps:{shape:"underlay-shape",color:"underlay-color",opacity:"underlay-opacity",padding:"underlay-padding",radius:"underlay-corner-radius"}}),n.drawing.addTextureAtlasRenderType("label",{collection:"label",getTexPickingMode:u,getKey:wh(t.getLabelKey,null),getBoundingBox:Eh(t.getLabelBox,null),drawClipped:!0,drawElement:t.drawLabel,getRotation:o(null),getRotationPoint:t.getLabelRotationPoint,getRotationOffset:t.getLabelRotationOffset,isVisible:s("label")}),n.drawing.addTextureAtlasRenderType("edge-source-label",{collection:"label",getTexPickingMode:u,getKey:wh(t.getSourceLabelKey,"source"),getBoundingBox:Eh(t.getSourceLabelBox,"source"),drawClipped:!0,drawElement:t.drawSourceLabel,getRotation:o("source"),getRotationPoint:t.getSourceLabelRotationPoint,getRotationOffset:t.getSourceLabelRotationOffset,isVisible:s("source-label")}),n.drawing.addTextureAtlasRenderType("edge-target-label",{collection:"label",getTexPickingMode:u,getKey:wh(t.getTargetLabelKey,"target"),getBoundingBox:Eh(t.getTargetLabelBox,"target"),drawClipped:!0,drawElement:t.drawTargetLabel,getRotation:o("target"),getRotationPoint:t.getTargetLabelRotationPoint,getRotationOffset:t.getTargetLabelRotationOffset,isVisible:s("target-label")});var d=Ae((function(){console.log("garbage collect flag set"),n.data.gc=!0}),1e4);n.onUpdateEleCalcs((function(e,t){var r=!1;t&&t.length>0&&(r|=n.drawing.invalidate(t)),r&&d()})),function(e){var t=e.render;e.render=function(n){n=n||{};var r=e.cy;e.webgl&&(r.zoom()>gd?(!function(e){var t=e.data.contexts[e.WEBGL];t.clear(t.COLOR_BUFFER_BIT|t.DEPTH_BUFFER_BIT)}(e),t.call(e,n)):(!function(e){var t=function(t){t.save(),t.setTransform(1,0,0,1,0,0),t.clearRect(0,0,e.canvasWidth,e.canvasHeight),t.restore()};t(e.data.contexts[e.NODE]),t(e.data.contexts[e.DRAG])}(e),Ch(e,n,gh.SCREEN)))};var n=e.matchCanvasSize;e.matchCanvasSize=function(t){n.call(e,t),e.pickingFrameBuffer.setFramebufferAttachmentSizes(e.canvasWidth,e.canvasHeight),e.pickingFrameBuffer.needsDraw=!0},e.findNearestElements=function(t,n,a,o){return function(e,t,n){var a,o,s,l=function(e,t,n){var r,a,o,s,l=Gd(e),u=l.pan,c=l.zoom,d=function(e,t,n,r,a){var i=r*n+t.x,o=a*n+t.y;return[i,o=Math.round(e.canvasHeight-o)]}(e,u,c,t,n),h=i(d,2),f=h[0],p=h[1],g=6;if(r=f-g/2,a=p-g/2,s=g,0===(o=g)||0===s)return[];var v=e.data.contexts[e.WEBGL];v.bindFramebuffer(v.FRAMEBUFFER,e.pickingFrameBuffer),e.pickingFrameBuffer.needsDraw&&(v.viewport(0,0,v.canvas.width,v.canvas.height),Ch(e,null,gh.PICKING),e.pickingFrameBuffer.needsDraw=!1);var y=o*s,m=new Uint8Array(4*y);v.readPixels(r,a,o,s,v.RGBA,v.UNSIGNED_BYTE,m),v.bindFramebuffer(v.FRAMEBUFFER,null);for(var b=new Set,x=0;x=0&&b.add(w)}return b}(e,t,n),u=e.getCachedZSortedEles(),c=r(l);try{for(c.s();!(s=c.n()).done;){var d=u[s.value];if(!a&&d.isNode()&&(a=d),!o&&d.isEdge()&&(o=d),a&&o)break}}catch(e){c.e(e)}finally{c.f()}return[a,o].filter(Boolean)}(e,t,n)};var a=e.invalidateCachedZSortedEles;e.invalidateCachedZSortedEles=function(){a.call(e),e.pickingFrameBuffer.needsDraw=!0};var o=e.notify;e.notify=function(t,n){o.call(e,t,n),"viewport"===t||"bounds"===t?e.pickingFrameBuffer.needsDraw=!0:"background"===t&&e.drawing.invalidate(n,{type:"node-body"})}}(n)};var wh=function(e,t){return function(n){var r=e(n),a=xh(n,t);return a.length>1?a.map((function(e,t){return"".concat(r,"_").concat(t)})):r}},Eh=function(e,t){return function(n,r){var a=e(n);if("string"==typeof r){var i=r.indexOf("_");if(i>0){var o=Number(r.substring(i+1)),s=xh(n,t),l=a.h/s.length,u=l*o,c=a.y1+u;return{x1:a.x1,w:a.w,y1:c,h:l,yOffset:u}}}return a}};function kh(e,t){var n=e.canvasWidth,r=e.canvasHeight,a=Gd(e),i=a.pan,o=a.zoom;t.setTransform(1,0,0,1,0,0),t.clearRect(0,0,n,r),t.translate(i.x,i.y),t.scale(o,o)}function Th(e,t,n){var r=e.drawing;t+=1,n.isNode()?(r.drawNode(n,t,"node-underlay"),r.drawNode(n,t,"node-body"),r.drawTexture(n,t,"label"),r.drawNode(n,t,"node-overlay")):(r.drawEdgeLine(n,t),r.drawEdgeArrow(n,t,"source"),r.drawEdgeArrow(n,t,"target"),r.drawTexture(n,t,"label"),r.drawTexture(n,t,"edge-source-label"),r.drawTexture(n,t,"edge-target-label"))}function Ch(e,t,n){var a;e.webglDebug&&(a=performance.now());var i=e.drawing,o=0;if(n.screen&&e.data.canvasNeedsRedraw[e.SELECT_BOX]&&function(e,t){e.drawSelectionRectangle(t,(function(t){return kh(e,t)}))}(e,t),e.data.canvasNeedsRedraw[e.NODE]||n.picking){var s=e.data.contexts[e.WEBGL];n.screen?(s.clearColor(0,0,0,0),s.enable(s.BLEND),s.blendFunc(s.ONE,s.ONE_MINUS_SRC_ALPHA)):s.disable(s.BLEND),s.clear(s.COLOR_BUFFER_BIT|s.DEPTH_BUFFER_BIT),s.viewport(0,0,s.canvas.width,s.canvas.height);var l=function(e){var t=e.canvasWidth,n=e.canvasHeight,r=Gd(e),a=r.pan,i=r.zoom,o=oh();lh(o,o,[a.x,a.y]),ch(o,o,[i,i]);var s=oh();!function(e,t,n){e[0]=2/t,e[1]=0,e[2]=0,e[3]=0,e[4]=-2/n,e[5]=0,e[6]=-1,e[7]=1,e[8]=1}(s,t,n);var l,u,c,d,h,f,p,g,v,y,m,b,x,w,E,k,T,C,P,S,B,D=oh();return l=D,c=o,d=(u=s)[0],h=u[1],f=u[2],p=u[3],g=u[4],v=u[5],y=u[6],m=u[7],b=u[8],x=c[0],w=c[1],E=c[2],k=c[3],T=c[4],C=c[5],P=c[6],S=c[7],B=c[8],l[0]=x*d+w*p+E*y,l[1]=x*h+w*g+E*m,l[2]=x*f+w*v+E*b,l[3]=k*d+T*p+C*y,l[4]=k*h+T*g+C*m,l[5]=k*f+T*v+C*b,l[6]=P*d+S*p+B*y,l[7]=P*h+S*g+B*m,l[8]=P*f+S*v+B*b,D}(e),u=e.getCachedZSortedEles();if(o=u.length,i.startFrame(l,n),n.screen){for(var c=0;c0&&i>0){h.clearRect(0,0,a,i),h.globalCompositeOperation="source-over";var f=this.getCachedZSortedEles();if(e.full)h.translate(-n.x1*l,-n.y1*l),h.scale(l,l),this.drawElements(h,f),h.scale(1/l,1/l),h.translate(n.x1*l,n.y1*l);else{var p=t.pan(),g={x:p.x*l,y:p.y*l};l*=t.zoom(),h.translate(g.x,g.y),h.scale(l,l),this.drawElements(h,f),h.scale(1/l,1/l),h.translate(-g.x,-g.y)}e.bg&&(h.globalCompositeOperation="destination-over",h.fillStyle=e.bg,h.rect(0,0,a,i),h.fill())}return d},Rh.png=function(e){return Nh(e,this.bufferCanvasImage(e),"image/png")},Rh.jpg=function(e){return Nh(e,this.bufferCanvasImage(e),"image/jpeg")};var Lh={nodeShapeImpl:function(e,t,n,r,a,i,o,s){switch(e){case"ellipse":return this.drawEllipsePath(t,n,r,a,i);case"polygon":return this.drawPolygonPath(t,n,r,a,i,o);case"round-polygon":return this.drawRoundPolygonPath(t,n,r,a,i,o,s);case"roundrectangle":case"round-rectangle":return this.drawRoundRectanglePath(t,n,r,a,i,s);case"cutrectangle":case"cut-rectangle":return this.drawCutRectanglePath(t,n,r,a,i,o,s);case"bottomroundrectangle":case"bottom-round-rectangle":return this.drawBottomRoundRectanglePath(t,n,r,a,i,s);case"barrel":return this.drawBarrelPath(t,n,r,a,i)}}},zh=Vh,Oh=Vh.prototype;function Vh(e){var t=this,n=t.cy.window().document;e.webgl&&(Oh.CANVAS_LAYERS=t.CANVAS_LAYERS=4,console.log("webgl rendering enabled")),t.data={canvases:new Array(Oh.CANVAS_LAYERS),contexts:new Array(Oh.CANVAS_LAYERS),canvasNeedsRedraw:new Array(Oh.CANVAS_LAYERS),bufferCanvases:new Array(Oh.BUFFER_COUNT),bufferContexts:new Array(Oh.CANVAS_LAYERS)};var r="-webkit-tap-highlight-color",a="rgba(0,0,0,0)";t.data.canvasContainer=n.createElement("div");var i=t.data.canvasContainer.style;t.data.canvasContainer.style[r]=a,i.position="relative",i.zIndex="0",i.overflow="hidden";var o=e.cy.container();o.appendChild(t.data.canvasContainer),o.style[r]=a;var s={"-webkit-user-select":"none","-moz-user-select":"-moz-none","user-select":"none","-webkit-tap-highlight-color":"rgba(0,0,0,0)","outline-style":"none"};d&&d.userAgent.match(/msie|trident|edge/i)&&(s["-ms-touch-action"]="none",s["touch-action"]="none");for(var l=0;l