From d8ecd9fc021db1e90521162f4656f67f57ea61ef Mon Sep 17 00:00:00 2001 From: Frank Loesche Date: Wed, 9 Sep 2026 11:57:14 -0400 Subject: [PATCH 1/3] Add per-neuron skeleton and mesh downloads to the neuroglancer view Closes #175. The "Neuron Visualization" header gets a download icon that opens a modal dialog. The dialog fetches one file per displayed neuron in the chosen format and saves them as a single zip archive, built in the browser with fflate. Neurons without a file are listed in missing_body_ids.txt inside the zip, and selections above 50 neurons ask for confirmation first. Formats come from a new `neuroglancer.download_formats` list in the dataset config (key, label, url_template with a `{body_id}` placeholder, kind). `kind: swc` stores the response verbatim; `kind: ngmesh` addresses the Neuroglancer legacy mesh manifest, fetches its fragments and converts them to Wavefront OBJ (vertices in nanometers) client-side. Entries are validated at load time (placeholder present, known kind, unique keys). The icon, dialog and scripts are only rendered when the list is non-empty, so datasets without configured formats are unchanged. The male CNS config offers coarse SWC, high-resolution SWC and OBJ mesh from the public flyem-male-cns bucket. The embedded viewer is a cross-origin iframe, so the page cannot read its current URL. Body IDs therefore default to the page's own neuroglancer state (type neurons plus ticked partners), which `initializeNeuroglancerLinks()` now exposes on `window`. As a fallback for selections changed inside the viewer, the dialog accepts a pasted Neuroglancer URL and takes the segments of the neuron layer, using the same layer lookup as the URL generator (`findMainSegmentationLayer`). Files are fetched through the GCS JSON API endpoint, since the plain storage.googleapis.com URLs send no CORS headers. --- README.md | 7 + config/README.md | 57 ++- config/config.cns.yaml | 17 + config/config.example.yaml | 17 + docs/developer-guide.md | 72 +++ docs/user-guide.md | 66 +++ src/neuview/config.py | 70 +++ .../services/neuroglancer_js_service.py | 18 + static/css/neuron-page.css | 109 +++++ static/images/neuroglancer-copy-url.png | Bin 0 -> 10003 bytes static/js/fflate.min.js | 2 + static/js/swc-download.js | 441 ++++++++++++++++++ templates/README.md.jinja | 6 + templates/help.html.jinja | 7 + templates/sections/neuroglancer.html.jinja | 39 ++ .../sections/neuron_page_scripts.html.jinja | 6 + .../js/neuroglancer-url-generator.js.jinja | 33 +- test/services/test_neuroglancer_js_service.py | 65 ++- test/test_config_download_formats.py | 74 +++ test/test_download_dialog_gating.py | 117 +++++ 20 files changed, 1217 insertions(+), 6 deletions(-) create mode 100644 static/images/neuroglancer-copy-url.png create mode 100644 static/js/fflate.min.js create mode 100644 static/js/swc-download.js create mode 100644 test/test_config_download_formats.py create mode 100644 test/test_download_dialog_gating.py 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/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..f3b36596 100644 --- a/static/css/neuron-page.css +++ b/static/css/neuron-page.css @@ -3235,3 +3235,112 @@ 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; +} +.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-copy-url.png b/static/images/neuroglancer-copy-url.png new file mode 100644 index 0000000000000000000000000000000000000000..5c4295e9fabe9bbc1a7e8cdc7e17c4d76f51dab2 GIT binary patch literal 10003 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

ODiV38ov? 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[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..06d9067f 100644 --- a/templates/help.html.jinja +++ b/templates/help.html.jinja @@ -444,6 +444,13 @@

  • Select: Click on neurons to highlight and inspect
  • Layers: Toggle reference layers on/off (as available). For example, the video below shows how you can use the "synapses" layer to view the synapses of the visible neurons.
  • + {% if config is defined and config.neuroglancer.download_formats %} +
  • Download neurons: The link in the + section header opens a dialog that downloads one file per displayed neuron as a single zip file, in the + chosen format ({{ config.neuroglancer.download_formats | map(attribute='label') | join(', ') }}). + Changes made inside the viewer are not visible to the page; to download exactly the neurons selected in + Neuroglancer, open the viewer in a new window, copy its URL and paste it into the URL field of the dialog.
  • + {% endif %} How to toggle reference layers in neuroglancer view. 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 %} +