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.
- geobrain-0.1.0/LICENSE +21 -0
- geobrain-0.1.0/PKG-INFO +101 -0
- geobrain-0.1.0/README.md +56 -0
- geobrain-0.1.0/geobrain/__init__.py +75 -0
- geobrain-0.1.0/geobrain/__main__.py +4 -0
- geobrain-0.1.0/geobrain/app/__init__.py +3 -0
- geobrain-0.1.0/geobrain/app/__main__.py +29 -0
- geobrain-0.1.0/geobrain/app/assets/GeoBrain_logo2.png +0 -0
- geobrain-0.1.0/geobrain/app/assets/plotly_brain_logo.png +0 -0
- geobrain-0.1.0/geobrain/app/assets/render.js +344 -0
- geobrain-0.1.0/geobrain/app/assets/styles.css +91 -0
- geobrain-0.1.0/geobrain/app/cache.py +51 -0
- geobrain-0.1.0/geobrain/app/callbacks.py +1119 -0
- geobrain-0.1.0/geobrain/app/figure.py +337 -0
- geobrain-0.1.0/geobrain/app/layout.py +697 -0
- geobrain-0.1.0/geobrain/app/server.py +51 -0
- geobrain-0.1.0/geobrain/build_geoJSON.py +796 -0
- geobrain-0.1.0/geobrain/choropleth_render.py +191 -0
- geobrain-0.1.0/geobrain/colormaps.py +29 -0
- geobrain-0.1.0/geobrain/coord_system.py +278 -0
- geobrain-0.1.0/geobrain/io.py +93 -0
- geobrain-0.1.0/geobrain/metadata.py +134 -0
- geobrain-0.1.0/geobrain/scores.py +669 -0
- geobrain-0.1.0/geobrain/types.py +17 -0
- geobrain-0.1.0/geobrain.egg-info/PKG-INFO +101 -0
- geobrain-0.1.0/geobrain.egg-info/SOURCES.txt +40 -0
- geobrain-0.1.0/geobrain.egg-info/dependency_links.txt +1 -0
- geobrain-0.1.0/geobrain.egg-info/entry_points.txt +3 -0
- geobrain-0.1.0/geobrain.egg-info/requires.txt +28 -0
- geobrain-0.1.0/geobrain.egg-info/top_level.txt +1 -0
- geobrain-0.1.0/pyproject.toml +105 -0
- geobrain-0.1.0/setup.cfg +4 -0
- geobrain-0.1.0/tests/test_app_callbacks.py +315 -0
- geobrain-0.1.0/tests/test_build_geojson.py +300 -0
- geobrain-0.1.0/tests/test_callbacks_guards.py +185 -0
- geobrain-0.1.0/tests/test_callbacks_helpers.py +248 -0
- geobrain-0.1.0/tests/test_choropleth_render.py +90 -0
- geobrain-0.1.0/tests/test_coord_system.py +118 -0
- geobrain-0.1.0/tests/test_io.py +54 -0
- geobrain-0.1.0/tests/test_metadata.py +89 -0
- geobrain-0.1.0/tests/test_scaling.py +79 -0
- 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.
|
geobrain-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
geobrain-0.1.0/README.md
ADDED
|
@@ -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,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()
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
};
|