import io from pathlib import Path from typing import Callable, Optional, cast from urllib.parse import parse_qsl import altair as alt import polars as pl import reacton.core import solara import solara.lab from cmap import Catalog, Colormap from ipyvuetify.extra import FileInput from make_link import encode_url from viewer import AxisProperties, ColorTransform, ProteinView, RoutedView DEFAULT_CMAP = "tol:rainbow_PuRd" NORM_CATEGORIES = ["linear", "diverging", "categorical"] VMIN_DEFAULT = 0.0 VMAX_DEFAULT = 1.0 HIGHLIGHT_COLOR = "#e933f8" MISSING_DATA_COLOR = "#8c8c8c" CMAP_OPTIONS = list(Catalog().namespaced_keys()) # BASE_URL = "http://localhost:8765" # local testing BASE_URL = "https://huggingface.co/spaces/Jhsmit/ipymolstar-annotate-colors" pth = Path(__file__).parent @solara.component def FileInputComponent( on_file: Callable[[solara.components.file_drop.FileInfo | None], None], ): """Adaptation of _FileDrop.""" file_info, set_file_info = solara.use_state(None) wired_files, set_wired_files = solara.use_state( cast(Optional[list[solara.components.file_drop.FileInfo]], None) ) file_drop = FileInput.element(on_file_info=set_file_info, multiple=False) # type: ignore def wire_files(): if not file_info: set_wired_files([]) return real = cast(FileInput, solara.get_widget(file_drop)) # workaround for @observe being cleared real.version += 1 real.reset_stats() set_wired_files( cast(list[solara.components.file_drop.FileInfo], real.get_files()) ) solara.use_effect(wire_files, [file_info]) def handle_file(): if not wired_files: on_file(None) return if on_file: f = wired_files[0].copy() f["data"] = None on_file(f) solara.lab.use_task(handle_file, dependencies=[wired_files]) return file_drop @solara.component def ColorPickerMenuButton(title: str, color: solara.Reactive[str]): local_color = solara.use_reactive(color.value) def on_open(value: bool): if not value: color.set(local_color.value) btn = solara.Button(title, color=local_color.value) with solara.lab.Menu( activator=btn, close_on_content_click=False, on_open_value=on_open ): solara.v.ColorPicker( v_model=local_color.value, on_v_model=local_color.set, ) empty_frame = pl.DataFrame() R_DEFAULT = "" V_DEFAULT = "" # %% @solara.component def MainApp(): dark_effective = solara.lab.use_dark_effective() title = solara.use_reactive("My annotated protein view") description = solara.use_reactive("") molecule_id = solara.use_reactive("1QYN") data = solara.use_reactive(empty_frame) warning_text = solara.use_reactive("") residue_column = solara.use_reactive(R_DEFAULT) color_column = solara.use_reactive(V_DEFAULT) label = solara.use_reactive("value") unit = solara.use_reactive("au") highlight_color = solara.use_reactive(HIGHLIGHT_COLOR) missing_data_color = solara.use_reactive(MISSING_DATA_COLOR) autoscale_y = solara.use_reactive(True) cmap_name = solara.use_reactive(DEFAULT_CMAP) reverse = solara.use_reactive(False) full_cmap_name = cmap_name.value + "_r" if reverse.value else cmap_name.value cmap = Colormap(full_cmap_name) vmin = solara.use_reactive(VMIN_DEFAULT) vmax = solara.use_reactive(VMAX_DEFAULT) norm_type = solara.use_reactive(NORM_CATEGORIES[0]) rc = reacton.core.get_render_context() def on_file(file_info: solara.components.file_drop.FileInfo | None): if not file_info: data.set(pl.DataFrame()) return try: df = pl.read_csv(file_info["file_obj"]) except Exception as e: warning_text.set(str(e)) return if len(df.columns) < 2: warning_text.set(f"Expected at least 2 columns, got {len(df.columns)}") data.set(pl.DataFrame()) return warning_text.set("") # order matters! # with solara.batch_update(): # https://github.com/widgetti/solara/issues/637 with rc: residue_column.set(df.columns[0]) color_column.set(df.columns[1]) data.set(df) colors = ColorTransform( name=full_cmap_name, norm_type=norm_type.value, vmin=vmin.value, vmax=vmax.value, missing_data_color=missing_data_color.value, highlight_color=highlight_color.value, ) axis_properties = AxisProperties( label=label.value, unit=unit.value, autoscale_y=autoscale_y.value, ) if data.value.is_empty(): data_view = pl.DataFrame({"residue_number": [], "value": []}) else: data_view = pl.DataFrame( { "residue_number": data.value[residue_column.value], "value": data.value[color_column.value], } ) def load_example_data(): bio = io.BytesIO(Path("example_data.csv").read_bytes()) bio.seek(0) file_info = solara.components.file_drop.FileInfo( name="example_data.csv", size=Path("example_data.csv").stat().st_size, file_obj=bio, data=None, ) # with solara.batch_update(): # https://github.com/widgetti/solara/issues/637 with rc: molecule_id.set("6GOX") title.set("SecA HDX-MS protein local flexibility") on_file(file_info) vmin.set(4e4) vmax.set(1e4) autoscale_y.set(False) description.set(Path("example_data_description.md").read_text()) with solara.AppBar(): with solara.Tooltip("Load example data and settings"): solara.Button( icon_name="mdi-test-tube", icon=True, on_click=load_example_data, ) query_string = encode_url( title=title.value, molecule_id=molecule_id.value, colors=colors, axis_properties=axis_properties, data=data_view, description=description.value, ) ProteinView( title.value, molecule_id=molecule_id.value, data=data_view, colors=colors, axis_properties=axis_properties, dark_effective=dark_effective, description=description.value, ) with solara.Sidebar(): with solara.Card("Settings"): solara.InputText(label="Title", value=title) solara.InputText(label="PDB ID", value=molecule_id) solara.Text("Choose .csv data file:") FileInputComponent(on_file) if warning_text.value: solara.Warning(warning_text.value) if not data.value.is_empty(): with solara.Row(): solara.Select( label="Residue Column", value=residue_column, values=list(data.value.columns), ) solara.Select( label="Color Column", value=color_column, values=list(data.value.columns), ) with solara.Row(): solara.InputText(label="Label", value=label) solara.InputText(label="Unit", value=unit) solara.Text("Colors") with solara.Row(gap="10px", justify="space-around"): ColorPickerMenuButton("Highlight", highlight_color) ColorPickerMenuButton("Missing data", missing_data_color) def set_cmap_name(name: str): try: Colormap(name) cmap_name.set(name) except TypeError: pass # with solara.Row(): solara.v.Autocomplete( v_model=cmap_name.value, on_v_model=set_cmap_name, items=CMAP_OPTIONS, ) solara.Select( label="Normalization type", value=norm_type, values=NORM_CATEGORIES ) with solara.Row(): def set_vmin(value: float): if norm_type.value == "diverging": vmin.set(value) vmax.set(-value) else: vmin.set(value) solara.InputFloat( label="vmin", value=vmin.value, on_value=set_vmin, disabled=norm_type.value == "categorical", ) solara.InputFloat( label="vmax", value=vmax, disabled=norm_type.value in ["diverging", "categorical"], ) with solara.GridFixed(columns=2): with solara.Tooltip("Reverses the color map"): solara.Checkbox(label="Reverse", value=reverse) with solara.Tooltip( "Uncheck to use color range as the scatterplot y scale" ): solara.Checkbox(label="Autoscale Y", value=autoscale_y) solara.Image(cmap._repr_png_(height=24), width="100%") solara.InputTextArea( label="Description", value=description, continuous_update=True ) solara.Div(style={"height": "10px"}) solara.Button( label="Open view in new tab", attributes={"href": BASE_URL + "?" + query_string, "target": "_blank"}, block=True, ) @solara.component def Page(): route = solara.use_router() solara.Style(Path("style.css")) dark_effective = solara.lab.use_dark_effective() dark_previous = solara.use_previous(dark_effective) if dark_previous != dark_effective: if dark_effective: alt.themes.enable("dark") else: alt.themes.enable("default") # todo: aways true, check valid if route.search: query_dict = {k: v for k, v in parse_qsl(route.search)} # needs more keys but if this is there then at least there has been an attempt if "molecule_id" in query_dict: RoutedView() else: MainApp() # %%