diff --git a/README.md b/README.md index 5cbc9bb4..e2dee3c3 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A modern Python CLI tool that generates beautiful HTML pages for neuron types us - **🎨 Beautiful Reports**: Clean, accessible HTML pages with interactive features - **🔍 Advanced Search**: Real-time filtering by cell count, neurotransmitter, brain regions - **📊 Rich Analytics**: Hemisphere balance, connectivity stats, ROI summaries +- **⬇️ Neuron Downloads**: One-click zip of SWC skeletons or OBJ meshes for the neurons shown in the 3D view (per-dataset, see `config/README.md`) ## 🚀 Quick Start @@ -136,6 +137,12 @@ We welcome contributions! Please see our comprehensive documentation: This project is licensed under the GPLv3 License - see the LICENSE file for details. +The generated website bundles third-party browser libraries from `static/js/`, +each under the MIT license with its notice kept in the file header: jQuery, +DataTables (with its export buttons and FileSaver), and fflate (zip creation +for the neuron download dialog). The written content of the generated site is +licensed under CC BY 4.0, see `static/LICENSE`. + ## 🆘 Support - **User Documentation**: [User Guide](docs/user-guide.md) for installation and usage diff --git a/config/README.md b/config/README.md index 0b28397d..9e2e6d90 100644 --- a/config/README.md +++ b/config/README.md @@ -2,7 +2,8 @@ Each `config..yaml` file configures one NeuPrint dataset (server, dataset name, output, discovery, neuroglancer template, HTML metadata, subsets). -This document describes the optional **`hide_features`** block. +This document describes the optional **`hide_features`** block and the optional +**`neuroglancer.download_formats`** list. ## `hide_features` @@ -96,3 +97,57 @@ hide_features: neuron: ["neuroglancer"] list: ["dimorphism", "hemilineage", "neuromere"] ``` + +## `neuroglancer.download_formats` + +Offer per-neuron file downloads (skeletons, meshes) from the neuron pages. When +the list has at least one entry, the "Neuron Visualization" header shows a +download icon that opens a dialog with one radio button per format. The dialog +fetches one file per displayed neuron and saves them as a single zip archive. +When the list is missing or empty, neither the icon nor the dialog is rendered. + +```yaml +neuroglancer: + base_url: "https://neuroglancer-demo.appspot.com/" + template: "neuroglancer-cns.js.jinja" + download_formats: + - key: swc + label: "SWC skeleton (coarse)" + kind: swc + url_template: "https://www.googleapis.com/storage/v1/b/flyem-male-cns/o/v1.0%2Fsegmentation%2Fskeletons-malecns%2Fskeletons-swc%2F{body_id}.swc?alt=media" + - key: obj + label: "OBJ mesh" + kind: ngmesh + url_template: "https://www.googleapis.com/storage/v1/b/flyem-male-cns/o/v1.0%2Fsegmentation%2Fsingle-res-meshes%2F{body_id}:0?alt=media" +``` + +| Field | Required | Meaning | +|---|---|---| +| `key` | yes | Unique identifier; also used in the zip file name (`_.zip`) | +| `label` | yes | Text of the radio button in the dialog | +| `url_template` | yes | URL with a `{body_id}` placeholder; one request per neuron | +| `kind` | no (default `swc`) | `swc` or `ngmesh`, see below | + +Kinds: + +| Kind | File in the zip | Behavior | +|---|---|---| +| `swc` | `.swc` | The response is stored verbatim. Works for any text or binary per-neuron file, not only SWC | +| `ngmesh` | `.obj` | `url_template` must address the Neuroglancer legacy mesh manifest `:0`. The fragments it lists are fetched from the same directory and converted to Wavefront OBJ in the browser (vertices in nanometers) | + +Requirements and notes: + +- The host must send CORS headers, because the browser fetches the files from + the neuView page's origin (or from `file://`). For Google Cloud Storage use + the JSON API form shown above; the plain + `https://storage.googleapis.com//` URL does not send them. + Object paths in the JSON API form are URL-encoded (`/` becomes `%2F`). +- Configuration loading fails with a clear error if `{body_id}` is missing, the + `kind` is unknown, or two entries share a `key`. +- The first entry is preselected. Order the list from cheapest to largest + download; mesh files are roughly a hundred times larger than coarse SWC files. +- Selections above 50 neurons ask the user for confirmation. Neurons without a + file are listed in `missing_body_ids.txt` inside the zip. +- The page only knows the neurons it sent to the viewer. Selections changed + inside Neuroglancer can be downloaded by pasting the viewer URL into the + dialog. diff --git a/config/config.cns.yaml b/config/config.cns.yaml index 687713d6..ed72e37e 100644 --- a/config/config.cns.yaml +++ b/config/config.cns.yaml @@ -9,6 +9,23 @@ output: neuroglancer: base_url: "https://neuroglancer-demo.appspot.com/" template: "neuroglancer-cns.js.jinja" + # Per-neuron downloads offered next to the Neuroglancer view. URLs go through + # the GCS JSON API so the browser gets CORS headers (the plain + # storage.googleapis.com URLs have none). SWC coordinates are 8 nm voxels, + # mesh vertices are nanometers. + download_formats: + - key: swc + label: "SWC skeleton (coarse, ~16 KB per neuron)" + kind: swc + url_template: "https://www.googleapis.com/storage/v1/b/flyem-male-cns/o/v1.0%2Fsegmentation%2Fskeletons-malecns%2Fskeletons-swc%2F{body_id}.swc?alt=media" + - key: swc-highres + label: "SWC skeleton (high resolution, ~300 KB per neuron)" + kind: swc + url_template: "https://www.googleapis.com/storage/v1/b/flyem-male-cns/o/v1.0%2Fsegmentation%2Fskeletons-malecns%2Fskeletons-highres-swc%2F{body_id}.swc?alt=media" + - key: obj + label: "OBJ mesh (converted from the Neuroglancer mesh, ~3 MB per neuron)" + kind: ngmesh + url_template: "https://www.googleapis.com/storage/v1/b/flyem-male-cns/o/v1.0%2Fsegmentation%2Fsingle-res-meshes%2F{body_id}:0?alt=media" # Auto-discovery settings for neuron types discovery: diff --git a/config/config.example.yaml b/config/config.example.yaml index fbc9fd19..554bce66 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -10,6 +10,23 @@ output: neuroglancer: base_url: "https://clio-ng.janelia.org/" template: "neuroglancer-cns.js.jinja" + # Optional: per-neuron file formats offered by a "Download" link next to the + # Neuroglancer view. Each entry needs a unique key, a label shown in the + # dialog, a url_template with a {body_id} placeholder, and a kind: + # swc the file is stored as-is (.swc) + # ngmesh Neuroglancer legacy mesh; url_template addresses the manifest + # ":0", the browser converts the fragments to OBJ + # The host must allow cross-origin requests (CORS). For Google Cloud Storage + # use the JSON API form shown here, not the storage.googleapis.com path. + # download_formats: + # - key: swc + # label: "SWC skeleton" + # kind: swc + # url_template: "https://www.googleapis.com/storage/v1/b//o/%2F{body_id}.swc?alt=media" + # - key: obj + # label: "OBJ mesh" + # kind: ngmesh + # url_template: "https://www.googleapis.com/storage/v1/b//o/%2F{body_id}:0?alt=media" html: title_prefix: "Neuron Type Report" diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 12e509eb..56fafe5b 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -89,6 +89,16 @@ The project relies on several key Python packages defined in `pyproject.toml`. T **Code Quality (dev environment):** - `ruff>=0.12.11,<0.13` - Fast Python linter and formatter +**Vendored browser libraries (`static/js/`, copied into the generated site):** +- `jquery-3.7.1.min.js` - jQuery (MIT) +- `jquery.dataTables.min.js`, `dataTables.buttons.min.js`, `buttons.html5.min.js` - DataTables and its export buttons (MIT) +- `fflate.min.js` - fflate 0.8.2 (MIT), builds the zip archive for the neuron download dialog client-side + +These files are checked in rather than installed through a package manager so +that the generated site works from `file://` without a build step. To update +one, download the new UMD build, keep the one-line license header at the top +of the file, and mention the version bump in the commit message. + **Workflow Tools:** - `parallel>=20200322,<20250623` - GNU Parallel for batch processing - `python-lsp-server>=1.13.1,<2` - Python language server @@ -401,6 +411,14 @@ Templates are organized hierarchically with inheritance and includes. - `sections/header.html.jinja` - Page header with search - `sections/footer.html.jinja` - Page footer - `sections/connectivity_table.html.jinja` - Connectivity tables +- `sections/neuroglancer.html.jinja` - Embedded Neuroglancer viewer, theme toggle, and the "Download neurons" dialog (rendered only when `config.neuroglancer.download_formats` is non-empty) +- `sections/neuron_page_scripts.html.jinja` - Per-page script includes and the `neuroglancerData` bootstrap + +**Browser JavaScript:** +- `templates/static/js/neuroglancer-url-generator.js.jinja` - Rendered once per build into `static/js/neuroglancer-url-generator.js` with the Neuroglancer state template, ROI tables, and the `NEUROGLANCER_DOWNLOAD_FORMATS` list embedded +- `static/js/swc-download.js` - Neuron download dialog logic (body ID collection, fetching, OBJ conversion, zip) +- `static/js/neuron-page.js`, `static/js/responsive-nav.js`, `static/js/iframe-bridge.js` - Page behavior, navigation, and the bridge used when a neuView page itself is embedded in an iframe +- `static/js/fflate.min.js`, `static/js/jquery*.js`, `static/js/*dataTables*.js` - Vendored libraries (see Key Dependencies) ### Template Context @@ -774,6 +792,7 @@ Environment variable support for sensitive configuration: **Configuration Validation** (`src/neuview/config/config.py`): - Automatic validation with clear error messages using dataclass `__post_init__()` methods - Example: `NeuPrintConfig.__post_init__()` validates required server and dataset fields +- Example: `DownloadFormat.__post_init__()` (in `src/neuview/config.py`) requires the `{body_id}` placeholder in `url_template` and a known `kind`; `NeuroglancerConfig.__post_init__()` turns the YAML dicts into `DownloadFormat` objects and rejects duplicate keys - Validation occurs at configuration loading time with descriptive error messages ## API Reference @@ -1591,6 +1610,59 @@ Automatic template selection based on dataset: - FAFB datasets use specialized `neuroglancer-fafb.js.jinja` template - Other datasets use standard `neuroglancer.js.jinja` template +### Neuron Downloads (Skeletons and Meshes) + +The "Download neurons" link next to the embedded Neuroglancer view lets users +save one file per displayed neuron as a single zip archive (issue #175). The +feature is entirely client-side and only appears when the dataset config lists +at least one entry in `neuroglancer.download_formats`. + +**Data flow:** + +1. `config/config..yaml` lists formats; `NeuroglancerConfig` (`src/neuview/config.py`) parses them into validated `DownloadFormat` objects (`key`, `label`, `url_template`, `kind`, derived `extension`). +2. `NeuroglancerJSService._download_formats()` serialises the list and the JS template embeds it as `const NEUROGLANCER_DOWNLOAD_FORMATS = [...]` in the generated `static/js/neuroglancer-url-generator.js`. +3. `templates/sections/neuroglancer.html.jinja` renders the header icon and a native `` with one radio button per format. `templates/sections/neuron_page_scripts.html.jinja` includes `fflate.min.js` and `swc-download.js` under the same condition. +4. `static/js/swc-download.js` collects body IDs, fetches the files with a small concurrency pool, converts meshes, zips everything with fflate, and triggers the download through a Blob URL. + +**Body ID sources.** The embedded viewer is a cross-origin iframe, so the page +cannot read its current URL or DOM. `initializeNeuroglancerLinks()` therefore +exposes the page's own state as `window.neuviewNeuroglancerPageData`; its +`visibleNeurons` array is the default source (type neurons plus ticked +partners). As a fallback for selections changed inside the viewer, the dialog +accepts a pasted Neuroglancer URL: the `#!` fragment is decoded, parsed as +JSON, and the segments of the neuron layer are taken. The layer is found with +`findMainSegmentationLayer()` (shared with URL generation, matches `cns-seg` and +`flywire-fafb:v783b`), falling back to the first segmentation layer with +segments. Hidden segments (leading `!`) and ROI layers are skipped. + +**Formats.** `kind: swc` stores the response verbatim. `kind: ngmesh` treats +the URL as the Neuroglancer legacy mesh manifest (`:0`, a JSON list of +fragment names), derives fragment URLs by replacing `{body_id}:0` in the +template, parses each fragment (uint32 vertex count, float32 x/y/z per vertex +in nanometers, uint32 triangle indices), and writes a Wavefront OBJ with +1-based, offset-corrected face indices. Adding a new kind means extending +`DOWNLOAD_FORMAT_KINDS` in `config.py` (kind to file extension) and adding a +branch in `fetchNeuron()` in `swc-download.js`. + +**CORS.** Browsers only allow `fetch()` across origins when the host sends +`Access-Control-Allow-Origin`. The plain `storage.googleapis.com//...` +URLs of the FlyEM buckets do not, but the GCS JSON API endpoint +(`https://www.googleapis.com/storage/v1/b//o/?alt=media`) +does, including for the `null` origin of `file://` pages. Configure that form. +Neuroglancer itself is unaffected because it reads through the JSON API too. + +**Safeguards.** Selections above 50 neurons ask for confirmation. Missing files +(HTTP 404) are skipped and listed in `missing_body_ids.txt` inside the zip. +Mesh zips use a lower compression level because the OBJ text is large (about +6 MB per neuron before compression). The whole archive is assembled in memory. + +**Tests.** `test/test_config_download_formats.py` (config parsing and +validation), `test/services/test_neuroglancer_js_service.py` (embedding in the +generated JS), and `test/test_download_dialog_gating.py` (the link, dialog and +scripts render only with formats configured). The OBJ converter has no +automated test; it was verified against an independent Python parse of a real +mesh fragment. + ### Dataset Detection Patterns Centralized dataset type detection: diff --git a/docs/user-guide.md b/docs/user-guide.md index ff92eb04..724bc529 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -464,6 +464,15 @@ neuprint: dataset: hemibrain:v1.2.1 # or cns:v1.0, optic-lobe:v1.0 ``` +**Neuron downloads (male CNS only):** +`config/config.cns.yaml` configures three download formats for the "Download +neurons" dialog: coarse SWC skeleton, high-resolution SWC skeleton, and OBJ +mesh, all served from the public `flyem-male-cns` bucket. The hemibrain, +optic-lobe, FAFB and wasp configs define no formats, so their pages show no +download link. See [Neuroglancer Integration](#neuroglancer-integration) for +the user-facing behavior and [Configuration Reference](#configuration-reference) +for the config format. + ### Dataset Detection The system automatically detects dataset type and adjusts: @@ -534,6 +543,28 @@ The system automatically detects dataset type and adjusts: - Multiple selection → Build complex views - URL updates automatically +**Downloading skeletons and meshes:** + +When the dataset config lists `neuroglancer.download_formats`, the Neuron +Visualization header shows a download link next to the "open in new window" +link. It opens a dialog with one radio button per configured format (for the +male CNS: coarse SWC skeleton, high resolution SWC skeleton, OBJ mesh). The +"Download" button fetches one file per neuron the page currently sends to the +viewer (the type's neurons plus ticked partners) and saves them as a single +zip file. Neurons without a file are listed in `missing_body_ids.txt` inside +the zip. Selections above 50 neurons ask for confirmation first. Progress and +errors are shown in the dialog. + +Format notes: SWC coordinates are in 8 nm voxels, OBJ vertices are in +nanometers. OBJ files are converted in the browser from Neuroglancer's legacy +mesh format and are large (about 3 MB per neuron before compression). + +The page cannot see selection changes made inside the embedded viewer (the +viewer runs on a different origin). To download exactly what is selected in +Neuroglancer, open the viewer in a new window, copy its URL, and paste it into +the URL field of the dialog. The page parses the state in the URL and +downloads the visible segments of the neuron layer. + **Tips:** - Start with just the main neuron - Add partners one at a time @@ -708,6 +739,21 @@ output: directory: "output" clean_before_build: false +neuroglancer: + base_url: "https://neuroglancer-demo.appspot.com/" + template: "neuroglancer-cns.js.jinja" + # Optional: per-neuron downloads offered in the "Download neurons" dialog. + # Leave out (or empty) to hide the download link. + download_formats: + - key: swc + label: "SWC skeleton (coarse)" + kind: swc + url_template: "https://www.googleapis.com/storage/v1/b//o/%2F{body_id}.swc?alt=media" + - key: obj + label: "OBJ mesh" + kind: ngmesh + url_template: "https://www.googleapis.com/storage/v1/b//o/%2F{body_id}:0?alt=media" + performance: max_workers: 4 cache_enabled: true @@ -738,6 +784,18 @@ subsets: - The analytics script is automatically included on all generated pages when configured - No additional setup required - just uncomment and set your site ID +**Neuron Downloads** (`neuroglancer.download_formats`): +- Each entry needs a unique `key`, a `label` shown as a radio button, a + `url_template` containing `{body_id}`, and a `kind` +- `kind: swc` stores the fetched file as `.swc` +- `kind: ngmesh` expects the template to address the Neuroglancer legacy mesh + manifest (`:0`); the browser fetches the fragments and converts them + to `.obj` +- The file host must send CORS headers. For Google Cloud Storage use the JSON + API form shown above; the plain `storage.googleapis.com` URL does not send them +- The first entry is preselected in the dialog; an empty or missing list hides + the download link entirely + ### Command Reference neuView provides a comprehensive command-line interface. All commands are run with the `pixi run neuview` prefix. @@ -851,3 +909,11 @@ A: Same data, different formats. JSON for web servers/APIs, JS for local file ac **Q: Why do some neurons have L/R pages and others don't?** A: Automatically determined by soma distribution. Bilateral neurons get separate pages. + +**Q: How do I download the skeletons or meshes of the neurons I see?** +A: Click the download icon next to the "open in new window" icon above the +Neuroglancer view, pick a format, and press "Download". You get one zip file +with one file per neuron. The page only knows the neurons it sent to the +viewer; if you changed the selection inside Neuroglancer, copy the viewer URL +and paste it into the dialog's URL field first. The link is only present when +the dataset config defines `neuroglancer.download_formats`. diff --git a/pyproject.toml b/pyproject.toml index 9a2f4838..da4c94dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "neuview" -version = "2.7.33" +version = "2.7.34" requires-python = ">= 3.11" authors = [ { name = "Frank Loesche", email = "loeschef@janelia.hhmi.org" }, diff --git a/src/neuview/config.py b/src/neuview/config.py index 2967a512..442ee0b8 100644 --- a/src/neuview/config.py +++ b/src/neuview/config.py @@ -38,12 +38,82 @@ class DiscoveryConfig: randomize: bool = True +# Supported kinds of per-neuron download formats and the file extension the +# browser writes for each. ``swc`` is stored verbatim; ``ngmesh`` (Neuroglancer +# legacy single-resolution mesh, addressed by its ``:0`` manifest) is +# converted to Wavefront OBJ in the browser. +DOWNLOAD_FORMAT_KINDS = {"swc": "swc", "ngmesh": "obj"} + + +@dataclass +class DownloadFormat: + """One per-neuron file format offered in the "Download" dialog. + + ``url_template`` must contain the ``{body_id}`` placeholder. The host has to + send CORS headers; for Google Cloud Storage that means the JSON API endpoint + (``https://www.googleapis.com/storage/v1/b//o/?alt=media``), + not the plain ``storage.googleapis.com`` path. For ``kind: ngmesh`` the + template addresses the mesh manifest (``...{body_id}:0``); fragment URLs are + derived from it by replacing ``{body_id}:0`` with the fragment name. + """ + + key: str + label: str + url_template: str + kind: str = "swc" + + def __post_init__(self): + if not self.key or not str(self.key).strip(): + raise ValueError("download format needs a non-empty 'key'") + self.key = str(self.key).strip() + self.label = str(self.label or self.key).strip() + self.url_template = str(self.url_template or "").strip() + if "{body_id}" not in self.url_template: + raise ValueError( + f"download format '{self.key}': url_template must contain {{body_id}}" + ) + if self.kind not in DOWNLOAD_FORMAT_KINDS: + raise ValueError( + f"download format '{self.key}': unknown kind '{self.kind}' " + f"(expected one of {sorted(DOWNLOAD_FORMAT_KINDS)})" + ) + + @property + def extension(self) -> str: + return DOWNLOAD_FORMAT_KINDS[self.kind] + + def to_dict(self) -> dict: + return { + "key": self.key, + "label": self.label, + "url_template": self.url_template, + "kind": self.kind, + "extension": self.extension, + } + + @dataclass class NeuroglancerConfig: """Neuroglancer configuration.""" base_url: str = "https://clio-ng.janelia.org/" template: str = "neuroglancer.js.jinja" + # Per-neuron file formats offered by the "Download" link next to the + # Neuroglancer view (see ``DownloadFormat``). Empty hides the link. + download_formats: list = field(default_factory=list) + + def __post_init__(self): + formats = [] + seen = set() + for entry in self.download_formats or []: + fmt = ( + entry if isinstance(entry, DownloadFormat) else DownloadFormat(**entry) + ) + if fmt.key in seen: + raise ValueError(f"duplicate download format key '{fmt.key}'") + seen.add(fmt.key) + formats.append(fmt) + self.download_formats = formats @dataclass diff --git a/src/neuview/services/neuroglancer_js_service.py b/src/neuview/services/neuroglancer_js_service.py index 21269879..a767e591 100644 --- a/src/neuview/services/neuroglancer_js_service.py +++ b/src/neuview/services/neuroglancer_js_service.py @@ -83,6 +83,7 @@ def generate_neuroglancer_js(self, output_dir: Path) -> bool: "neuroglancer_json": neuroglancer_json, "dataset_name": self.config.neuprint.dataset, "neuroglancer_base_url": self.config.neuroglancer.base_url.rstrip("/"), + "neuroglancer_download_formats": self._download_formats(), } # Add ROI data from environment globals if they exist @@ -117,6 +118,23 @@ def generate_neuroglancer_js(self, output_dir: Path) -> bool: logger.debug(f"Traceback: {traceback.format_exc()}") return False + def _download_formats(self) -> list: + """Return the configured download formats as JSON-serialisable dicts. + + Tolerates configs without the attribute (older configs, mocks) so the + generated JavaScript always has a list constant to work with. + """ + formats = getattr(self.config.neuroglancer, "download_formats", None) + if not isinstance(formats, (list, tuple)): + return [] + result = [] + for fmt in formats: + if hasattr(fmt, "to_dict"): + result.append(fmt.to_dict()) + elif isinstance(fmt, dict): + result.append(dict(fmt)) + return result + def get_neuroglancer_template_name(self) -> str: """ Get the name of the neuroglancer template that would be used. diff --git a/static/css/neuron-page.css b/static/css/neuron-page.css index 74a86f16..ba7b6420 100644 --- a/static/css/neuron-page.css +++ b/static/css/neuron-page.css @@ -3235,3 +3235,113 @@ table.dataTable#roi-table tbody > tr > td:first-child { color: #666; margin-top: 2px; } + +/* SWC skeleton download dialog (opened from the Neuroglancer header) */ +.swc-download-dialog { + width: min(40rem, calc(100vw - 2rem)); + max-height: calc(100vh - 2rem); + padding: 1.25rem 1.5rem; + border: 1px solid rgba(127, 127, 127, 0.4); + border-radius: 6px; + background: #fff; + color: var(--pm-app-text-color, #333); + font-size: 0.95rem; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3); +} +.swc-download-dialog::backdrop { + background: rgba(0, 0, 0, 0.5); +} +.swc-download-dialog-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.75rem; +} +.swc-download-dialog h3 { + margin: 0; + font-size: 1.4rem; +} +/* Scoped under .plume to outrank the framework's generic button rule */ +.plume .swc-download-close { + flex: 0 0 auto; + min-width: unset; + min-height: unset; + width: 2rem; + height: 2rem; + margin: 0; + padding: 0; + border: none; + background: transparent; + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + color: inherit; +} +.swc-download-dialog p { + margin: 0 0 0.75rem; + font-family: var(--pm-font-family-primary); +} +.swc-download-formats { + width: auto; + margin: 0 0 0.75rem; + padding: 0.5rem 0.75rem 0.25rem; +} +.swc-download-formats legend { + padding: 0 0.4rem; + font-weight: 600; +} +.swc-download-format { + display: flex; + align-items: center; + gap: 0.25rem; + margin: 0 0 0.35rem; + font-weight: normal; + cursor: pointer; +} +.swc-download-format input[type="radio"] { + flex: 0 0 auto; + margin: 0 0.5rem 0 0; +} +/* Screenshot of Neuroglancer's "copy URL" button, floated beside the hint text */ +#swc-download-hint-image { + float: right; + width: 12rem; + max-width: 45%; + height: auto; + margin: 0 0 0.5rem 1rem; + border: 1px solid rgba(127, 127, 127, 0.4); + border-radius: 4px; +} +/* Contain the float so the URL field below starts on its own line */ +.swc-download-dialog p:has(#swc-download-hint-image) { + display: flow-root; +} +.swc-download-label { + margin-bottom: 0.25rem; + font-weight: 600; +} +.swc-download-dialog input[type="text"] { + width: 100%; + margin-bottom: 0.5rem; + font: inherit; +} +.swc-download-status { + min-height: 1.4em; + margin: 0 0 0.75rem; +} +.swc-download-status.is-error { + color: var(--pm-color-error, #c0392b); +} +.swc-download-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.5rem; +} +.swc-download-actions button { + margin-bottom: 0; +} +.swc-download-link.is-busy { + opacity: 0.5; +} diff --git a/static/images/Neuroglancer-help.png b/static/images/Neuroglancer-help.png index cf1fa915..3b395bfc 100644 Binary files a/static/images/Neuroglancer-help.png and b/static/images/Neuroglancer-help.png differ diff --git a/static/images/download-neurons-dialog.png b/static/images/download-neurons-dialog.png new file mode 100644 index 00000000..c9cbb8a0 Binary files /dev/null and b/static/images/download-neurons-dialog.png differ diff --git a/static/images/neuroglancer-copy-url.png b/static/images/neuroglancer-copy-url.png new file mode 100644 index 00000000..5c4295e9 Binary files /dev/null and b/static/images/neuroglancer-copy-url.png differ diff --git a/static/js/fflate.min.js b/static/js/fflate.min.js new file mode 100644 index 00000000..510d162f --- /dev/null +++ b/static/js/fflate.min.js @@ -0,0 +1,2 @@ +/*! fflate 0.8.2 | MIT License | (c) 2023 Arjun Barrett | https://github.com/101arrowz/fflate */ +!function(f){typeof module!='undefined'&&typeof exports=='object'?module.exports=f():typeof define!='undefined'&&define.amd?define(f):(typeof self!='undefined'?self:this).fflate=f()}(function(){var _e={};"use strict";var t=(typeof module!='undefined'&&typeof exports=='object'?function(_f){"use strict";var e,t=";var __w=require('worker_threads');__w.parentPort.on('message',function(m){onmessage({data:m})}),postMessage=function(m,t){__w.parentPort.postMessage(m,t)},close=process.exit;self=global";try{e=require("worker_threads").Worker}catch(e){}exports.default=e?function(r,n,o,a,s){var u=!1,i=new e(r+t,{eval:!0}).on("error",(function(e){return s(e,null)})).on("message",(function(e){return s(null,e)})).on("exit",(function(e){e&&!u&&s(Error("exited with code "+e),null)}));return i.postMessage(o,a),i.terminate=function(){return u=!0,e.prototype.terminate.call(i)},i}:function(e,t,r,n,o){setImmediate((function(){return o(Error("async operations unsupported - update to Node 12+ (or Node 10-11 with the --experimental-worker CLI flag)"),null)}));var a=function(){};return{terminate:a,postMessage:a}};return _f}:function(_f){"use strict";var e={};_f.default=function(r,t,s,a,n){var o=new Worker(e[t]||(e[t]=URL.createObjectURL(new Blob([r+';addEventListener("error",function(e){e=e.error;postMessage({$e$:[e.message,e.code,e.stack]})})'],{type:"text/javascript"}))));return o.onmessage=function(e){var r=e.data,t=r.$e$;if(t){var s=Error(t[0]);s.code=t[1],s.stack=t[2],n(s,null)}else n(null,r)},o.postMessage(s,a),o};return _f})({}),n=Uint8Array,r=Uint16Array,e=Int32Array,i=new n([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),o=new n([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),s=new n([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),a=function(t,n){for(var i=new r(31),o=0;o<31;++o)i[o]=n+=1<>1|(21845&d)<<1;v[d]=((65280&(g=(61680&(g=(52428&g)>>2|(13107&g)<<2))>>4|(3855&g)<<4))>>8|(255&g)<<8)>>1}var y=function(t,n,e){for(var i=t.length,o=0,s=new r(n);o>h]=f}else for(a=new r(i),o=0;o>15-t[o]);return a},m=new n(288);for(d=0;d<144;++d)m[d]=8;for(d=144;d<256;++d)m[d]=9;for(d=256;d<280;++d)m[d]=7;for(d=280;d<288;++d)m[d]=8;var b=new n(32);for(d=0;d<32;++d)b[d]=5;var w=y(m,9,0),x=y(m,9,1),z=y(b,5,0),k=y(b,5,1),M=function(t){for(var n=t[0],r=1;rn&&(n=t[r]);return n},S=function(t,n,r){var e=n/8|0;return(t[e]|t[e+1]<<8)>>(7&n)&r},A=function(t,n){var r=n/8|0;return(t[r]|t[r+1]<<8|t[r+2]<<16)>>(7&n)},T=function(t){return(t+7)/8|0},D=function(t,r,e){return(null==r||r<0)&&(r=0),(null==e||e>t.length)&&(e=t.length),new n(t.subarray(r,e))};_e.FlateErrorCode={UnexpectedEOF:0,InvalidBlockType:1,InvalidLengthLiteral:2,InvalidDistance:3,StreamFinished:4,NoStreamHandler:5,InvalidHeader:6,NoCallback:7,InvalidUTF8:8,ExtraFieldTooLong:9,InvalidDate:10,FilenameTooLong:11,StreamFinishing:12,InvalidZipData:13,UnknownCompressionMethod:14};var C=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],I=function(t,n,r){var e=Error(n||C[t]);if(e.code=t,Error.captureStackTrace&&Error.captureStackTrace(e,I),!r)throw e;return e},U=function(t,r,e,a){var u=t.length,f=a?a.length:0;if(!u||r.f&&!r.l)return e||new n(0);var l=!e,p=l||2!=r.i,v=r.i;l&&(e=new n(3*u));var d=function(t){var r=e.length;if(t>r){var i=new n(Math.max(2*r,t));i.set(e),e=i}},g=r.f||0,m=r.p||0,b=r.b||0,w=r.l,z=r.d,C=r.m,U=r.n,F=8*u;do{if(!w){g=S(t,m,1);var E=S(t,m+1,3);if(m+=3,!E){var Z=t[(J=T(m)+4)-4]|t[J-3]<<8,q=J+Z;if(q>u){v&&I(0);break}p&&d(b+Z),e.set(t.subarray(J,q),b),r.b=b+=Z,r.p=m=8*q,r.f=g;continue}if(1==E)w=x,z=k,C=9,U=5;else if(2==E){var O=S(t,m,31)+257,G=S(t,m+10,15)+4,L=O+S(t,m+5,31)+1;m+=14;for(var H=new n(L),j=new n(19),N=0;N>4)<16)H[N++]=J;else{var Q=0,R=0;for(16==J?(R=3+S(t,m,3),m+=2,Q=H[N-1]):17==J?(R=3+S(t,m,7),m+=3):18==J&&(R=11+S(t,m,127),m+=7);R--;)H[N++]=Q}}var V=H.subarray(0,O),W=H.subarray(O);C=M(V),U=M(W),w=y(V,C,1),z=y(W,U,1)}else I(1);if(m>F){v&&I(0);break}}p&&d(b+131072);for(var X=(1<>4;if((m+=15&Q)>F){v&&I(0);break}if(Q||I(2),tt<256)e[b++]=tt;else{if(256==tt){_=m,w=null;break}var nt=tt-254;tt>264&&(nt=S(t,m,(1<<(it=i[N=tt-257]))-1)+h[N],m+=it);var rt=z[A(t,m)&$],et=rt>>4;if(rt||I(3),m+=15&rt,W=c[et],et>3){var it=o[et];W+=A(t,m)&(1<F){v&&I(0);break}p&&d(b+131072);var ot=b+nt;if(b>8},E=function(t,n,r){var e=n/8|0;t[e]|=r<<=7&n,t[e+1]|=r>>8,t[e+2]|=r>>16},Z=function(t,e){for(var i=[],o=0;ov&&(v=a[o].s);var d=new r(v+1),g=q(i[c-1],d,0);if(g>e){o=0;var y=0,m=g-e,b=1<e))break;y+=b-(1<>=m;y>0;){var x=a[o].s;d[x]=0&&y;--o){var z=a[o].s;d[z]==e&&(--d[z],++y)}g=e}return{t:new n(d),l:g}},q=function(t,n,r){return-1==t.s?Math.max(q(t.l,n,r+1),q(t.r,n,r+1)):n[t.s]=r},O=function(t){for(var n=t.length;n&&!t[--n];);for(var e=new r(++n),i=0,o=t[0],s=1,a=function(t){e[i++]=t},u=1;u<=n;++u)if(t[u]==o&&u!=n)++s;else{if(!o&&s>2){for(;s>138;s-=138)a(32754);s>2&&(a(s>10?s-11<<5|28690:s-3<<5|12305),s=0)}else if(s>3){for(a(o),--s;s>6;s-=6)a(8304);s>2&&(a(s-3<<5|8208),s=0)}for(;s--;)a(o);s=1,o=t[u]}return{c:e.subarray(0,i),n:n}},G=function(t,n){for(var r=0,e=0;e>8,t[i+2]=255^t[i],t[i+3]=255^t[i+1];for(var o=0;o4&&!N[s[B-1]];--B);var Y,J,K,Q,R=p+5<<3,V=G(u,m)+G(h,b)+f,W=G(u,g)+G(h,M)+f+14+3*B+G(q,N)+2*q[16]+3*q[17]+7*q[18];if(c>=0&&R<=V&&R<=W)return L(n,v,t.subarray(c,c+p));if(F(n,v,1+(W15&&(F(n,v,tt[H]>>5&127),v+=tt[H]>>12)}}else Y=w,J=m,K=z,Q=b;for(H=0;H255){var rt;E(n,v,Y[257+(rt=nt>>18&31)]),v+=J[rt+257],rt>7&&(F(n,v,nt>>23&31),v+=i[rt]);var et=31&nt;E(n,v,K[et]),v+=Q[et],et>3&&(E(n,v,nt>>5&8191),v+=o[et])}else E(n,v,Y[nt]),v+=J[nt]}return E(n,v,Y[256]),v+J[256]},j=new e([65540,131080,131088,131104,262176,1048704,1048832,2114560,2117632]),N=new n(0),P=function(t,s,a,u,h,l){var c=l.z||t.length,v=new n(u+c+5*(1+Math.ceil(c/7e3))+h),d=v.subarray(u,v.length-h),g=l.l,y=7&(l.r||0);if(s){y&&(d[0]=l.r>>3);for(var m=j[s-1],b=m>>13,w=8191&m,x=(1<7e3||q>24576)&&(Y>423||!g)){y=H(t,d,0,C,I,U,E,q,G,Z-G,y),q=F=E=0,G=Z;for(var J=0;J<286;++J)I[J]=0;for(J=0;J<30;++J)U[J]=0}var K=2,Q=0,R=w,V=P-B&32767;if(Y>2&&N==A(Z-V))for(var W=Math.min(b,Y)-1,X=Math.min(32767,Z),$=Math.min(258,Y);V<=X&&--R&&P!=B;){if(t[Z+K]==t[Z+K-V]){for(var _=0;_<$&&t[Z+_]==t[Z+_-V];++_);if(_>K){if(K=_,Q=V,_>W)break;var tt=Math.min(V,_-2),nt=0;for(J=0;Jnt&&(nt=et,B=rt)}}}V+=(P=B)-(B=z[P])&32767}if(Q){C[q++]=268435456|f[K]<<18|p[Q];var it=31&f[K],ot=31&p[Q];E+=i[it]+o[ot],++I[257+it],++U[ot],O=Z+K,++F}else C[q++]=t[Z],++I[t[Z]]}}for(Z=Math.max(Z,O);Z=c&&(d[y/8|0]=g,st=c),y=L(d,y+1,t.subarray(Z,st))}l.i=c}return D(v,0,u+T(y)+h)},B=function(){for(var t=new Int32Array(256),n=0;n<256;++n){for(var r=n,e=9;--e;)r=(1&r&&-306674912)^r>>>1;t[n]=r}return t}(),Y=function(){var t=-1;return{p:function(n){for(var r=t,e=0;e>>8;t=r},d:function(){return~t}}},J=function(){var t=1,n=0;return{p:function(r){for(var e=t,i=n,o=0|r.length,s=0;s!=o;){for(var a=Math.min(s+2655,o);s>16),i=(65535&i)+15*(i>>16)}t=e,n=i},d:function(){return(255&(t%=65521))<<24|(65280&t)<<8|(255&(n%=65521))<<8|n>>8}}},K=function(t,r,e,i,o){if(!o&&(o={l:1},r.dictionary)){var s=r.dictionary.subarray(-32768),a=new n(s.length+t.length);a.set(s),a.set(t,s.length),t=a,o.w=s.length}return P(t,null==r.level?6:r.level,null==r.mem?o.l?Math.ceil(1.5*Math.max(8,Math.min(13,Math.log(t.length)))):20:12+r.mem,e,i,o)},Q=function(t,n){var r={};for(var e in t)r[e]=t[e];for(var e in n)r[e]=n[e];return r},R=function(t,n,r){for(var e=t(),i=""+t,o=i.slice(i.indexOf("[")+1,i.lastIndexOf("]")).replace(/\s+/g,"").split(","),s=0;s>>0},lt=function(t,n){return ft(t,n)+4294967296*ft(t,n+4)},ct=function(t,n,r){for(;r;++n)t[n]=r,r>>>=8},pt=function(t,n){var r=n.filename;if(t[0]=31,t[1]=139,t[2]=8,t[8]=n.level<2?4:9==n.level?2:0,t[9]=3,0!=n.mtime&&ct(t,4,Math.floor(new Date(n.mtime||Date.now())/1e3)),r){t[3]=8;for(var e=0;e<=r.length;++e)t[e+10]=r.charCodeAt(e)}},vt=function(t){31==t[0]&&139==t[1]&&8==t[2]||I(6,"invalid gzip data");var n=t[3],r=10;4&n&&(r+=2+(t[10]|t[11]<<8));for(var e=(n>>3&1)+(n>>4&1);e>0;e-=!t[r++]);return r+(2&n)},dt=function(t){var n=t.length;return(t[n-4]|t[n-3]<<8|t[n-2]<<16|t[n-1]<<24)>>>0},gt=function(t){return 10+(t.filename?t.filename.length+1:0)},yt=function(t,n){var r=n.level,e=0==r?0:r<6?1:9==r?3:2;if(t[0]=120,t[1]=e<<6|(n.dictionary&&32),t[1]|=31-(t[0]<<8|t[1])%31,n.dictionary){var i=J();i.p(n.dictionary),ct(t,2,i.d())}},mt=function(t,n){return(8!=(15&t[0])||t[0]>>4>7||(t[0]<<8|t[1])%31)&&I(6,"invalid zlib data"),(t[1]>>5&1)==+!n&&I(6,"invalid zlib data: "+(32&t[1]?"need":"unexpected")+" dictionary"),2+(t[1]>>3&4)};function bt(t,n){return"function"==typeof t&&(n=t,t={}),this.ondata=n,t}var wt=function(){function t(t,r){if("function"==typeof t&&(r=t,t={}),this.ondata=r,this.o=t||{},this.s={l:0,i:32768,w:32768,z:32768},this.b=new n(98304),this.o.dictionary){var e=this.o.dictionary.subarray(-32768);this.b.set(e,32768-e.length),this.s.i=32768-e.length}}return t.prototype.p=function(t,n){this.ondata(K(t,this.o,0,0,this.s),n)},t.prototype.push=function(t,r){this.ondata||I(5),this.s.l&&I(4);var e=t.length+this.s.z;if(e>this.b.length){if(e>2*this.b.length-32768){var i=new n(-32768&e);i.set(this.b.subarray(0,this.s.z)),this.b=i}var o=this.b.length-this.s.z;this.b.set(t.subarray(0,o),this.s.z),this.s.z=this.b.length,this.p(this.b,!1),this.b.set(this.b.subarray(-32768)),this.b.set(t.subarray(o),32768),this.s.z=t.length-o+32768,this.s.i=32766,this.s.w=32768}else this.b.set(t,this.s.z),this.s.z+=t.length;this.s.l=1&r,(this.s.z>this.s.w+8191||r)&&(this.p(this.b,r||!1),this.s.w=this.s.i,this.s.i-=2)},t.prototype.flush=function(){this.ondata||I(5),this.s.l&&I(4),this.p(this.b,!1),this.s.w=this.s.i,this.s.i-=2},t}();_e.Deflate=wt;var xt=function(){return function(t,n){ut([_,function(){return[at,wt]}],this,bt.call(this,t,n),(function(t){var n=new wt(t.data);onmessage=at(n)}),6,1)}}();function zt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[_],(function(t){return it(kt(t.data[0],t.data[1]))}),0,r)}function kt(t,n){return K(t,n||{},0,0)}_e.AsyncDeflate=xt,_e.deflate=zt,_e.deflateSync=kt;var Mt=function(){function t(t,r){"function"==typeof t&&(r=t,t={}),this.ondata=r;var e=t&&t.dictionary&&t.dictionary.subarray(-32768);this.s={i:0,b:e?e.length:0},this.o=new n(32768),this.p=new n(0),e&&this.o.set(e)}return t.prototype.e=function(t){if(this.ondata||I(5),this.d&&I(4),this.p.length){if(t.length){var r=new n(this.p.length+t.length);r.set(this.p),r.set(t,this.p.length),this.p=r}}else this.p=t},t.prototype.c=function(t){this.s.i=+(this.d=t||!1);var n=this.s.b,r=U(this.p,this.s,this.o);this.ondata(D(r,n,this.s.b),this.d),this.o=D(r,this.s.b-32768),this.s.b=this.o.length,this.p=D(this.p,this.s.p/8|0),this.s.p&=7},t.prototype.push=function(t,n){this.e(t),this.c(n)},t}();_e.Inflate=Mt;var St=function(){return function(t,n){ut([$,function(){return[at,Mt]}],this,bt.call(this,t,n),(function(t){var n=new Mt(t.data);onmessage=at(n)}),7,0)}}();function At(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[$],(function(t){return it(Tt(t.data[0],ot(t.data[1])))}),1,r)}function Tt(t,n){return U(t,{i:2},n&&n.out,n&&n.dictionary)}_e.AsyncInflate=St,_e.inflate=At,_e.inflateSync=Tt;var Dt=function(){function t(t,n){this.c=Y(),this.l=0,this.v=1,wt.call(this,t,n)}return t.prototype.push=function(t,n){this.c.p(t),this.l+=t.length,wt.prototype.push.call(this,t,n)},t.prototype.p=function(t,n){var r=K(t,this.o,this.v&>(this.o),n&&8,this.s);this.v&&(pt(r,this.o),this.v=0),n&&(ct(r,r.length-8,this.c.d()),ct(r,r.length-4,this.l)),this.ondata(r,n)},t.prototype.flush=function(){wt.prototype.flush.call(this)},t}();_e.Gzip=Dt,_e.Compress=Dt;var Ct=function(){return function(t,n){ut([_,tt,function(){return[at,wt,Dt]}],this,bt.call(this,t,n),(function(t){var n=new Dt(t.data);onmessage=at(n)}),8,1)}}();function It(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[_,tt,function(){return[Ut]}],(function(t){return it(Ut(t.data[0],t.data[1]))}),2,r)}function Ut(t,n){n||(n={});var r=Y(),e=t.length;r.p(t);var i=K(t,n,gt(n),8),o=i.length;return pt(i,n),ct(i,o-8,r.d()),ct(i,o-4,e),i}_e.AsyncGzip=Ct,_e.AsyncCompress=Ct,_e.gzip=It,_e.compress=It,_e.gzipSync=Ut,_e.compressSync=Ut;var Ft=function(){function t(t,n){this.v=1,this.r=0,Mt.call(this,t,n)}return t.prototype.push=function(t,r){if(Mt.prototype.e.call(this,t),this.r+=t.length,this.v){var e=this.p.subarray(this.v-1),i=e.length>3?vt(e):4;if(i>e.length){if(!r)return}else this.v>1&&this.onmember&&this.onmember(this.r-e.length);this.p=e.subarray(i),this.v=0}Mt.prototype.c.call(this,r),!this.s.f||this.s.l||r||(this.v=T(this.s.p)+9,this.s={i:0},this.o=new n(0),this.push(new n(0),r))},t}();_e.Gunzip=Ft;var Et=function(){return function(t,n){var r=this;ut([$,nt,function(){return[at,Mt,Ft]}],this,bt.call(this,t,n),(function(t){var n=new Ft(t.data);n.onmember=function(t){return postMessage(t)},onmessage=at(n)}),9,0,(function(t){return r.onmember&&r.onmember(t)}))}}();function Zt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[$,nt,function(){return[qt]}],(function(t){return it(qt(t.data[0],t.data[1]))}),3,r)}function qt(t,r){var e=vt(t);return e+8>t.length&&I(6,"invalid gzip data"),U(t.subarray(e,-8),{i:2},r&&r.out||new n(dt(t)),r&&r.dictionary)}_e.AsyncGunzip=Et,_e.gunzip=Zt,_e.gunzipSync=qt;var Ot=function(){function t(t,n){this.c=J(),this.v=1,wt.call(this,t,n)}return t.prototype.push=function(t,n){this.c.p(t),wt.prototype.push.call(this,t,n)},t.prototype.p=function(t,n){var r=K(t,this.o,this.v&&(this.o.dictionary?6:2),n&&4,this.s);this.v&&(yt(r,this.o),this.v=0),n&&ct(r,r.length-4,this.c.d()),this.ondata(r,n)},t.prototype.flush=function(){wt.prototype.flush.call(this)},t}();_e.Zlib=Ot;var Gt=function(){return function(t,n){ut([_,rt,function(){return[at,wt,Ot]}],this,bt.call(this,t,n),(function(t){var n=new Ot(t.data);onmessage=at(n)}),10,1)}}();function Lt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[_,rt,function(){return[Ht]}],(function(t){return it(Ht(t.data[0],t.data[1]))}),4,r)}function Ht(t,n){n||(n={});var r=J();r.p(t);var e=K(t,n,n.dictionary?6:2,4);return yt(e,n),ct(e,e.length-4,r.d()),e}_e.AsyncZlib=Gt,_e.zlib=Lt,_e.zlibSync=Ht;var jt=function(){function t(t,n){Mt.call(this,t,n),this.v=t&&t.dictionary?2:1}return t.prototype.push=function(t,n){if(Mt.prototype.e.call(this,t),this.v){if(this.p.length<6&&!n)return;this.p=this.p.subarray(mt(this.p,this.v-1)),this.v=0}n&&(this.p.length<4&&I(6,"invalid zlib data"),this.p=this.p.subarray(0,-4)),Mt.prototype.c.call(this,n)},t}();_e.Unzlib=jt;var Nt=function(){return function(t,n){ut([$,et,function(){return[at,Mt,jt]}],this,bt.call(this,t,n),(function(t){var n=new jt(t.data);onmessage=at(n)}),11,0)}}();function Pt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[$,et,function(){return[Bt]}],(function(t){return it(Bt(t.data[0],ot(t.data[1])))}),5,r)}function Bt(t,n){return U(t.subarray(mt(t,n&&n.dictionary),-4),{i:2},n&&n.out,n&&n.dictionary)}_e.AsyncUnzlib=Nt,_e.unzlib=Pt,_e.unzlibSync=Bt;var Yt=function(){function t(t,n){this.o=bt.call(this,t,n)||{},this.G=Ft,this.I=Mt,this.Z=jt}return t.prototype.i=function(){var t=this;this.s.ondata=function(n,r){t.ondata(n,r)}},t.prototype.push=function(t,r){if(this.ondata||I(5),this.s)this.s.push(t,r);else{if(this.p&&this.p.length){var e=new n(this.p.length+t.length);e.set(this.p),e.set(t,this.p.length)}else this.p=t;this.p.length>2&&(this.s=31==this.p[0]&&139==this.p[1]&&8==this.p[2]?new this.G(this.o):8!=(15&this.p[0])||this.p[0]>>4>7||(this.p[0]<<8|this.p[1])%31?new this.I(this.o):new this.Z(this.o),this.i(),this.s.push(this.p,r),this.p=null)}},t}();_e.Decompress=Yt;var Jt=function(){function t(t,n){Yt.call(this,t,n),this.queuedSize=0,this.G=Et,this.I=St,this.Z=Nt}return t.prototype.i=function(){var t=this;this.s.ondata=function(n,r,e){t.ondata(n,r,e)},this.s.ondrain=function(n){t.queuedSize-=n,t.ondrain&&t.ondrain(n)}},t.prototype.push=function(t,n){this.queuedSize+=t.length,Yt.prototype.push.call(this,t,n)},t}();function Kt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),31==t[0]&&139==t[1]&&8==t[2]?Zt(t,n,r):8!=(15&t[0])||t[0]>>4>7||(t[0]<<8|t[1])%31?At(t,n,r):Pt(t,n,r)}function Qt(t,n){return 31==t[0]&&139==t[1]&&8==t[2]?qt(t,n):8!=(15&t[0])||t[0]>>4>7||(t[0]<<8|t[1])%31?Tt(t,n):Bt(t,n)}_e.AsyncDecompress=Jt,_e.decompress=Kt,_e.decompressSync=Qt;var Rt=function(t,r,e,i){for(var o in t){var s=t[o],a=r+o,u=i;Array.isArray(s)&&(u=Q(i,s[1]),s=s[0]),s instanceof n?e[a]=[s,u]:(e[a+="/"]=[new n(0),u],Rt(s,a,e,i))}},Vt="undefined"!=typeof TextEncoder&&new TextEncoder,Wt="undefined"!=typeof TextDecoder&&new TextDecoder,Xt=0;try{Wt.decode(N,{stream:!0}),Xt=1}catch(t){}var $t=function(t){for(var n="",r=0;;){var e=t[r++],i=(e>127)+(e>223)+(e>239);if(r+i>t.length)return{s:n,r:D(t,r-1)};i?3==i?(e=((15&e)<<18|(63&t[r++])<<12|(63&t[r++])<<6|63&t[r++])-65536,n+=String.fromCharCode(55296|e>>10,56320|1023&e)):n+=String.fromCharCode(1&i?(31&e)<<6|63&t[r++]:(15&e)<<12|(63&t[r++])<<6|63&t[r++]):n+=String.fromCharCode(e)}},_t=function(){function t(t){this.ondata=t,Xt?this.t=new TextDecoder:this.p=N}return t.prototype.push=function(t,r){if(this.ondata||I(5),r=!!r,this.t)return this.ondata(this.t.decode(t,{stream:!0}),r),void(r&&(this.t.decode().length&&I(8),this.t=null));this.p||I(4);var e=new n(this.p.length+t.length);e.set(this.p),e.set(t,this.p.length);var i=$t(e),o=i.s,s=i.r;r?(s.length&&I(8),this.p=null):this.p=s,this.ondata(o,r)},t}();_e.DecodeUTF8=_t;var tn=function(){function t(t){this.ondata=t}return t.prototype.push=function(t,n){this.ondata||I(5),this.d&&I(4),this.ondata(nn(t),this.d=n||!1)},t}();function nn(t,r){if(r){for(var e=new n(t.length),i=0;i>1)),a=0,u=function(t){s[a++]=t};for(i=0;is.length){var h=new n(a+8+(o-i<<1));h.set(s),s=h}var f=t.charCodeAt(i);f<128||r?u(f):f<2048?(u(192|f>>6),u(128|63&f)):f>55295&&f<57344?(u(240|(f=65536+(1047552&f)|1023&t.charCodeAt(++i))>>18),u(128|f>>12&63),u(128|f>>6&63),u(128|63&f)):(u(224|f>>12),u(128|f>>6&63),u(128|63&f))}return D(s,0,a)}function rn(t,n){if(n){for(var r="",e=0;e65535&&I(9),n+=e+4}return n},hn=function(t,n,r,e,i,o,s,a){var u=e.length,h=r.extra,f=a&&a.length,l=un(h);ct(t,n,null!=s?33639248:67324752),n+=4,null!=s&&(t[n++]=20,t[n++]=r.os),t[n]=20,n+=2,t[n++]=r.flag<<1|(o<0&&8),t[n++]=i&&8,t[n++]=255&r.compression,t[n++]=r.compression>>8;var c=new Date(null==r.mtime?Date.now():r.mtime),p=c.getFullYear()-1980;if((p<0||p>119)&&I(10),ct(t,n,p<<25|c.getMonth()+1<<21|c.getDate()<<16|c.getHours()<<11|c.getMinutes()<<5|c.getSeconds()>>1),n+=4,-1!=o&&(ct(t,n,r.crc),ct(t,n+4,o<0?-o-2:o),ct(t,n+8,r.size)),ct(t,n+12,u),ct(t,n+14,l),n+=16,null!=s&&(ct(t,n,f),ct(t,n+6,r.attrs),ct(t,n+10,s),n+=14),t.set(e,n),n+=u,l)for(var v in h){var d=h[v],g=d.length;ct(t,n,+v),ct(t,n+2,g),t.set(d,n+4),n+=4+g}return f&&(t.set(a,n),n+=f),n},fn=function(t,n,r,e,i){ct(t,n,101010256),ct(t,n+8,r),ct(t,n+10,r),ct(t,n+12,e),ct(t,n+16,i)},ln=function(){function t(t){this.filename=t,this.c=Y(),this.size=0,this.compression=0}return t.prototype.process=function(t,n){this.ondata(null,t,n)},t.prototype.push=function(t,n){this.ondata||I(5),this.c.p(t),this.size+=t.length,n&&(this.crc=this.c.d()),this.process(t,n||!1)},t}();_e.ZipPassThrough=ln;var cn=function(){function t(t,n){var r=this;n||(n={}),ln.call(this,t),this.d=new wt(n,(function(t,n){r.ondata(null,t,n)})),this.compression=8,this.flag=en(n.level)}return t.prototype.process=function(t,n){try{this.d.push(t,n)}catch(t){this.ondata(t,null,n)}},t.prototype.push=function(t,n){ln.prototype.push.call(this,t,n)},t}();_e.ZipDeflate=cn;var pn=function(){function t(t,n){var r=this;n||(n={}),ln.call(this,t),this.d=new xt(n,(function(t,n,e){r.ondata(t,n,e)})),this.compression=8,this.flag=en(n.level),this.terminate=this.d.terminate}return t.prototype.process=function(t,n){this.d.push(t,n)},t.prototype.push=function(t,n){ln.prototype.push.call(this,t,n)},t}();_e.AsyncZipDeflate=pn;var vn=function(){function t(t){this.ondata=t,this.u=[],this.d=1}return t.prototype.add=function(t){var r=this;if(this.ondata||I(5),2&this.d)this.ondata(I(4+8*(1&this.d),0,1),null,!1);else{var e=nn(t.filename),i=e.length,o=t.comment,s=o&&nn(o),a=i!=t.filename.length||s&&o.length!=s.length,u=i+un(t.extra)+30;i>65535&&this.ondata(I(11,0,1),null,!1);var h=new n(u);hn(h,0,t,e,a,-1);var f=[h],l=function(){for(var t=0,n=f;t65535&&M(I(11,0,1),null),k)if(g<16e4)try{M(null,kt(e,h))}catch(t){M(t,null)}else l.push(zt(e,h,M));else M(null,e)},g=0;g65535&&I(11);var y=l?kt(h,f):h,m=y.length,b=Y();b.p(h),i.push(Q(f,{size:h.length,crc:b.d(),c:y,f:M,m:v,u:c!=a.length||v&&p.length!=d,o:o,compression:l})),o+=30+c+g+m,s+=76+2*(c+g)+(d||0)+m}for(var w=new n(s+22),x=o,z=s-o,k=0;k0){var i=Math.min(this.c,t.length),o=t.subarray(0,i);if(this.c-=i,this.d?this.d.push(o,!this.c):this.k[0].push(o),(t=t.subarray(i)).length)return this.push(t,r)}else{var s=0,a=0,u=void 0,h=void 0;this.p.length?t.length?((h=new n(this.p.length+t.length)).set(this.p),h.set(t,this.p.length)):h=this.p:h=t;for(var f=h.length,l=this.c,c=l&&this.d,p=function(){var t,n=ft(h,a);if(67324752==n){s=1,u=a,v.d=null,v.c=0;var r=ht(h,a+6),i=ht(h,a+8),o=2048&r,c=8&r,p=ht(h,a+26),d=ht(h,a+28);if(f>a+30+p+d){var g=[];v.k.unshift(g),s=2;var y,m=ft(h,a+18),b=ft(h,a+22),w=rn(h.subarray(a+30,a+=30+p),!o);4294967295==m?(t=c?[-2]:an(h,a),m=t[0],b=t[1]):c&&(m=-1),a+=d,v.c=m;var x={name:w,compression:i,start:function(){if(x.ondata||I(5),m){var t=e.o[i];t||x.ondata(I(14,"unknown compression type "+i,1),null,!1),(y=m<0?new t(w):new t(w,m,b)).ondata=function(t,n,r){x.ondata(t,n,r)};for(var n=0,r=g;n=0&&(x.size=m,x.originalSize=b),v.onfile(x)}return"break"}if(l){if(134695760==n)return u=a+=12+(-2==l&&8),s=3,v.c=0,"break";if(33639248==n)return u=a-=4,s=3,v.c=0,"break"}},v=this;a65558)return a(I(13,0,1),null),o;var h=ht(t,u+8);if(h){var f=h,l=ft(t,u+16),c=4294967295==l||65535==f;if(c){var p=ft(t,u-12);(c=101075792==ft(t,p))&&(f=h=ft(t,p+32),l=ft(t,p+48))}for(var v=r&&r.filter,d=function(r){var e=sn(t,l,c),u=e[0],f=e[1],p=e[2],d=e[3],g=e[4],y=on(t,e[5]);l=g;var m=function(t,n){t?(o(),a(t,null)):(n&&(s[d]=n),--h||a(null,s))};if(!v||v({name:d,size:f,originalSize:p,compression:u}))if(u)if(8==u){var b=t.subarray(y,y+f);if(p<524288||f>.8*p)try{m(null,Tt(b,{out:new n(p)}))}catch(t){m(t,null)}else i.push(At(b,{size:p},m))}else m(I(14,"unknown compression type "+u,1),null);else m(null,D(t,y,y+f));else m(null,null)},g=0;g65558)&&I(13);var o=ht(t,i+8);if(!o)return{};var s=ft(t,i+16),a=4294967295==s||65535==o;if(a){var u=ft(t,i-12);(a=101075792==ft(t,u))&&(o=ft(t,u+32),s=ft(t,u+48))}for(var h=r&&r.filter,f=0;f:0" + * manifest, fragments are fetched and converted to Wavefront OBJ + * The hosts must send CORS headers. + */ +(function () { + "use strict"; + + const MAX_FILES_WITHOUT_CONFIRM = 50; + const CONCURRENCY = 6; + + let running = false; + + function formats() { + return Array.isArray(NEUROGLANCER_DOWNLOAD_FORMATS) + ? NEUROGLANCER_DOWNLOAD_FORMATS.filter((f) => f && f.url_template) + : []; + } + + function selectedFormat() { + const all = formats(); + const checked = document.querySelector('input[name="swc-download-format"]:checked'); + if (checked) { + const match = all.find((f) => f.key === checked.value); + if (match) return match; + } + return all[0]; + } + + function fileUrl(fmt, bodyId) { + return fmt.url_template.replace("{body_id}", encodeURIComponent(String(bodyId))); + } + + /** URL of one mesh fragment, derived from the manifest URL template. */ + function fragmentUrl(fmt, bodyId, fragment) { + const encoded = encodeURIComponent(String(fragment)); + if (fmt.url_template.includes("{body_id}:0")) { + return fmt.url_template.replace("{body_id}:0", encoded); + } + return fmt.url_template.replace("{body_id}", encoded); + } + + function setStatus(text, isError) { + const el = document.getElementById("swc-download-status"); + if (!el) return; + el.textContent = text || ""; + el.classList.toggle("is-error", Boolean(isError)); + } + + function dialogElement() { + return document.getElementById("swc-download-dialog"); + } + + function openDialog(event) { + if (event) event.preventDefault(); + const dialog = dialogElement(); + if (!dialog) return; + if (!running) setStatus(""); + if (typeof dialog.showModal === "function") { + if (!dialog.open) dialog.showModal(); + } else { + dialog.setAttribute("open", ""); + } + const input = document.getElementById("swc-url-input"); + if (input) input.focus(); + } + + function closeDialog(event) { + if (event) event.preventDefault(); + const dialog = dialogElement(); + if (!dialog) return; + if (typeof dialog.close === "function") { + if (dialog.open) dialog.close(); + } else { + dialog.removeAttribute("open"); + } + } + + function setBusy(busy) { + running = busy; + const link = document.getElementById("swc-download-link"); + const button = document.getElementById("swc-download-button"); + if (link) link.classList.toggle("is-busy", busy); + if (button) button.disabled = busy; + } + + /** + * Normalise a neuroglancer segments list to an array of unique, visible + * body ID strings. Hidden segments are stored with a leading "!". + */ + function normaliseSegments(segments) { + const ids = []; + const seen = new Set(); + (Array.isArray(segments) ? segments : []).forEach((seg) => { + if (seg === null || seg === undefined) return; + const str = String(seg).trim(); + if (!str || str.startsWith("!")) return; + if (!/^\d+$/.test(str)) return; + if (!seen.has(str)) { + seen.add(str); + ids.push(str); + } + }); + return ids; + } + + /** + * Extract the neuron body IDs from a Neuroglancer URL. + * @param {string} url + * @returns {{bodyIds: string[], error?: string}} + */ + function parseNeuroglancerUrl(url) { + const text = String(url || "").trim(); + const idx = text.indexOf("#!"); + if (idx === -1) { + return { bodyIds: [], error: "No Neuroglancer state found in the URL (expected \"#!\" followed by JSON)." }; + } + const fragment = text.slice(idx + 2); + if (/^(https?:|gs:|s3:)/i.test(fragment)) { + return { + bodyIds: [], + error: "This URL points to a state stored on a server. Open it in Neuroglancer, then copy the URL again once the JSON state is shown in the address bar.", + }; + } + let state; + try { + state = JSON.parse(decodeURIComponent(fragment)); + } catch (e1) { + try { + state = JSON.parse(fragment); + } catch (e2) { + return { bodyIds: [], error: "Could not parse the Neuroglancer state in the URL." }; + } + } + const layers = state && Array.isArray(state.layers) ? state.layers : []; + let layer = + typeof window.findMainSegmentationLayer === "function" + ? window.findMainSegmentationLayer(layers) + : undefined; + if (!layer) { + // Fallback for renamed layers: first segmentation layer with segments. + layer = layers.find( + (l) => l && l.type === "segmentation" && Array.isArray(l.segments) && l.segments.length > 0, + ); + } + if (!layer) { + return { bodyIds: [], error: "No segmentation layer with selected neurons found in the URL." }; + } + return { bodyIds: normaliseSegments(layer.segments) }; + } + + /** + * Determine which body IDs to download. + * @returns {{bodyIds: string[], source: "url"|"page", error?: string}} + */ + function collectBodyIds() { + const input = document.getElementById("swc-url-input"); + const pasted = input ? input.value.trim() : ""; + if (pasted) { + const parsed = parseNeuroglancerUrl(pasted); + return { bodyIds: parsed.bodyIds, source: "url", error: parsed.error }; + } + const pageData = window.neuviewNeuroglancerPageData || {}; + return { bodyIds: normaliseSegments(pageData.visibleNeurons), source: "page" }; + } + + async function fetchBytes(url) { + const response = await fetch(url, { mode: "cors" }); + if (!response.ok) { + const err = new Error(`HTTP ${response.status}`); + err.status = response.status; + throw err; + } + return new Uint8Array(await response.arrayBuffer()); + } + + /** + * Convert Neuroglancer legacy mesh fragments to Wavefront OBJ text. + * Fragment layout: uint32 vertex count, float32 x/y/z per vertex (nm), + * then uint32 triangle indices until the end of the buffer. + */ + function ngmeshToObj(fragments, bodyId) { + const lines = [ + `# body ID ${bodyId}`, + "# converted from Neuroglancer legacy mesh; vertex units: nanometers", + ]; + const faces = []; + let offset = 0; + fragments.forEach((bytes) => { + if (bytes.byteLength < 4) return; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const nVerts = view.getUint32(0, true); + const vertBytes = nVerts * 12; + if (4 + vertBytes > bytes.byteLength) { + throw new Error("truncated mesh fragment"); + } + for (let i = 0; i < nVerts; i++) { + const b = 4 + i * 12; + lines.push( + `v ${view.getFloat32(b, true).toFixed(2)} ${view.getFloat32(b + 4, true).toFixed(2)} ${view.getFloat32(b + 8, true).toFixed(2)}`, + ); + } + const triStart = 4 + vertBytes; + const nIdx = Math.floor((bytes.byteLength - triStart) / 4); + for (let i = 0; i + 2 < nIdx; i += 3) { + const b = triStart + i * 4; + faces.push( + `f ${view.getUint32(b, true) + offset + 1} ${view.getUint32(b + 4, true) + offset + 1} ${view.getUint32(b + 8, true) + offset + 1}`, + ); + } + offset += nVerts; + }); + return lines.concat(faces).join("\n") + "\n"; + } + + async function fetchNgmesh(fmt, bodyId) { + const manifest = JSON.parse( + new TextDecoder().decode(await fetchBytes(fileUrl(fmt, bodyId))), + ); + const names = Array.isArray(manifest.fragments) ? manifest.fragments : []; + if (names.length === 0) throw new Error("mesh manifest lists no fragments"); + const fragments = []; + for (const name of names) { + fragments.push(await fetchBytes(fragmentUrl(fmt, bodyId, name))); + } + return fflate.strToU8(ngmeshToObj(fragments, bodyId)); + } + + /** Fetch one neuron in the given format. Never throws; data is null on failure. */ + async function fetchNeuron(fmt, bodyId) { + try { + const data = + fmt.kind === "ngmesh" + ? await fetchNgmesh(fmt, bodyId) + : await fetchBytes(fileUrl(fmt, bodyId)); + return { bodyId, data }; + } catch (err) { + if (err && err.status !== 404) console.warn("[download] failed for", bodyId, err); + return { bodyId, data: null }; + } + } + + async function runPool(items, worker, limit, onProgress) { + const results = new Array(items.length); + let next = 0; + let done = 0; + async function lane() { + while (next < items.length) { + const i = next++; + results[i] = await worker(items[i]); + done += 1; + if (onProgress) onProgress(done, items.length); + } + } + const lanes = []; + for (let i = 0; i < Math.min(limit, items.length); i++) lanes.push(lane()); + await Promise.all(lanes); + return results; + } + + function safeFileName(name) { + return String(name || "neurons") + .replace(/[^A-Za-z0-9._-]+/g, "_") + .replace(/^_+|_+$/g, "") || "neurons"; + } + + function zipFileName(fmt) { + const pageData = window.neuviewNeuroglancerPageData || {}; + const base = pageData.currentNeuronType || pageData.websiteTitle || document.title; + return `${safeFileName(base)}_${safeFileName(fmt.key)}.zip`; + } + + function saveBlob(bytes, fileName) { + const blob = new Blob([bytes], { type: "application/zip" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 10000); + } + + async function downloadSwc(event) { + if (event) event.preventDefault(); + if (running) return; + const fmt = selectedFormat(); + if (!fmt) { + setStatus("Downloads are not configured for this dataset.", true); + return; + } + if (typeof fflate === "undefined") { + setStatus("The zip library (fflate) did not load.", true); + return; + } + + const { bodyIds, source, error } = collectBodyIds(); + if (error) { + setStatus(error, true); + return; + } + if (bodyIds.length === 0) { + setStatus( + source === "url" + ? "The pasted Neuroglancer URL has no visible neurons selected." + : "No neurons are currently displayed.", + true, + ); + return; + } + if (bodyIds.length > MAX_FILES_WITHOUT_CONFIRM) { + const ok = window.confirm( + `This will fetch ${bodyIds.length} files (${fmt.label}) and pack them into one zip file. Continue?`, + ); + if (!ok) { + setStatus("Download cancelled.", false); + return; + } + } + + setBusy(true); + const origin = source === "url" ? "pasted URL" : "this page"; + const ext = fmt.extension || (fmt.kind === "ngmesh" ? "obj" : "swc"); + setStatus(`Fetching 0 of ${bodyIds.length} ${fmt.label} files (neurons from ${origin})...`); + try { + const results = await runPool( + bodyIds, + (id) => fetchNeuron(fmt, id), + CONCURRENCY, + (done, total) => { + setStatus(`Fetching ${done} of ${total} ${fmt.label} files (neurons from ${origin})...`); + }, + ); + + const files = {}; + const missing = []; + results.forEach((r) => { + if (r.data) { + files[`${r.bodyId}.${ext}`] = r.data; + } else { + missing.push(r.bodyId); + } + }); + + const found = Object.keys(files).length; + if (found === 0) { + setStatus( + `None of the ${bodyIds.length} neurons has a ${fmt.label} file at the configured location.`, + true, + ); + return; + } + if (missing.length > 0) { + files["missing_body_ids.txt"] = fflate.strToU8( + `Body IDs without a ${fmt.label} file:\n` + missing.join("\n") + "\n", + ); + } + + setStatus(`Packing ${found} files into a zip archive...`); + // Meshes are large text files; trade compression for speed there. + const level = fmt.kind === "ngmesh" ? 3 : 6; + const zipped = fflate.zipSync(files, { level }); + saveBlob(zipped, zipFileName(fmt)); + + const skipped = + missing.length > 0 + ? `, ${missing.length} without a file (listed in missing_body_ids.txt)` + : ""; + setStatus(`Downloaded ${found} file${found === 1 ? "" : "s"}${skipped}.`); + } catch (err) { + console.error("[download] failed", err); + setStatus(`Download failed: ${err && err.message ? err.message : err}`, true); + } finally { + setBusy(false); + } + } + + function init() { + const link = document.getElementById("swc-download-link"); + const dialog = dialogElement(); + const button = document.getElementById("swc-download-button"); + const closeButtons = [ + document.getElementById("swc-download-close"), + document.getElementById("swc-download-cancel"), + ]; + const input = document.getElementById("swc-url-input"); + + // The header link opens the dialog; the download starts from its button. + if (link) link.addEventListener("click", openDialog); + if (button) button.addEventListener("click", downloadSwc); + closeButtons.forEach((b) => b && b.addEventListener("click", closeDialog)); + if (dialog) { + // Click on the backdrop (outside the dialog box) closes it. + dialog.addEventListener("click", (e) => { + if (e.target === dialog) closeDialog(); + }); + } + if (input) { + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") downloadSwc(e); + }); + input.addEventListener("input", () => setStatus("")); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } + + // Expose for debugging and tests. + window.neuviewSwcDownload = { + parseNeuroglancerUrl, + collectBodyIds, + selectedFormat, + ngmeshToObj, + downloadSwc, + openDialog, + closeDialog, + }; +})(); diff --git a/templates/README.md.jinja b/templates/README.md.jinja index 1729e97a..ed8ec081 100644 --- a/templates/README.md.jinja +++ b/templates/README.md.jinja @@ -97,6 +97,9 @@ Each neuron type in the index table links to a comprehensive analysis page conta - **Direct links** to explore neuron reconstructions in 3D space - **Zoom, rotate, and navigate** through the brain volume - **View neuron reconstructions** in their anatomical context +{% if config.neuroglancer.download_formats %} +- **Download neurons** as one zip file with one file per displayed neuron. Available formats: {{ config.neuroglancer.download_formats | map(attribute='label') | join(', ') }}. Paste a Neuroglancer URL into the dialog to download exactly the neurons selected in the viewer +{% endif %} ### Layer Analysis - **Detailed breakdown** of neuron presence across brain regions: Lamina (LA), Medulla (ME), Lobula (LO), Lobula Plate (LOP), Accessory Medulla (AME), and Central Brain (CB) @@ -180,6 +183,9 @@ Neurotransmitter assignments are computational predictions based on: - **Printable pages** - Browser print function works with all pages - **Bookmarkable URLs** - Save direct links to specific neuron types - **Shareable content** - URLs can be shared with colleagues +{% if config.neuroglancer.download_formats %} +- **Neuron files** - The download icon above the 3D view saves skeletons or meshes of the displayed neurons ({{ config.neuroglancer.download_formats | map(attribute='label') | join(', ') }}) as a zip archive +{% endif %} ## Dataset Information diff --git a/templates/help.html.jinja b/templates/help.html.jinja index 59333fe3..495f37cf 100644 --- a/templates/help.html.jinja +++ b/templates/help.html.jinja @@ -364,7 +364,7 @@ Combined upstream and downstream connections, with breakdowns and log ratios where applicable. Here, connections are the number of anatomical synapses between neurons. Technically, this number comes from the number of connected synapses, which are combined to form SynapseSets and assigned to Neurons within the neuprint database. - A single pre-synaptic site (T-bar) can form multiple anatomical synaptic contacts with different post-synaptic partners. Each such + A single pre-synaptic site (T-bar) can form multiple anatomical synaptic contacts with different post-synaptic partners. Each such contact counts as one downstream connection, so a neuron's number of downstream connections can exceed its number of pre-synaptic sites.
For combined pages the breakdown shows the total number of connections assigned to each hemisphere and their ratio. Whereas for unilateral pages the breakdown shows the number of upstream and downstream connections across all neurons of the type assigned to that side. @@ -477,9 +477,66 @@

