from __future__ import annotations
import warnings
from importlib import resources
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Optional
from urllib.parse import quote
from uuid import uuid4
import numpy as np
import pandas as pd
import pyvista as pv
from parq_blockmodel.utils.pyvista.categorical_utils import load_mapping_dict
from parq_blockmodel.utils.pyvista.custom_plotter import CustomPlotter
from parq_blockmodel.visualization.blockmodel_plot import (
BlockModelPlotState,
_bin_to_deciles,
_calculate_deciles,
_plotter_add_mesh_kwargs,
prepare_plot_state,
)
from parq_blockmodel.visualization.asset_selector import HivePbmCatalog
if TYPE_CHECKING:
from parq_blockmodel.blockmodel import ParquetBlockModel
[docs]
@dataclass(slots=True)
class ThresholdRange:
minimum: float
maximum: float
value: float
step: float
[docs]
@dataclass(slots=True)
class DataFilterSlot:
attribute: str = ""
is_categorical: bool = False
minimum: float = 0.0
maximum: float = 1.0
step: float = 0.005
range_values: list[float] = None # type: ignore[assignment]
category_options: list[str] = None # type: ignore[assignment]
selected_categories: list[str] = None # type: ignore[assignment]
preset_min: Optional[float] = None
preset_max: Optional[float] = None
preset_categories: Optional[list[str]] = None
def __post_init__(self) -> None:
if self.range_values is None:
self.range_values = [0.0, 1.0]
if self.category_options is None:
self.category_options = []
if self.selected_categories is None:
self.selected_categories = []
[docs]
class BlockModelTrameApp:
"""Read-only Trame-friendly block-model session.
The app keeps a PBM model separate from the rendered scene state. It is
intentionally backend-first so the same state can be reused by a future
loaded-PBM view or a hive-directory browser.
"""
[docs]
def __init__(
self,
blockmodel: Optional["ParquetBlockModel"] = None,
*,
scalar: Optional[str] = None,
threshold_value: Optional[float] = None,
data_filter_1_attribute: Optional[str] = None,
data_filter_1_min: Optional[float] = None,
data_filter_1_max: Optional[float] = None,
data_filter_1_categories: Optional[list[str]] = None,
data_filter_2_attribute: Optional[str] = None,
data_filter_2_min: Optional[float] = None,
data_filter_2_max: Optional[float] = None,
data_filter_2_categories: Optional[list[str]] = None,
grid_type: str = "image",
frame: str = "world",
title: Optional[str] = None,
show_edges: bool = True,
z_up_lock: bool = False,
z_up_hotkey: str = "z",
app_name: str = "ParquetBlockModel Viewer",
asset_catalog: Optional[HivePbmCatalog] = None,
asset_catalog_root: Optional[str | Path] = None,
) -> None:
self.blockmodel = blockmodel
self.grid_type = grid_type
self.frame = frame
self._title_override = title is not None
self.title = title or (blockmodel.name if blockmodel else "")
self.show_edges = show_edges
self.z_up_lock = bool(z_up_lock)
self.z_up_hotkey = str(z_up_hotkey).strip().lower()
self.app_name = str(app_name)
self._initial_scalar = scalar or (self._default_scalar() if blockmodel is not None else "")
self._initial_threshold_value = (
float(threshold_value) if threshold_value is not None else None
)
self.asset_catalog = asset_catalog
self.asset_catalog_root = (
Path(asset_catalog_root).resolve() if asset_catalog_root is not None else None
)
self._asset_level_keys: list[str] = []
self._asset_level_values: dict[str, str] = {}
self._selected_asset_name = ""
self._registered_asset_level_indices: set[int] = set()
self._asset_name_handler_registered = False
self._asset_selectors_autofilled = False
self._source_mode = "hive" if asset_catalog is not None else "file"
self._source_path = str(self.asset_catalog_root or (self.blockmodel.blockmodel_path if blockmodel else ""))
self._duckdb_query: Optional[str] = None
self._duckdb_path: Optional[str] = None
self._skip_initial_blockmodel_load = False
self.state: Optional[BlockModelPlotState] = None
self.threshold: Optional[ThresholdRange] = None
self.filter_enabled = False
self.show_model_bounds = False
self.colormap = "jet"
self.discretize_deciles = False
self.available_colormaps: list[str] = []
self.plotter = CustomPlotter(off_screen=True)
self._remote_view = None
self._server = None
self._syncing_state = False
self._drawer_default_open = True
self._attribute_data_range: dict[str, tuple[float, float]] = {}
self._filter_attribute_options: list[str] = []
self._filter_attribute_cache: dict[str, np.ndarray] = {}
self._filter_category_code_to_label: dict[str, dict[float, str]] = {}
self._active_display_mesh: Optional[pv.DataSet] = None
self._pick_debug_count = 0
self._pick_debug_last = ""
self._data_filters = [
DataFilterSlot(
attribute=str(data_filter_1_attribute or ""),
preset_min=(float(data_filter_1_min) if data_filter_1_min is not None else None),
preset_max=(float(data_filter_1_max) if data_filter_1_max is not None else None),
preset_categories=(
list(data_filter_1_categories)
if data_filter_1_categories is not None
else None
),
),
DataFilterSlot(
attribute=str(data_filter_2_attribute or ""),
preset_min=float(data_filter_2_min) if data_filter_2_min is not None else None,
preset_max=float(data_filter_2_max) if data_filter_2_max is not None else None,
preset_categories=(
list(data_filter_2_categories)
if data_filter_2_categories is not None
else None
),
),
]
self._view_initialized = False
@classmethod
def from_pbm_file(
cls,
blockmodel_path: str | Path,
*,
scalar: Optional[str] = None,
threshold_value: Optional[float] = None,
data_filter_1_attribute: Optional[str] = None,
data_filter_1_min: Optional[float] = None,
data_filter_1_max: Optional[float] = None,
data_filter_1_categories: Optional[list[str]] = None,
data_filter_2_attribute: Optional[str] = None,
data_filter_2_min: Optional[float] = None,
data_filter_2_max: Optional[float] = None,
data_filter_2_categories: Optional[list[str]] = None,
grid_type: str = "image",
frame: str = "world",
title: Optional[str] = None,
show_edges: bool = True,
z_up_lock: bool = False,
z_up_hotkey: str = "z",
app_name: str = "ParquetBlockModel Viewer",
) -> "BlockModelTrameApp":
from parq_blockmodel.blockmodel import ParquetBlockModel
path = Path(blockmodel_path).expanduser()
if not path.is_absolute():
path = path.resolve()
if not path.exists():
raise FileNotFoundError(f"Path does not exist: {path}")
if not path.is_file() or path.suffix.lower() != ".pbm":
raise ValueError(f"Selected file must be a .pbm file: {path}")
return cls(
ParquetBlockModel(blockmodel_path=path),
scalar=scalar,
threshold_value=threshold_value,
data_filter_1_attribute=data_filter_1_attribute,
data_filter_1_min=data_filter_1_min,
data_filter_1_max=data_filter_1_max,
data_filter_1_categories=data_filter_1_categories,
data_filter_2_attribute=data_filter_2_attribute,
data_filter_2_min=data_filter_2_min,
data_filter_2_max=data_filter_2_max,
data_filter_2_categories=data_filter_2_categories,
grid_type=grid_type,
frame=frame,
title=title,
show_edges=show_edges,
z_up_lock=z_up_lock,
z_up_hotkey=z_up_hotkey,
app_name=app_name,
)
@classmethod
def from_path(
cls,
blockmodel_path: str | Path,
*,
scalar: Optional[str] = None,
threshold_value: Optional[float] = None,
data_filter_1_attribute: Optional[str] = None,
data_filter_1_min: Optional[float] = None,
data_filter_1_max: Optional[float] = None,
data_filter_1_categories: Optional[list[str]] = None,
data_filter_2_attribute: Optional[str] = None,
data_filter_2_min: Optional[float] = None,
data_filter_2_max: Optional[float] = None,
data_filter_2_categories: Optional[list[str]] = None,
grid_type: str = "image",
frame: str = "world",
title: Optional[str] = None,
show_edges: bool = True,
z_up_lock: bool = False,
z_up_hotkey: str = "z",
app_name: str = "ParquetBlockModel Viewer",
) -> "BlockModelTrameApp":
return cls.from_pbm_file(
blockmodel_path,
scalar=scalar,
threshold_value=threshold_value,
data_filter_1_attribute=data_filter_1_attribute,
data_filter_1_min=data_filter_1_min,
data_filter_1_max=data_filter_1_max,
data_filter_1_categories=data_filter_1_categories,
data_filter_2_attribute=data_filter_2_attribute,
data_filter_2_min=data_filter_2_min,
data_filter_2_max=data_filter_2_max,
data_filter_2_categories=data_filter_2_categories,
grid_type=grid_type,
frame=frame,
title=title,
show_edges=show_edges,
z_up_lock=z_up_lock,
z_up_hotkey=z_up_hotkey,
app_name=app_name,
)
@classmethod
def from_hive_directory(
cls,
root_path: str | Path,
*,
scalar: Optional[str] = None,
threshold_value: Optional[float] = None,
data_filter_1_attribute: Optional[str] = None,
data_filter_1_min: Optional[float] = None,
data_filter_1_max: Optional[float] = None,
data_filter_1_categories: Optional[list[str]] = None,
data_filter_2_attribute: Optional[str] = None,
data_filter_2_min: Optional[float] = None,
data_filter_2_max: Optional[float] = None,
data_filter_2_categories: Optional[list[str]] = None,
grid_type: str = "image",
frame: str = "world",
title: Optional[str] = None,
show_edges: bool = True,
z_up_lock: bool = False,
z_up_hotkey: str = "z",
app_name: str = "ParquetBlockModel Viewer",
) -> "BlockModelTrameApp":
root = Path(root_path).expanduser()
if not root.is_absolute():
root = root.resolve()
if not root.exists():
raise FileNotFoundError(f"Path does not exist: {root}")
if not root.is_dir():
raise ValueError(f"Selected path is not a directory: {root}")
catalog = HivePbmCatalog.discover(root)
# For hive mode, don't load any blockmodel initially - user selects via UI
# Start with blockmodel=None to avoid unnecessary loading of first asset
app = cls(
blockmodel=None,
scalar=scalar,
threshold_value=threshold_value,
data_filter_1_attribute=data_filter_1_attribute,
data_filter_1_min=data_filter_1_min,
data_filter_1_max=data_filter_1_max,
data_filter_1_categories=data_filter_1_categories,
data_filter_2_attribute=data_filter_2_attribute,
data_filter_2_min=data_filter_2_min,
data_filter_2_max=data_filter_2_max,
data_filter_2_categories=data_filter_2_categories,
grid_type=grid_type,
frame=frame,
title=title,
show_edges=show_edges,
z_up_lock=z_up_lock,
z_up_hotkey=z_up_hotkey,
app_name=app_name,
asset_catalog=catalog,
asset_catalog_root=root,
)
# Mark that we should skip loading placeholder blockmodel on launch
app._skip_initial_blockmodel_load = True
return app
@classmethod
def from_duckdb_query(
cls,
duckdb_query: str,
*,
duckdb_path: Optional[str | Path] = None,
scalar: Optional[str] = None,
threshold_value: Optional[float] = None,
data_filter_1_attribute: Optional[str] = None,
data_filter_1_min: Optional[float] = None,
data_filter_1_max: Optional[float] = None,
data_filter_1_categories: Optional[list[str]] = None,
data_filter_2_attribute: Optional[str] = None,
data_filter_2_min: Optional[float] = None,
data_filter_2_max: Optional[float] = None,
data_filter_2_categories: Optional[list[str]] = None,
grid_type: str = "image",
frame: str = "world",
title: Optional[str] = None,
show_edges: bool = True,
z_up_lock: bool = False,
z_up_hotkey: str = "z",
app_name: str = "ParquetBlockModel Viewer",
) -> "BlockModelTrameApp":
query_text = str(duckdb_query).strip()
if not query_text:
raise ValueError("duckdb_query must be a non-empty SQL string.")
resolved_duckdb_path: Optional[str] = None
if duckdb_path is not None:
resolved_path = Path(duckdb_path).expanduser()
if not resolved_path.is_absolute():
resolved_path = resolved_path.resolve()
resolved_duckdb_path = str(resolved_path)
app = cls(
blockmodel=None,
scalar=scalar,
threshold_value=threshold_value,
data_filter_1_attribute=data_filter_1_attribute,
data_filter_1_min=data_filter_1_min,
data_filter_1_max=data_filter_1_max,
data_filter_1_categories=data_filter_1_categories,
data_filter_2_attribute=data_filter_2_attribute,
data_filter_2_min=data_filter_2_min,
data_filter_2_max=data_filter_2_max,
data_filter_2_categories=data_filter_2_categories,
grid_type=grid_type,
frame=frame,
title=title,
show_edges=show_edges,
z_up_lock=z_up_lock,
z_up_hotkey=z_up_hotkey,
app_name=app_name,
)
app._source_mode = "duckdb_query"
app._source_path = resolved_duckdb_path or "<duckdb in-memory>"
app._duckdb_query = query_text
app._duckdb_path = resolved_duckdb_path
app._skip_initial_blockmodel_load = True
return app
@classmethod
def from_source_path(
cls,
source_path: str | Path,
*,
scalar: Optional[str] = None,
threshold_value: Optional[float] = None,
data_filter_1_attribute: Optional[str] = None,
data_filter_1_min: Optional[float] = None,
data_filter_1_max: Optional[float] = None,
data_filter_1_categories: Optional[list[str]] = None,
data_filter_2_attribute: Optional[str] = None,
data_filter_2_min: Optional[float] = None,
data_filter_2_max: Optional[float] = None,
data_filter_2_categories: Optional[list[str]] = None,
grid_type: str = "image",
frame: str = "world",
title: Optional[str] = None,
show_edges: bool = True,
z_up_lock: bool = False,
z_up_hotkey: str = "z",
app_name: str = "ParquetBlockModel Viewer",
) -> "BlockModelTrameApp":
warnings.warn(
"BlockModelTrameApp.from_source_path(...) is deprecated; "
"use from_pbm_file(...) or from_hive_directory(...) for explicit startup modes.",
DeprecationWarning,
stacklevel=2,
)
path = Path(source_path).expanduser()
if not path.is_absolute():
path = path.resolve()
if not path.exists():
raise FileNotFoundError(f"Path does not exist: {path}")
if path.is_file():
return cls.from_pbm_file(
path,
scalar=scalar,
threshold_value=threshold_value,
data_filter_1_attribute=data_filter_1_attribute,
data_filter_1_min=data_filter_1_min,
data_filter_1_max=data_filter_1_max,
data_filter_1_categories=data_filter_1_categories,
data_filter_2_attribute=data_filter_2_attribute,
data_filter_2_min=data_filter_2_min,
data_filter_2_max=data_filter_2_max,
data_filter_2_categories=data_filter_2_categories,
grid_type=grid_type,
frame=frame,
title=title,
show_edges=show_edges,
z_up_lock=z_up_lock,
z_up_hotkey=z_up_hotkey,
app_name=app_name,
)
if path.is_dir():
return cls.from_hive_directory(
path,
scalar=scalar,
threshold_value=threshold_value,
data_filter_1_attribute=data_filter_1_attribute,
data_filter_1_min=data_filter_1_min,
data_filter_1_max=data_filter_1_max,
data_filter_1_categories=data_filter_1_categories,
data_filter_2_attribute=data_filter_2_attribute,
data_filter_2_min=data_filter_2_min,
data_filter_2_max=data_filter_2_max,
data_filter_2_categories=data_filter_2_categories,
grid_type=grid_type,
frame=frame,
title=title,
show_edges=show_edges,
z_up_lock=z_up_lock,
z_up_hotkey=z_up_hotkey,
app_name=app_name,
)
raise ValueError(f"Selected path is not a file or directory: {path}")
def _default_scalar(self) -> str:
if self.blockmodel is None:
return ""
return self.blockmodel.available_attributes[0]
def _resolve_initial_scalar(self, preferred_scalar: Optional[str] = None) -> str:
if self.blockmodel is None:
return ""
candidate = preferred_scalar or self._initial_scalar
if candidate in self.blockmodel.available_attributes:
return candidate
return self.blockmodel.available_attributes[0]
def _attribute_values(self, attribute: str) -> np.ndarray:
if self.state is None:
raise RuntimeError("Plot state has not been loaded yet.")
values = np.asarray(self.state.mesh.cell_data[attribute], dtype=float)
return values[np.isfinite(values)]
def _build_threshold_range(self, attribute: str) -> ThresholdRange:
values = self._attribute_values(attribute)
if values.size == 0:
return ThresholdRange(minimum=0.0, maximum=1.0, value=0.0, step=0.01)
minimum = float(np.min(values))
maximum = float(np.max(values))
span = max(maximum - minimum, 1.0)
return ThresholdRange(
minimum=minimum,
maximum=maximum,
value=minimum,
step=span / 200.0,
)
def _apply_initial_threshold_value(self) -> None:
if self.threshold is None or self._initial_threshold_value is None:
return
clamped = max(self.threshold.minimum, min(self.threshold.maximum, self._initial_threshold_value))
self.threshold.value = float(clamped)
def _ensure_startup_threshold_range(self) -> None:
if self.threshold is not None:
return
if self._initial_threshold_value is None:
self.threshold = ThresholdRange(minimum=0.0, maximum=1.0, value=0.0, step=0.005)
return
minimum = min(0.0, float(self._initial_threshold_value))
maximum = max(1.0, float(self._initial_threshold_value))
span = max(maximum - minimum, 1.0)
self.threshold = ThresholdRange(
minimum=minimum,
maximum=maximum,
value=float(self._initial_threshold_value),
step=span / 200.0,
)
def _clear_filter_cache(self) -> None:
self._filter_attribute_cache = {}
self._filter_category_code_to_label = {}
def _startup_filter_attribute_options(self) -> list[str]:
options: list[str] = []
if self._initial_scalar:
options.append(self._initial_scalar)
for slot in self._data_filters:
if slot.attribute and slot.attribute not in options:
options.append(slot.attribute)
return options
def _is_categorical_attribute(self, attribute: str) -> bool:
if self.blockmodel is None:
return False
dtype = self.blockmodel.column_dtypes.get(attribute)
if dtype is None:
return False
return isinstance(dtype, pd.CategoricalDtype)
def _is_continuous_attribute(self, attribute: str) -> bool:
if self.blockmodel is None:
return False
dtype = self.blockmodel.column_dtypes.get(attribute)
if dtype is None:
return False
if self._is_categorical_attribute(attribute):
return False
try:
return bool(np.issubdtype(np.dtype(dtype), np.number))
except TypeError:
return False
def _all_filter_attribute_options(self) -> list[str]:
if self.blockmodel is None:
return self._startup_filter_attribute_options()
return list(self.blockmodel.available_attributes)
def _load_filter_category_mapping(self, mesh: pv.DataSet, attribute: str) -> dict[float, str]:
if f"{attribute}_json" not in mesh.field_data:
return {}
raw_mapping = load_mapping_dict(mesh, attribute)
code_to_label: dict[float, str] = {}
for raw_code, label in raw_mapping.items():
code_to_label[float(raw_code)] = str(label)
return code_to_label
def _load_filter_attribute_values(self, attribute: str) -> np.ndarray:
if self.blockmodel is None:
raise RuntimeError("No blockmodel is loaded.")
if self.state is not None and attribute in self.state.mesh.cell_data:
self._filter_category_code_to_label[attribute] = self._load_filter_category_mapping(
self.state.mesh, attribute
)
return np.asarray(self.state.mesh.cell_data[attribute], dtype=float)
attribute_mesh = self.blockmodel.to_pyvista(
grid_type=self.grid_type,
attributes=[attribute],
frame=self.frame,
)
self._filter_category_code_to_label[attribute] = self._load_filter_category_mapping(
attribute_mesh, attribute
)
return np.asarray(attribute_mesh.cell_data[attribute], dtype=float)
def _get_filter_attribute_values(self, attribute: str) -> np.ndarray:
cached = self._filter_attribute_cache.get(attribute)
if cached is not None:
return cached
loaded = self._load_filter_attribute_values(attribute)
self._filter_attribute_cache[attribute] = loaded
return loaded
def _build_data_filter_range(self, attribute: str) -> tuple[float, float, float]:
values = self._get_filter_attribute_values(attribute)
finite_values = values[np.isfinite(values)]
if finite_values.size == 0:
return 0.0, 1.0, 0.01
minimum = float(np.min(finite_values))
maximum = float(np.max(finite_values))
span = max(maximum - minimum, 1.0)
return minimum, maximum, span / 200.0
def _filter_slot_name(self, slot_index: int) -> str:
return f"data_filter_{slot_index + 1}"
def _get_filter_slot(self, slot_index: int) -> DataFilterSlot:
if slot_index not in (0, 1):
raise ValueError("data filter slot_index must be 0 or 1.")
return self._data_filters[slot_index]
def _reset_filter_slot(self, slot_index: int) -> None:
slot = self._get_filter_slot(slot_index)
slot.attribute = ""
slot.is_categorical = False
slot.minimum = 0.0
slot.maximum = 1.0
slot.step = 0.005
slot.range_values = [0.0, 1.0]
slot.category_options = []
slot.selected_categories = []
def _set_filter_slot_attribute(
self,
slot_index: int,
attribute: str,
*,
selected_min: Optional[float] = None,
selected_max: Optional[float] = None,
selected_categories: Optional[list[str]] = None,
) -> None:
slot = self._get_filter_slot(slot_index)
normalized = str(attribute or "")
if normalized == "":
self._reset_filter_slot(slot_index)
return
if normalized not in self._filter_attribute_options:
raise ValueError(f"Filter attribute '{normalized}' is not available.")
slot.attribute = normalized
slot.is_categorical = self._is_categorical_attribute(normalized)
values = self._get_filter_attribute_values(normalized)
if slot.is_categorical:
code_to_label = self._filter_category_code_to_label.get(normalized, {})
ordered_labels = [label for _, label in sorted(code_to_label.items(), key=lambda item: item[0])]
if not ordered_labels:
finite_values = values[np.isfinite(values)]
unique_codes = sorted({float(v) for v in finite_values.tolist()})
ordered_labels = [str(int(code) if float(code).is_integer() else code) for code in unique_codes]
code_to_label = {
float(code): str(int(code) if float(code).is_integer() else code) for code in unique_codes
}
self._filter_category_code_to_label[normalized] = code_to_label
slot.category_options = ordered_labels
if selected_categories is None:
chosen = list(slot.preset_categories or ordered_labels)
else:
chosen = list(selected_categories)
slot.selected_categories = [label for label in chosen if label in slot.category_options]
slot.minimum = 0.0
slot.maximum = 1.0
slot.step = 0.005
slot.range_values = [0.0, 1.0]
else:
minimum, maximum, step = self._build_data_filter_range(normalized)
low_source = selected_min if selected_min is not None else slot.preset_min
high_source = selected_max if selected_max is not None else slot.preset_max
low = minimum if low_source is None else max(minimum, min(maximum, float(low_source)))
high = maximum if high_source is None else max(minimum, min(maximum, float(high_source)))
if low > high:
low, high = high, low
slot.minimum = minimum
slot.maximum = maximum
slot.step = step
slot.range_values = [low, high]
slot.category_options = []
slot.selected_categories = []
slot.preset_min = None
slot.preset_max = None
slot.preset_categories = None
def _apply_initial_data_filters(self) -> None:
for idx, slot in enumerate(self._data_filters):
if slot.attribute:
self._set_filter_slot_attribute(
idx,
slot.attribute,
selected_min=slot.preset_min,
selected_max=slot.preset_max,
selected_categories=slot.preset_categories,
)
def _apply_startup_filter_presets_without_data(self) -> None:
for slot in self._data_filters:
if not slot.attribute:
continue
low = slot.preset_min
high = slot.preset_max
if low is None and high is None:
continue
if low is None:
low = float(high)
if high is None:
high = float(low)
low_value = float(low)
high_value = float(high)
if low_value > high_value:
low_value, high_value = high_value, low_value
slot.minimum = min(0.0, low_value)
slot.maximum = max(1.0, high_value)
span = max(slot.maximum - slot.minimum, 1.0)
slot.step = span / 200.0
slot.range_values = [low_value, high_value]
def _data_filter_slot_summary(self, slot: DataFilterSlot) -> str:
if not slot.attribute:
return "None"
if slot.is_categorical:
selected = slot.selected_categories
total = len(slot.category_options)
if not selected:
return f"{slot.attribute}: 0/{total} selected"
if len(selected) <= 3:
return f"{slot.attribute}: {', '.join(selected)}"
return f"{slot.attribute}: {len(selected)}/{total} selected"
low, high = slot.range_values
return f"{slot.attribute}: {self._format_threshold(low)} to {self._format_threshold(high)}"
def _sync_data_filter_state(self) -> None:
if self._server is None:
return
state = self._server.state
state.data_filter_attribute_options = list(self._filter_attribute_options)
for idx, slot in enumerate(self._data_filters):
prefix = self._filter_slot_name(idx)
setattr(state, f"{prefix}_attribute", slot.attribute)
setattr(state, f"{prefix}_is_categorical", slot.is_categorical)
setattr(state, f"{prefix}_range_min", slot.minimum)
setattr(state, f"{prefix}_range_max", slot.maximum)
setattr(state, f"{prefix}_range_step", slot.step)
setattr(state, f"{prefix}_range", list(slot.range_values))
setattr(state, f"{prefix}_category_options", list(slot.category_options))
setattr(state, f"{prefix}_selected_categories", list(slot.selected_categories))
setattr(state, f"{prefix}_summary", self._data_filter_slot_summary(slot))
def set_data_filter_attribute(self, slot_index: int, attribute: str) -> None:
if self.blockmodel is None:
slot = self._get_filter_slot(slot_index)
normalized = str(attribute or "")
if normalized == "":
self._reset_filter_slot(slot_index)
else:
if normalized not in self._filter_attribute_options:
raise ValueError(f"Filter attribute '{normalized}' is not available.")
slot.attribute = normalized
slot.is_categorical = False
low = slot.preset_min if slot.preset_min is not None else slot.range_values[0]
high = slot.preset_max if slot.preset_max is not None else slot.range_values[1]
low_value = float(low)
high_value = float(high)
if low_value > high_value:
low_value, high_value = high_value, low_value
slot.minimum = min(0.0, low_value)
slot.maximum = max(1.0, high_value)
span = max(slot.maximum - slot.minimum, 1.0)
slot.step = span / 200.0
slot.range_values = [low_value, high_value]
slot.category_options = []
slot.selected_categories = []
if self._server is not None:
self._syncing_state = True
try:
self._sync_data_filter_state()
finally:
self._syncing_state = False
return
self._set_filter_slot_attribute(slot_index, attribute)
if self.state is not None:
self._refresh_plot(preserve_camera=self._view_initialized)
if self._server is not None:
self._syncing_state = True
try:
self._sync_data_filter_state()
finally:
self._syncing_state = False
def set_data_filter_range(self, slot_index: int, values: list[float] | tuple[float, float]) -> None:
slot = self._get_filter_slot(slot_index)
if not slot.attribute or slot.is_categorical:
return
if len(values) != 2:
raise ValueError("Filter range must have exactly two values.")
lower = max(slot.minimum, min(slot.maximum, float(values[0])))
upper = max(slot.minimum, min(slot.maximum, float(values[1])))
if lower > upper:
lower, upper = upper, lower
if lower == slot.range_values[0] and upper == slot.range_values[1]:
return
slot.range_values = [lower, upper]
if self.state is not None:
self._refresh_plot(preserve_camera=self._view_initialized)
if self._server is not None:
self._syncing_state = True
try:
prefix = self._filter_slot_name(slot_index)
setattr(self._server.state, f"{prefix}_range", list(slot.range_values))
setattr(self._server.state, f"{prefix}_summary", self._data_filter_slot_summary(slot))
finally:
self._syncing_state = False
def set_data_filter_categories(self, slot_index: int, categories: list[str]) -> None:
slot = self._get_filter_slot(slot_index)
if not slot.attribute or not slot.is_categorical:
return
slot.selected_categories = [label for label in categories if label in slot.category_options]
if self.state is not None:
self._refresh_plot(preserve_camera=self._view_initialized)
if self._server is not None:
self._syncing_state = True
try:
prefix = self._filter_slot_name(slot_index)
setattr(
self._server.state,
f"{prefix}_selected_categories",
list(slot.selected_categories),
)
setattr(self._server.state, f"{prefix}_summary", self._data_filter_slot_summary(slot))
finally:
self._syncing_state = False
def reset_data_filter(self, slot_index: Optional[int] = None) -> None:
if slot_index is None:
self._reset_filter_slot(0)
self._reset_filter_slot(1)
else:
self._reset_filter_slot(slot_index)
if self.state is not None:
self._refresh_plot(preserve_camera=self._view_initialized)
if self._server is not None:
self._syncing_state = True
try:
self._sync_data_filter_state()
finally:
self._syncing_state = False
def _refresh_filter_options(self) -> None:
self._filter_attribute_options = self._all_filter_attribute_options()
if self.blockmodel is None:
return
for idx, slot in enumerate(self._data_filters):
if slot.attribute and slot.attribute not in self._filter_attribute_options:
self._reset_filter_slot(idx)
def _format_threshold(self, value: float) -> str:
return f"{value:,.3f}".rstrip("0").rstrip(".")
def _branding_logo_resource(self):
return resources.files("parq_blockmodel.assets.branding").joinpath("parq-blockmodel.svg")
def _branding_logo_data_url(self) -> str:
with resources.as_file(self._branding_logo_resource()) as brand_logo_path:
svg_text = Path(brand_logo_path).read_text(encoding="utf-8")
return "data:image/svg+xml;charset=utf-8," + quote(svg_text)
def _setup_picking_callback(self):
"""Create and setup cell picking callback with categorical support."""
if self.state is None:
return
def _push_pick_state(
*,
dialog_open: Optional[bool] = None,
dialog_text: Optional[str] = None,
debug_text: Optional[str] = None,
) -> None:
if self._server is None:
return
state = self._server.state
changed_keys: list[str] = []
if dialog_open is not None:
state.picking_dialog_open = bool(dialog_open)
changed_keys.append("picking_dialog_open")
if dialog_text is not None:
state.picking_dialog_text = str(dialog_text)
changed_keys.append("picking_dialog_text")
if debug_text is not None:
state.picking_debug_last = str(debug_text)
changed_keys.append("picking_debug_last")
state.picking_debug_count = int(self._pick_debug_count)
changed_keys.append("picking_debug_count")
if hasattr(state, "dirty"):
for key in changed_keys:
try:
state.dirty(key)
except Exception:
break
if hasattr(state, "flush"):
try:
state.flush()
except Exception:
pass
def _extract_cell_id_from_pick(picked_cell) -> Optional[int]:
def _to_int(value: object) -> Optional[int]:
try:
arr = np.asarray(value).ravel()
if arr.size == 0:
return None
cell_id = int(np.rint(float(arr[0])))
except (TypeError, ValueError):
return None
return cell_id if cell_id >= 0 else None
data_sources = []
if isinstance(picked_cell, dict):
data_sources.append(picked_cell)
for attr_name in ("cell_data", "point_data", "field_data"):
data = getattr(picked_cell, attr_name, None)
if data is not None:
data_sources.append(data)
id_keys = (
"vtkOriginalCellIds",
"vtkOriginalCellId",
"vtkCellIds",
"cell_ids",
"cellIds",
"cell_id",
"cellId",
"id",
)
for source in data_sources:
for key in id_keys:
try:
if key in source:
maybe_id = _to_int(source[key])
if maybe_id is not None:
return maybe_id
except Exception:
continue
for attr_name in ("cell_id", "cellId", "id"):
maybe_id = _to_int(getattr(picked_cell, attr_name, None))
if maybe_id is not None:
return maybe_id
return None
def cell_callback(picked_cell):
if self.state is None:
return
self._pick_debug_count += 1
picked_cells = int(getattr(picked_cell, "n_cells", 0) or 0)
cell_id = _extract_cell_id_from_pick(picked_cell)
payload_keys: list[str] = []
for source_name in ("cell_data", "point_data", "field_data"):
source = getattr(picked_cell, source_name, None)
try:
if source is not None and hasattr(source, "keys"):
payload_keys.extend([f"{source_name}.{k}" for k in list(source.keys())[:4]])
except Exception:
continue
self._pick_debug_last = (
f"count={self._pick_debug_count}, n_cells={picked_cells}, cell_id={cell_id}, "
f"keys={payload_keys[:6]}"
)
if picked_cells != 1 or cell_id is None or cell_id >= self.state.mesh.n_cells:
_push_pick_state(
dialog_open=False,
dialog_text="",
debug_text=self._pick_debug_last,
)
return
cell_centers = self.state.mesh.cell_centers().points
centroid = cell_centers[cell_id]
centroid_str = f"({centroid[0]:.1f}, {centroid[1]:.1f}, {centroid[2]:.1f})"
values: dict[str, object] = {}
for attr in self.state.attributes:
raw_value = self.state.mesh.cell_data[attr][cell_id]
if attr in self.state.categorical_mappings:
if pd.isna(raw_value):
values[attr] = "<NA>"
else:
code = int(np.rint(float(raw_value)))
values[attr] = self.state.categorical_mappings[attr].get(code, f"<unknown:{code}>")
else:
values[attr] = raw_value
msg = f"Cell ID: {cell_id}, {centroid_str}, " + ", ".join(
f"{k}: {v}" for k, v in values.items()
)
_push_pick_state(
dialog_open=True,
dialog_text=msg,
debug_text=self._pick_debug_last,
)
self.plotter.setup_picking_with_callback(cell_callback)
def _filtered_mesh(self) -> pv.DataSet:
if self.state is None:
raise RuntimeError("Plot state has not been loaded yet.")
cell_count = self.state.mesh.n_cells
mask = np.ones(cell_count, dtype=bool)
if not self.state.scalar_is_categorical and self.filter_enabled:
threshold_values = np.asarray(self.state.mesh.cell_data[self.state.scalar], dtype=float)
mask &= np.isfinite(threshold_values) & (threshold_values >= self.threshold.value)
for slot in self._data_filters:
if not slot.attribute:
continue
values = self._get_filter_attribute_values(slot.attribute)
if values.shape[0] != cell_count:
continue
if slot.is_categorical:
code_to_label = self._filter_category_code_to_label.get(slot.attribute, {})
selected_labels = set(slot.selected_categories)
selected_codes = [
code
for code, label in code_to_label.items()
if label in selected_labels
]
if selected_codes:
mask &= np.isin(values, np.asarray(selected_codes, dtype=float))
else:
mask &= np.zeros(cell_count, dtype=bool)
else:
lower, upper = slot.range_values
mask &= np.isfinite(values) & (values >= lower) & (values <= upper)
if bool(np.all(mask)):
return self.state.mesh
return self.state.mesh.extract_cells(mask)
def _mesh_with_decile_scalars(self, mesh: pv.DataSet) -> tuple[pv.DataSet, np.ndarray]:
if self.state is None:
raise RuntimeError("Plot state has not been loaded yet.")
scalar_values = np.asarray(self.state.mesh.cell_data[self.state.scalar], dtype=float)
decile_edges = _calculate_deciles(scalar_values)
mesh_values = np.asarray(mesh.cell_data[self.state.scalar], dtype=float)
decile_bins = _bin_to_deciles(mesh_values, decile_edges)
display_mesh = mesh.copy(deep=True)
display_mesh.cell_data["__pbm_decile_bin__"] = decile_bins
return display_mesh, decile_edges
def _refresh_plot(self, *, preserve_camera: bool = True) -> None:
if self.state is None:
return
camera_position = None
if preserve_camera and self._view_initialized:
camera_position = getattr(self.plotter, "camera_position", None)
self.plotter.clear()
mesh = self._filtered_mesh()
self._active_display_mesh = mesh
is_decile_mode = self.discretize_deciles and not self.state.scalar_is_categorical
decile_edges: Optional[np.ndarray] = None
scalar_for_coloring = self.state.scalar
if is_decile_mode:
mesh, decile_edges = self._mesh_with_decile_scalars(mesh)
scalar_for_coloring = "__pbm_decile_bin__"
mesh_kwargs = _plotter_add_mesh_kwargs(
self.state,
colormap=self.colormap,
discretize_to_deciles=is_decile_mode,
decile_edges=decile_edges,
scalar_for_coloring=scalar_for_coloring,
)
mesh_kwargs["show_edges"] = self.show_edges
if not self.state.scalar_is_categorical and not is_decile_mode:
mesh_values = np.asarray(self.state.mesh.cell_data[self.state.scalar], dtype=float)
finite_values = mesh_values[np.isfinite(mesh_values)]
if finite_values.size > 0:
mesh_kwargs["clim"] = (float(np.min(finite_values)), float(np.max(finite_values)))
self.plotter.add_mesh(mesh, name="blockmodel", **mesh_kwargs)
if self.show_model_bounds:
outline = mesh.outline()
self.plotter.add_mesh(
outline,
color="dodgerblue",
line_width=2,
name="model_bounds",
)
self.plotter.title = self.title
self.plotter.add_axes()
if preserve_camera and camera_position not in (None, []):
self.plotter.camera_position = camera_position
else:
self.plotter.set_directional_view(direction='WSW', elevation_deg=30)
self.plotter.reset_camera_clipping_range()
if bool(getattr(self.plotter, "hotkey_pressed", {}).get("z")):
self.plotter.enforce_z_up()
if bool(getattr(self.plotter, "picking_enabled", False)):
self._setup_picking_callback()
self.plotter.render()
self._view_initialized = True
if self._remote_view is not None:
self._remote_view.update()
def _reset_model_view(self, *, preserve_presets: bool = False) -> None:
self.blockmodel = None
self.state = None
self.threshold = None
self._ensure_startup_threshold_range()
self.filter_enabled = False
if not preserve_presets:
self._reset_filter_slot(0)
self._reset_filter_slot(1)
else:
self._apply_startup_filter_presets_without_data()
self._filter_attribute_options = []
self._clear_filter_cache()
self._view_initialized = False
self.set_picking_active(False)
self._active_display_mesh = None
self.plotter.clear()
if self._remote_view is not None:
self._remote_view.update()
if self._server is not None:
state = self._server.state
state.attribute_options = self._startup_filter_attribute_options() if preserve_presets else []
state.active_attribute = self._initial_scalar if preserve_presets else ""
state.threshold_min = self.threshold.minimum
state.threshold_max = self.threshold.maximum
state.threshold = self.threshold.value
state.threshold_display = self._format_threshold(self.threshold.value)
state.threshold_step = self.threshold.step
state.filter_active = False
state.picking_active = False
state.picking_dialog_open = False
state.picking_dialog_text = ""
state.picking_debug_count = int(self._pick_debug_count)
state.picking_debug_last = self._pick_debug_last
state.show_model_bounds = self.show_model_bounds
if preserve_presets:
self._refresh_filter_options()
self._sync_data_filter_state()
else:
state.data_filter_attribute_options = []
for idx in range(2):
prefix = self._filter_slot_name(idx)
setattr(state, f"{prefix}_attribute", "")
setattr(state, f"{prefix}_is_categorical", False)
setattr(state, f"{prefix}_range_min", 0.0)
setattr(state, f"{prefix}_range_max", 1.0)
setattr(state, f"{prefix}_range_step", 0.005)
setattr(state, f"{prefix}_range", [0.0, 1.0])
setattr(state, f"{prefix}_category_options", [])
setattr(state, f"{prefix}_selected_categories", [])
setattr(state, f"{prefix}_summary", "None")
state.model_name = ""
state.model_path = ""
def _load_plot_state(self, scalar: str) -> None:
self.state = prepare_plot_state(
self.blockmodel,
scalar=scalar,
grid_type=self.grid_type,
frame=self.frame,
enable_picking=False,
title=self.title,
)
self.threshold = self._build_threshold_range(self.state.scalar)
self.filter_enabled = True
def load_blockmodel(self, blockmodel: "ParquetBlockModel", *, preferred_scalar: Optional[str] = None, auto_select_scalar: bool = True) -> None:
self.blockmodel = blockmodel
if not self._title_override:
self.title = self.blockmodel.name
self._clear_filter_cache()
self._refresh_filter_options()
self._syncing_state = True
try:
# Only load plot state if auto_select_scalar is True (file mode)
# For hive mode, just populate attributes and wait for user selection
if auto_select_scalar:
scalar = self._resolve_initial_scalar(preferred_scalar)
self._initial_scalar = scalar
self._load_plot_state(scalar)
self._apply_initial_threshold_value()
self._apply_initial_data_filters()
self._refresh_plot(preserve_camera=False)
else:
# Hive mode: preserve any preset selections until a concrete asset is loaded.
self._apply_initial_data_filters()
if self._server is not None:
self._server.state.attribute_options = self.blockmodel.available_attributes
self._server.state.model_name = self.blockmodel.name
self._server.state.model_path = str(self.blockmodel.blockmodel_path)
if auto_select_scalar and self.threshold is not None and self.state is not None:
# File mode: populate all threshold/attribute state
self._server.state.active_attribute = self.state.scalar
self._server.state.threshold_min = self.threshold.minimum
self._server.state.threshold_max = self.threshold.maximum
self._server.state.threshold = self.threshold.value
self._server.state.threshold_step = self.threshold.step
self._server.state.threshold_display = self._format_threshold(self.threshold.value)
self._server.state.filter_active = self.filter_enabled
else:
# Hive mode: preserve the preset scalar label until an asset is loaded.
self._server.state.active_attribute = self._initial_scalar
self._ensure_startup_threshold_range()
self._server.state.threshold_min = self.threshold.minimum
self._server.state.threshold_max = self.threshold.maximum
self._server.state.threshold = self.threshold.value
self._server.state.threshold_step = self.threshold.step
self._server.state.threshold_display = self._format_threshold(self.threshold.value)
self._server.state.filter_active = False
self._sync_data_filter_state()
finally:
self._syncing_state = False
def _asset_selection(self) -> dict[str, str]:
return {
key: value
for key, value in self._asset_level_values.items()
if value not in ("", None)
}
def _refresh_asset_selector_values(self) -> tuple[list[list[str]], list[str]]:
if self.asset_catalog is None:
return [], []
level_options: list[list[str]] = []
selection_prefix: dict[str, str] = {}
for key in self._asset_level_keys:
options = self.asset_catalog.level_options(key, selection_prefix)
current_value = self._asset_level_values.get(key, "")
# IMPORTANT: Don't auto-fill to first option on initial load
# Only auto-fill if user has explicitly selected values AND an option is missing
if current_value and current_value not in options:
# Only auto-select if user has already interacted with this level
if self._asset_selectors_autofilled:
current_value = options[0] if options else ""
# Don't set to first option just because list is empty
self._asset_level_values[key] = current_value
if current_value:
selection_prefix[key] = current_value
level_options.append(options)
name_options = self.asset_catalog.pbm_name_options(self._asset_selection())
# Similar logic for asset name - don't auto-select first PBM on initial load
if self._selected_asset_name and self._selected_asset_name not in name_options:
if self._asset_selectors_autofilled:
self._selected_asset_name = name_options[0] if name_options else ""
return level_options, name_options
def _sync_asset_selector_state(self) -> None:
if self._server is None:
return
state = self._server.state
state.asset_selector_enabled = self.asset_catalog is not None
if self.asset_catalog is None:
state.asset_name_options = []
state.selected_asset_name = ""
state.asset_selected_path = ""
return
level_options, name_options = self._refresh_asset_selector_values()
self._syncing_state = True
try:
# IMPORTANT: Set values FIRST before options to prevent Vuetify from clearing v_model
# when items array is updated
for idx, key in enumerate(self._asset_level_keys):
setattr(state, f"asset_level_{idx}_value", self._asset_level_values.get(key, ""))
state.selected_asset_name = self._selected_asset_name
# Now set options after values are in place
for idx, key in enumerate(self._asset_level_keys):
setattr(state, f"asset_level_{idx}_options", level_options[idx])
state.asset_name_options = name_options
try:
asset = self.asset_catalog.select_asset(self._asset_selection(), self._selected_asset_name)
state.asset_selected_path = str(asset.path)
except LookupError:
state.asset_selected_path = ""
finally:
self._syncing_state = False
def _register_asset_selector_handlers(self) -> None:
if self._server is None:
return
state, ctrl = self._server.state, self._server.controller
for idx, _ in enumerate(self._asset_level_keys):
if idx in self._registered_asset_level_indices:
continue
self._registered_asset_level_indices.add(idx)
field_name = f"asset_level_{idx}_value"
def _make_asset_level_handler(level_index: int, watched_field: str):
@state.change(watched_field)
def _asset_level_changed(**kwargs):
# IMPORTANT: Only process if the watched field is actually in kwargs
# Trame sometimes calls handlers with empty kwargs for internal events
if watched_field not in kwargs:
return
if self._syncing_state:
return
if level_index >= len(self._asset_level_keys):
return
level_key = self._asset_level_keys[level_index]
new_value = str(kwargs.get(watched_field) or "")
old_value = self._asset_level_values.get(level_key, "")
# Only sync if value actually changed
if new_value == old_value:
return
# Update current level
self._asset_level_values[level_key] = new_value
# Clear all child levels and asset name when parent changes
for idx in range(level_index + 1, len(self._asset_level_keys)):
self._asset_level_values[self._asset_level_keys[idx]] = ""
self._selected_asset_name = ""
# Clear model view immediately when selection changes
self._reset_model_view(preserve_presets=True)
self._asset_selectors_autofilled = True
# Let _sync_asset_selector_state manage _syncing_state
self._sync_asset_selector_state()
# NOTE: Don't call _load_selected_asset() - only load when asset name is selected
return _asset_level_changed
setattr(ctrl, f"update_asset_level_{idx}", _make_asset_level_handler(idx, field_name))
if not self._asset_name_handler_registered:
@state.change("selected_asset_name")
def _asset_name_changed(selected_asset_name=None, **_):
# IMPORTANT: Only process if selected_asset_name is not None
# Trame sometimes calls handlers with None for internal events
if selected_asset_name is None:
return
if self._syncing_state:
return
new_name = str(selected_asset_name or "")
old_name = self._selected_asset_name
# Only proceed if value actually changed
if new_name == old_name:
return
self._selected_asset_name = new_name
self._asset_selectors_autofilled = True
# Only load when asset name is explicitly selected (not empty)
if new_name:
self._reset_model_view(preserve_presets=True)
self._load_selected_asset()
# Sync state AFTER loading asset so attribute_options are available
self._sync_asset_selector_state()
else:
# Clear canvas if asset name is deselected
self._reset_model_view(preserve_presets=True)
self._sync_asset_selector_state()
ctrl.update_asset_name = _asset_name_changed
self._asset_name_handler_registered = True
def _set_asset_selection_from_current_model(self) -> None:
if self.asset_catalog is None:
return
found = self.asset_catalog.find_by_path(self.blockmodel.blockmodel_path)
if found is None:
return
level_map = found.level_map
self._asset_level_values = {key: level_map.get(key, "") for key in self._asset_level_keys}
self._selected_asset_name = found.name
def _load_selected_asset(self) -> None:
if self.asset_catalog is None or not self._selected_asset_name:
return
try:
selected = self.asset_catalog.select_asset(self._asset_selection(), self._selected_asset_name)
except LookupError:
return
# In file mode, skip if asset is already loaded
# In hive mode with None blockmodel, always load (first time)
if self._source_mode == "file" and self.blockmodel is not None:
if selected.path.resolve() == self.blockmodel.blockmodel_path.resolve():
return
from parq_blockmodel.blockmodel import ParquetBlockModel
self.load_blockmodel(
ParquetBlockModel(blockmodel_path=selected.path),
preferred_scalar=self._initial_scalar,
)
self._set_asset_selection_from_current_model()
self._sync_asset_selector_state()
def load_source_path(self, source_path: str | Path) -> None:
from parq_blockmodel.blockmodel import ParquetBlockModel
path = Path(source_path).expanduser()
if not path.is_absolute():
path = path.resolve()
if not path.exists():
raise FileNotFoundError(f"Path does not exist: {path}")
if path.is_file():
if path.suffix.lower() != ".pbm":
raise ValueError(f"Selected file must be a .pbm file: {path}")
self.asset_catalog = None
self.asset_catalog_root = None
self._asset_level_keys = []
self._asset_level_values = {}
self._selected_asset_name = ""
self._asset_selectors_autofilled = False
self._source_mode = "file"
self._source_path = str(path)
if self._server is not None:
self._server.state.source_mode = self._source_mode
self._server.state.source_path_input = self._source_path
self.load_blockmodel(ParquetBlockModel(blockmodel_path=path))
if self._server is not None:
self._server.state.source_status = f"Loaded PBM: {path.name}"
self._sync_asset_selector_state()
return
if not path.is_dir():
raise ValueError(f"Selected path is not a file or directory: {path}")
catalog = HivePbmCatalog.discover(path)
self.asset_catalog = catalog
self.asset_catalog_root = path.resolve()
self._asset_level_keys = list(catalog.level_keys)
self._asset_level_values = {key: "" for key in self._asset_level_keys}
self._selected_asset_name = ""
self._asset_selectors_autofilled = False
self._source_mode = "hive"
self._source_path = str(path.resolve())
# Don't auto-load first asset - let user select from DDLs
if self._server is not None:
self._server.state.source_mode = self._source_mode
self._server.state.source_path_input = self._source_path
self._server.state.source_status = f"Loaded hive directory: {self._source_path}"
self._register_asset_selector_handlers()
self._sync_asset_selector_state()
def set_attribute(self, attribute: str) -> None:
if self.blockmodel is None:
self._initial_scalar = str(attribute or "")
self._ensure_startup_threshold_range()
if self._server is not None and self.threshold is not None:
self._syncing_state = True
try:
self._server.state.active_attribute = self._initial_scalar
self._server.state.threshold_min = self.threshold.minimum
self._server.state.threshold_max = self.threshold.maximum
self._server.state.threshold = self.threshold.value
self._server.state.threshold_display = self._format_threshold(self.threshold.value)
self._server.state.threshold_step = self.threshold.step
self._sync_data_filter_state()
finally:
self._syncing_state = False
return
self._syncing_state = True
try:
self._load_plot_state(attribute)
self._refresh_plot(preserve_camera=self._view_initialized)
if self._server is not None and self.threshold is not None:
self._server.state.active_attribute = attribute
self._server.state.threshold_min = self.threshold.minimum
self._server.state.threshold_max = self.threshold.maximum
self._server.state.threshold = self.threshold.value
self._server.state.filter_active = self.filter_enabled
self._sync_data_filter_state()
finally:
self._syncing_state = False
def set_threshold(self, value: float) -> None:
if self.threshold is None:
raise RuntimeError("Threshold range has not been initialised.")
self.threshold.value = float(value)
self.filter_enabled = True
self._refresh_plot(preserve_camera=self._view_initialized)
if self._server is not None:
self._server.state.threshold = self.threshold.value
self._server.state.threshold_display = self._format_threshold(self.threshold.value)
self._server.state.filter_active = self.filter_enabled
def set_threshold_from_text(self, text_value: str) -> None:
if self.threshold is None:
raise RuntimeError("Threshold range has not been initialised.")
try:
value = float(text_value)
if not (self.threshold.minimum <= value <= self.threshold.maximum):
if self._server is not None:
self._server.state.threshold_display = self._format_threshold(self.threshold.value)
return
self.set_threshold(value)
except (ValueError, TypeError):
if self._server is not None:
self._server.state.threshold_display = self._format_threshold(self.threshold.value)
def reset_filter(self) -> None:
if self.threshold is None:
raise RuntimeError("Threshold range has not been initialised.")
self.filter_enabled = False
self.threshold.value = self.threshold.minimum
self._refresh_plot(preserve_camera=self._view_initialized)
if self._server is not None:
self._syncing_state = True
try:
self._server.state.threshold = self.threshold.value
self._server.state.threshold_display = self._format_threshold(self.threshold.value)
self._server.state.filter_active = False
finally:
self._syncing_state = False
def set_colormap(self, colormap: str) -> None:
self.colormap = colormap
self._refresh_plot(preserve_camera=self._view_initialized)
if self._server is not None:
self._server.state.selected_colormap = colormap
def set_discretize_deciles(self, enabled: bool) -> None:
self.discretize_deciles = bool(enabled)
self._refresh_plot(preserve_camera=self._view_initialized)
if self._server is not None:
self._server.state.discretize_deciles = self.discretize_deciles
def set_picking_active(self, enabled: bool) -> None:
enabled = bool(enabled)
if not hasattr(self.plotter, "picking_enabled"):
return
if enabled and not self.plotter.picking_enabled:
self._setup_picking_callback()
self.plotter.picking_enabled = True
self._pick_debug_last = "Picking enabled"
elif not enabled and self.plotter.picking_enabled:
self.plotter.disable_picking()
self.plotter.picking_enabled = False
if self._server is not None:
self._server.state.picking_dialog_open = False
self._server.state.picking_dialog_text = ""
self._pick_debug_last = "Picking disabled"
if self._server is not None:
self._server.state.picking_active = self.plotter.picking_enabled
self._server.state.picking_debug_count = int(self._pick_debug_count)
self._server.state.picking_debug_last = self._pick_debug_last
self.plotter.render()
if self._remote_view is not None:
self._remote_view.update()
def set_show_model_bounds(self, enabled: bool) -> None:
self.show_model_bounds = bool(enabled)
self._refresh_plot(preserve_camera=self._view_initialized)
if self._server is not None:
self._server.state.show_model_bounds = self.show_model_bounds
def _get_available_colormaps(self) -> list[str]:
try:
import matplotlib.pyplot as plt
all_cmaps = list(plt.colormaps())
except (ImportError, AttributeError):
all_cmaps = ["viridis", "plasma", "inferno", "cool", "hot"]
useful_cmaps = [
"viridis", "plasma", "inferno", "magma", "cividis",
"cool", "hot", "spring", "summer", "autumn", "winter",
"jet", "RdYlBu", "RdYlGn", "Spectral", "coolwarm", "seismic",
"twilight", "twilight_shifted", "hsv", "bone", "copper",
"gray", "hot_r", "cool_r", "Greys", "Blues", "Greens", "Oranges",
"Reds", "Purples", "RdPu", "BuGn", "BuPu", "GnBu", "OrRd", "PuBu"
]
available = [c for c in useful_cmaps if c in all_cmaps]
if not available:
available = all_cmaps[:10]
return sorted(available)
def _build_launch_kwargs(self, port: Optional[int], host: Optional[str]) -> dict[str, object]:
start_kwargs: dict[str, object] = {"open_browser": True, "show_connection_info": True}
if port is not None:
start_kwargs["port"] = port
if host is not None:
start_kwargs["host"] = host
return start_kwargs
[docs]
def launch(
self,
server_name: str = "parq-blockmodel-trame",
port: Optional[int] = None,
host: Optional[str] = None,
):
"""Launch the Trame visualization app.
Parameters
----------
server_name : str, optional
Base name for the Trame server, by default "parq-blockmodel-trame"
A UUID suffix is appended to ensure unique server instances.
port : int, optional
Port number for the server. If None, Trame will auto-assign a port.
Useful for running multiple instances without server state conflicts.
Example: launch(port=8080), launch(port=8081), etc.
host : str, optional
Host interface to bind to. Use "0.0.0.0" to accept external connections.
"""
try:
from trame.app import get_server
from trame.ui.vuetify import SinglePageWithDrawerLayout
from trame.widgets import vtk, vuetify
except ImportError as exc: # pragma: no cover
raise ImportError(
"Trame dependencies are required for the interactive app. "
"Install the 'trame', 'trame-vtk', and 'trame-vuetify' packages."
) from exc
try: # Optional: used for browser key-capture hotkeys.
from trame.widgets import trame as trame_widgets
except ImportError: # pragma: no cover
trame_widgets = None
# Reset all instance state to ensure fresh launch (not stale from previous launch)
self._asset_level_values = {}
self._selected_asset_name = ""
self._asset_selectors_autofilled = False
# Use unique server name to force fresh Trame server instance (prevents caching)
unique_server_name = f"{server_name}-{uuid4().hex[:8]}"
server = get_server(unique_server_name, client_type="vue2")
self._server = server
state, ctrl = server.state, server.controller
self.plotter.clear()
self._syncing_state = True
try:
state.source_mode = self._source_mode
state.source_path_input = self._source_path
state.picking_dialog_open = False
state.picking_dialog_text = ""
state.picking_debug_count = int(self._pick_debug_count)
state.picking_debug_last = self._pick_debug_last
state.picking_active = bool(getattr(self.plotter, "picking_enabled", False))
state.show_model_bounds = self.show_model_bounds
# For hive mode, skip initial blockmodel load - user will select via dropdowns
if not self._skip_initial_blockmodel_load:
self.load_blockmodel(self.blockmodel, preferred_scalar=self._initial_scalar)
# Initialize available colormaps
self.available_colormaps = self._get_available_colormaps()
self._refresh_filter_options()
# Set state only if blockmodel was loaded
if self.state is not None:
state.attribute_options = self.blockmodel.available_attributes
state.active_attribute = self.state.scalar
state.threshold_min = self.threshold.minimum
state.threshold_max = self.threshold.maximum
state.threshold = self.threshold.value
state.threshold_display = self._format_threshold(self.threshold.value)
state.threshold_step = self.threshold.step
state.filter_active = self.filter_enabled
else:
# Hive mode before user selection: show presets, but keep the canvas empty.
self._ensure_startup_threshold_range()
self._apply_startup_filter_presets_without_data()
state.attribute_options = self._startup_filter_attribute_options()
state.active_attribute = self._initial_scalar
state.threshold_min = self.threshold.minimum
state.threshold_max = self.threshold.maximum
state.threshold = self.threshold.value
state.threshold_display = self._format_threshold(self.threshold.value)
state.threshold_step = self.threshold.step
state.filter_active = False
self._sync_data_filter_state()
state.colormap_options = self.available_colormaps
state.selected_colormap = self.colormap
state.discretize_deciles = self.discretize_deciles
state.control_panel = 0
if self.state is not None:
state.model_name = self.blockmodel.name
state.model_path = str(self.blockmodel.blockmodel_path)
else:
state.model_name = ""
state.model_path = ""
state.source_mode = self._source_mode
state.source_path_input = self._source_path
if self._source_mode == "duckdb_query":
state.source_status = "DuckDB query mode is scaffolded; execution is not implemented yet."
elif self._source_mode == "file" and self.blockmodel is not None:
state.source_status = f"Loaded PBM: {self.blockmodel.blockmodel_path.name}"
elif self._source_mode == "hive" and self._source_path:
state.source_status = f"Loaded hive directory: {self._source_path}"
else:
state.source_status = ""
state.asset_selector_enabled = self.asset_catalog is not None
if self.asset_catalog is not None:
self._asset_level_keys = list(self.asset_catalog.level_keys)
self._asset_level_values = {key: "" for key in self._asset_level_keys}
finally:
self._syncing_state = False
@state.change("active_attribute")
def _attribute_changed(active_attribute=None, **_):
if self._syncing_state or active_attribute is None or not active_attribute:
return
if self.state is not None and str(active_attribute) == self.state.scalar:
return
if self.blockmodel is None:
self._initial_scalar = str(active_attribute)
return
self.set_attribute(active_attribute)
@state.change("threshold")
def _threshold_changed(threshold=None, **_):
if self._syncing_state or threshold is None:
return
if self.threshold is not None and float(threshold) == float(self.threshold.value):
return
self.set_threshold(threshold)
@state.change("filter_active")
def _filter_active_changed(filter_active=None, **_):
if self._syncing_state or filter_active is None:
return
self.filter_enabled = bool(filter_active)
self._refresh_plot(preserve_camera=self._view_initialized)
@state.change("data_filter_1_attribute")
def _data_filter_1_attribute_changed(data_filter_1_attribute=None, **_):
if self._syncing_state or data_filter_1_attribute is None:
return
if str(data_filter_1_attribute) == self._data_filters[0].attribute:
return
self.set_data_filter_attribute(0, str(data_filter_1_attribute))
@state.change("data_filter_2_attribute")
def _data_filter_2_attribute_changed(data_filter_2_attribute=None, **_):
if self._syncing_state or data_filter_2_attribute is None:
return
if str(data_filter_2_attribute) == self._data_filters[1].attribute:
return
self.set_data_filter_attribute(1, str(data_filter_2_attribute))
@state.change("data_filter_1_range")
def _data_filter_1_range_changed(data_filter_1_range=None, **_):
if self._syncing_state or data_filter_1_range is None:
return
self.set_data_filter_range(0, data_filter_1_range)
@state.change("data_filter_2_range")
def _data_filter_2_range_changed(data_filter_2_range=None, **_):
if self._syncing_state or data_filter_2_range is None:
return
self.set_data_filter_range(1, data_filter_2_range)
@state.change("data_filter_1_selected_categories")
def _data_filter_1_categories_changed(data_filter_1_selected_categories=None, **_):
if self._syncing_state or data_filter_1_selected_categories is None:
return
self.set_data_filter_categories(0, list(data_filter_1_selected_categories))
@state.change("data_filter_2_selected_categories")
def _data_filter_2_categories_changed(data_filter_2_selected_categories=None, **_):
if self._syncing_state or data_filter_2_selected_categories is None:
return
self.set_data_filter_categories(1, list(data_filter_2_selected_categories))
@state.change("selected_colormap")
def _colormap_changed(selected_colormap=None, **_):
if self._syncing_state or selected_colormap is None or not selected_colormap:
return
self.set_colormap(selected_colormap)
@state.change("discretize_deciles")
def _discretize_deciles_changed(discretize_deciles=None, **_):
if self._syncing_state or discretize_deciles is None:
return
self.set_discretize_deciles(bool(discretize_deciles))
@state.change("picking_active")
def _picking_active_changed(picking_active=None, **_):
if self._syncing_state or picking_active is None:
return
if bool(picking_active) == bool(getattr(self.plotter, "picking_enabled", False)):
return
self.set_picking_active(bool(picking_active))
@state.change("show_model_bounds")
def _show_model_bounds_changed(show_model_bounds=None, **_):
if self._syncing_state or show_model_bounds is None:
return
if bool(show_model_bounds) == self.show_model_bounds:
return
self.set_show_model_bounds(bool(show_model_bounds))
ctrl.update_attribute = _attribute_changed
ctrl.update_threshold = _threshold_changed
ctrl.update_colormap = _colormap_changed
ctrl.update_discretize_deciles = _discretize_deciles_changed
ctrl.update_data_filter_1_attribute = _data_filter_1_attribute_changed
ctrl.update_data_filter_2_attribute = _data_filter_2_attribute_changed
ctrl.update_data_filter_1_range = _data_filter_1_range_changed
ctrl.update_data_filter_2_range = _data_filter_2_range_changed
ctrl.update_data_filter_1_categories = _data_filter_1_categories_changed
ctrl.update_data_filter_2_categories = _data_filter_2_categories_changed
ctrl.update_picking_active = _picking_active_changed
ctrl.update_show_model_bounds = _show_model_bounds_changed
def _apply_threshold_text(**_):
if self._syncing_state or self._server is None:
return
self.set_threshold_from_text(str(self._server.state.threshold_display))
ctrl.apply_threshold_text = _apply_threshold_text
ctrl.reset_filter = self.reset_filter
ctrl.reset_data_filter_1 = lambda **_: self.reset_data_filter(0)
ctrl.reset_data_filter_2 = lambda **_: self.reset_data_filter(1)
ctrl.update_asset_name = lambda **_: None
ctrl.close_picking_dialog = lambda **_: setattr(state, "picking_dialog_open", False)
def _push_pick_state(
*,
dialog_open: Optional[bool] = None,
dialog_text: Optional[str] = None,
debug_text: Optional[str] = None,
) -> None:
changed_keys: list[str] = []
if dialog_open is not None:
state.picking_dialog_open = bool(dialog_open)
changed_keys.append("picking_dialog_open")
if dialog_text is not None:
state.picking_dialog_text = str(dialog_text)
changed_keys.append("picking_dialog_text")
if debug_text is not None:
state.picking_debug_last = str(debug_text)
changed_keys.append("picking_debug_last")
state.picking_debug_count = int(self._pick_debug_count)
changed_keys.append("picking_debug_count")
if hasattr(state, "dirty"):
for key in changed_keys:
try:
state.dirty(key)
except Exception:
break
if hasattr(state, "flush"):
try:
state.flush()
except Exception:
pass
def _extract_exact_cell_id_from_event(event_payload=None, **kwargs) -> Optional[int]:
def _to_int(value: object) -> Optional[int]:
try:
arr = np.asarray(value).ravel()
if arr.size == 0:
return None
cell_id = int(np.rint(float(arr[0])))
except (TypeError, ValueError):
return None
return cell_id if cell_id >= 0 else None
def _walk(obj):
if isinstance(obj, dict):
yield obj
for v in obj.values():
yield from _walk(v)
elif isinstance(obj, (list, tuple)):
for item in obj:
yield from _walk(item)
id_keys = (
"vtkOriginalCellIds",
"vtkOriginalCellId",
"vtkCellIds",
"cell_ids",
"cellIds",
"cell_id",
"cellId",
"id",
)
containers = []
if event_payload is not None:
containers.append(event_payload)
if kwargs:
containers.append(kwargs)
for container in containers:
for mapping in _walk(container):
for key in id_keys:
if key in mapping:
maybe_id = _to_int(mapping[key])
if maybe_id is not None:
return maybe_id
return None
def _extract_world_position_from_event(event_payload=None, **kwargs) -> Optional[np.ndarray]:
def _to_xyz(value: object) -> Optional[np.ndarray]:
if isinstance(value, dict):
if {"x", "y", "z"} <= set(value.keys()):
try:
return np.asarray(
[float(value["x"]), float(value["y"]), float(value["z"])],
dtype=float,
)
except (TypeError, ValueError):
return None
try:
arr = np.asarray(value, dtype=float).ravel()
except (TypeError, ValueError):
return None
if arr.size < 3:
return None
return arr[:3]
def _walk(obj):
if isinstance(obj, dict):
yield obj
for v in obj.values():
yield from _walk(v)
elif isinstance(obj, (list, tuple)):
for item in obj:
yield from _walk(item)
position_keys = (
"worldPosition",
"world_position",
"position",
"coords",
"point",
"xyz",
)
containers = []
if event_payload is not None:
containers.append(event_payload)
if kwargs:
containers.append(kwargs)
for container in containers:
for mapping in _walk(container):
for key in position_keys:
if key in mapping:
pos = _to_xyz(mapping[key])
if pos is not None:
return pos
if {"x", "y", "z"} <= set(mapping.keys()):
pos = _to_xyz(mapping)
if pos is not None:
return pos
return None
def _extract_display_position_from_event(event_payload=None, **kwargs) -> Optional[tuple[float, float]]:
def _to_xy(value: object) -> Optional[tuple[float, float]]:
if isinstance(value, dict):
if {"x", "y"} <= set(value.keys()):
try:
return (float(value["x"]), float(value["y"]))
except (TypeError, ValueError):
return None
try:
arr = np.asarray(value, dtype=float).ravel()
except (TypeError, ValueError):
return None
if arr.size < 2:
return None
return (float(arr[0]), float(arr[1]))
def _to_rect(value: object) -> Optional[dict[str, float]]:
if not isinstance(value, dict):
return None
required = ("left", "top", "width", "height")
if not all(k in value for k in required):
return None
try:
return {
"left": float(value["left"]),
"top": float(value["top"]),
"width": float(value["width"]),
"height": float(value["height"]),
}
except (TypeError, ValueError):
return None
def _walk(obj):
if isinstance(obj, dict):
yield obj
for v in obj.values():
yield from _walk(v)
elif isinstance(obj, (list, tuple)):
for item in obj:
yield from _walk(item)
pos_keys = (
"position",
"displayPosition",
"display_position",
"mouse",
"xy",
"display_xy",
"displayXY",
"canvasXY",
)
x_keys = ("offsetX", "clientX", "x", "screenX", "pageX")
y_keys = ("offsetY", "clientY", "y", "screenY", "pageY")
containers = []
if event_payload is not None:
containers.append(event_payload)
if kwargs:
containers.append(kwargs)
for container in containers:
for mapping in _walk(container):
for key in pos_keys:
if key in mapping:
maybe = _to_xy(mapping[key])
if maybe is not None:
return maybe
rect = None
for rect_key in ("targetRect", "target_rect", "rect", "boundingRect", "bounding_rect"):
if rect_key in mapping:
rect = _to_rect(mapping[rect_key])
if rect is not None:
break
x_val = None
y_val = None
for k in x_keys:
if k in mapping:
x_val = mapping[k]
break
for k in y_keys:
if k in mapping:
y_val = mapping[k]
break
if x_val is not None and y_val is not None:
if (
rect is not None
and ("offsetX" not in mapping and "offsetY" not in mapping)
and ("clientX" in mapping or "pageX" in mapping)
):
try:
x_val = float(x_val) - rect["left"]
y_val = float(y_val) - rect["top"]
except (TypeError, ValueError):
pass
maybe = _to_xy((x_val, y_val))
if maybe is not None:
return maybe
return None
def _summarize_pick_payload(event_payload=None, **kwargs) -> str:
keys: set[str] = set()
if isinstance(event_payload, dict):
keys.update(str(k) for k in event_payload.keys())
if kwargs:
keys.update(str(k) for k in kwargs.keys())
if not keys:
return "keys=none"
top = ",".join(sorted(keys)[:8])
return f"keys={top}"
def _resolve_cell_id_from_world_position(world_pos: np.ndarray) -> Optional[int]:
mesh = self._active_display_mesh
if mesh is None or mesh.n_cells <= 0:
return None
centers = mesh.cell_centers().points
if centers.size == 0:
return None
deltas = centers - np.asarray(world_pos, dtype=float)
distances = np.einsum("ij,ij->i", deltas, deltas)
nearest_local = int(np.argmin(distances))
if "vtkOriginalCellIds" in mesh.cell_data:
try:
return int(np.rint(float(mesh.cell_data["vtkOriginalCellIds"][nearest_local])))
except (TypeError, ValueError, IndexError):
return None
return nearest_local
def _resolve_cell_id_from_display_position(display_xy: tuple[float, float]) -> Optional[int]:
try:
vtk_mod = pv._vtk
picker = vtk_mod.vtkCellPicker()
except Exception:
return None
renderer = getattr(self.plotter, "renderer", None)
if renderer is None:
try:
renderers = getattr(self.plotter, "renderers", None)
if renderers is not None and len(renderers) > 0:
renderer = renderers[0]
except Exception:
renderer = None
if renderer is None:
return None
x, y = float(display_xy[0]), float(display_xy[1])
h = None
try:
h = int(getattr(self.plotter, "window_size", [0, 0])[1])
except Exception:
h = None
candidates = [(x, y)]
if h and h > 0:
candidates.append((x, float(max(h - y, 0))))
for cx, cy in candidates:
try:
success = int(picker.Pick(cx, cy, 0.0, renderer))
except Exception:
continue
if success <= 0:
continue
local_id = int(picker.GetCellId())
if local_id < 0:
continue
mesh = self._active_display_mesh
if mesh is not None and "vtkOriginalCellIds" in mesh.cell_data:
try:
return int(np.rint(float(mesh.cell_data["vtkOriginalCellIds"][local_id])))
except (TypeError, ValueError, IndexError):
return None
return local_id
return None
def _extract_display_position_from_interactor() -> Optional[tuple[float, float]]:
iren = getattr(self.plotter, "iren", None)
if iren is None:
return None
try:
pos = iren.GetEventPosition()
except Exception:
return None
if pos is None:
return None
try:
arr = np.asarray(pos, dtype=float).ravel()
except (TypeError, ValueError):
return None
if arr.size < 2:
return None
return (float(arr[0]), float(arr[1]))
def _build_pick_message(cell_id: int) -> Optional[str]:
if self.state is None:
return None
if cell_id < 0 or cell_id >= self.state.mesh.n_cells:
return None
cell_centers = self.state.mesh.cell_centers().points
centroid = cell_centers[cell_id]
centroid_str = f"({centroid[0]:.1f}, {centroid[1]:.1f}, {centroid[2]:.1f})"
values: dict[str, object] = {}
for attr in self.state.attributes:
raw_value = self.state.mesh.cell_data[attr][cell_id]
if attr in self.state.categorical_mappings:
if pd.isna(raw_value):
values[attr] = "<NA>"
else:
code = int(np.rint(float(raw_value)))
values[attr] = self.state.categorical_mappings[attr].get(code, f"<unknown:{code}>")
else:
values[attr] = raw_value
return f"Cell ID: {cell_id}, {centroid_str}, " + ", ".join(f"{k}: {v}" for k, v in values.items())
def _extract_client_key(event_payload=None, **kwargs) -> str:
values = []
if isinstance(event_payload, dict):
values.extend(
[
event_payload.get("key"),
event_payload.get("keySym"),
event_payload.get("keysym"),
event_payload.get("code"),
event_payload.get("keyCode"),
]
)
values.extend(
[
kwargs.get("key"),
kwargs.get("keySym"),
kwargs.get("keysym"),
kwargs.get("code"),
kwargs.get("keyCode"),
]
)
for value in values:
if value is None:
continue
if isinstance(value, (int, float)):
code = int(value)
if 0 <= code <= 255:
return chr(code).lower()
key_text = str(value).strip().lower()
if key_text.startswith("key") and len(key_text) == 4:
return key_text[-1]
if key_text:
return key_text
return ""
def _on_zup_key_down(event=None, **kwargs):
key = _extract_client_key(event, **kwargs)
if key and key != self.z_up_hotkey:
return
if hasattr(self.plotter, 'hotkey_pressed'):
self.plotter.hotkey_pressed['z'] = True
self.plotter.enforce_z_up()
self.plotter.render()
if self._remote_view is not None:
self._remote_view.update()
def _on_zup_key_up(event=None, **kwargs):
key = _extract_client_key(event, **kwargs)
if key and key != self.z_up_hotkey:
return
if hasattr(self.plotter, 'hotkey_pressed'):
self.plotter.hotkey_pressed['z'] = False
def _on_key_down(event=None, **kwargs):
key = _extract_client_key(event, **kwargs)
if not key:
return
if key == self.z_up_hotkey:
_on_zup_key_down(event, **kwargs)
def _on_pick_click(event=None, _source: str = "bridge", **kwargs):
if not bool(getattr(self.plotter, "picking_enabled", False)):
return
self._pick_debug_count += 1
cell_id = _extract_exact_cell_id_from_event(event, **kwargs)
world_pos = _extract_world_position_from_event(event, **kwargs)
if cell_id is None and world_pos is not None:
cell_id = _resolve_cell_id_from_world_position(world_pos)
display_xy = _extract_display_position_from_event(event, **kwargs)
if display_xy is None:
display_xy = _extract_display_position_from_interactor()
if cell_id is None and display_xy is not None:
cell_id = _resolve_cell_id_from_display_position(display_xy)
payload_summary = _summarize_pick_payload(event, **kwargs)
self._pick_debug_last = (
f"bridge count={self._pick_debug_count}, source={_source}, cell_id={cell_id}, "
f"world_pos={None if world_pos is None else np.round(world_pos, 3).tolist()}, "
f"display_xy={None if display_xy is None else [round(display_xy[0], 2), round(display_xy[1], 2)]}, "
f"{payload_summary}"
)
if cell_id is None:
_push_pick_state(dialog_open=False, dialog_text="", debug_text=self._pick_debug_last)
return
msg = _build_pick_message(cell_id)
if not msg:
_push_pick_state(dialog_open=False, dialog_text="", debug_text=self._pick_debug_last)
return
_push_pick_state(dialog_open=True, dialog_text=msg, debug_text=self._pick_debug_last)
def _on_pick_click_client(event=None, **kwargs):
_on_pick_click(event, _source="client", **kwargs)
ctrl.zup_key_down = _on_zup_key_down
ctrl.zup_key_up = _on_zup_key_up
ctrl.key_down = _on_key_down
ctrl.pick_click = _on_pick_click
ctrl.pick_click_client = _on_pick_click_client
if self.asset_catalog is not None:
self._register_asset_selector_handlers()
self._syncing_state = True
try:
self._sync_asset_selector_state()
finally:
self._syncing_state = False
def _load_data_source(**_):
if self._syncing_state:
return
if self._source_mode == "duckdb_query":
state.source_status = "DuckDB query mode is scaffolded; execution is not implemented yet."
return
try:
self.load_source_path(state.source_path_input)
except (FileNotFoundError, ValueError) as exc:
state.source_status = str(exc)
ctrl.load_data_source = _load_data_source
brand_logo_src = self._branding_logo_data_url()
with SinglePageWithDrawerLayout(server, show_drawer=self._drawer_default_open, width=340) as layout:
layout.toolbar.color = "grey lighten-3"
layout.toolbar.dense = True
layout.toolbar.dark = False
if hasattr(layout, "title") and hasattr(layout.title, "set_text"):
layout.title.set_text("")
with layout.toolbar:
vuetify.VImg(src=brand_logo_src, max_width=50, contain=True, classes="mr-2")
vuetify.VToolbarTitle(self.app_name)
vuetify.VSpacer()
vuetify.VChip(
"{{ model_name }}",
label=True,
small=True,
outlined=True,
classes="mr-2",
)
with layout.drawer:
with vuetify.VSheet(classes="pa-2 fill-height"):
with vuetify.VExpansionPanels(
v_model=("source_panel", [1]),
multiple=True,
focusable=True,
flat=True,
classes="mb-2",
):
with vuetify.VExpansionPanel():
vuetify.VExpansionPanelHeader(
"Data source",
dense=True,
classes="py-1 px-2 text-subtitle-2",
)
with vuetify.VExpansionPanelContent(classes="pt-1 pb-2 px-2"):
vuetify.VTextField(
v_model=("source_path_input", self._source_path),
label="Server path (.pbm file or hive directory)",
dense=True,
hide_details=True,
outlined=True,
classes="mt-2",
)
vuetify.VBtn(
"Load",
click=ctrl.load_data_source,
block=True,
color="primary",
classes="mt-2",
)
vuetify.VChip(
"{{ source_mode }}",
label=True,
small=True,
outlined=True,
classes="mt-2",
)
vuetify.VChip(
"{{ source_status }}",
label=True,
small=True,
outlined=True,
classes="mt-2 text-caption text-truncate",
style="max-width: 100%;",
)
with vuetify.VExpansionPanel(v_if=("asset_selector_enabled", False)):
vuetify.VExpansionPanelHeader(
"Asset selector",
dense=True,
classes="py-1 px-2 text-subtitle-2",
)
with vuetify.VExpansionPanelContent(classes="pt-1 pb-2 px-2"):
for idx, key in enumerate(self._asset_level_keys):
vuetify.VSelect(
v_model=(
f"asset_level_{idx}_value",
self._asset_level_values.get(key, ""),
),
items=(f"asset_level_{idx}_options", []),
label=key,
dense=True,
hide_details=True,
outlined=True,
classes="mt-2",
change=getattr(ctrl, f"update_asset_level_{idx}"),
)
vuetify.VSelect(
v_model=("selected_asset_name", self._selected_asset_name),
items=("asset_name_options", []),
label="PBM name",
dense=True,
hide_details=True,
outlined=True,
classes="mt-2",
change=ctrl.update_asset_name,
)
vuetify.VChip(
"{{ asset_selected_path }}",
label=True,
small=True,
outlined=True,
classes="text-caption text-truncate mt-2",
style="max-width: 100%;",
)
with vuetify.VExpansionPanels(
v_model=("control_panel", 0),
multiple=False,
focusable=True,
flat=True,
):
with vuetify.VExpansionPanel():
vuetify.VExpansionPanelHeader(
"Data Filter",
dense=True,
classes="py-1 px-2 text-subtitle-2",
)
with vuetify.VExpansionPanelContent(classes="pt-1 pb-2 px-2"):
vuetify.VSelect(
v_model=("data_filter_1_attribute", self._data_filters[0].attribute),
items=("data_filter_attribute_options", self._filter_attribute_options),
label="Filter 1 attribute",
dense=True,
hide_details=True,
outlined=True,
classes="mt-2",
clearable=True,
change=ctrl.update_data_filter_1_attribute,
)
vuetify.VRangeSlider(
v_if=("!data_filter_1_is_categorical",),
v_model=("data_filter_1_range", list(self._data_filters[0].range_values)),
min=("data_filter_1_range_min", self._data_filters[0].minimum),
max=("data_filter_1_range_max", self._data_filters[0].maximum),
step=("data_filter_1_range_step", self._data_filters[0].step),
label="Filter 1 range",
dense=True,
hide_details=True,
thumb_label=False,
classes="mt-2",
disabled=("data_filter_1_attribute === ''",),
change=ctrl.update_data_filter_1_range,
)
vuetify.VSelect(
v_if=("data_filter_1_is_categorical",),
v_model=("data_filter_1_selected_categories", list(self._data_filters[0].selected_categories)),
items=("data_filter_1_category_options", list(self._data_filters[0].category_options)),
label="Filter 1 categories",
dense=True,
hide_details=True,
outlined=True,
classes="mt-2",
multiple=True,
chips=True,
deletable_chips=True,
change=ctrl.update_data_filter_1_categories,
)
vuetify.VBtn(
"Reset filter 1",
click=ctrl.reset_data_filter_1,
block=True,
color="primary",
classes="mt-2",
)
vuetify.VChip(
"{{ data_filter_1_summary }}",
label=True,
small=True,
outlined=True,
classes="mt-2 text-caption text-truncate",
style="max-width: 100%;",
)
vuetify.VSelect(
v_model=("data_filter_2_attribute", self._data_filters[1].attribute),
items=("data_filter_attribute_options", self._filter_attribute_options),
label="Filter 2 attribute",
dense=True,
hide_details=True,
outlined=True,
classes="mt-4",
clearable=True,
change=ctrl.update_data_filter_2_attribute,
)
vuetify.VRangeSlider(
v_if=("!data_filter_2_is_categorical",),
v_model=("data_filter_2_range", list(self._data_filters[1].range_values)),
min=("data_filter_2_range_min", self._data_filters[1].minimum),
max=("data_filter_2_range_max", self._data_filters[1].maximum),
step=("data_filter_2_range_step", self._data_filters[1].step),
label="Filter 2 range",
dense=True,
hide_details=True,
thumb_label=False,
classes="mt-2",
disabled=("data_filter_2_attribute === ''",),
change=ctrl.update_data_filter_2_range,
)
vuetify.VSelect(
v_if=("data_filter_2_is_categorical",),
v_model=("data_filter_2_selected_categories", list(self._data_filters[1].selected_categories)),
items=("data_filter_2_category_options", list(self._data_filters[1].category_options)),
label="Filter 2 categories",
dense=True,
hide_details=True,
outlined=True,
classes="mt-2",
multiple=True,
chips=True,
deletable_chips=True,
change=ctrl.update_data_filter_2_categories,
)
vuetify.VBtn(
"Reset filter 2",
click=ctrl.reset_data_filter_2,
block=True,
color="primary",
classes="mt-2",
)
vuetify.VChip(
"{{ data_filter_2_summary }}",
label=True,
small=True,
outlined=True,
classes="mt-2 text-caption text-truncate",
style="max-width: 100%;",
)
with vuetify.VExpansionPanel():
vuetify.VExpansionPanelHeader(
"Controls",
dense=True,
classes="py-1 px-2 text-subtitle-2",
)
with vuetify.VExpansionPanelContent(classes="pt-1 pb-2 px-2"):
vuetify.VSelect(
v_model=("active_attribute", self.state.scalar if self.state else ""),
items=("attribute_options", self.blockmodel.available_attributes if self.state else []),
label="Attribute",
dense=True,
hide_details=True,
outlined=True,
classes="mt-2",
change=ctrl.update_attribute,
)
vuetify.VSelect(
v_model=("selected_colormap", self.colormap),
items=("colormap_options", self.available_colormaps),
label="Colormap",
dense=True,
hide_details=True,
outlined=True,
classes="mt-2",
change=ctrl.update_colormap,
)
vuetify.VCheckbox(
v_model=("discretize_deciles", self.discretize_deciles),
label="Discretise to deciles",
dense=True,
hide_details=True,
classes="mt-2",
change=ctrl.update_discretize_deciles,
)
vuetify.VSlider(
v_model=("threshold", self.threshold.value),
min=("threshold_min", self.threshold.minimum),
max=("threshold_max", self.threshold.maximum),
step=("threshold_step", self.threshold.step),
label="Threshold",
dense=True,
hide_details=True,
thumb_label=False,
outlined=True,
classes="mt-2",
change=ctrl.update_threshold,
)
vuetify.VTextField(
v_model=("threshold_display", self._format_threshold(self.threshold.value)),
label="Threshold value",
dense=True,
outlined=True,
hide_details=True,
classes="mt-2",
)
vuetify.VBtn(
"Apply",
click=ctrl.apply_threshold_text,
block=True,
color="primary",
classes="mt-2",
)
vuetify.VBtn(
"Reset threshold",
click=ctrl.reset_filter,
block=True,
color="primary",
classes="mt-2",
)
with vuetify.VExpansionPanel():
vuetify.VExpansionPanelHeader(
"View status",
dense=True,
classes="py-1 px-2 text-subtitle-2",
)
with vuetify.VExpansionPanelContent(classes="pt-1 pb-2 px-2"):
vuetify.VChip("Read-on-demand", color="success", small=True, outlined=True)
vuetify.VSpacer()
vuetify.VCheckbox(
v_model=("filter_active", self.filter_enabled),
label="Filter active",
readonly=True,
dense=True,
hide_details=True,
)
vuetify.VCheckbox(
v_model=("picking_active", bool(getattr(self.plotter, "picking_enabled", False))),
label="Picking active",
dense=True,
hide_details=True,
change=ctrl.update_picking_active,
)
vuetify.VCheckbox(
v_model=("show_model_bounds", self.show_model_bounds),
label="Show model bounds",
dense=True,
hide_details=True,
change=ctrl.update_show_model_bounds,
)
vuetify.VChip(
"Pick events: {{ picking_debug_count }}",
label=True,
small=True,
outlined=True,
classes="mt-2",
)
vuetify.VChip(
"{{ picking_debug_last }}",
label=True,
small=True,
outlined=True,
classes="mt-2 text-caption text-truncate",
style="max-width: 100%;",
)
with layout.content:
layout.content.classes = "pa-0 ma-0"
layout.content.style = "height: calc(100vh - 64px); overflow: hidden;"
if trame_widgets is not None:
key_trap = trame_widgets.MouseTrap(
ZUpKeyDown=ctrl.zup_key_down,
ZUpKeyUp=ctrl.zup_key_up,
)
key_trap.bind(self.z_up_hotkey, "ZUpKeyDown", listen_to="keydown")
key_trap.bind(self.z_up_hotkey, "ZUpKeyUp", listen_to="keyup")
with vuetify.VDialog(v_model=("picking_dialog_open", False), max_width=700):
with vuetify.VCard():
vuetify.VCardTitle("Picked Cell")
vuetify.VCardText(
"{{ picking_dialog_text }}",
style="white-space: pre-wrap; word-break: break-word;",
)
with vuetify.VSheet(classes="pa-2 d-flex justify-end"):
vuetify.VBtn(
"Close",
click=ctrl.close_picking_dialog,
color="primary",
text=True,
)
remote_view_kwargs = {
"interactive_ratio": 1,
"style": "width: 100%; height: 100%; min-height: 600px;",
"interactor_events": (
"pbm_interactor_events",
[
"KeyDown",
"KeyPress",
"KeyUp",
"LeftButtonPress",
"LeftButtonRelease",
],
),
"KeyDown": ctrl.key_down,
"KeyPress": ctrl.zup_key_down,
"KeyUp": ctrl.zup_key_up,
"LeftButtonPress": ctrl.pick_click,
}
with vuetify.VSheet(
style="width: 100%; height: 100%;",
click=(
ctrl.pick_click_client,
"""[{
clientX: $event.clientX,
clientY: $event.clientY,
offsetX: $event.offsetX,
offsetY: $event.offsetY,
targetRect: (
$event.target && $event.target.getBoundingClientRect
) ? {
left: $event.target.getBoundingClientRect().left,
top: $event.target.getBoundingClientRect().top,
width: $event.target.getBoundingClientRect().width,
height: $event.target.getBoundingClientRect().height
} : null
}]""",
),
):
self._remote_view = vtk.VtkRemoteView(
self.plotter.ren_win,
**remote_view_kwargs,
)
self._remote_view.update()
state.ready()
server.start(**self._build_launch_kwargs(port, host))
return server