geobrain 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. geobrain-0.1.0/LICENSE +21 -0
  2. geobrain-0.1.0/PKG-INFO +101 -0
  3. geobrain-0.1.0/README.md +56 -0
  4. geobrain-0.1.0/geobrain/__init__.py +75 -0
  5. geobrain-0.1.0/geobrain/__main__.py +4 -0
  6. geobrain-0.1.0/geobrain/app/__init__.py +3 -0
  7. geobrain-0.1.0/geobrain/app/__main__.py +29 -0
  8. geobrain-0.1.0/geobrain/app/assets/GeoBrain_logo2.png +0 -0
  9. geobrain-0.1.0/geobrain/app/assets/plotly_brain_logo.png +0 -0
  10. geobrain-0.1.0/geobrain/app/assets/render.js +344 -0
  11. geobrain-0.1.0/geobrain/app/assets/styles.css +91 -0
  12. geobrain-0.1.0/geobrain/app/cache.py +51 -0
  13. geobrain-0.1.0/geobrain/app/callbacks.py +1119 -0
  14. geobrain-0.1.0/geobrain/app/figure.py +337 -0
  15. geobrain-0.1.0/geobrain/app/layout.py +697 -0
  16. geobrain-0.1.0/geobrain/app/server.py +51 -0
  17. geobrain-0.1.0/geobrain/build_geoJSON.py +796 -0
  18. geobrain-0.1.0/geobrain/choropleth_render.py +191 -0
  19. geobrain-0.1.0/geobrain/colormaps.py +29 -0
  20. geobrain-0.1.0/geobrain/coord_system.py +278 -0
  21. geobrain-0.1.0/geobrain/io.py +93 -0
  22. geobrain-0.1.0/geobrain/metadata.py +134 -0
  23. geobrain-0.1.0/geobrain/scores.py +669 -0
  24. geobrain-0.1.0/geobrain/types.py +17 -0
  25. geobrain-0.1.0/geobrain.egg-info/PKG-INFO +101 -0
  26. geobrain-0.1.0/geobrain.egg-info/SOURCES.txt +40 -0
  27. geobrain-0.1.0/geobrain.egg-info/dependency_links.txt +1 -0
  28. geobrain-0.1.0/geobrain.egg-info/entry_points.txt +3 -0
  29. geobrain-0.1.0/geobrain.egg-info/requires.txt +28 -0
  30. geobrain-0.1.0/geobrain.egg-info/top_level.txt +1 -0
  31. geobrain-0.1.0/pyproject.toml +105 -0
  32. geobrain-0.1.0/setup.cfg +4 -0
  33. geobrain-0.1.0/tests/test_app_callbacks.py +315 -0
  34. geobrain-0.1.0/tests/test_build_geojson.py +300 -0
  35. geobrain-0.1.0/tests/test_callbacks_guards.py +185 -0
  36. geobrain-0.1.0/tests/test_callbacks_helpers.py +248 -0
  37. geobrain-0.1.0/tests/test_choropleth_render.py +90 -0
  38. geobrain-0.1.0/tests/test_coord_system.py +118 -0
  39. geobrain-0.1.0/tests/test_io.py +54 -0
  40. geobrain-0.1.0/tests/test_metadata.py +89 -0
  41. geobrain-0.1.0/tests/test_scaling.py +79 -0
  42. geobrain-0.1.0/tests/test_scores.py +237 -0