External neuPrint Link

- Direct link to view the same neurons in the full neuPrint interface for advanced analysis is provided on - the page. + A direct link to view the same neurons in the full neuPrint interface for advanced analysis is provided + on the page.

+ {% if config is defined and config.neuroglancer.download_formats %} + +
+

Downloading Neurons

+

+ The icon in the header of the Neuron Visualization section opens + the "Download neurons" dialog. Pick a format and press "Download" to receive a single zip file that + contains one file per neuron. Neurons for which no file can be found are listed in the file + missing_body_ids.txt inside the zip. +

+ The 'Download neurons' dialog with the format selection and the optional Neuroglancer URL field. +

Formats available for this dataset: {{ config.neuroglancer.download_formats | map(attribute='label') | join(', ') }}.

+
+
SWC skeleton, coarse
+
+ A downsampled skeleton with a few hundred nodes per neuron and files of some tens of kilobytes. + It is well suited for whole-population analyses, overview plots, and quick loading in tools such as + navis, the natverse, or VVD Viewer. +
+
SWC skeleton, high resolution
+
+ The same neurons skeletonized at full detail, with roughly twenty times as many nodes and files of + a few hundred kilobytes each. Use it when branch-level morphology, path lengths, or Strahler orders + matter. +
+
OBJ mesh
+
+ The surface mesh that Neuroglancer renders, saved as OBJ files (vertices and triangular faces). + Meshes are large, several megabytes per neuron before compression. The zip is assembled in your + browser's memory, so large selections might slow down your computer. Prefer a small selection for + this format. OBJ files open in Blender, MeshLab, ParaView, VVD Viewer, and most 3D libraries. +
+
+