geobrain-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anna Teruel-Sanchis and Konrad Danielewski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: geobrain
3
+ Version: 0.1.0
4
+ Summary: An interactive framework for atlas-based visualization of quantitative histological traits.
5
+ Author-email: Anna Teruel-Sanchis <anna.teruel@uv.es>, Konrad Danielewski <k.danielewski@nencki.edu.pl>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/anna-teruel/geobrain
8
+ Project-URL: Issues, https://github.com/anna-teruel/geobrain/issues
9
+ Keywords: neuroscience,data-visualization,data-analysis
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
14
+ Classifier: Topic :: Scientific/Engineering :: Visualization
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: ipykernel
19
+ Requires-Dist: kaleido
20
+ Requires-Dist: numpy
21
+ Requires-Dist: plotly
22
+ Requires-Dist: pandas
23
+ Requires-Dist: scipy
24
+ Requires-Dist: h5py
25
+ Requires-Dist: requests
26
+ Requires-Dist: pynrrd
27
+ Requires-Dist: rasterio
28
+ Requires-Dist: shapely
29
+ Requires-Dist: scikit-image
30
+ Requires-Dist: dash>=2.17
31
+ Requires-Dist: dash-mantine-components>=0.14
32
+ Requires-Dist: diskcache
33
+ Requires-Dist: multiprocess
34
+ Requires-Dist: psutil
35
+ Requires-Dist: tqdm
36
+ Provides-Extra: test
37
+ Requires-Dist: pytest; extra == "test"
38
+ Requires-Dist: pytest-cov; extra == "test"
39
+ Requires-Dist: dash[testing]; extra == "test"
40
+ Requires-Dist: selenium; extra == "test"
41
+ Provides-Extra: dev
42
+ Requires-Dist: pre-commit; extra == "dev"
43
+ Requires-Dist: ruff; extra == "dev"
44
+ Dynamic: license-file
45
+
46
+ <div align="center">
47
+
48
+ <p align="center">
49
+ <img src="docs/logos/GeoBrain_logo1.png" width="75%">
50
+ </p>
51
+ </div>
52
+
53
+ GeoBrain is an interactive Python framework for atlas-based visualization of quantitative histological data. It maps region-wise metrics derived from atlas-registered workflows, such as QUINT [1], onto the Allen Mouse Brain Common Coordinate Framework (CCFv3) [2]. Built on Plotly, geobrain provides interactive 2D atlas navigation, group comparisons, customizable color mapping, web-based dashboard for exploratory analysis and publication-quality exports.
54
+
55
+
56
+ ## Installation
57
+
58
+ Requires Python 3.12+. Install from PyPI:
59
+
60
+ ```
61
+ uv venv
62
+ source .venv/bin/activate # Linux / macOS
63
+ .venv\Scripts\activate # Windows
64
+ uv pip install geobrain
65
+ ```
66
+
67
+ Then launch the dashboard with:
68
+
69
+ ```
70
+ geobrain
71
+ ```
72
+
73
+ Or from source:
74
+
75
+ ```
76
+ git clone https://github.com/anna-teruel/geobrain
77
+ cd geobrain
78
+ uv venv
79
+ .venv\Scripts\activate
80
+ uv pip install .
81
+ ```
82
+
83
+ ## Documentation
84
+
85
+ - [Using the dashboard](docs/dashboard_usage.md) — launching the app and the full workflow: load atlas, build slices, compute scores, view and export.
86
+ - [Filtering & coloring rendered slices](docs/filtering_and_coloring.md) — how to color regions by score, filter and select which regions stay highlighted, and apply a flat color.
87
+ - [Understanding Scores](docs/score_definitions.md) — score definitions, normalization methods and interpretation.
88
+ - [Tutorial](examples/demo_API.ipynb) — end-to-end example using the API.
89
+
90
+ ## License
91
+
92
+ This project is licensed under the MIT License © 2026
93
+ Anna Teruel-Sanchis and Konrad Danielewski.
94
+
95
+ See the [LICENSE](LICENSE) file for details.
96
+
97
+ ## References
98
+
99
+ [1] Yates, S. C., et al. (2019). *QUINT: Workflow for Quantification and Spatial Analysis of Features in Histological Images From Rodent Brain*. Frontiers in Neuroinformatics, 13, 75.
100
+
101
+ [2] Wang, Q., et al. (2020). *The Allen Mouse Brain Common Coordinate Framework: A 3D Reference Atlas*. Cell, 181(4), 936–953.e20.
@@ -0,0 +1,56 @@
1
+ <div align="center">
2
+
3
+ <p align="center">
4
+ <img src="docs/logos/GeoBrain_logo1.png" width="75%">
5
+ </p>
6
+ </div>
7
+
8
+ GeoBrain is an interactive Python framework for atlas-based visualization of quantitative histological data. It maps region-wise metrics derived from atlas-registered workflows, such as QUINT [1], onto the Allen Mouse Brain Common Coordinate Framework (CCFv3) [2]. Built on Plotly, geobrain provides interactive 2D atlas navigation, group comparisons, customizable color mapping, web-based dashboard for exploratory analysis and publication-quality exports.
9
+
10
+
11
+ ## Installation
12
+
13
+ Requires Python 3.12+. Install from PyPI:
14
+
15
+ ```
16
+ uv venv
17
+ source .venv/bin/activate # Linux / macOS
18
+ .venv\Scripts\activate # Windows
19
+ uv pip install geobrain
20
+ ```
21
+
22
+ Then launch the dashboard with:
23
+
24
+ ```
25
+ geobrain
26
+ ```
27
+
28
+ Or from source:
29
+
30
+ ```
31
+ git clone https://github.com/anna-teruel/geobrain
32
+ cd geobrain
33
+ uv venv
34
+ .venv\Scripts\activate
35
+ uv pip install .
36
+ ```
37
+
38
+ ## Documentation
39
+
40
+ - [Using the dashboard](docs/dashboard_usage.md) — launching the app and the full workflow: load atlas, build slices, compute scores, view and export.
41
+ - [Filtering & coloring rendered slices](docs/filtering_and_coloring.md) — how to color regions by score, filter and select which regions stay highlighted, and apply a flat color.
42
+ - [Understanding Scores](docs/score_definitions.md) — score definitions, normalization methods and interpretation.
43
+ - [Tutorial](examples/demo_API.ipynb) — end-to-end example using the API.
44
+
45
+ ## License
46
+
47
+ This project is licensed under the MIT License © 2026
48
+ Anna Teruel-Sanchis and Konrad Danielewski.
49
+
50
+ See the [LICENSE](LICENSE) file for details.
51
+
52
+ ## References
53
+
54
+ [1] Yates, S. C., et al. (2019). *QUINT: Workflow for Quantification and Spatial Analysis of Features in Histological Images From Rodent Brain*. Frontiers in Neuroinformatics, 13, 75.
55
+
56
+ [2] Wang, Q., et al. (2020). *The Allen Mouse Brain Common Coordinate Framework: A 3D Reference Atlas*. Cell, 181(4), 936–953.e20.
@@ -0,0 +1,75 @@
1
+ from geobrain.build_geoJSON import (
2
+ ANNOTATION_URLS,
3
+ STRUCTURE_GRAPH_URL,
4
+ BuildConfig,
5
+ build_geojson,
6
+ clean_polygons_geometry,
7
+ download_bytes,
8
+ get_slice_view,
9
+ load_annotation_volume,
10
+ load_structure_graph,
11
+ mask_to_polygon,
12
+ save_geojson,
13
+ scale_cartesian_to_lonlat,
14
+ )
15
+ from geobrain.choropleth_render import render_brain_slice
16
+ from geobrain.coord_system import (
17
+ CCFConfig,
18
+ coord_mm_to_slice_index,
19
+ get_ccf_config,
20
+ range_mm_to_slice_indices,
21
+ slice_index_to_coordinate_mm,
22
+ )
23
+ from geobrain.io import load_geojson, load_score, save_figure
24
+ from geobrain.metadata import MetadataConfig
25
+ from geobrain.scores import (
26
+ compute_animal_region_counts,
27
+ compute_reference_stats,
28
+ compute_region_counts,
29
+ consistency_score,
30
+ density_score,
31
+ find_animal_id,
32
+ load_refatlas_regions,
33
+ relative_abundance,
34
+ save_scores,
35
+ score_table,
36
+ )
37
+ from geobrain.types import ReferenceMode, RelAbundanceMethod, ScoreName
38
+
39
+ __all__ = [
40
+ "ANNOTATION_URLS",
41
+ "STRUCTURE_GRAPH_URL",
42
+ "BuildConfig",
43
+ "build_geojson",
44
+ "clean_polygons_geometry",
45
+ "download_bytes",
46
+ "get_slice_view",
47
+ "load_annotation_volume",
48
+ "load_structure_graph",
49
+ "mask_to_polygon",
50
+ "save_geojson",
51
+ "scale_cartesian_to_lonlat",
52
+ "render_brain_slice",
53
+ "CCFConfig",
54
+ "coord_mm_to_slice_index",
55
+ "get_ccf_config",
56
+ "range_mm_to_slice_indices",
57
+ "slice_index_to_coordinate_mm",
58
+ "load_geojson",
59
+ "load_score",
60
+ "save_figure",
61
+ "MetadataConfig",
62
+ "compute_animal_region_counts",
63
+ "compute_reference_stats",
64
+ "compute_region_counts",
65
+ "consistency_score",
66
+ "density_score",
67
+ "find_animal_id",
68
+ "load_refatlas_regions",
69
+ "relative_abundance",
70
+ "save_scores",
71
+ "score_table",
72
+ "ReferenceMode",
73
+ "RelAbundanceMethod",
74
+ "ScoreName",
75
+ ]
@@ -0,0 +1,4 @@
1
+ from geobrain.app.__main__ import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,3 @@
1
+ from geobrain.app.server import create_app
2
+
3
+ __all__ = ["create_app"]
@@ -0,0 +1,29 @@
1
+ import argparse
2
+ import os
3
+ import webbrowser
4
+ from threading import Timer
5
+
6
+
7
+ def main() -> None:
8
+ parser = argparse.ArgumentParser(description="Launch the GeoBrain dashboard.")
9
+ parser.add_argument("--host", default="127.0.0.1")
10
+ parser.add_argument("--port", type=int, default=8050)
11
+ parser.add_argument("--debug", action="store_true")
12
+ args = parser.parse_args()
13
+
14
+ from geobrain.app.server import create_app
15
+
16
+ app = create_app()
17
+
18
+ # Open the browser once, after the server has had a moment to start. The
19
+ # WERKZEUG_RUN_MAIN guard keeps the reloader from opening a new tab on
20
+ # every hot reload.
21
+ if not os.environ.get("WERKZEUG_RUN_MAIN"):
22
+ url = f"http://{args.host}:{args.port}"
23
+ Timer(1, lambda: webbrowser.open(url)).start()
24
+
25
+ app.run(host=args.host, port=args.port, debug=args.debug)
26
+
27
+
28
+ if __name__ == "__main__":
29
+ main()
@@ -0,0 +1,344 @@
1
+ /*
2
+ * Clientside rendering for the GeoBrain dashboard.
3
+ *
4
+ * The brain figure, the results table and the slice label are all assembled in
5
+ * the browser from compact `dcc.Store` payloads, so dragging the slice slider
6
+ * or toggling the score/colorscale gives immediate feedback with no server
7
+ * round-trip. Geometry (polygon rings) is built once on the server; here we only
8
+ * look up each region's value and interpolate a fill color.
9
+ */
10
+
11
+ window.dash_clientside = window.dash_clientside || {};
12
+
13
+ const NA_COLOR = "#d9d9d9";
14
+ const VALUE_COL = {
15
+ rel_abundance: "relative_abundance_z",
16
+ frequency: "frequency",
17
+ density: "density",
18
+ };
19
+ const FALLBACK_STOPS = [
20
+ [0, [68, 1, 84]],
21
+ [0.5, [33, 145, 140]],
22
+ [1, [253, 231, 37]],
23
+ ];
24
+
25
+ function isNum(v) {
26
+ return v !== null && v !== undefined && v !== "" && !Number.isNaN(Number(v));
27
+ }
28
+
29
+ function pickRecords(scores, group) {
30
+ if (!scores) return [];
31
+ if (group && scores[group]) return scores[group];
32
+ const keys = Object.keys(scores);
33
+ return keys.length ? scores[keys[0]] : [];
34
+ }
35
+
36
+ function buildValueMap(records, valueCol) {
37
+ const map = {};
38
+ for (let i = 0; i < records.length; i++) {
39
+ const row = records[i];
40
+ const rid = row["Region ID"];
41
+ if (rid === null || rid === undefined) continue;
42
+ const v = row[valueCol];
43
+ map[rid] = isNum(v) ? Number(v) : null;
44
+ }
45
+ return map;
46
+ }
47
+
48
+ function colorFromStops(value, stops, vmin, vmax) {
49
+ if (!isNum(value)) return NA_COLOR;
50
+ let t;
51
+ if (!isNum(vmin) || !isNum(vmax) || vmax === vmin) {
52
+ t = 0.5;
53
+ } else {
54
+ t = (Number(value) - vmin) / (vmax - vmin);
55
+ t = Math.max(0, Math.min(1, t));
56
+ }
57
+ for (let i = 0; i < stops.length - 1; i++) {
58
+ const [p0, c0] = stops[i];
59
+ const [p1, c1] = stops[i + 1];
60
+ if (t >= p0 && t <= p1) {
61
+ const f = p1 === p0 ? 0 : (t - p0) / (p1 - p0);
62
+ const r = Math.round(c0[0] + f * (c1[0] - c0[0]));
63
+ const g = Math.round(c0[1] + f * (c1[1] - c0[1]));
64
+ const b = Math.round(c0[2] + f * (c1[2] - c0[2]));
65
+ return `rgb(${r},${g},${b})`;
66
+ }
67
+ }
68
+ const last = stops[stops.length - 1][1];
69
+ return `rgb(${last[0]},${last[1]},${last[2]})`;
70
+ }
71
+
72
+ function emptyFigure(message) {
73
+ return {
74
+ data: [],
75
+ layout: {
76
+ xaxis: { visible: false },
77
+ yaxis: { visible: false },
78
+ paper_bgcolor: "rgba(0,0,0,0)",
79
+ plot_bgcolor: "rgba(0,0,0,0)",
80
+ margin: { l: 10, r: 10, t: 30, b: 10 },
81
+ annotations: [
82
+ {
83
+ text: message,
84
+ showarrow: false,
85
+ xref: "paper",
86
+ yref: "paper",
87
+ x: 0.5,
88
+ y: 0.5,
89
+ font: { size: 14, color: "#868e96" },
90
+ },
91
+ ],
92
+ },
93
+ };
94
+ }
95
+
96
+ function autoRange(valueMap, vmin, vmax) {
97
+ if (isNum(vmin) && isNum(vmax)) return [Number(vmin), Number(vmax)];
98
+ let lo = Infinity;
99
+ let hi = -Infinity;
100
+ for (const k in valueMap) {
101
+ const v = valueMap[k];
102
+ if (isNum(v)) {
103
+ if (v < lo) lo = v;
104
+ if (v > hi) hi = v;
105
+ }
106
+ }
107
+ if (lo === Infinity) return [0, 1];
108
+ if (lo === hi) hi = lo + 1;
109
+ return [isNum(vmin) ? Number(vmin) : lo, isNum(vmax) ? Number(vmax) : hi];
110
+ }
111
+
112
+ window.dash_clientside.geobrain = {
113
+ // Coalesce rapid slider drags to one render per animation frame. Each call
114
+ // stashes the latest inputs; a single requestAnimationFrame then rebuilds
115
+ // with the newest values and patches the existing plot via Plotly.react. Fast
116
+ // scrolling thus skips the intermediate slices it can't keep up with instead
117
+ // of queueing a full rebuild for every one of them on the main thread.
118
+ _rafPending: false,
119
+ _latestArgs: null,
120
+
121
+ render: function () {
122
+ const ns = window.dash_clientside.geobrain;
123
+ const host = document.getElementById("brain-graph");
124
+ const gd = host && host.querySelector(".js-plotly-plot");
125
+ if (!gd || !window.Plotly) {
126
+ // Plot not initialized yet - let Dash create it from the figure prop.
127
+ return ns.buildFigure.apply(ns, arguments);
128
+ }
129
+ ns._latestArgs = arguments;
130
+ if (!ns._rafPending) {
131
+ ns._rafPending = true;
132
+ window.requestAnimationFrame(function () {
133
+ ns._rafPending = false;
134
+ const args = ns._latestArgs;
135
+ ns._latestArgs = null;
136
+ const fig = ns.buildFigure.apply(ns, args);
137
+ window.Plotly.react(gd, fig.data, fig.layout);
138
+ });
139
+ }
140
+ return window.dash_clientside.no_update;
141
+ },
142
+
143
+ buildFigure: function (sliderVal, score, group, stops, zmin, zmax, geometry, scores, slices, dark, highlight, staticColor, useFlat) {
144
+ if (!geometry || !geometry.by_slice || !slices || !slices.length) {
145
+ return emptyFigure("Build or load slices to begin");
146
+ }
147
+ const pos = Math.max(0, Math.min(slices.length - 1, sliderVal || 0));
148
+ const sliceIndex = slices[pos].slice_index;
149
+ const regions = geometry.by_slice[String(sliceIndex)] || [];
150
+ if (!regions.length) return emptyFigure("No regions on this slice");
151
+ const dims = geometry.dims;
152
+
153
+ if (!stops || !stops.length) stops = FALLBACK_STOPS;
154
+ const valueCol = VALUE_COL[score] || "relative_abundance_z";
155
+ const valueMap = buildValueMap(pickRecords(scores, group), valueCol);
156
+ const [vmin, vmax] = autoRange(valueMap, zmin, zmax);
157
+
158
+ // Coloring is narrowed by the row selection (checkboxes) only: once any
159
+ // rows are selected, just those regions keep their score color and every
160
+ // other region renders as NA (gray). With no selection, all regions keep
161
+ // color. The table's text filter only filters the table list, not the
162
+ // brain. If none of the selected regions are on this slice (e.g. after
163
+ // moving the slider) we skip gating rather than gray it all.
164
+ const selected = new Set(Array.isArray(highlight) ? highlight : []);
165
+ let selActive = selected.size > 0;
166
+ if (selActive && !regions.some((rg) => selected.has(rg.rid))) {
167
+ selActive = false;
168
+ }
169
+
170
+ // When the flat-color toggle is on and a selection is narrowing this
171
+ // slice, turn the colormap off: kept regions get one flat color, the rest
172
+ // stay gray (NA). Otherwise, value-based coloring.
173
+ const staticMode = !!useFlat && selActive;
174
+ const staticFill = staticColor || "#fa5252";
175
+
176
+ const traces = [];
177
+ for (let i = 0; i < regions.length; i++) {
178
+ const region = regions[i];
179
+ const gated = selActive && !selected.has(region.rid);
180
+ const value = gated ? null : valueMap[region.rid];
181
+ const fill = gated
182
+ ? NA_COLOR
183
+ : staticMode
184
+ ? staticFill
185
+ : colorFromStops(value, stops, vmin, vmax);
186
+ const xs = [];
187
+ const ys = [];
188
+ const cd = [];
189
+ for (let r = 0; r < region.rings.length; r++) {
190
+ const ring = region.rings[r];
191
+ for (let p = 0; p < ring.length; p++) {
192
+ xs.push(ring[p][0]);
193
+ ys.push(ring[p][1]);
194
+ cd.push(region.rid);
195
+ }
196
+ xs.push(null);
197
+ ys.push(null);
198
+ cd.push(null);
199
+ }
200
+ const vTxt = isNum(value) ? Number(value).toFixed(3) : "n/a";
201
+ traces.push({
202
+ type: "scatter",
203
+ x: xs,
204
+ y: ys,
205
+ customdata: cd,
206
+ mode: "lines",
207
+ fill: "toself",
208
+ fillcolor: fill,
209
+ line: { color: "rgba(255,255,255,0.95)", width: 0.7, shape: "spline", smoothing: 0.6 },
210
+ text: `Region ID: ${region.rid}<br>Region: ${region.name}<br>${score}: ${vTxt}`,
211
+ hoverinfo: "text",
212
+ hoveron: "fills",
213
+ showlegend: false,
214
+ });
215
+ }
216
+
217
+ // Invisible colorbar trace - omitted in static mode (colormap is off).
218
+ if (!staticMode) {
219
+ const plotlyScale = stops.map((s) => [s[0], `rgb(${s[1][0]},${s[1][1]},${s[1][2]})`]);
220
+ traces.push({
221
+ type: "scatter",
222
+ x: [null, null],
223
+ y: [null, null],
224
+ mode: "markers",
225
+ marker: {
226
+ size: 0,
227
+ color: [vmin, vmax],
228
+ colorscale: plotlyScale,
229
+ cmin: vmin,
230
+ cmax: vmax,
231
+ showscale: true,
232
+ colorbar: { title: { text: valueCol, side: "right" }, thickness: 14, len: 0.85 },
233
+ },
234
+ hoverinfo: "skip",
235
+ showlegend: false,
236
+ });
237
+ }
238
+
239
+ // Pixel-space geometry: fix the frame to the slice dimensions and put
240
+ // the dorsal side up via a reversed y-range (top = row 0). Cached lon/lat
241
+ // geometry has no dims and is already y-up, so it just autoranges.
242
+ let xaxis;
243
+ let yaxis;
244
+ if (dims && dims.w && dims.h) {
245
+ xaxis = { visible: false, range: [0, dims.w], constrain: "range" };
246
+ yaxis = { visible: false, range: [dims.h, 0], scaleanchor: "x", constrain: "range" };
247
+ } else {
248
+ xaxis = { visible: false };
249
+ yaxis = { visible: false, scaleanchor: "x" };
250
+ }
251
+
252
+ return {
253
+ data: traces,
254
+ layout: {
255
+ uirevision: geometry.orientation || "brain",
256
+ xaxis: xaxis,
257
+ yaxis: yaxis,
258
+ paper_bgcolor: "rgba(0,0,0,0)",
259
+ plot_bgcolor: "rgba(0,0,0,0)",
260
+ font: { color: dark ? "#c1c2c5" : "#444444" },
261
+ margin: { l: 10, r: 10, t: 20, b: 10 },
262
+ hovermode: "closest",
263
+ },
264
+ };
265
+ },
266
+
267
+ // Debounce the results table so it only rebuilds once the slider settles,
268
+ // not on every drag tick. The DataTable re-render is DOM-heavy and would
269
+ // otherwise compete with the figure for the main thread while scrolling. The
270
+ // trailing rebuild is pushed straight onto the table via set_props, so the
271
+ // live callback returns no_update for every intermediate value.
272
+ _tableTimer: null,
273
+ _tableArgs: null,
274
+
275
+ table: function () {
276
+ const ns = window.dash_clientside.geobrain;
277
+ ns._tableArgs = arguments;
278
+ if (ns._tableTimer) clearTimeout(ns._tableTimer);
279
+ ns._tableTimer = setTimeout(function () {
280
+ ns._tableTimer = null;
281
+ const out = ns.buildTable.apply(ns, ns._tableArgs);
282
+ window.dash_clientside.set_props("results-table", {
283
+ data: out[0],
284
+ columns: out[1],
285
+ style_data_conditional: out[2],
286
+ });
287
+ }, 150);
288
+ return [
289
+ window.dash_clientside.no_update,
290
+ window.dash_clientside.no_update,
291
+ window.dash_clientside.no_update,
292
+ ];
293
+ },
294
+
295
+ buildTable: function (sliderVal, score, group, geometry, scores, slices, dark) {
296
+ const emptyCols = [
297
+ { name: "Region ID", id: "rid" },
298
+ { name: "Region", id: "name" },
299
+ { name: "Value", id: "value" },
300
+ ];
301
+ if (!geometry || !geometry.by_slice || !slices || !slices.length || !scores) {
302
+ return [[], emptyCols, []];
303
+ }
304
+ const pos = Math.max(0, Math.min(slices.length - 1, sliderVal || 0));
305
+ const sliceIndex = slices[pos].slice_index;
306
+ const regions = geometry.by_slice[String(sliceIndex)] || [];
307
+ const present = {};
308
+ regions.forEach((r) => (present[r.rid] = true));
309
+
310
+ const valueCol = VALUE_COL[score] || "relative_abundance_z";
311
+ const records = pickRecords(scores, group);
312
+ const rows = [];
313
+ for (let i = 0; i < records.length; i++) {
314
+ const row = records[i];
315
+ const rid = row["Region ID"];
316
+ if (!present[rid]) continue;
317
+ const v = row[valueCol];
318
+ rows.push({
319
+ id: rid,
320
+ rid: rid,
321
+ name: row["Region name"],
322
+ value: isNum(v) ? Number(Number(v).toFixed(3)) : null,
323
+ });
324
+ }
325
+ const columns = [
326
+ { name: "Region ID", id: "rid", type: "numeric" },
327
+ { name: "Region", id: "name" },
328
+ { name: score, id: "value", type: "numeric" },
329
+ ];
330
+ const styleData = [
331
+ { if: { column_id: "value" }, backgroundColor: dark ? "#2a2f45" : "#eef2ff" },
332
+ ];
333
+ return [rows, columns, styleData];
334
+ },
335
+
336
+ sliceLabel: function (sliderVal, slices) {
337
+ if (!slices || !slices.length) return "";
338
+ const pos = Math.max(0, Math.min(slices.length - 1, sliderVal || 0));
339
+ const s = slices[pos];
340
+ const mm = s.coordinate_mm;
341
+ const mmTxt = mm === null || mm === undefined ? "" : ` • ${mm >= 0 ? "+" : ""}${mm.toFixed(2)} mm`;
342
+ return `Slice index ${s.slice_index}${mmTxt} (${pos + 1}/${slices.length})`;
343
+ },
344
+ };