+ Which neurons are downloaded? That depends on whether you fill in the "Neuroglancer URL" + field of the dialog: +

+
    +
  • + Field left empty: the download contains the neurons selected by the Cell Type Explorer. + That is, the neurons of the current cell type plus every partner you ticked in the Connectivity tables + (see Adding Partners). Anything you changed inside the + Neuroglancer view itself, such as adding a neuron through its search box or hiding one by clicking on + it, is not included. This is a technical limitation: the Cell Type Explorer cannot look inside the + Neuroglancer viewer. +
  • +
  • + Neuroglancer URL pasted: the download covers exactly the neurons that are visible in the + pasted view, including changes made inside Neuroglancer. Neuroglancer stores its complete state in the + page address, so first make your selection, then copy the URL: either open the view in a new window with + the icon and copy the address bar, or use Neuroglancer's own + "copy URL" button shown below. Paste it into the dialog and press "Download". Neurons that are hidden in + the view are skipped, and only the neuron layer is used; ROI and shell meshes are never downloaded. +
  • +
+ The 'copy URL' button in Neuroglancer. + {% endif %} {# ROI Innervation #} diff --git a/templates/sections/neuroglancer.html.jinja b/templates/sections/neuroglancer.html.jinja index 7da9c663..daa53ecb 100644 --- a/templates/sections/neuroglancer.html.jinja +++ b/templates/sections/neuroglancer.html.jinja @@ -5,6 +5,9 @@

Neuron Visualization ⧉ + {% if config is defined and config.neuroglancer.download_formats %} + ⤓ + {% endif %} {% if youtube_url and (features is not defined or features.nav_visible('youtube')) %} @@ -58,6 +61,42 @@ + {% if config is defined and config.neuroglancer.download_formats %} + +
+

Download neurons

+ +
+

+ Downloads one file per neuron that this page sends to the viewer (the neurons of this type plus any + partners ticked in the tables below), packed into a single zip file. Neurons without a file are listed in + missing_body_ids.txt inside the zip. +

+
+ Format + {% for fmt in config.neuroglancer.download_formats %} + + {% endfor %} +
+

+ screenshot of the 'copy URL' button in Neuroglancer + Changes made inside Neuroglancer are invisible to the Cell Type Explorer. To download exactly the neurons selected + in Neuroglancer, copy the URL from Neuroglancer and paste it here: +

+ + +

+
+ + +
+ + {% endif %} + {{ iframe_embed("about:blank", title="Neuroglancer Visualization", extra_class="neuroglancer-iframe") }} diff --git a/templates/sections/neuron_page_scripts.html.jinja b/templates/sections/neuron_page_scripts.html.jinja index 556b2648..c8be03aa 100644 --- a/templates/sections/neuron_page_scripts.html.jinja +++ b/templates/sections/neuron_page_scripts.html.jinja @@ -7,6 +7,12 @@ {# -- Iframe communication bridge for cross-origin parent-child communication -- #} +{# -- Skeleton / mesh download (zip built client-side with fflate), only when configured -- #} +{% if config.neuroglancer.download_formats and features.neuron_visible('neuroglancer') %} + + +{% endif %} +