diff --git a/cellmap_flow/blockwise/blockwise_processor.py b/cellmap_flow/blockwise/blockwise_processor.py index 32e10b9..92f72e1 100644 --- a/cellmap_flow/blockwise/blockwise_processor.py +++ b/cellmap_flow/blockwise/blockwise_processor.py @@ -1,4 +1,10 @@ import logging +logging.getLogger().setLevel(logging.INFO) +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) +logging.getLogger().setLevel(logging.INFO) +logging.basicConfig(level=logging.INFO) + import subprocess from pathlib import Path @@ -16,8 +22,9 @@ from cellmap_flow.utils.config_utils import build_models, load_config from cellmap_flow.utils.serilization_utils import get_process_dataset from cellmap_flow.utils.ds import generate_singlescale_metadata +from cellmap_flow.models.model_merger import get_model_merger + -logger = logging.getLogger(__name__) class CellMapFlowBlockwiseProcessor: @@ -98,15 +105,33 @@ def __init__(self, yaml_config: str, create=False): # Support multiple models with model_mode self.models = models - self.model_mode = self.config.get("model_mode", "AND").upper() - if self.model_mode not in ["AND", "OR", "SUM"]: - raise Exception( - f"Invalid model_mode: {self.model_mode}. Must be one of: AND, OR, SUM" - ) + self.model_mode_str = self.config.get("model_mode", "AND").upper() + try: + self.model_merger = get_model_merger(self.model_mode_str) + except ValueError as e: + raise Exception(str(e)) if len(models) > 1: logger.info( - f"Using {len(models)} models with merge mode: {self.model_mode}" + f"Using {len(models)} models with merge mode: {self.model_mode_str}" + ) + + # Support cross-channel processing + self.process_only = self.config.get("process_only", None) + self.cross_channels_mode = self.config.get("cross_channels", None) + + if self.cross_channels_mode: + self.cross_channels_mode = self.cross_channels_mode.upper() + try: + self.cross_channels_merger = get_model_merger(self.cross_channels_mode) + except ValueError as e: + raise Exception(f"Invalid cross_channels setting: {e}") + else: + self.cross_channels_merger = None + + if self.process_only: + logger.info( + f"Processing only channels: {self.process_only} with merge mode: {self.cross_channels_mode}" ) self.model_config = models[0] @@ -114,7 +139,7 @@ def __init__(self, yaml_config: str, create=False): # this is zyx block_shape = [int(x) for x in self.model_config.config.block_shape][:3] - self.block_shape = self.config.get("block_size", block_shape) + self.block_shape = tuple(self.config.get("block_size", block_shape)) self.input_voxel_size = Coordinate(self.model_config.config.input_voxel_size) self.output_voxel_size = Coordinate(self.model_config.config.output_voxel_size) @@ -133,6 +158,9 @@ def __init__(self, yaml_config: str, create=False): self.output_channel_names = self.channels self.output_channel_indices = None + if not isinstance(self.output_channels, list): + self.output_channels = [self.output_channels] + if json_data: g.input_norms, g.postprocess = get_process_dataset(json_data) @@ -161,6 +189,12 @@ def __init__(self, yaml_config: str, create=False): # Ensure we have output channels to iterate over channels_to_create = self.output_channels if self.output_channels else [] + if not isinstance(channels_to_create, list): + channels_to_create = [channels_to_create] + + # check if there is two channels_to_create with same name + if len(channels_to_create) != len(set(channels_to_create)): + raise Exception(f"output_channels has duplicated channel names. channels: {channels_to_create}") for channel in channels_to_create: if create: @@ -190,7 +224,7 @@ def __init__(self, yaml_config: str, create=False): chunk_shape=( self.block_shape if len(final_output_shape) == 3 - else (len(channel_indices),) + tuple(self.block_shape) + else (len(channel_indices),) + self.block_shape ), voxel_size=( self.output_voxel_size @@ -254,6 +288,8 @@ def __init__(self, yaml_config: str, create=False): if "multiscales" in list(zg.attrs): old_multiscales = zg.attrs["multiscales"] if old_multiscales != zattrs["multiscales"]: + logger.info(f"Old multiscales: {old_multiscales}") + logger.info(f"New multiscales: {zattrs['multiscales']}") raise ValueError( f"multiscales attribute already exists in {z_store.path} and is different from the new one" ) @@ -302,10 +338,15 @@ def process_fn(self, block): model_outputs = [] for inferencer in self.inferencers: output = inferencer.process_chunk(self.idi_raw, block.write_roi) + if self.process_only and self.cross_channels_merger: + # Extract only the specified channels + channel_outputs = [output[ch_idx] for ch_idx in self.process_only] + # Merge the extracted channels based on cross_channels mode + output = self.cross_channels_merger.merge(channel_outputs) model_outputs.append(output) # Merge outputs based on model_mode - chunk_data = self._merge_model_outputs(model_outputs) + chunk_data = self.model_merger.merge(model_outputs) chunk_data = chunk_data.astype(self.dtype) @@ -372,38 +413,6 @@ def process_fn(self, block): continue array[array_write_roi] = predictions.to_ndarray(array_write_roi) - def _merge_model_outputs(self, model_outputs): - """ - Merge outputs from multiple models based on the configured model_mode. - - Args: - model_outputs: List of numpy arrays from different models - - Returns: - Merged numpy array - """ - if self.model_mode == "AND": - # Element-wise minimum (logical AND for binary, minimum for continuous) - merged = model_outputs[0] - for output in model_outputs[1:]: - merged = np.minimum(merged, output) - return merged - - elif self.model_mode == "OR": - # Element-wise maximum (logical OR for binary, maximum for continuous) - merged = model_outputs[0] - for output in model_outputs[1:]: - merged = np.maximum(merged, output) - return merged - - elif self.model_mode == "SUM": - # Sum all outputs and normalize by number of models - merged = np.sum(model_outputs, axis=0) / len(model_outputs) - return merged - - else: - raise ValueError(f"Unknown model_mode: {self.model_mode}") - def client(self): client = daisy.Client() while True: @@ -484,7 +493,7 @@ def run_worker(): f"prediction_logs/out.out", "-e", f"prediction_logs/out.err", - "cellmap_flow_blockwise_processor", + "cellmap_flow_blockwise", f"{yaml_config}", "--client", ] diff --git a/cellmap_flow/blockwise/cli.py b/cellmap_flow/blockwise/cli.py index 28a3dc0..6aee0fc 100644 --- a/cellmap_flow/blockwise/cli.py +++ b/cellmap_flow/blockwise/cli.py @@ -1,6 +1,8 @@ import click import logging - +import logging +logging.getLogger().setLevel(logging.INFO) +logging.basicConfig(level=logging.INFO) from cellmap_flow.blockwise import CellMapFlowBlockwiseProcessor @@ -32,3 +34,7 @@ def cli(yaml_config, client, log_level): logger = logging.getLogger(__name__) + + +if __name__ == "__main__": + cli() diff --git a/cellmap_flow/cli/yaml_cli.py b/cellmap_flow/cli/yaml_cli.py index 3d34b5b..929af72 100644 --- a/cellmap_flow/cli/yaml_cli.py +++ b/cellmap_flow/cli/yaml_cli.py @@ -172,23 +172,23 @@ def main(config_path: str, log_level: str, list_types: bool, validate_only: bool # Build model configuration objects dynamically logger.info("Building model configurations...") - models = build_models(config["models"]) + g.models_config = build_models(config["models"]) - logger.info(f"Configured {len(models)} model(s):") - for i, model in enumerate(models, 1): + logger.info(f"Configured {len(g.models_config)} model(s):") + for i, model in enumerate(g.models_config, 1): model_name = getattr(model, "name", None) or type(model).__name__ logger.info(f" {i}. {model_name} ({type(model).__name__})") # Validation mode - exit without running if validate_only: click.echo("\n✓ Configuration is valid!") - click.echo(f" - Models: {len(models)}") + click.echo(f" - Models: {len(g.models_config)}") click.echo(f" - Data path: {data_path}") click.echo(f" - Queue: {queue}") return # Run the models - run_multiple(models, data_path, charge_group, queue) + run_multiple(g.models_config, data_path, charge_group, queue) if __name__ == "__main__": diff --git a/cellmap_flow/dashboard/PIPELINE_BUILDER.md b/cellmap_flow/dashboard/PIPELINE_BUILDER.md new file mode 100644 index 0000000..553d193 --- /dev/null +++ b/cellmap_flow/dashboard/PIPELINE_BUILDER.md @@ -0,0 +1,222 @@ +# CellMapFlow Pipeline Builder + +A drag-and-drop visual pipeline builder for creating and managing CellMapFlow inference workflows. + +## Features + +- **Visual Pipeline Editor**: Drag-and-drop interface to connect normalization models and postprocessors +- **Node-based Workflow**: Connect input → normalizers → postprocessors → output +- **Parameter Configuration**: Set parameters for each processing step +- **Export/Import**: Save pipelines as YAML or JSON and reload them later +- **Real-time Validation**: Validate pipelines before applying them + +## Architecture + +### Frontend (React Flow) + +The pipeline builder uses **React Flow** for the visual node editor: + +**Components:** +- [pipeline-builder.js](static/js/pipeline-builder.js) - Main React component +- [pipeline-nodes.js](static/js/pipeline-nodes.js) - Node styling and layout +- [pipeline-toolbar.js](static/js/pipeline-toolbar.js) - Toolbar for adding nodes +- [pipeline-exporter.js](static/js/pipeline-exporter.js) - Export/Import functionality +- [pipeline_builder.html](templates/pipeline_builder.html) - HTML template + +### Backend (Flask) + +Flask routes handle validation and application of pipelines: + +**Endpoints:** +- `GET /pipeline-builder` - Render the pipeline builder UI +- `POST /api/pipeline/validate` - Validate pipeline configuration +- `POST /api/pipeline/apply` - Apply pipeline to inference + +## File Structure + +``` +cellmap_flow/dashboard/ +├── app.py # Flask app with pipeline routes +├── package.json # npm dependencies +├── static/ +│ └── js/ +│ ├── pipeline-builder.js # Main React component +│ ├── pipeline-nodes.js # Node components +│ ├── pipeline-toolbar.js # Toolbar component +│ ├── pipeline-exporter.js # Export/import logic +│ └── index-pipeline.js # Entry point +└── templates/ + └── pipeline_builder.html # HTML template +``` + +## Usage + +### Access the Pipeline Builder + +Navigate to: `http://localhost:PORT/pipeline-builder` + +### Creating a Pipeline + +1. **Add Normalizers**: Select from dropdown in toolbar to add normalization steps +2. **Add Postprocessors**: Select from dropdown to add post-processing steps +3. **Connect Nodes**: Drag from output handle to input handle to connect steps +4. **Configure Parameters**: Click a node and enter JSON parameters in the panel +5. **Apply**: Use the "Apply Pipeline" button to use the configuration + +### Exporting a Pipeline + +**As YAML:** +```yaml +input_normalizers: + - name: StandardNormalizer + params: + mean: 0.5 + std: 0.1 +postprocessors: + - name: InstanceSegmentation + params: + threshold: 0.5 +``` + +**As JSON:** +```json +{ + "nodes": [...], + "edges": [...], + "timestamp": "2024-01-28T..." +} +``` + +### Importing a Pipeline + +Click "Import Pipeline" and select a previously saved `.yaml` or `.json` file. + +## Configuration + +### Node Types + +- **Input** (green): Data entry point +- **Normalizer** (blue): Input normalization steps +- **Postprocessor** (pink): Post-processing operations +- **Output** (gold): Final output + +### Parameter Format + +Parameters are specified as JSON. Examples: + +```json +{ + "clip_min": -1.0, + "clip_max": 1.0, + "bias": 1.0, + "multiplier": 127.5 +} +``` + +## Integration with CellMapFlow + +The pipeline builder integrates with your existing CellMapFlow infrastructure: + +1. **Normalizers**: Uses `get_input_normalizers()` from [input_normalize.py](../norm/input_normalize.py) +2. **Postprocessors**: Uses `get_postprocessors_list()` from [postprocessors.py](../post/postprocessors.py) +3. **Validation**: Validates against available models before applying +4. **Application**: Applies to global state (`g.input_norms`, `g.postprocess`) + +## API Reference + +### GET /pipeline-builder + +Returns the pipeline builder UI with available normalizers and postprocessors. + +**Response:** +- HTML page with React Flow editor + +### POST /api/pipeline/validate + +Validates a pipeline configuration. + +**Request:** +```json +{ + "input_normalizers": [ + { "name": "StandardNormalizer", "params": {...} } + ], + "postprocessors": [ + { "name": "InstanceSegmentation", "params": {...} } + ] +} +``` + +**Response:** +```json +{ + "valid": true, + "message": "Pipeline is valid" +} +``` + +### POST /api/pipeline/apply + +Applies a validated pipeline to the current inference. + +**Request:** Same as validate + +**Response:** +```json +{ + "message": "Pipeline applied successfully", + "normalizers_applied": 2, + "postprocessors_applied": 1 +} +``` + +## Development + +### Setup + +```bash +cd cellmap_flow/dashboard +npm install +npm run build # Build for production +npm run dev # Watch mode for development +``` + +### Building the Frontend + +React Flow components are bundled using webpack. Run `npm run build` to generate the bundled JavaScript. + +## Dependencies + +### Frontend +- **React** 18.2+ - UI library +- **React DOM** 18.2+ - DOM rendering +- **React Flow** 11.10+ - Node-based visual editor + +### Backend +- **Flask** - Web framework (already in CellMapFlow) +- **Pydantic** - Configuration validation (already in CellMapFlow) + +## Future Enhancements + +- [ ] Real-time pipeline preview/simulation +- [ ] Custom node templates for complex operations +- [ ] Pipeline library/templates for common workflows +- [ ] Performance profiling for pipeline execution +- [ ] Undo/Redo functionality +- [ ] Keyboard shortcuts for faster node creation +- [ ] Search/filter for large model lists + +## Troubleshooting + +### Pipeline not applying +- Check browser console for errors +- Verify all node names match available normalizers/postprocessors +- Ensure JSON parameter format is valid + +### Import fails +- Ensure file is valid YAML or JSON +- Check that all referenced models exist in your installation + +### Styling issues +- Clear browser cache +- Rebuild with `npm run build` diff --git a/cellmap_flow/dashboard/app.py b/cellmap_flow/dashboard/app.py index 901a1e9..154b406 100644 --- a/cellmap_flow/dashboard/app.py +++ b/cellmap_flow/dashboard/app.py @@ -6,12 +6,17 @@ from flask import Flask, request, jsonify, render_template from flask_cors import CORS import logging +import subprocess +import yaml +import tempfile +import re from cellmap_flow.utils.web_utils import get_free_port from cellmap_flow.norm.input_normalize import ( get_input_normalizers, get_normalizations, ) from cellmap_flow.post.postprocessors import get_postprocessors_list, get_postprocessors +from cellmap_flow.models.model_merger import get_model_mergers_list from cellmap_flow.utils.load_py import load_safe_config from cellmap_flow.utils.scale_pyramid import get_raw_layer from cellmap_flow.utils.web_utils import ( @@ -34,6 +39,12 @@ CORS(app) NEUROGLANCER_URL = None INFERENCE_SERVER = None + +# Blockwise task directory will be set from globals or use default +def get_blockwise_tasks_dir(): + tasks_dir = getattr(g, 'blockwise_tasks_dir', None) or os.path.expanduser("~/.cellmap_flow/blockwise_tasks") + os.makedirs(tasks_dir, exist_ok=True) + return tasks_dir CUSTOM_CODE_FOLDER = os.path.expanduser( os.environ.get( "CUSTOM_CODE_FOLDER", @@ -47,6 +58,7 @@ def index(): # Render the main page with tabs input_norms = get_input_normalizers() output_postprocessors = get_postprocessors_list() + model_mergers = get_model_mergers_list() model_catalog = g.model_catalog model_catalog["User"] = {j.model_name: "" for j in g.jobs} default_post_process = {d.to_dict()["name"]: d.to_dict() for d in g.postprocess} @@ -61,6 +73,7 @@ def index(): inference_servers=INFERENCE_SERVER, input_normalizers=input_norms, output_postprocessors=output_postprocessors, + model_mergers=model_mergers, default_post_process=default_post_process, default_input_norm=default_input_norm, model_catalog=model_catalog, @@ -68,6 +81,192 @@ def index(): ) +@app.route("/pipeline-builder") +def pipeline_builder(): + """Render the drag-and-drop pipeline builder interface with current state from globals""" + input_norms = get_input_normalizers() + output_postprocessors = get_postprocessors_list() + + # Get available models from models_config (not from catalog) + available_models = {} + # if hasattr(g, 'models_config') and g.models_config: + # for model_config in g.models_config: + # model_dict = model_config.to_dict() + # available_models[model_config.name] = model_dict + + logger.warning(f"\n{'='*80}") + logger.warning(f"AVAILABLE MODELS DEBUG:") + logger.warning(f" Initial available_models keys: {list(available_models.keys())}") + logger.warning(f" g.models_config: {g.models_config if hasattr(g, 'models_config') else 'NOT SET'}") + logger.warning(f" Sample model with config:") + for model_name, model_data in list(available_models.items())[:1]: + logger.warning(f" {model_name}: {model_data}") + models_with_config = {} + for model_name in available_models.keys(): + # Find matching config (strip _server suffix for matching) + model_name_stripped = model_name.replace('_server', '') + for model_config in g.models_config: + config_name = getattr(model_config, 'name', '').replace('_server', '') + if config_name == model_name_stripped: + if hasattr(model_config, 'to_dict'): + models_with_config[model_name] = { + 'name': model_name, + 'config': model_config.to_dict() + } + break + # If no config found, just use the name + if model_name not in models_with_config: + models_with_config[model_name] = {'name': model_name} + available_models = models_with_config + + # Check if we have stored pipeline state from previous apply + if hasattr(g, 'pipeline_normalizers') and len(g.pipeline_normalizers) > 0: + # Use stored pipeline state (includes IDs, positions, params) + current_normalizers = g.pipeline_normalizers + current_postprocessors = g.pipeline_postprocessors + current_models = g.pipeline_models + # Enrich current_models with config from g.models_config if available + if hasattr(g, 'models_config') and g.models_config: + for model_dict in current_models: + if 'config' not in model_dict: + # Strip _server suffix for matching + model_name = model_dict['name'].replace('_server', '') + for model_config in g.models_config: + config_name = getattr(model_config, 'name', '').replace('_server', '') + if config_name == model_name: + if hasattr(model_config, 'to_dict'): + model_dict['config'] = model_config.to_dict() + break + current_inputs = g.pipeline_inputs + current_outputs = g.pipeline_outputs + current_edges = g.pipeline_edges + else: + # Fall back to converting from globals.input_norms and globals.postprocess + current_normalizers = [] + for idx, norm in enumerate(g.input_norms): + norm_dict = norm.to_dict() if hasattr(norm, 'to_dict') else {'name': str(norm)} + norm_name = norm_dict.get('name', str(norm)) + # Extract params: all dict items except 'name' + params = {k: v for k, v in norm_dict.items() if k != 'name'} + current_normalizers.append({ + 'id': f'norm-{idx}-{int(time.time()*1000)}', + 'name': norm_name, + 'params': params + }) + + # Current models (from jobs and models_config) + current_models = [] + logger.warning(f"\n{'='*80}") + logger.warning(f"Building current_models from g.jobs:") + logger.warning(f" g.jobs count: {len(g.jobs)}") + logger.warning(f" g.models_config exists: {hasattr(g, 'models_config')}") + if hasattr(g, 'models_config'): + logger.warning(f" g.models_config count: {len(g.models_config) if g.models_config else 0}") + logger.warning(f" g.models_config type: {type(g.models_config)}") + logger.warning(f" g.models_config value: {g.models_config}") + if g.models_config: + logger.warning(f" g.models_config names: {[getattr(mc, 'name', 'NO_NAME') for mc in g.models_config]}") + for mc in g.models_config: + logger.warning(f" Config object: {mc}, has to_dict: {hasattr(mc, 'to_dict')}") + + # If models_config is empty but we have jobs, try to get configs from model_catalog + if (not hasattr(g, 'models_config') or not g.models_config) and hasattr(g, 'model_catalog'): + logger.warning(f" models_config is empty, checking model_catalog for configs...") + # Check if available_models dict has configs + if available_models: + logger.warning(f" available_models has {len(available_models)} entries with potential configs") + + for idx, job in enumerate(g.jobs): + if hasattr(job, 'model_name'): + logger.warning(f"\n Processing job {idx}: model_name={job.model_name}") + model_dict = {'id': f'model-{idx}-{int(time.time()*1000)}', 'name': job.model_name, 'params': {}} + # Try to find the corresponding ModelConfig to get full configuration + config_found = False + + # First try g.models_config + if hasattr(g, 'models_config') and g.models_config: + # Strip _server suffix for matching + job_model_name = job.model_name.replace('_server', '') + for model_config in g.models_config: + model_config_name = getattr(model_config, 'name', None) + config_name_stripped = model_config_name.replace('_server', '') if model_config_name else None + logger.warning(f" Checking model_config: {model_config_name} (stripped: {config_name_stripped}) vs job: {job.model_name} (stripped: {job_model_name})") + if config_name_stripped and config_name_stripped == job_model_name: + # Export the full model config using to_dict() + if hasattr(model_config, 'to_dict'): + model_dict['config'] = model_config.to_dict() + logger.warning(f" ✓ Config attached from models_config: {model_dict['config']}") + config_found = True + break + + # Fallback: check available_models dict (which was enriched earlier) + if not config_found and available_models: + job_model_name = job.model_name.replace('_server', '') + for model_name, model_data in available_models.items(): + model_name_stripped = model_name.replace('_server', '') + logger.warning(f" Checking available_models: {model_name} (stripped: {model_name_stripped}) vs job: {job.model_name} (stripped: {job_model_name})") + if model_name_stripped == job_model_name and isinstance(model_data, dict) and 'config' in model_data: + model_dict['config'] = model_data['config'] + logger.warning(f" ✓ Config attached from available_models: {model_dict['config']}") + config_found = True + break + + # Second fallback: check previously saved pipeline_model_configs + if not config_found and hasattr(g, 'pipeline_model_configs'): + job_model_name = job.model_name.replace('_server', '') + for saved_name, saved_config in g.pipeline_model_configs.items(): + saved_name_stripped = saved_name.replace('_server', '') + logger.warning(f" Checking pipeline_model_configs: {saved_name} (stripped: {saved_name_stripped}) vs job: {job.model_name} (stripped: {job_model_name})") + if saved_name_stripped == job_model_name: + model_dict['config'] = saved_config + logger.warning(f" ✓ Config attached from pipeline_model_configs: {model_dict['config']}") + config_found = True + break + + if not config_found: + logger.warning(f" ✗ No matching config found for {job.model_name}") + logger.warning(f" TIP: Import a YAML with full model configs to populate g.pipeline_model_configs") + current_models.append(model_dict) + logger.warning(f"{'='*80}\n") + + current_postprocessors = [] + for idx, post in enumerate(g.postprocess): + post_dict = post.to_dict() if hasattr(post, 'to_dict') else {'name': str(post)} + post_name = post_dict.get('name', str(post)) + # Extract params: all dict items except 'name' + params = {k: v for k, v in post_dict.items() if k != 'name'} + current_postprocessors.append({ + 'id': f'post-{idx}-{int(time.time()*1000)}', + 'name': post_name, + 'params': params + }) + + current_inputs = [] + current_outputs = [] + current_edges = [] + + # Get current dataset_path from globals + dataset_path = getattr(g, 'dataset_path', None) or '' + + # Get available model mergers + model_mergers = get_model_mergers_list() + + return render_template( + "pipeline_builder_v2.html", + input_normalizers=input_norms or {}, + available_models=available_models or {}, + output_postprocessors=output_postprocessors or {}, + model_mergers=model_mergers or {}, + current_normalizers=current_normalizers, + current_models=current_models, + current_postprocessors=current_postprocessors, + current_inputs=current_inputs, + current_outputs=current_outputs, + current_edges=current_edges, + dataset_path=dataset_path, + ) + + def is_output_segmentation(): if len(g.postprocess) == 0: return False @@ -177,6 +376,464 @@ def process(): ) +@app.route("/api/pipeline/validate", methods=["POST"]) +def validate_pipeline(): + """Validate a pipeline configuration""" + try: + data = request.get_json() + + # Validate normalizers + normalizer_names = [n.get("name") for n in data.get("input_normalizers", [])] + available_norms = get_input_normalizers() + # Extract just the normalizer names from the list of dicts + available_norm_names = [norm["name"] for norm in available_norms] + for norm_name in normalizer_names: + if norm_name not in available_norm_names: + return jsonify( + {"valid": False, "error": f"Unknown normalizer: {norm_name}"} + ), 400 + + # Validate postprocessors + processor_names = [p.get("name") for p in data.get("postprocessors", [])] + available_procs = get_postprocessors_list() + # Extract just the postprocessor names from the list of dicts + available_proc_names = [proc["name"] for proc in available_procs] + for proc_name in processor_names: + if proc_name not in available_proc_names: + return jsonify( + {"valid": False, "error": f"Unknown postprocessor: {proc_name}"} + ), 400 + + return jsonify({"valid": True, "message": "Pipeline is valid"}) + + except Exception as e: + logger.error(f"Error validating pipeline: {e}") + return jsonify({"valid": False, "error": str(e)}), 500 + + +@app.route("/api/dataset-path", methods=["GET", "POST"]) +def dataset_path_api(): + """Get or set the dataset path in globals""" + if request.method == "GET": + dataset_path = getattr(g, 'dataset_path', None) or '' + return jsonify({'dataset_path': dataset_path}) + elif request.method == "POST": + data = request.get_json() + dataset_path = data.get('dataset_path', '') + g.dataset_path = dataset_path + logger.warning(f"Dataset path updated to: {dataset_path}") + return jsonify({'success': True, 'dataset_path': g.dataset_path}) + + +@app.route("/api/blockwise-config", methods=["GET", "POST"]) +def blockwise_config_api(): + """Get or set blockwise configuration in globals""" + if request.method == "GET": + return jsonify({ + 'queue': g.queue, + 'charge_group': g.charge_group, + 'nb_cores_master': g.nb_cores_master, + 'nb_cores_worker': g.nb_cores_worker, + 'nb_workers': g.nb_workers, + 'tmp_dir': g.tmp_dir, + 'blockwise_tasks_dir': g.blockwise_tasks_dir + }) + elif request.method == "POST": + data = request.get_json() + g.queue = data.get('queue') + g.charge_group = data.get('charge_group') + g.nb_cores_master = int(data.get('nb_cores_master')) + g.nb_cores_worker = int(data.get('nb_cores_worker')) + g.nb_workers = int(data.get('nb_workers')) + g.tmp_dir = data.get('tmp_dir') + g.blockwise_tasks_dir = data.get('blockwise_tasks_dir') + logger.warning(f"Blockwise config updated: queue={g.queue}, charge_group={g.charge_group}, cores_master={g.nb_cores_master}, cores_worker={g.nb_cores_worker}, workers={g.nb_workers}, tmp_dir={g.tmp_dir}, blockwise_tasks_dir={g.blockwise_tasks_dir}") + return jsonify({'success': True, 'config': { + 'queue': g.queue, + 'charge_group': g.charge_group, + 'nb_cores_master': g.nb_cores_master, + 'nb_cores_worker': g.nb_cores_worker, + 'nb_workers': g.nb_workers, + 'tmp_dir': g.tmp_dir, + 'blockwise_tasks_dir': g.blockwise_tasks_dir + }}) + + +@app.route("/api/pipeline/apply", methods=["POST"]) +def apply_pipeline(): + """Apply a pipeline configuration to the current inference""" + try: + data = request.get_json() + logger.warning(f"\n{'='*80}") + logger.warning(f"APPLY PIPELINE - Received data:") + logger.warning(f" Input normalizers: {data.get('input_normalizers', [])}") + logger.warning(f" Postprocessors: {data.get('postprocessors', [])}") + + # Validate first + validation = validate_pipeline_config(data) + if not validation["valid"]: + return jsonify(validation), 400 + + # Apply normalizers + input_norms_config = { + n["name"]: n.get("params", {}) for n in data.get("input_normalizers", []) + } + logger.warning(f"\nNormalizers config dict: {input_norms_config}") + g.input_norms = get_normalizations(input_norms_config) + + # Apply postprocessors + postprocs_config = { + p["name"]: p.get("params", {}) for p in data.get("postprocessors", []) + } + logger.warning(f"Postprocessors config dict: {postprocs_config}") + g.postprocess = get_postprocessors(postprocs_config) + + # Save complete pipeline visual state to globals + g.pipeline_inputs = data.get("inputs", []) + g.pipeline_outputs = data.get("outputs", []) + g.pipeline_edges = data.get("edges", []) + g.pipeline_normalizers = data.get("input_normalizers", []) + g.pipeline_models = data.get("models", []) + g.pipeline_postprocessors = data.get("postprocessors", []) + + # Also save model configs separately for easier access + if not hasattr(g, 'pipeline_model_configs'): + g.pipeline_model_configs = {} + for model in data.get("models", []): + if 'config' in model and model['config']: + g.pipeline_model_configs[model['name']] = model['config'] + + # Log the updated globals state + logger.warning(f"\n{'='*80}") + logger.warning(f"UPDATED GLOBALS (g) STATE:") + logger.warning(f"{'='*80}") + logger.warning(f"\ng.input_norms ({len(g.input_norms)} items):") + for idx, norm in enumerate(g.input_norms): + logger.warning(f" [{idx}] {norm}") + + logger.warning(f"\ng.postprocess ({len(g.postprocess)} items):") + for idx, post in enumerate(g.postprocess): + logger.warning(f" [{idx}] {post}") + + logger.warning(f"\ng.jobs ({len(g.jobs)} items):") + for idx, job in enumerate(g.jobs): + logger.warning(f" [{idx}] model_name={getattr(job, 'model_name', 'N/A')}, host={getattr(job, 'host', 'N/A')}") + + logger.warning(f"\ng.pipeline_inputs ({len(g.pipeline_inputs)} items): {g.pipeline_inputs}") + logger.warning(f"\ng.pipeline_outputs ({len(g.pipeline_outputs)} items): {g.pipeline_outputs}") + logger.warning(f"\ng.pipeline_edges ({len(g.pipeline_edges)} items): {g.pipeline_edges}") + logger.warning(f"\ng.pipeline_normalizers ({len(g.pipeline_normalizers)} items): {g.pipeline_normalizers}") + logger.warning(f"\ng.pipeline_models ({len(g.pipeline_models)} items): {g.pipeline_models}") + logger.warning(f"\ng.pipeline_postprocessors ({len(g.pipeline_postprocessors)} items): {g.pipeline_postprocessors}") + + logger.warning(f"{'='*80}\n") + + return jsonify({ + "message": "Pipeline applied successfully", + "normalizers_applied": len(g.input_norms), + "postprocessors_applied": len(g.postprocess), + }) + + except Exception as e: + logger.error(f"Error applying pipeline: {e}") + return jsonify({"error": str(e)}), 500 + + +def validate_pipeline_config(config): + """Helper function to validate pipeline configuration""" + try: + normalizer_names = [n.get("name") for n in config.get("input_normalizers", [])] + available_norms = get_input_normalizers() + # Extract just the normalizer names from the list of dicts + available_norm_names = [norm["name"] for norm in available_norms] + for norm_name in normalizer_names: + if norm_name not in available_norm_names: + return {"valid": False, "error": f"Unknown normalizer: {norm_name}"} + + processor_names = [p.get("name") for p in config.get("postprocessors", [])] + available_procs = get_postprocessors_list() + # Extract just the postprocessor names from the list of dicts + available_proc_names = [proc["name"] for proc in available_procs] + for proc_name in processor_names: + if proc_name not in available_proc_names: + return {"valid": False, "error": f"Unknown postprocessor: {proc_name}"} + + return {"valid": True} + + except Exception as e: + return {"valid": False, "error": str(e)} + + +@app.route("/api/blockwise/validate", methods=["POST"]) +def validate_blockwise(): + """Validate if pipeline is ready for blockwise processing""" + try: + data = request.get_json() + pipeline = data.get("pipeline", {}) + + # Check required components + if not pipeline.get("inputs") or len(pipeline["inputs"]) == 0: + return {"valid": False, "error": "No input nodes defined"} + + if not pipeline.get("outputs") or len(pipeline["outputs"]) == 0: + return {"valid": False, "error": "No output nodes defined"} + + if not pipeline.get("models") or len(pipeline["models"]) == 0: + return {"valid": False, "error": "No models defined"} + + # Check blockwise config + if not pipeline.get("blockwise_config") or len(pipeline["blockwise_config"]) == 0: + return {"valid": False, "error": "No blockwise configuration defined"} + + # Check input has dataset_path + input_node = pipeline["inputs"][0] + if not input_node.get("params", {}).get("dataset_path"): + return {"valid": False, "error": "Input node missing dataset_path"} + + # Check output has dataset_path + output_node = pipeline["outputs"][0] + if not output_node.get("params", {}).get("dataset_path"): + return {"valid": False, "error": "Output node missing dataset_path"} + + logger.info("Pipeline validation passed") + return {"valid": True, "message": "Pipeline is ready for blockwise processing"} + + except Exception as e: + logger.error(f"Validation error: {str(e)}") + return {"valid": False, "error": str(e)} + + +@app.route("/api/blockwise/generate", methods=["POST"]) +def generate_blockwise_task(): + """Generate blockwise task YAML files""" + try: + data = request.get_json() + pipeline = data.get("pipeline", {}) + + # First validate + validation = validate_blockwise() + if not validation.get("valid"): + return {"success": False, "error": validation.get("error")} + + # Get blockwise config + blockwise_config = pipeline["blockwise_config"][0] + input_node = pipeline["inputs"][0] + output_node = pipeline["outputs"][0] + + # Create task YAML content + task_name = f"cellmap_flow_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + task_yaml = { + "data_path": input_node["params"]["dataset_path"], + "output_path": output_node["params"]["dataset_path"], + "task_name": task_name, + "charge_group": blockwise_config["params"]["charge_group"], + "queue": blockwise_config["params"]["queue"], + "workers": blockwise_config["params"]["nb_workers"], + "cpu_workers": blockwise_config["params"]["nb_cores_worker"], + "tmp_dir": blockwise_config["params"]["tmp_dir"], + "models": [], + "input_normalizers": [], + "postprocessors": [] + } + + # Add model_mode if multiple models are present and a merge mode is selected + model_count = len(pipeline.get("models", [])) + model_mode = pipeline.get("model_mode", "") + if model_count > 1 and model_mode: + task_yaml["model_mode"] = model_mode + logger.info(f"Adding model_mode: {model_mode} for {model_count} models") + + # Add models with full config + for model in pipeline.get("models", []): + model_entry = { + "name": model.get("name"), + **model.get("params", model.get("config", {})) + } + # Parse string representations of lists/tuples back to actual lists for specific fields + import ast + import re + for field in ["channels", "input_size", "output_size", "input_voxel_size", "output_voxel_size"]: + if field in model_entry: + value = model_entry[field] + # If it's already a list, keep it + if isinstance(value, (list, tuple)): + model_entry[field] = list(value) + logger.info(f"Field {field} is already a list: {model_entry[field]}") + # If it's a string that looks like a list/tuple, parse it + elif isinstance(value, str): + value_stripped = value.strip().strip("'\"") # Remove outer quotes + if (value_stripped.startswith('[') or value_stripped.startswith('(')) and \ + (value_stripped.endswith(']') or value_stripped.endswith(')')): + try: + # Fix unquoted identifiers: convert [mito] to ['mito'] + # Replace word characters not inside quotes with quoted versions + fixed_value = re.sub(r'\b([a-zA-Z_][a-zA-Z0-9_]*)\b', r"'\1'", value_stripped) + # Remove duplicate quotes: ''mito'' -> 'mito' + fixed_value = re.sub(r"''+", "'", fixed_value) + logger.info(f"Fixing {field}: {value_stripped!r} -> {fixed_value!r}") + + parsed = ast.literal_eval(fixed_value) + if isinstance(parsed, (list, tuple)): + model_entry[field] = list(parsed) + logger.info(f"Parsed {field} from string {value!r} to list {model_entry[field]}") + except Exception as e: + logger.warning(f"Failed to parse {field}: {value}, error: {e}") + + task_yaml["models"].append(model_entry) + + # Add normalizers + for norm in pipeline.get("normalizers", []): + norm_entry = { + "name": norm.get("name"), + "params": norm.get("params", {}) + } + task_yaml["input_normalizers"].append(norm_entry) + + # Add postprocessors + for post in pipeline.get("postprocessors", []): + post_entry = { + "name": post.get("name"), + "params": post.get("params", {}) + } + task_yaml["postprocessors"].append(post_entry) + + # Add output_channels from OUTPUT node if configured + output_channels = output_node.get("params", {}).get("output_channels", []) + if output_channels and isinstance(output_channels, list) and len(output_channels) > 0: + task_yaml["output_channels"] = output_channels + logger.info(f"Adding output_channels to YAML: {output_channels}") + + # Convert to YAML format with proper list handling + yaml_content = yaml.dump(task_yaml, default_flow_style=False, allow_unicode=True) + + # Save to file + yaml_filename = f"{task_name}.yaml" + tasks_dir = get_blockwise_tasks_dir() + yaml_path = os.path.join(tasks_dir, yaml_filename) + with open(yaml_path, 'w') as f: + f.write(yaml_content) + + logger.info(f"Generated blockwise task YAML at: {yaml_path}") + logger.info(f"Task YAML content:\n{yaml_content}") + + return { + "success": True, + "task_yaml": yaml_content, + "task_config": task_yaml, + "task_path": yaml_path, + "task_name": task_name, + "message": "Blockwise task generated successfully" + } + + except Exception as e: + logger.error(f"Task generation error: {str(e)}") + return {"success": False, "error": str(e)} + + +@app.route("/api/blockwise/precheck", methods=["POST"]) +def precheck_blockwise_task(): + """Precheck blockwise task configuration""" + try: + from cellmap_flow.blockwise.blockwise_processor import CellMapFlowBlockwiseProcessor + + data = request.get_json() + pipeline = data.get("pipeline", {}) + + # First validate + validation = validate_blockwise() + if not validation.get("valid"): + return {"success": False, "error": validation.get("error")} + + # Generate task YAML first + gen_result = generate_blockwise_task() + if not gen_result.get("success"): + return {"success": False, "error": gen_result.get("error")} + + yaml_path = gen_result.get("task_path") + + # Try to instantiate the processor to validate configuration + try: + _ = CellMapFlowBlockwiseProcessor(yaml_path, create=True) + logger.info(f"Blockwise precheck passed for: {yaml_path}") + return { + "success": True, + "message": "success" + } + except Exception as e: + logger.error(f"Blockwise precheck failed: {str(e)}") + return {"success": False, "error": str(e)} + + except Exception as e: + logger.error(f"Precheck error: {str(e)}") + return {"success": False, "error": str(e)} + + +@app.route("/api/blockwise/submit", methods=["POST"]) +def submit_blockwise_task(): + """Submit blockwise task to LSF""" + try: + data = request.get_json() + pipeline = data.get("pipeline", {}) + job_name = data.get("job_name", f"cellmap_flow_{int(time.time())}") + + # First validate + validation = validate_blockwise() + if not validation.get("valid"): + return {"success": False, "error": validation.get("error")} + + # Generate task YAML + gen_result = generate_blockwise_task() + if not gen_result.get("success"): + return {"success": False, "error": gen_result.get("error")} + + yaml_path = gen_result["task_path"] + blockwise_config = pipeline["blockwise_config"][0] + + # Build bsub command + cores_master = blockwise_config["params"]["nb_cores_master"] + charge_group = blockwise_config["params"]["charge_group"] + queue = blockwise_config["params"]["queue"] + + bsub_cmd = [ + "bsub", + "-J", job_name, + "-n", str(cores_master), + "-P", charge_group, + # "-q", queue, + "python", "-m", "cellmap_flow.blockwise.cli", + yaml_path + ] + + logger.info(f"Submitting LSF job: {' '.join(bsub_cmd)}") + + # Submit job - use same environment as parent process + result = subprocess.run(bsub_cmd, capture_output=True, text=True, env=os.environ) + + if result.returncode == 0: + output = result.stdout.strip() + logger.info(f"Job submitted successfully: {output}") + + # Extract job ID from bsub output (format: "Job <12345> is submitted") + match = re.search(r'<(\d+)>', output) + job_id = match.group(1) if match else "unknown" + + return { + "success": True, + "job_id": job_id, + "task_path": yaml_path, + "command": " ".join(bsub_cmd), + "message": f"Task submitted as job {job_id}" + } + else: + error_msg = result.stderr or result.stdout + logger.error(f"LSF submission failed: {error_msg}") + return {"success": False, "error": f"LSF error: {error_msg}"} + + except Exception as e: + logger.error(f"Submission error: {str(e)}") + return {"success": False, "error": str(e)} + + def create_and_run_app(neuroglancer_url=None, inference_servers=None): global NEUROGLANCER_URL, INFERENCE_SERVER NEUROGLANCER_URL = neuroglancer_url diff --git a/cellmap_flow/dashboard/package.json b/cellmap_flow/dashboard/package.json new file mode 100644 index 0000000..168dba6 --- /dev/null +++ b/cellmap_flow/dashboard/package.json @@ -0,0 +1,21 @@ +{ + "name": "cellmap-flow-pipeline-builder", + "version": "0.1.0", + "description": "Drag-and-drop pipeline builder for CellMapFlow", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "reactflow": "^11.10.0" + }, + "devDependencies": { + "@babel/preset-react": "^7.22.0", + "@babel/preset-env": "^7.22.0", + "webpack": "^5.88.0", + "webpack-cli": "^5.1.0", + "babel-loader": "^9.1.3" + }, + "scripts": { + "build": "webpack --mode production", + "dev": "webpack --mode development --watch" + } +} diff --git a/cellmap_flow/dashboard/static/css/pipeline_builder.css b/cellmap_flow/dashboard/static/css/pipeline_builder.css new file mode 100644 index 0000000..57f55d4 --- /dev/null +++ b/cellmap_flow/dashboard/static/css/pipeline_builder.css @@ -0,0 +1,726 @@ + + :root { + --bg-primary: #0f172a; + --bg-secondary: #1e293b; + --bg-tertiary: #334155; + --text-primary: #f1f5f9; + --text-secondary: #cbd5e1; + --border-color: #475569; + --accent-blue: #3b82f6; + --accent-purple: #8b5cf6; + --accent-green: #10b981; + --accent-red: #ef4444; + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + height: 100vh; + overflow: hidden; + } + + .app-container { + display: flex; + height: 100vh; + flex-direction: column; + } + + .header { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + padding: 16px 24px; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + } + + .header h1 { + font-size: 20px; + font-weight: 600; + margin: 0; + } + + .apply-btn { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + border: none; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s; + } + + .apply-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); + } + + .header-actions { + display: flex; + gap: 8px; + align-items: center; + } + + .action-btn { + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border-color); + padding: 8px 14px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all 0.2s; + } + + .action-btn:hover { + background: var(--bg-hover); + border-color: #64748b; + } + + .action-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + color: var(--text-secondary); + } + + .action-btn:disabled:hover { + background: var(--bg-primary); + border-color: var(--border-color); + transform: none; + } + + .import-btn { + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border-color); + padding: 8px 14px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all 0.2s; + display: inline-block; + } + + .import-btn:hover { + background: var(--bg-hover); + border-color: #64748b; + } + + .file-input { + display: none; + } + + .main { + display: flex; + flex: 1; + overflow: hidden; + gap: 0; + } + + /* Sidebar */ + .sidebar { + width: 280px; + background: var(--bg-secondary); + border-right: 1px solid var(--border-color); + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; + } + + .library-section { + display: flex; + flex-direction: column; + gap: 8px; + } + + .section-title { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-secondary); + opacity: 0.7; + margin-bottom: 4px; + } + + .section-toggle { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all 0.2s; + display: flex; + justify-content: space-between; + align-items: center; + } + + .section-toggle:hover { + background: var(--accent-blue); + border-color: var(--accent-blue); + } + + .section-toggle.active { + background: var(--accent-blue); + border-color: var(--accent-blue); + } + + .library-items { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 200px; + overflow-y: auto; + display: none; + } + + .library-items.show { + display: flex; + } + + .library-item { + background: var(--bg-primary); + border: 1px solid var(--border-color); + padding: 8px 12px; + border-radius: 4px; + cursor: grab; + font-size: 12px; + transition: all 0.2s; + user-select: none; + } + + .library-item:hover { + background: var(--accent-blue); + border-color: var(--accent-blue); + color: white; + } + + .library-item:active { + cursor: grabbing; + opacity: 0.8; + } + + /* Canvas */ + .canvas { + flex: 1; + background: linear-gradient(135deg, #0f172a 0%, #1a1f35 100%); + overflow: auto; + padding: 32px; + position: relative; + } + + .canvas-content { + position: relative; + width: 100%; + height: 100%; + min-height: 600px; + } + + /* SVG connections layer */ + .connections-svg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 1; + } + + .connection-path { + fill: none; + stroke: rgba(16, 185, 129, 0.6); + stroke-width: 3; + pointer-events: stroke; + cursor: pointer; + transition: all 0.2s; + } + + .connection-path:hover { + stroke: rgba(16, 185, 129, 1); + stroke-width: 4; + filter: drop-shadow(0 0 8px rgba(16, 185, 129, 0.8)); + } + + .connection-path.dragging-connection { + stroke: rgba(59, 130, 246, 0.8); + stroke-dasharray: 5, 5; + animation: dash 0.5s linear infinite; + } + + @keyframes dash { + to { + stroke-dashoffset: -10; + } + } + + /* Nodes */ + .node-box { + background: var(--bg-secondary); + border: 2px solid var(--border-color); + border-radius: 8px; + padding: 16px; + min-width: 280px; + max-width: 320px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + transition: box-shadow 0.2s; + position: absolute; + cursor: grab; + user-select: none; + z-index: 10; + } + + .node-box:active { + cursor: grabbing; + } + + .node-box.dragging { + z-index: 1000; + box-shadow: 0 8px 24px rgba(59, 130, 246, 0.4); + opacity: 0.95; + } + + .node-box.dirty { + border-color: var(--accent-blue); + box-shadow: 0 0 12px rgba(59, 130, 246, 0.3); + } + + .node-box:hover { + border-color: var(--accent-blue); + box-shadow: 0 6px 16px rgba(59, 130, 246, 0.2); + } + + /* Special INPUT/OUTPUT nodes */ + .node-box.io-node { + min-width: 120px; + max-width: 1600px; + padding: 12px; + background: linear-gradient(135deg, var(--bg-tertiary), var(--bg-secondary)); + } + + .node-box.io-node.input-node { + border-color: var(--accent-purple); + } + + .node-box.io-node.output-node { + border-color: var(--accent-green); + } + + .node-box.io-node .node-input { + display: none; + } + + .node-box.io-node.input-node .node-output { + display: block; + } + + .node-box.io-node.output-node .node-input { + display: block; + } + + .node-box.io-node.output-node .node-output { + display: none; + } + + /* Configuration nodes */ + .node-box.config-node { + min-width: 350px; + max-width: 450px; + background: linear-gradient(135deg, #2a2a3e, var(--bg-secondary)); + border-color: #f59e0b; + } + + .node-box.config-node:hover { + border-color: #fbbf24; + box-shadow: 0 6px 16px rgba(245, 158, 11, 0.2); + } + + .node-box.config-node .node-input, + .node-box.config-node .node-output { + display: none; + } + + /* Node connection dots */ + .node-input, .node-output { + position: absolute; + width: 14px; + height: 14px; + border-radius: 50%; + background: radial-gradient(circle at 30% 30%, #10b981, #059669); + box-shadow: 0 0 8px rgba(16, 185, 129, 0.5), inset 0 1px 2px rgba(255,255,255,0.2); + cursor: crosshair; + transition: all 0.2s; + top: 50%; + transform: translateY(-50%); + z-index: 100; + pointer-events: all; + } + + .node-input:hover, .node-output:hover { + transform: translateY(-50%) scale(1.4); + box-shadow: 0 0 16px rgba(16, 185, 129, 1), inset 0 1px 2px rgba(255,255,255,0.4); + } + + .node-input { + left: -9px; + } + + .node-output { + right: -9px; + } + + .node-input.active, .node-output.active { + background: radial-gradient(circle at 30% 30%, #3b82f6, #2563eb); + box-shadow: 0 0 16px rgba(59, 130, 246, 1); + transform: translateY(-50%) scale(1.5); + } + + .node-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-color); + } + + .node-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + } + + .node-type { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); + opacity: 0.7; + } + + .node-actions { + display: flex; + gap: 6px; + } + + .icon-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 16px; + padding: 4px 8px; + border-radius: 4px; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + } + + .icon-btn:hover { + color: var(--text-primary); + background: var(--bg-tertiary); + } + + .icon-btn.delete:hover { + color: var(--accent-red); + } + + .icon-btn.save { + display: none; + } + + .node-box.dirty .icon-btn.save { + display: flex; + color: var(--accent-green); + } + + .configure-btn { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + padding: 6px 10px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + width: 100%; + transition: all 0.2s; + } + + .configure-btn:hover { + background: var(--accent-blue); + border-color: var(--accent-blue); + color: white; + } + + .selected-channels { + padding: 4px; + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; + max-height: 50px; + overflow-y: auto; + word-break: break-word; + } + + .node-box.dirty .icon-btn.save:hover { + color: white; + background: var(--accent-green); + } + + .node-params { + display: flex; + flex-direction: column; + gap: 10px; + } + + .param-group { + display: flex; + flex-direction: column; + gap: 4px; + } + + .param-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); + opacity: 0.8; + } + + .param-input { + background: var(--bg-primary); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 3px 6px; + border-radius: 3px; + font-size: 11px; + font-family: 'Menlo', 'Monaco', monospace; + transition: all 0.2s; + height: 24px; + min-height: 24px; + } + + .param-input:focus { + outline: none; + border-color: var(--accent-blue); + box-shadow: 0 0 8px rgba(59, 130, 246, 0.2); + } + + .param-input:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + /* Blockwise config fields */ + .blockwise-fields { + display: flex; + flex-direction: column; + gap: 10px; + } + + .field-group { + display: flex; + flex-direction: column; + gap: 4px; + } + + /* Message */ + .message { + position: fixed; + bottom: 20px; + right: 20px; + padding: 12px 16px; + border-radius: 6px; + font-size: 13px; + max-width: 400px; + animation: slideIn 0.3s ease-out; + z-index: 10000; + } + + .message.success { + background: rgb(16, 185, 129); + color: #cae7d5; + border: 1px solid rgba(16, 185, 129, 0.4); + } + + .message.error { + background: rgba(239, 68, 68); + color: #fefefe; + border: 1px solid rgba(239, 68, 68, 0.4); + } + + .message.info { + background: rgba(59, 130, 246, 0.2); + color: #93c5fd; + border: 1px solid rgba(59, 130, 246, 0.4); + } + + @keyframes slideIn { + from { + transform: translateX(400px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } + } + + /* Modal Styles */ + .modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 5000; + } + + .modal { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 5001; + } + + .modal-content { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + } + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid var(--border-color); + } + + .modal-header h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + } + + .modal-close-btn { + background: none; + border: none; + color: var(--text-secondary); + font-size: 20px; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + } + + .modal-close-btn:hover { + color: var(--text-primary); + } + + .modal-body { + padding: 16px; + flex: 1; + overflow-y: auto; + } + + .modal-footer { + display: flex; + gap: 8px; + padding: 16px; + border-top: 1px solid var(--border-color); + justify-content: flex-end; + } + + .channel-checkbox-group { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border-radius: 4px; + margin-bottom: 8px; + background: var(--bg-tertiary); + } + + .channel-checkbox-group input[type="checkbox"] { + cursor: pointer; + width: 16px; + height: 16px; + } + + .channel-checkbox-group label { + cursor: pointer; + flex: 1; + margin: 0; + display: flex; + align-items: center; + gap: 4px; + } + + /* Scrollbars */ + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + ::-webkit-scrollbar-track { + background: var(--bg-primary); + } + + ::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; + } + + ::-webkit-scrollbar-thumb:hover { + background: #64748b; + } + + /* Empty state */ + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-secondary); + opacity: 0.5; + gap: 12px; + } + + .empty-state-icon { + font-size: 48px; + opacity: 0.3; + } + + .empty-state-text { + font-size: 14px; + text-align: center; + max-width: 300px; + line-height: 1.5; + } \ No newline at end of file diff --git a/cellmap_flow/dashboard/static/js/bundle-pipeline.js b/cellmap_flow/dashboard/static/js/bundle-pipeline.js new file mode 100644 index 0000000..381a974 --- /dev/null +++ b/cellmap_flow/dashboard/static/js/bundle-pipeline.js @@ -0,0 +1,324 @@ +// In-browser bundle for the pipeline builder — uses UMD React and ReactFlow globals +// This file is transpiled by Babel in the browser (development convenience). + +// Defer accessing ReactFlow UMD globals until runtime to avoid errors +let ReactFlowProvider, ReactFlow, addEdge, useNodesState, useEdgesState, Controls, Background, useReactFlow, Handle, Position; +const { useCallback, useState } = React; + +const nodeTypes = { + normalizer: PipelineNode, + postprocessor: PipelineNode, + input: PipelineNode, + output: PipelineNode, +}; + +function PipelineNode({ data, selected, id }) { + const nodeColors = { + input: '#90EE90', + normalizer: '#87CEEB', + postprocessor: '#FFB6C1', + output: '#FFD700', + }; + const bgColor = nodeColors[data.type] || '#fff'; + + return ( + React.createElement('div', { + style: { + padding: '10px', + border: `2px ${selected ? 'blue' : '#ddd'} solid`, + borderRadius: '8px', + background: bgColor, + minWidth: '120px', + textAlign: 'center', + fontWeight: 'bold', + cursor: 'pointer', + }, + }, + data.type !== 'input' && React.createElement(Handle, { type: 'target', position: Position.Top }), + React.createElement('div', null, data.label), + data.type !== 'output' && React.createElement(Handle, { type: 'source', position: Position.Bottom }) + ) + ); +} + +function PipelineToolbar({ + availableNormalizers, + availablePostprocessors, + onAddNormalizer, + onAddPostprocessor, +}) { + return ( + React.createElement('div', { style: { + padding: '10px', + background: '#f5f5f5', + borderBottom: '1px solid #ddd', + display: 'flex', + gap: '10px', + alignItems: 'center', + } }, + React.createElement('label', { style: { fontWeight: 'bold' } }, 'Add Normalizer:'), + React.createElement('select', { + onChange: (e) => { + if (e.target.value) { + onAddNormalizer(e.target.value); + e.target.value = ''; + } + } + }, + React.createElement('option', { value: '' }, 'Select a normalizer...'), + availableNormalizers && Object.keys(availableNormalizers).map((norm) => React.createElement('option', { key: norm, value: norm }, norm)) + ), + + React.createElement('label', { style: { fontWeight: 'bold', marginLeft: '20px' } }, 'Add Postprocessor:'), + React.createElement('select', { + onChange: (e) => { + if (e.target.value) { + onAddPostprocessor(e.target.value); + e.target.value = ''; + } + } + }, + React.createElement('option', { value: '' }, 'Select a postprocessor...'), + availablePostprocessors && Object.keys(availablePostprocessors).map((post) => React.createElement('option', { key: post, value: post }, post)) + ) + ) + ); +} + +function PipelineExporter({ + selectedNode, + nodes, + edges, + onExport, + onImport, + onUpdateParams, + onDeleteNode, +}) { + const [paramInputs, setParamInputs] = useState({}); + const selectedNodeData = nodes.find((n) => n.id === selectedNode); + + const applyParams = () => { + if (selectedNode && selectedNodeData) { + try { + const params = {}; + Object.keys(paramInputs).forEach((key) => { + try { + params[key] = JSON.parse(paramInputs[key]); + } catch { + params[key] = paramInputs[key]; + } + }); + onUpdateParams(selectedNode, params); + setParamInputs({}); + } catch (error) { + alert('Invalid parameter format: ' + error.message); + } + } + }; + + const exportToYAML = () => { + const workflow = { + input_normalizers: [], + postprocessors: [], + }; + nodes.forEach((node) => { + if (node.type === 'normalizer') { + workflow.input_normalizers.push({ name: node.data.name, params: node.data.params || {} }); + } else if (node.type === 'postprocessor') { + workflow.postprocessors.push({ name: node.data.name, params: node.data.params || {} }); + } + }); + const yamlContent = generateYAML(workflow); + downloadFile(yamlContent, 'pipeline.yaml', 'text/yaml'); + }; + + const exportToJSON = () => { + const pipelineData = onExport(); + const jsonContent = JSON.stringify(pipelineData, null, 2); + downloadFile(jsonContent, 'pipeline.json', 'application/json'); + }; + + const importFromFile = (event) => { + const file = event.target.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e) => { + try { + const content = e.target.result; + const data = file.name.endsWith('.yaml') ? parseYAML(content) : JSON.parse(content); + onImport(data); + } catch (error) { + alert('Error importing file: ' + error.message); + } + }; + reader.readAsText(file); + } + }; + + return ( + React.createElement('div', { style: { + width: '300px', + padding: '15px', + background: '#fff', + borderLeft: '1px solid #ddd', + overflowY: 'auto', + display: 'flex', + flexDirection: 'column', + } }, + React.createElement('h3', null, 'Pipeline Controls'), + selectedNodeData && selectedNodeData.type !== 'input' && selectedNodeData.type !== 'output' && ( + React.createElement('div', { style: { marginBottom: '20px', padding: '10px', background: '#f9f9f9', borderRadius: '5px' } }, + React.createElement('h4', { style: { marginTop: 0 } }, selectedNodeData.data.label), + React.createElement('p', { style: { fontSize: '12px', color: '#666' } }, `Node ID: ${selectedNode}`), + React.createElement('label', { style: { fontWeight: 'bold', fontSize: '12px' } }, 'Parameters:'), + React.createElement('div', { style: { marginTop: '8px', marginBottom: '10px' } }, + React.createElement('input', { + type: 'text', + placeholder: '{"param1": 0.5}', + onChange: (e) => setParamInputs({ param_input: e.target.value }), + style: { width: '100%', padding: '5px', fontSize: '11px' }, + }) + ), + React.createElement('button', { onClick: applyParams, style: { width: '100%', padding: '6px', background: '#4CAF50', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px', marginBottom: '8px' } }, 'Apply Parameters'), + React.createElement('button', { onClick: () => onDeleteNode(selectedNode), style: { width: '100%', padding: '6px', background: '#f44336', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px' } }, 'Delete Node') + ) + ), + React.createElement('div', { style: { marginTop: 'auto' } }, + React.createElement('h4', null, 'Export Pipeline'), + React.createElement('button', { onClick: exportToYAML, style: { width: '100%', padding: '8px', background: '#2196F3', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', marginBottom: '8px' } }, 'Export as YAML'), + React.createElement('button', { onClick: exportToJSON, style: { width: '100%', padding: '8px', background: '#2196F3', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', marginBottom: '8px' } }, 'Export as JSON'), + React.createElement('h4', { style: { marginTop: '15px' } }, 'Import Pipeline'), + React.createElement('input', { type: 'file', accept: '.yaml,.json', onChange: importFromFile, style: { width: '100%', fontSize: '12px' } }) + ) + ) + ); +} + +function PipelineBuilder({ availableNormalizers, availablePostprocessors }) { + const [nodes, setNodes, onNodesChange] = useNodesState([ + { id: 'input', data: { label: 'Input Data', type: 'input' }, position: { x: 0, y: 0 }, type: 'input' }, + { id: 'output', data: { label: 'Output', type: 'output' }, position: { x: 400, y: 300 }, type: 'output' }, + ]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [selectedNode, setSelectedNode] = useState(null); + const reactFlowInstance = useReactFlow(); + + const onConnectCb = useCallback((connection) => setEdges((eds) => addEdge(connection, eds)), [setEdges]); + + const addNormalizer = useCallback((normalizerName) => { + const id = `norm-${Date.now()}`; + const newNode = { id, type: 'normalizer', data: { label: normalizerName, type: 'normalizer', name: normalizerName, params: {} }, position: { x: 100, y: 150 } }; + setNodes((nds) => [...nds, newNode]); + }, [setNodes]); + + const addPostprocessor = useCallback((processorName) => { + const id = `post-${Date.now()}`; + const newNode = { id, type: 'postprocessor', data: { label: processorName, type: 'postprocessor', name: processorName, params: {} }, position: { x: 250, y: 150 } }; + setNodes((nds) => [...nds, newNode]); + }, [setNodes]); + + const updateNodeParams = useCallback((nodeId, params) => setNodes((nds) => nds.map((node) => node.id === nodeId ? { ...node, data: { ...node.data, params } } : node)), [setNodes]); + + const deleteNode = useCallback((nodeId) => { setNodes((nds) => nds.filter((node) => node.id !== nodeId)); setEdges((eds) => eds.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)); }, [setNodes, setEdges]); + + const exportPipeline = useCallback(() => ({ nodes, edges, timestamp: new Date().toISOString() }), [nodes, edges]); + const importPipeline = useCallback((pipelineData) => { if (pipelineData.nodes) setNodes(pipelineData.nodes); if (pipelineData.edges) setEdges(pipelineData.edges); }, [setNodes, setEdges]); + + return ( + React.createElement('div', { style: { width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' } }, + React.createElement(PipelineToolbar, { availableNormalizers, availablePostprocessors, onAddNormalizer: addNormalizer, onAddPostprocessor: addPostprocessor }), + React.createElement('div', { style: { flex: 1, display: 'flex' } }, + React.createElement('div', { style: { flex: 1 } }, + React.createElement(ReactFlow, { nodes, edges, onNodesChange, onEdgesChange, onConnect: onConnectCb, nodeTypes, onNodeClick: (event, node) => setSelectedNode(node.id) }, + React.createElement(Background, null), + React.createElement(Controls, null) + ) + ), + React.createElement(PipelineExporter, { selectedNode, nodes, edges, onExport: exportPipeline, onImport: importPipeline, onUpdateParams: updateNodeParams, onDeleteNode: deleteNode }) + ) + ) + ); +} + +// Helpers +function generateYAML(data) { + let yaml = ''; + yaml += 'input_normalizers:\n'; + data.input_normalizers.forEach((norm) => { + yaml += ` - name: ${norm.name}\n`; + yaml += ' params:\n'; + Object.keys(norm.params).forEach((key) => { yaml += ` ${key}: ${JSON.stringify(norm.params[key])}\n`; }); + }); + yaml += 'postprocessors:\n'; + data.postprocessors.forEach((post) => { + yaml += ` - name: ${post.name}\n`; + yaml += ' params:\n'; + Object.keys(post.params).forEach((key) => { yaml += ` ${key}: ${JSON.stringify(post.params[key])}\n`; }); + }); + return yaml; +} + +function parseYAML(yaml) { + const lines = yaml.split('\n'); + const result = { input_normalizers: [], postprocessors: [] }; + let currentSection = null; + let currentItem = null; + lines.forEach((line) => { + const trimmed = line.trim(); + if (trimmed.startsWith('input_normalizers:')) currentSection = 'input_normalizers'; + else if (trimmed.startsWith('postprocessors:')) currentSection = 'postprocessors'; + else if (trimmed.startsWith('- name:')) { currentItem = { name: trimmed.replace('- name: ', ''), params: {} }; } + else if (trimmed.startsWith('name:') && !trimmed.startsWith('- name:')) { if (currentItem) currentItem.name = trimmed.replace('name: ', ''); } + else if (trimmed.startsWith('params:')) {} + else if (trimmed && currentItem && !trimmed.startsWith('-')) { + const [key, value] = trimmed.split(':').map((s) => s.trim()); + if (key && value) { + try { currentItem.params[key] = JSON.parse(value); } catch { currentItem.params[key] = value; } + } + } + if ((trimmed.startsWith('- name:') || trimmed.startsWith('name:')) && currentItem && Object.keys(currentItem.params).length > 0) { if (currentSection) result[currentSection].push(currentItem); currentItem = null; } + }); + if (currentItem && currentSection) result[currentSection].push(currentItem); + return result; +} + +function downloadFile(content, filename, mimeType) { + const element = document.createElement('a'); + element.setAttribute('href', 'data:' + mimeType + ';charset=utf-8,' + encodeURIComponent(content)); + element.setAttribute('download', filename); + element.style.display = 'none'; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); +} + +// Mount app +document.addEventListener('DOMContentLoaded', function () { + const APP_CONFIG = window.APP_CONFIG || { availableNormalizers: {}, availablePostprocessors: {} }; + + // Ensure ReactFlow UMD global is available + const RF = window.ReactFlow; + if (!RF) { + console.error( + 'ReactFlow UMD not found on window.ReactFlow. Ensure ReactFlow script is loaded before bundle.' + ); + return; + } + + // Extract needed ReactFlow exports at runtime + ReactFlowProvider = RF.ReactFlowProvider; + ReactFlow = RF.ReactFlow; + addEdge = RF.addEdge; + useNodesState = RF.useNodesState; + useEdgesState = RF.useEdgesState; + Controls = RF.Controls; + Background = RF.Background; + useReactFlow = RF.useReactFlow; + Handle = RF.Handle; + Position = RF.Position; + + ReactDOM.render( + React.createElement(ReactFlowProvider, null, React.createElement(PipelineBuilder, { availableNormalizers: APP_CONFIG.availableNormalizers, availablePostprocessors: APP_CONFIG.availablePostprocessors })), + document.getElementById('pipeline-root') + ); +}); diff --git a/cellmap_flow/dashboard/static/js/index-pipeline.js b/cellmap_flow/dashboard/static/js/index-pipeline.js new file mode 100644 index 0000000..c0d3d35 --- /dev/null +++ b/cellmap_flow/dashboard/static/js/index-pipeline.js @@ -0,0 +1,20 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { ReactFlowProvider } from 'reactflow'; +import { PipelineBuilder } from './pipeline-builder'; + +// This will be injected by Flask +const APP_CONFIG = window.APP_CONFIG || { + availableNormalizers: {}, + availablePostprocessors: {}, +}; + +ReactDOM.render( + + + , + document.getElementById('pipeline-root') +); diff --git a/cellmap_flow/dashboard/static/js/pipeline-builder.js b/cellmap_flow/dashboard/static/js/pipeline-builder.js new file mode 100644 index 0000000..5c500af --- /dev/null +++ b/cellmap_flow/dashboard/static/js/pipeline-builder.js @@ -0,0 +1,170 @@ +import React, { useCallback, useState, useRef } from 'react'; +import ReactFlow, { + Node, + Edge, + addEdge, + useNodesState, + useEdgesState, + Controls, + Background, + useReactFlow, + NodeTypes, +} from 'reactflow'; +import 'reactflow/dist/style.css'; +import { PipelineNode } from './pipeline-nodes'; +import { PipelineToolbar } from './pipeline-toolbar'; +import { PipelineExporter } from './pipeline-exporter'; + +const nodeTypes = { + normalizer: PipelineNode, + postprocessor: PipelineNode, + input: PipelineNode, + output: PipelineNode, +}; + +export const PipelineBuilder = ({ availableNormalizers, availablePostprocessors }) => { + const [nodes, setNodes, onNodesChange] = useNodesState([ + { + id: 'input', + data: { label: 'Input Data', type: 'input' }, + position: { x: 0, y: 0 }, + type: 'input', + }, + { + id: 'output', + data: { label: 'Output', type: 'output' }, + position: { x: 400, y: 300 }, + type: 'output', + }, + ]); + + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [selectedNode, setSelectedNode] = useState(null); + const reactFlowInstance = useReactFlow(); + + const onConnect = useCallback( + (connection) => { + setEdges((eds) => addEdge(connection, eds)); + }, + [setEdges] + ); + + const addNormalizer = useCallback( + (normalizerName) => { + const id = `norm-${Date.now()}`; + const newNode = { + id, + type: 'normalizer', + data: { + label: normalizerName, + type: 'normalizer', + name: normalizerName, + params: {}, + }, + position: { x: 100, y: 150 }, + }; + setNodes((nds) => [...nds, newNode]); + }, + [setNodes] + ); + + const addPostprocessor = useCallback( + (processorName) => { + const id = `post-${Date.now()}`; + const newNode = { + id, + type: 'postprocessor', + data: { + label: processorName, + type: 'postprocessor', + name: processorName, + params: {}, + }, + position: { x: 250, y: 150 }, + }; + setNodes((nds) => [...nds, newNode]); + }, + [setNodes] + ); + + const updateNodeParams = useCallback( + (nodeId, params) => { + setNodes((nds) => + nds.map((node) => + node.id === nodeId + ? { ...node, data: { ...node.data, params } } + : node + ) + ); + }, + [setNodes] + ); + + const deleteNode = useCallback( + (nodeId) => { + setNodes((nds) => nds.filter((node) => node.id !== nodeId)); + setEdges((eds) => + eds.filter((edge) => edge.source !== nodeId && edge.target !== nodeId) + ); + }, + [setNodes, setEdges] + ); + + const exportPipeline = useCallback(() => { + return { + nodes, + edges, + timestamp: new Date().toISOString(), + }; + }, [nodes, edges]); + + const importPipeline = useCallback( + (pipelineData) => { + if (pipelineData.nodes) { + setNodes(pipelineData.nodes); + } + if (pipelineData.edges) { + setEdges(pipelineData.edges); + } + }, + [setNodes, setEdges] + ); + + return ( +
+ + +
+
+ setSelectedNode(node.id)} + > + + + +
+ + +
+
+ ); +}; diff --git a/cellmap_flow/dashboard/static/js/pipeline-exporter.js b/cellmap_flow/dashboard/static/js/pipeline-exporter.js new file mode 100644 index 0000000..5997103 --- /dev/null +++ b/cellmap_flow/dashboard/static/js/pipeline-exporter.js @@ -0,0 +1,337 @@ +import React, { useState } from 'react'; + +export const PipelineExporter = ({ + selectedNode, + nodes, + edges, + onExport, + onImport, + onUpdateParams, + onDeleteNode, +}) => { + const [paramInputs, setParamInputs] = useState({}); + + const selectedNodeData = nodes.find((n) => n.id === selectedNode); + + const handleParamChange = (key, value) => { + setParamInputs({ ...paramInputs, [key]: value }); + }; + + const saveNodeData = () => { + if (selectedNodeData && (selectedNodeData.type === 'input' || selectedNodeData.type === 'output')) { + alert(`${selectedNodeData.type.charAt(0).toUpperCase() + selectedNodeData.type.slice(1)} node saved.`); + } + }; + + const applyParams = () => { + if (selectedNode && selectedNodeData) { + try { + const params = {}; + Object.keys(paramInputs).forEach((key) => { + try { + params[key] = JSON.parse(paramInputs[key]); + } catch { + params[key] = paramInputs[key]; + } + }); + onUpdateParams(selectedNode, params); + setParamInputs({}); + } catch (error) { + alert('Invalid parameter format: ' + error.message); + } + } + }; + + const exportToYAML = async () => { + try { + // Fetch dataset_path from backend + const response = await fetch('/api/dataset_path'); + const data = await response.json(); + const datasetPath = data.dataset_path || ''; + + const pipelineData = onExport(); + + // Extract workflow structure + const workflow = { + input_normalizers: [], + postprocessors: [], + }; + + // Order nodes by edges + nodes.forEach((node) => { + if (node.type === 'normalizer') { + workflow.input_normalizers.push({ + name: node.data.name, + params: node.data.params || {}, + }); + } else if (node.type === 'postprocessor') { + workflow.postprocessors.push({ + name: node.data.name, + params: node.data.params || {}, + }); + } + }); + + const yamlContent = generateYAML(workflow, datasetPath); + downloadFile(yamlContent, 'pipeline.yaml', 'text/yaml'); + } catch (error) { + console.error('Error exporting YAML:', error); + alert('Error exporting YAML: ' + error.message); + } + }; + + const exportToJSON = () => { + const pipelineData = onExport(); + const jsonContent = JSON.stringify(pipelineData, null, 2); + downloadFile(jsonContent, 'pipeline.json', 'application/json'); + }; + + const importFromFile = (event) => { + const file = event.target.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e) => { + try { + const content = e.target.result; + const data = file.name.endsWith('.yaml') + ? parseYAML(content) + : JSON.parse(content); + onImport(data); + } catch (error) { + alert('Error importing file: ' + error.message); + } + }; + reader.readAsText(file); + } + }; + + return ( +
+

Pipeline Controls

+ + {/* Node Inspector */} + {selectedNodeData && ( +
+

{selectedNodeData.data.label}

+

Node ID: {selectedNode}

+

Type: {selectedNodeData.type}

+ + {(selectedNodeData.type === 'input' || selectedNodeData.type === 'output') ? ( + + ) : ( + <> + +
+ setParamInputs({ param_input: e.target.value })} + style={{ width: '100%', padding: '5px', fontSize: '11px' }} + /> +
+ + + + + + )} +
+ )} + + {/* Export/Import */} +
+

Export Pipeline

+ + + +

Import Pipeline

+ +
+
+ ); +}; + +// Helper functions +const generateYAML = (data, datasetPath = '') => { + let yaml = ''; + + if (datasetPath) { + yaml += `dataset_path: ${datasetPath}\n`; + yaml += '\n'; + } + + yaml += 'input_normalizers:\n'; + if (data.input_normalizers.length === 0) { + yaml += ' []\n'; + } else { + data.input_normalizers.forEach((norm) => { + yaml += ` - name: ${norm.name}\n`; + yaml += ' params:\n'; + if (Object.keys(norm.params).length === 0) { + yaml += ' {}\n'; + } else { + Object.keys(norm.params).forEach((key) => { + yaml += ` ${key}: ${JSON.stringify(norm.params[key])}\n`; + }); + } + }); + } + + yaml += 'postprocessors:\n'; + if (data.postprocessors.length === 0) { + yaml += ' []\n'; + } else { + data.postprocessors.forEach((post) => { + yaml += ` - name: ${post.name}\n`; + yaml += ' params:\n'; + if (Object.keys(post.params).length === 0) { + yaml += ' {}\n'; + } else { + Object.keys(post.params).forEach((key) => { + yaml += ` ${key}: ${JSON.stringify(post.params[key])}\n`; + }); + } + }); + } + + return yaml; +}; + +const parseYAML = (yaml) => { + // Simple YAML parser for our use case + const lines = yaml.split('\n'); + const result = { + input_normalizers: [], + postprocessors: [], + }; + + let currentSection = null; + let currentItem = null; + + lines.forEach((line) => { + const trimmed = line.trim(); + if (trimmed.startsWith('input_normalizers:')) currentSection = 'input_normalizers'; + else if (trimmed.startsWith('postprocessors:')) currentSection = 'postprocessors'; + else if (trimmed.startsWith('- name:')) { + currentItem = { name: trimmed.replace('- name: ', ''), params: {} }; + } else if (trimmed.startsWith('name:') && !trimmed.startsWith('- name:')) { + if (currentItem) currentItem.name = trimmed.replace('name: ', ''); + } else if (trimmed.startsWith('params:')) { + // params section + } else if (trimmed && currentItem && !trimmed.startsWith('-')) { + const [key, value] = trimmed.split(':').map((s) => s.trim()); + if (key && value) { + try { + currentItem.params[key] = JSON.parse(value); + } catch { + currentItem.params[key] = value; + } + } + } + + // Save item when moving to next + if ((trimmed.startsWith('- name:') || trimmed.startsWith('name:')) && currentItem && Object.keys(currentItem.params).length > 0) { + if (currentSection) result[currentSection].push(currentItem); + currentItem = null; + } + }); + + // Push last item + if (currentItem && currentSection) result[currentSection].push(currentItem); + + return result; +}; + +const downloadFile = (content, filename, mimeType) => { + const element = document.createElement('a'); + element.setAttribute('href', 'data:' + mimeType + ';charset=utf-8,' + encodeURIComponent(content)); + element.setAttribute('download', filename); + element.style.display = 'none'; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); +}; diff --git a/cellmap_flow/dashboard/static/js/pipeline-nodes.js b/cellmap_flow/dashboard/static/js/pipeline-nodes.js new file mode 100644 index 0000000..2a649cc --- /dev/null +++ b/cellmap_flow/dashboard/static/js/pipeline-nodes.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { Handle, Position } from 'reactflow'; + +const nodeColors = { + input: '#90EE90', + normalizer: '#87CEEB', + postprocessor: '#FFB6C1', + output: '#FFD700', +}; + +export const PipelineNode = ({ data, selected, id }) => { + const bgColor = nodeColors[data.type] || '#fff'; + + return ( +
+ {data.type !== 'input' && ( + + )} +
{data.label}
+ {data.type !== 'output' && ( + + )} +
+ ); +}; diff --git a/cellmap_flow/dashboard/static/js/pipeline-toolbar.js b/cellmap_flow/dashboard/static/js/pipeline-toolbar.js new file mode 100644 index 0000000..fbf6e3e --- /dev/null +++ b/cellmap_flow/dashboard/static/js/pipeline-toolbar.js @@ -0,0 +1,53 @@ +import React from 'react'; + +export const PipelineToolbar = ({ + availableNormalizers, + availablePostprocessors, + onAddNormalizer, + onAddPostprocessor, +}) => { + return ( +
+ + + + + +
+ ); +}; diff --git a/cellmap_flow/dashboard/static/js/react-dom.production.min.js b/cellmap_flow/dashboard/static/js/react-dom.production.min.js new file mode 100644 index 0000000..fb4e099 --- /dev/null +++ b/cellmap_flow/dashboard/static/js/react-dom.production.min.js @@ -0,0 +1,267 @@ +/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +(function(){/* + Modernizr 3.0.0pre (Custom Build) | MIT +*/ +'use strict';(function(Q,zb){"object"===typeof exports&&"undefined"!==typeof module?zb(exports,require("react")):"function"===typeof define&&define.amd?define(["exports","react"],zb):(Q=Q||self,zb(Q.ReactDOM={},Q.React))})(this,function(Q,zb){function m(a){for(var b="https://reactjs.org/docs/error-decoder.html?invariant="+a,c=1;cb}return!1}function Y(a,b,c,d,e,f,g){this.acceptsBooleans=2===b||3===b||4===b;this.attributeName=d;this.attributeNamespace=e;this.mustUseProperty=c;this.propertyName=a;this.type=b;this.sanitizeURL=f;this.removeEmptyString=g}function $d(a,b,c,d){var e=R.hasOwnProperty(b)?R[b]:null;if(null!==e?0!==e.type:d||!(2h||e[g]!==f[h]){var k="\n"+e[g].replace(" at new "," at ");a.displayName&&k.includes("")&&(k=k.replace("",a.displayName));return k}while(1<=g&&0<=h)}break}}}finally{ce=!1,Error.prepareStackTrace=c}return(a=a?a.displayName||a.name:"")?bc(a): +""}function fj(a){switch(a.tag){case 5:return bc(a.type);case 16:return bc("Lazy");case 13:return bc("Suspense");case 19:return bc("SuspenseList");case 0:case 2:case 15:return a=be(a.type,!1),a;case 11:return a=be(a.type.render,!1),a;case 1:return a=be(a.type,!0),a;default:return""}}function de(a){if(null==a)return null;if("function"===typeof a)return a.displayName||a.name||null;if("string"===typeof a)return a;switch(a){case Bb:return"Fragment";case Cb:return"Portal";case ee:return"Profiler";case fe:return"StrictMode"; +case ge:return"Suspense";case he:return"SuspenseList"}if("object"===typeof a)switch(a.$$typeof){case gg:return(a.displayName||"Context")+".Consumer";case hg:return(a._context.displayName||"Context")+".Provider";case ie:var b=a.render;a=a.displayName;a||(a=b.displayName||b.name||"",a=""!==a?"ForwardRef("+a+")":"ForwardRef");return a;case je:return b=a.displayName||null,null!==b?b:de(a.type)||"Memo";case Ta:b=a._payload;a=a._init;try{return de(a(b))}catch(c){}}return null}function gj(a){var b=a.type; +switch(a.tag){case 24:return"Cache";case 9:return(b.displayName||"Context")+".Consumer";case 10:return(b._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return a=b.render,a=a.displayName||a.name||"",b.displayName||(""!==a?"ForwardRef("+a+")":"ForwardRef");case 7:return"Fragment";case 5:return b;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return de(b);case 8:return b===fe?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler"; +case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if("function"===typeof b)return b.displayName||b.name||null;if("string"===typeof b)return b}return null}function Ua(a){switch(typeof a){case "boolean":case "number":case "string":case "undefined":return a;case "object":return a;default:return""}}function ig(a){var b=a.type;return(a=a.nodeName)&&"input"===a.toLowerCase()&&("checkbox"===b||"radio"=== +b)}function hj(a){var b=ig(a)?"checked":"value",c=Object.getOwnPropertyDescriptor(a.constructor.prototype,b),d=""+a[b];if(!a.hasOwnProperty(b)&&"undefined"!==typeof c&&"function"===typeof c.get&&"function"===typeof c.set){var e=c.get,f=c.set;Object.defineProperty(a,b,{configurable:!0,get:function(){return e.call(this)},set:function(a){d=""+a;f.call(this,a)}});Object.defineProperty(a,b,{enumerable:c.enumerable});return{getValue:function(){return d},setValue:function(a){d=""+a},stopTracking:function(){a._valueTracker= +null;delete a[b]}}}}function Pc(a){a._valueTracker||(a._valueTracker=hj(a))}function jg(a){if(!a)return!1;var b=a._valueTracker;if(!b)return!0;var c=b.getValue();var d="";a&&(d=ig(a)?a.checked?"true":"false":a.value);a=d;return a!==c?(b.setValue(a),!0):!1}function Qc(a){a=a||("undefined"!==typeof document?document:void 0);if("undefined"===typeof a)return null;try{return a.activeElement||a.body}catch(b){return a.body}}function ke(a,b){var c=b.checked;return E({},b,{defaultChecked:void 0,defaultValue:void 0, +value:void 0,checked:null!=c?c:a._wrapperState.initialChecked})}function kg(a,b){var c=null==b.defaultValue?"":b.defaultValue,d=null!=b.checked?b.checked:b.defaultChecked;c=Ua(null!=b.value?b.value:c);a._wrapperState={initialChecked:d,initialValue:c,controlled:"checkbox"===b.type||"radio"===b.type?null!=b.checked:null!=b.value}}function lg(a,b){b=b.checked;null!=b&&$d(a,"checked",b,!1)}function le(a,b){lg(a,b);var c=Ua(b.value),d=b.type;if(null!=c)if("number"===d){if(0===c&&""===a.value||a.value!= +c)a.value=""+c}else a.value!==""+c&&(a.value=""+c);else if("submit"===d||"reset"===d){a.removeAttribute("value");return}b.hasOwnProperty("value")?me(a,b.type,c):b.hasOwnProperty("defaultValue")&&me(a,b.type,Ua(b.defaultValue));null==b.checked&&null!=b.defaultChecked&&(a.defaultChecked=!!b.defaultChecked)}function mg(a,b,c){if(b.hasOwnProperty("value")||b.hasOwnProperty("defaultValue")){var d=b.type;if(!("submit"!==d&&"reset"!==d||void 0!==b.value&&null!==b.value))return;b=""+a._wrapperState.initialValue; +c||b===a.value||(a.value=b);a.defaultValue=b}c=a.name;""!==c&&(a.name="");a.defaultChecked=!!a._wrapperState.initialChecked;""!==c&&(a.name=c)}function me(a,b,c){if("number"!==b||Qc(a.ownerDocument)!==a)null==c?a.defaultValue=""+a._wrapperState.initialValue:a.defaultValue!==""+c&&(a.defaultValue=""+c)}function Db(a,b,c,d){a=a.options;if(b){b={};for(var e=0;e>>=0;return 0===a?32:31-(qj(a)/rj|0)|0}function hc(a){switch(a&-a){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return a& +4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return a&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return a}}function Vc(a,b){var c=a.pendingLanes;if(0===c)return 0;var d=0,e=a.suspendedLanes,f=a.pingedLanes,g=c&268435455;if(0!==g){var h=g&~e;0!==h?d=hc(h):(f&=g,0!==f&&(d=hc(f)))}else g=c&~e,0!==g?d=hc(g):0!==f&&(d=hc(f));if(0===d)return 0;if(0!==b&&b!==d&&0===(b&e)&& +(e=d&-d,f=b&-b,e>=f||16===e&&0!==(f&4194240)))return b;0!==(d&4)&&(d|=c&16);b=a.entangledLanes;if(0!==b)for(a=a.entanglements,b&=d;0c;c++)b.push(a); +return b}function ic(a,b,c){a.pendingLanes|=b;536870912!==b&&(a.suspendedLanes=0,a.pingedLanes=0);a=a.eventTimes;b=31-ta(b);a[b]=c}function uj(a,b){var c=a.pendingLanes&~b;a.pendingLanes=b;a.suspendedLanes=0;a.pingedLanes=0;a.expiredLanes&=b;a.mutableReadLanes&=b;a.entangledLanes&=b;b=a.entanglements;var d=a.eventTimes;for(a=a.expirationTimes;0=b)return{node:c,offset:b-a};a=d}a:{for(;c;){if(c.nextSibling){c=c.nextSibling;break a}c=c.parentNode}c=void 0}c=$g(c)}}function bh(a,b){return a&&b?a===b?!0:a&&3===a.nodeType?!1:b&&3===b.nodeType?bh(a,b.parentNode):"contains"in a?a.contains(b):a.compareDocumentPosition?!!(a.compareDocumentPosition(b)&16):!1:!1}function ch(){for(var a=window,b=Qc();b instanceof a.HTMLIFrameElement;){try{var c="string"===typeof b.contentWindow.location.href}catch(d){c=!1}if(c)a=b.contentWindow;else break; +b=Qc(a.document)}return b}function Ie(a){var b=a&&a.nodeName&&a.nodeName.toLowerCase();return b&&("input"===b&&("text"===a.type||"search"===a.type||"tel"===a.type||"url"===a.type||"password"===a.type)||"textarea"===b||"true"===a.contentEditable)}function Tj(a){var b=ch(),c=a.focusedElem,d=a.selectionRange;if(b!==c&&c&&c.ownerDocument&&bh(c.ownerDocument.documentElement,c)){if(null!==d&&Ie(c))if(b=d.start,a=d.end,void 0===a&&(a=b),"selectionStart"in c)c.selectionStart=b,c.selectionEnd=Math.min(a,c.value.length); +else if(a=(b=c.ownerDocument||document)&&b.defaultView||window,a.getSelection){a=a.getSelection();var e=c.textContent.length,f=Math.min(d.start,e);d=void 0===d.end?f:Math.min(d.end,e);!a.extend&&f>d&&(e=d,d=f,f=e);e=ah(c,f);var g=ah(c,d);e&&g&&(1!==a.rangeCount||a.anchorNode!==e.node||a.anchorOffset!==e.offset||a.focusNode!==g.node||a.focusOffset!==g.offset)&&(b=b.createRange(),b.setStart(e.node,e.offset),a.removeAllRanges(),f>d?(a.addRange(b),a.extend(g.node,g.offset)):(b.setEnd(g.node,g.offset), +a.addRange(b)))}b=[];for(a=c;a=a.parentNode;)1===a.nodeType&&b.push({element:a,left:a.scrollLeft,top:a.scrollTop});"function"===typeof c.focus&&c.focus();for(c=0;cMb||(a.current=Se[Mb],Se[Mb]=null,Mb--)} +function y(a,b,c){Mb++;Se[Mb]=a.current;a.current=b}function Nb(a,b){var c=a.type.contextTypes;if(!c)return cb;var d=a.stateNode;if(d&&d.__reactInternalMemoizedUnmaskedChildContext===b)return d.__reactInternalMemoizedMaskedChildContext;var e={},f;for(f in c)e[f]=b[f];d&&(a=a.stateNode,a.__reactInternalMemoizedUnmaskedChildContext=b,a.__reactInternalMemoizedMaskedChildContext=e);return e}function ea(a){a=a.childContextTypes;return null!==a&&void 0!==a}function th(a,b,c){if(J.current!==cb)throw Error(m(168)); +y(J,b);y(S,c)}function uh(a,b,c){var d=a.stateNode;b=b.childContextTypes;if("function"!==typeof d.getChildContext)return c;d=d.getChildContext();for(var e in d)if(!(e in b))throw Error(m(108,gj(a)||"Unknown",e));return E({},c,d)}function ld(a){a=(a=a.stateNode)&&a.__reactInternalMemoizedMergedChildContext||cb;pb=J.current;y(J,a);y(S,S.current);return!0}function vh(a,b,c){var d=a.stateNode;if(!d)throw Error(m(169));c?(a=uh(a,b,pb),d.__reactInternalMemoizedMergedChildContext=a,v(S),v(J),y(J,a)):v(S); +y(S,c)}function wh(a){null===La?La=[a]:La.push(a)}function jk(a){md=!0;wh(a)}function db(){if(!Te&&null!==La){Te=!0;var a=0,b=z;try{var c=La;for(z=1;a>=g;e-=g;Ma=1<<32-ta(b)+e|c<t?(q=l,l=null):q=l.sibling;var A=r(e,l,h[t],k);if(null===A){null===l&&(l=q);break}a&&l&&null===A.alternate&&b(e,l);g=f(A,g,t);null===m?n=A:m.sibling=A;m=A;l=q}if(t===h.length)return c(e,l),D&&qb(e,t),n;if(null===l){for(;t< +h.length;t++)l=u(e,h[t],k),null!==l&&(g=f(l,g,t),null===m?n=l:m.sibling=l,m=l);D&&qb(e,t);return n}for(l=d(e,l);tt?(A=q,q=null):A=q.sibling;var x=r(e,q,w.value,k);if(null===x){null===q&&(q=A);break}a&&q&&null===x.alternate&&b(e,q);g=f(x,g,t);null===l?n=x:l.sibling=x;l=x;q=A}if(w.done)return c(e,q),D&&qb(e,t),n;if(null===q){for(;!w.done;t++,w=h.next())w=u(e,w.value,k),null!==w&&(g=f(w,g,t),null===l?n=w:l.sibling=w,l=w);D&&qb(e,t);return n}for(q=d(e,q);!w.done;t++,w=h.next())w=p(q,e,t,w.value,k),null!==w&&(a&&null!==w.alternate&&q.delete(null===w.key?t:w.key),g=f(w,g,t),null===l?n=w:l.sibling= +w,l=w);a&&q.forEach(function(a){return b(e,a)});D&&qb(e,t);return n}function v(a,d,f,h){"object"===typeof f&&null!==f&&f.type===Bb&&null===f.key&&(f=f.props.children);if("object"===typeof f&&null!==f){switch(f.$$typeof){case sd:a:{for(var k=f.key,n=d;null!==n;){if(n.key===k){k=f.type;if(k===Bb){if(7===n.tag){c(a,n.sibling);d=e(n,f.props.children);d.return=a;a=d;break a}}else if(n.elementType===k||"object"===typeof k&&null!==k&&k.$$typeof===Ta&&Ch(k)===n.type){c(a,n.sibling);d=e(n,f.props);d.ref=vc(a, +n,f);d.return=a;a=d;break a}c(a,n);break}else b(a,n);n=n.sibling}f.type===Bb?(d=sb(f.props.children,a.mode,h,f.key),d.return=a,a=d):(h=rd(f.type,f.key,f.props,null,a.mode,h),h.ref=vc(a,d,f),h.return=a,a=h)}return g(a);case Cb:a:{for(n=f.key;null!==d;){if(d.key===n)if(4===d.tag&&d.stateNode.containerInfo===f.containerInfo&&d.stateNode.implementation===f.implementation){c(a,d.sibling);d=e(d,f.children||[]);d.return=a;a=d;break a}else{c(a,d);break}else b(a,d);d=d.sibling}d=$e(f,a.mode,h);d.return=a; +a=d}return g(a);case Ta:return n=f._init,v(a,d,n(f._payload),h)}if(cc(f))return x(a,d,f,h);if(ac(f))return I(a,d,f,h);qd(a,f)}return"string"===typeof f&&""!==f||"number"===typeof f?(f=""+f,null!==d&&6===d.tag?(c(a,d.sibling),d=e(d,f),d.return=a,a=d):(c(a,d),d=Ze(f,a.mode,h),d.return=a,a=d),g(a)):c(a,d)}return v}function af(){bf=Rb=td=null}function cf(a,b){b=ud.current;v(ud);a._currentValue=b}function df(a,b,c){for(;null!==a;){var d=a.alternate;(a.childLanes&b)!==b?(a.childLanes|=b,null!==d&&(d.childLanes|= +b)):null!==d&&(d.childLanes&b)!==b&&(d.childLanes|=b);if(a===c)break;a=a.return}}function Sb(a,b){td=a;bf=Rb=null;a=a.dependencies;null!==a&&null!==a.firstContext&&(0!==(a.lanes&b)&&(ha=!0),a.firstContext=null)}function qa(a){var b=a._currentValue;if(bf!==a)if(a={context:a,memoizedValue:b,next:null},null===Rb){if(null===td)throw Error(m(308));Rb=a;td.dependencies={lanes:0,firstContext:a}}else Rb=Rb.next=a;return b}function ef(a){null===tb?tb=[a]:tb.push(a)}function Eh(a,b,c,d){var e=b.interleaved; +null===e?(c.next=c,ef(b)):(c.next=e.next,e.next=c);b.interleaved=c;return Oa(a,d)}function Oa(a,b){a.lanes|=b;var c=a.alternate;null!==c&&(c.lanes|=b);c=a;for(a=a.return;null!==a;)a.childLanes|=b,c=a.alternate,null!==c&&(c.childLanes|=b),c=a,a=a.return;return 3===c.tag?c.stateNode:null}function ff(a){a.updateQueue={baseState:a.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Fh(a,b){a=a.updateQueue;b.updateQueue===a&&(b.updateQueue= +{baseState:a.baseState,firstBaseUpdate:a.firstBaseUpdate,lastBaseUpdate:a.lastBaseUpdate,shared:a.shared,effects:a.effects})}function Pa(a,b){return{eventTime:a,lane:b,tag:0,payload:null,callback:null,next:null}}function fb(a,b,c){var d=a.updateQueue;if(null===d)return null;d=d.shared;if(0!==(p&2)){var e=d.pending;null===e?b.next=b:(b.next=e.next,e.next=b);d.pending=b;return kk(a,c)}e=d.interleaved;null===e?(b.next=b,ef(d)):(b.next=e.next,e.next=b);d.interleaved=b;return Oa(a,c)}function vd(a,b,c){b= +b.updateQueue;if(null!==b&&(b=b.shared,0!==(c&4194240))){var d=b.lanes;d&=a.pendingLanes;c|=d;b.lanes=c;xe(a,c)}}function Gh(a,b){var c=a.updateQueue,d=a.alternate;if(null!==d&&(d=d.updateQueue,c===d)){var e=null,f=null;c=c.firstBaseUpdate;if(null!==c){do{var g={eventTime:c.eventTime,lane:c.lane,tag:c.tag,payload:c.payload,callback:c.callback,next:null};null===f?e=f=g:f=f.next=g;c=c.next}while(null!==c);null===f?e=f=b:f=f.next=b}else e=f=b;c={baseState:d.baseState,firstBaseUpdate:e,lastBaseUpdate:f, +shared:d.shared,effects:d.effects};a.updateQueue=c;return}a=c.lastBaseUpdate;null===a?c.firstBaseUpdate=b:a.next=b;c.lastBaseUpdate=b}function wd(a,b,c,d){var e=a.updateQueue;gb=!1;var f=e.firstBaseUpdate,g=e.lastBaseUpdate,h=e.shared.pending;if(null!==h){e.shared.pending=null;var k=h,n=k.next;k.next=null;null===g?f=n:g.next=n;g=k;var l=a.alternate;null!==l&&(l=l.updateQueue,h=l.lastBaseUpdate,h!==g&&(null===h?l.firstBaseUpdate=n:h.next=n,l.lastBaseUpdate=k))}if(null!==f){var m=e.baseState;g=0;l= +n=k=null;h=f;do{var r=h.lane,p=h.eventTime;if((d&r)===r){null!==l&&(l=l.next={eventTime:p,lane:0,tag:h.tag,payload:h.payload,callback:h.callback,next:null});a:{var x=a,v=h;r=b;p=c;switch(v.tag){case 1:x=v.payload;if("function"===typeof x){m=x.call(p,m,r);break a}m=x;break a;case 3:x.flags=x.flags&-65537|128;case 0:x=v.payload;r="function"===typeof x?x.call(p,m,r):x;if(null===r||void 0===r)break a;m=E({},m,r);break a;case 2:gb=!0}}null!==h.callback&&0!==h.lane&&(a.flags|=64,r=e.effects,null===r?e.effects= +[h]:r.push(h))}else p={eventTime:p,lane:r,tag:h.tag,payload:h.payload,callback:h.callback,next:null},null===l?(n=l=p,k=m):l=l.next=p,g|=r;h=h.next;if(null===h)if(h=e.shared.pending,null===h)break;else r=h,h=r.next,r.next=null,e.lastBaseUpdate=r,e.shared.pending=null}while(1);null===l&&(k=m);e.baseState=k;e.firstBaseUpdate=n;e.lastBaseUpdate=l;b=e.shared.interleaved;if(null!==b){e=b;do g|=e.lane,e=e.next;while(e!==b)}else null===f&&(e.shared.lanes=0);ra|=g;a.lanes=g;a.memoizedState=m}}function Hh(a, +b,c){a=b.effects;b.effects=null;if(null!==a)for(b=0;bc?c:4;a(!0);var d=sf.transition;sf.transition= +{};try{a(!1),b()}finally{z=c,sf.transition=d}}function $h(){return sa().memoizedState}function qk(a,b,c){var d=hb(a);c={lane:d,action:c,hasEagerState:!1,eagerState:null,next:null};if(ai(a))bi(b,c);else if(c=Eh(a,b,c,d),null!==c){var e=Z();xa(c,a,d,e);ci(c,b,d)}}function ok(a,b,c){var d=hb(a),e={lane:d,action:c,hasEagerState:!1,eagerState:null,next:null};if(ai(a))bi(b,e);else{var f=a.alternate;if(0===a.lanes&&(null===f||0===f.lanes)&&(f=b.lastRenderedReducer,null!==f))try{var g=b.lastRenderedState, +h=f(g,c);e.hasEagerState=!0;e.eagerState=h;if(ua(h,g)){var k=b.interleaved;null===k?(e.next=e,ef(b)):(e.next=k.next,k.next=e);b.interleaved=e;return}}catch(n){}finally{}c=Eh(a,b,e,d);null!==c&&(e=Z(),xa(c,a,d,e),ci(c,b,d))}}function ai(a){var b=a.alternate;return a===C||null!==b&&b===C}function bi(a,b){zc=Ad=!0;var c=a.pending;null===c?b.next=b:(b.next=c.next,c.next=b);a.pending=b}function ci(a,b,c){if(0!==(c&4194240)){var d=b.lanes;d&=a.pendingLanes;c|=d;b.lanes=c;xe(a,c)}}function ya(a,b){if(a&& +a.defaultProps){b=E({},b);a=a.defaultProps;for(var c in a)void 0===b[c]&&(b[c]=a[c]);return b}return b}function tf(a,b,c,d){b=a.memoizedState;c=c(d,b);c=null===c||void 0===c?b:E({},b,c);a.memoizedState=c;0===a.lanes&&(a.updateQueue.baseState=c)}function di(a,b,c,d,e,f,g){a=a.stateNode;return"function"===typeof a.shouldComponentUpdate?a.shouldComponentUpdate(d,f,g):b.prototype&&b.prototype.isPureReactComponent?!qc(c,d)||!qc(e,f):!0}function ei(a,b,c){var d=!1,e=cb;var f=b.contextType;"object"===typeof f&& +null!==f?f=qa(f):(e=ea(b)?pb:J.current,d=b.contextTypes,f=(d=null!==d&&void 0!==d)?Nb(a,e):cb);b=new b(c,f);a.memoizedState=null!==b.state&&void 0!==b.state?b.state:null;b.updater=Dd;a.stateNode=b;b._reactInternals=a;d&&(a=a.stateNode,a.__reactInternalMemoizedUnmaskedChildContext=e,a.__reactInternalMemoizedMaskedChildContext=f);return b}function fi(a,b,c,d){a=b.state;"function"===typeof b.componentWillReceiveProps&&b.componentWillReceiveProps(c,d);"function"===typeof b.UNSAFE_componentWillReceiveProps&& +b.UNSAFE_componentWillReceiveProps(c,d);b.state!==a&&Dd.enqueueReplaceState(b,b.state,null)}function uf(a,b,c,d){var e=a.stateNode;e.props=c;e.state=a.memoizedState;e.refs={};ff(a);var f=b.contextType;"object"===typeof f&&null!==f?e.context=qa(f):(f=ea(b)?pb:J.current,e.context=Nb(a,f));e.state=a.memoizedState;f=b.getDerivedStateFromProps;"function"===typeof f&&(tf(a,b,f,c),e.state=a.memoizedState);"function"===typeof b.getDerivedStateFromProps||"function"===typeof e.getSnapshotBeforeUpdate||"function"!== +typeof e.UNSAFE_componentWillMount&&"function"!==typeof e.componentWillMount||(b=e.state,"function"===typeof e.componentWillMount&&e.componentWillMount(),"function"===typeof e.UNSAFE_componentWillMount&&e.UNSAFE_componentWillMount(),b!==e.state&&Dd.enqueueReplaceState(e,e.state,null),wd(a,c,e,d),e.state=a.memoizedState);"function"===typeof e.componentDidMount&&(a.flags|=4194308)}function Ub(a,b){try{var c="",d=b;do c+=fj(d),d=d.return;while(d);var e=c}catch(f){e="\nError generating stack: "+f.message+ +"\n"+f.stack}return{value:a,source:b,stack:e,digest:null}}function vf(a,b,c){return{value:a,source:null,stack:null!=c?c:null,digest:null!=b?b:null}}function wf(a,b){try{console.error(b.value)}catch(c){setTimeout(function(){throw c;})}}function gi(a,b,c){c=Pa(-1,c);c.tag=3;c.payload={element:null};var d=b.value;c.callback=function(){Ed||(Ed=!0,xf=d);wf(a,b)};return c}function hi(a,b,c){c=Pa(-1,c);c.tag=3;var d=a.type.getDerivedStateFromError;if("function"===typeof d){var e=b.value;c.payload=function(){return d(e)}; +c.callback=function(){wf(a,b)}}var f=a.stateNode;null!==f&&"function"===typeof f.componentDidCatch&&(c.callback=function(){wf(a,b);"function"!==typeof d&&(null===ib?ib=new Set([this]):ib.add(this));var c=b.stack;this.componentDidCatch(b.value,{componentStack:null!==c?c:""})});return c}function ii(a,b,c){var d=a.pingCache;if(null===d){d=a.pingCache=new rk;var e=new Set;d.set(b,e)}else e=d.get(b),void 0===e&&(e=new Set,d.set(b,e));e.has(c)||(e.add(c),a=sk.bind(null,a,b,c),b.then(a,a))}function ji(a){do{var b; +if(b=13===a.tag)b=a.memoizedState,b=null!==b?null!==b.dehydrated?!0:!1:!0;if(b)return a;a=a.return}while(null!==a);return null}function ki(a,b,c,d,e){if(0===(a.mode&1))return a===b?a.flags|=65536:(a.flags|=128,c.flags|=131072,c.flags&=-52805,1===c.tag&&(null===c.alternate?c.tag=17:(b=Pa(-1,1),b.tag=2,fb(c,b,1))),c.lanes|=1),a;a.flags|=65536;a.lanes=e;return a}function aa(a,b,c,d){b.child=null===a?li(b,null,c,d):Vb(b,a.child,c,d)}function mi(a,b,c,d,e){c=c.render;var f=b.ref;Sb(b,e);d=mf(a,b,c,d,f, +e);c=nf();if(null!==a&&!ha)return b.updateQueue=a.updateQueue,b.flags&=-2053,a.lanes&=~e,Qa(a,b,e);D&&c&&Ue(b);b.flags|=1;aa(a,b,d,e);return b.child}function ni(a,b,c,d,e){if(null===a){var f=c.type;if("function"===typeof f&&!yf(f)&&void 0===f.defaultProps&&null===c.compare&&void 0===c.defaultProps)return b.tag=15,b.type=f,oi(a,b,f,d,e);a=rd(c.type,null,d,b,b.mode,e);a.ref=b.ref;a.return=b;return b.child=a}f=a.child;if(0===(a.lanes&e)){var g=f.memoizedProps;c=c.compare;c=null!==c?c:qc;if(c(g,d)&&a.ref=== +b.ref)return Qa(a,b,e)}b.flags|=1;a=eb(f,d);a.ref=b.ref;a.return=b;return b.child=a}function oi(a,b,c,d,e){if(null!==a){var f=a.memoizedProps;if(qc(f,d)&&a.ref===b.ref)if(ha=!1,b.pendingProps=d=f,0!==(a.lanes&e))0!==(a.flags&131072)&&(ha=!0);else return b.lanes=a.lanes,Qa(a,b,e)}return zf(a,b,c,d,e)}function pi(a,b,c){var d=b.pendingProps,e=d.children,f=null!==a?a.memoizedState:null;if("hidden"===d.mode)if(0===(b.mode&1))b.memoizedState={baseLanes:0,cachePool:null,transitions:null},y(Ga,ba),ba|=c; +else{if(0===(c&1073741824))return a=null!==f?f.baseLanes|c:c,b.lanes=b.childLanes=1073741824,b.memoizedState={baseLanes:a,cachePool:null,transitions:null},b.updateQueue=null,y(Ga,ba),ba|=a,null;b.memoizedState={baseLanes:0,cachePool:null,transitions:null};d=null!==f?f.baseLanes:c;y(Ga,ba);ba|=d}else null!==f?(d=f.baseLanes|c,b.memoizedState=null):d=c,y(Ga,ba),ba|=d;aa(a,b,e,c);return b.child}function qi(a,b){var c=b.ref;if(null===a&&null!==c||null!==a&&a.ref!==c)b.flags|=512,b.flags|=2097152}function zf(a, +b,c,d,e){var f=ea(c)?pb:J.current;f=Nb(b,f);Sb(b,e);c=mf(a,b,c,d,f,e);d=nf();if(null!==a&&!ha)return b.updateQueue=a.updateQueue,b.flags&=-2053,a.lanes&=~e,Qa(a,b,e);D&&d&&Ue(b);b.flags|=1;aa(a,b,c,e);return b.child}function ri(a,b,c,d,e){if(ea(c)){var f=!0;ld(b)}else f=!1;Sb(b,e);if(null===b.stateNode)Fd(a,b),ei(b,c,d),uf(b,c,d,e),d=!0;else if(null===a){var g=b.stateNode,h=b.memoizedProps;g.props=h;var k=g.context,n=c.contextType;"object"===typeof n&&null!==n?n=qa(n):(n=ea(c)?pb:J.current,n=Nb(b, +n));var l=c.getDerivedStateFromProps,m="function"===typeof l||"function"===typeof g.getSnapshotBeforeUpdate;m||"function"!==typeof g.UNSAFE_componentWillReceiveProps&&"function"!==typeof g.componentWillReceiveProps||(h!==d||k!==n)&&fi(b,g,d,n);gb=!1;var r=b.memoizedState;g.state=r;wd(b,d,g,e);k=b.memoizedState;h!==d||r!==k||S.current||gb?("function"===typeof l&&(tf(b,c,l,d),k=b.memoizedState),(h=gb||di(b,c,h,d,r,k,n))?(m||"function"!==typeof g.UNSAFE_componentWillMount&&"function"!==typeof g.componentWillMount|| +("function"===typeof g.componentWillMount&&g.componentWillMount(),"function"===typeof g.UNSAFE_componentWillMount&&g.UNSAFE_componentWillMount()),"function"===typeof g.componentDidMount&&(b.flags|=4194308)):("function"===typeof g.componentDidMount&&(b.flags|=4194308),b.memoizedProps=d,b.memoizedState=k),g.props=d,g.state=k,g.context=n,d=h):("function"===typeof g.componentDidMount&&(b.flags|=4194308),d=!1)}else{g=b.stateNode;Fh(a,b);h=b.memoizedProps;n=b.type===b.elementType?h:ya(b.type,h);g.props= +n;m=b.pendingProps;r=g.context;k=c.contextType;"object"===typeof k&&null!==k?k=qa(k):(k=ea(c)?pb:J.current,k=Nb(b,k));var p=c.getDerivedStateFromProps;(l="function"===typeof p||"function"===typeof g.getSnapshotBeforeUpdate)||"function"!==typeof g.UNSAFE_componentWillReceiveProps&&"function"!==typeof g.componentWillReceiveProps||(h!==m||r!==k)&&fi(b,g,d,k);gb=!1;r=b.memoizedState;g.state=r;wd(b,d,g,e);var x=b.memoizedState;h!==m||r!==x||S.current||gb?("function"===typeof p&&(tf(b,c,p,d),x=b.memoizedState), +(n=gb||di(b,c,n,d,r,x,k)||!1)?(l||"function"!==typeof g.UNSAFE_componentWillUpdate&&"function"!==typeof g.componentWillUpdate||("function"===typeof g.componentWillUpdate&&g.componentWillUpdate(d,x,k),"function"===typeof g.UNSAFE_componentWillUpdate&&g.UNSAFE_componentWillUpdate(d,x,k)),"function"===typeof g.componentDidUpdate&&(b.flags|=4),"function"===typeof g.getSnapshotBeforeUpdate&&(b.flags|=1024)):("function"!==typeof g.componentDidUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|= +4),"function"!==typeof g.getSnapshotBeforeUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=1024),b.memoizedProps=d,b.memoizedState=x),g.props=d,g.state=x,g.context=k,d=n):("function"!==typeof g.componentDidUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=4),"function"!==typeof g.getSnapshotBeforeUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=1024),d=!1)}return Af(a,b,c,d,f,e)}function Af(a,b,c,d,e,f){qi(a,b);var g=0!==(b.flags&128);if(!d&&!g)return e&&vh(b,c,!1), +Qa(a,b,f);d=b.stateNode;tk.current=b;var h=g&&"function"!==typeof c.getDerivedStateFromError?null:d.render();b.flags|=1;null!==a&&g?(b.child=Vb(b,a.child,null,f),b.child=Vb(b,null,h,f)):aa(a,b,h,f);b.memoizedState=d.state;e&&vh(b,c,!0);return b.child}function si(a){var b=a.stateNode;b.pendingContext?th(a,b.pendingContext,b.pendingContext!==b.context):b.context&&th(a,b.context,!1);gf(a,b.containerInfo)}function ti(a,b,c,d,e){Qb();Ye(e);b.flags|=256;aa(a,b,c,d);return b.child}function Bf(a){return{baseLanes:a, +cachePool:null,transitions:null}}function ui(a,b,c){var d=b.pendingProps,e=F.current,f=!1,g=0!==(b.flags&128),h;(h=g)||(h=null!==a&&null===a.memoizedState?!1:0!==(e&2));if(h)f=!0,b.flags&=-129;else if(null===a||null!==a.memoizedState)e|=1;y(F,e&1);if(null===a){Xe(b);a=b.memoizedState;if(null!==a&&(a=a.dehydrated,null!==a))return 0===(b.mode&1)?b.lanes=1:"$!"===a.data?b.lanes=8:b.lanes=1073741824,null;g=d.children;a=d.fallback;return f?(d=b.mode,f=b.child,g={mode:"hidden",children:g},0===(d&1)&&null!== +f?(f.childLanes=0,f.pendingProps=g):f=Gd(g,d,0,null),a=sb(a,d,c,null),f.return=b,a.return=b,f.sibling=a,b.child=f,b.child.memoizedState=Bf(c),b.memoizedState=Cf,a):Df(b,g)}e=a.memoizedState;if(null!==e&&(h=e.dehydrated,null!==h))return uk(a,b,g,d,h,e,c);if(f){f=d.fallback;g=b.mode;e=a.child;h=e.sibling;var k={mode:"hidden",children:d.children};0===(g&1)&&b.child!==e?(d=b.child,d.childLanes=0,d.pendingProps=k,b.deletions=null):(d=eb(e,k),d.subtreeFlags=e.subtreeFlags&14680064);null!==h?f=eb(h,f):(f= +sb(f,g,c,null),f.flags|=2);f.return=b;d.return=b;d.sibling=f;b.child=d;d=f;f=b.child;g=a.child.memoizedState;g=null===g?Bf(c):{baseLanes:g.baseLanes|c,cachePool:null,transitions:g.transitions};f.memoizedState=g;f.childLanes=a.childLanes&~c;b.memoizedState=Cf;return d}f=a.child;a=f.sibling;d=eb(f,{mode:"visible",children:d.children});0===(b.mode&1)&&(d.lanes=c);d.return=b;d.sibling=null;null!==a&&(c=b.deletions,null===c?(b.deletions=[a],b.flags|=16):c.push(a));b.child=d;b.memoizedState=null;return d} +function Df(a,b,c){b=Gd({mode:"visible",children:b},a.mode,0,null);b.return=a;return a.child=b}function Hd(a,b,c,d){null!==d&&Ye(d);Vb(b,a.child,null,c);a=Df(b,b.pendingProps.children);a.flags|=2;b.memoizedState=null;return a}function uk(a,b,c,d,e,f,g){if(c){if(b.flags&256)return b.flags&=-257,d=vf(Error(m(422))),Hd(a,b,g,d);if(null!==b.memoizedState)return b.child=a.child,b.flags|=128,null;f=d.fallback;e=b.mode;d=Gd({mode:"visible",children:d.children},e,0,null);f=sb(f,e,g,null);f.flags|=2;d.return= +b;f.return=b;d.sibling=f;b.child=d;0!==(b.mode&1)&&Vb(b,a.child,null,g);b.child.memoizedState=Bf(g);b.memoizedState=Cf;return f}if(0===(b.mode&1))return Hd(a,b,g,null);if("$!"===e.data){d=e.nextSibling&&e.nextSibling.dataset;if(d)var h=d.dgst;d=h;f=Error(m(419));d=vf(f,d,void 0);return Hd(a,b,g,d)}h=0!==(g&a.childLanes);if(ha||h){d=O;if(null!==d){switch(g&-g){case 4:e=2;break;case 16:e=8;break;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:e= +32;break;case 536870912:e=268435456;break;default:e=0}e=0!==(e&(d.suspendedLanes|g))?0:e;0!==e&&e!==f.retryLane&&(f.retryLane=e,Oa(a,e),xa(d,a,e,-1))}Ef();d=vf(Error(m(421)));return Hd(a,b,g,d)}if("$?"===e.data)return b.flags|=128,b.child=a.child,b=vk.bind(null,a),e._reactRetry=b,null;a=f.treeContext;fa=Ka(e.nextSibling);la=b;D=!0;wa=null;null!==a&&(na[oa++]=Ma,na[oa++]=Na,na[oa++]=rb,Ma=a.id,Na=a.overflow,rb=b);b=Df(b,d.children);b.flags|=4096;return b}function vi(a,b,c){a.lanes|=b;var d=a.alternate; +null!==d&&(d.lanes|=b);df(a.return,b,c)}function Ff(a,b,c,d,e){var f=a.memoizedState;null===f?a.memoizedState={isBackwards:b,rendering:null,renderingStartTime:0,last:d,tail:c,tailMode:e}:(f.isBackwards=b,f.rendering=null,f.renderingStartTime=0,f.last=d,f.tail=c,f.tailMode=e)}function wi(a,b,c){var d=b.pendingProps,e=d.revealOrder,f=d.tail;aa(a,b,d.children,c);d=F.current;if(0!==(d&2))d=d&1|2,b.flags|=128;else{if(null!==a&&0!==(a.flags&128))a:for(a=b.child;null!==a;){if(13===a.tag)null!==a.memoizedState&& +vi(a,c,b);else if(19===a.tag)vi(a,c,b);else if(null!==a.child){a.child.return=a;a=a.child;continue}if(a===b)break a;for(;null===a.sibling;){if(null===a.return||a.return===b)break a;a=a.return}a.sibling.return=a.return;a=a.sibling}d&=1}y(F,d);if(0===(b.mode&1))b.memoizedState=null;else switch(e){case "forwards":c=b.child;for(e=null;null!==c;)a=c.alternate,null!==a&&null===xd(a)&&(e=c),c=c.sibling;c=e;null===c?(e=b.child,b.child=null):(e=c.sibling,c.sibling=null);Ff(b,!1,e,c,f);break;case "backwards":c= +null;e=b.child;for(b.child=null;null!==e;){a=e.alternate;if(null!==a&&null===xd(a)){b.child=e;break}a=e.sibling;e.sibling=c;c=e;e=a}Ff(b,!0,c,null,f);break;case "together":Ff(b,!1,null,null,void 0);break;default:b.memoizedState=null}return b.child}function Fd(a,b){0===(b.mode&1)&&null!==a&&(a.alternate=null,b.alternate=null,b.flags|=2)}function Qa(a,b,c){null!==a&&(b.dependencies=a.dependencies);ra|=b.lanes;if(0===(c&b.childLanes))return null;if(null!==a&&b.child!==a.child)throw Error(m(153));if(null!== +b.child){a=b.child;c=eb(a,a.pendingProps);b.child=c;for(c.return=b;null!==a.sibling;)a=a.sibling,c=c.sibling=eb(a,a.pendingProps),c.return=b;c.sibling=null}return b.child}function wk(a,b,c){switch(b.tag){case 3:si(b);Qb();break;case 5:Ih(b);break;case 1:ea(b.type)&&ld(b);break;case 4:gf(b,b.stateNode.containerInfo);break;case 10:var d=b.type._context,e=b.memoizedProps.value;y(ud,d._currentValue);d._currentValue=e;break;case 13:d=b.memoizedState;if(null!==d){if(null!==d.dehydrated)return y(F,F.current& +1),b.flags|=128,null;if(0!==(c&b.child.childLanes))return ui(a,b,c);y(F,F.current&1);a=Qa(a,b,c);return null!==a?a.sibling:null}y(F,F.current&1);break;case 19:d=0!==(c&b.childLanes);if(0!==(a.flags&128)){if(d)return wi(a,b,c);b.flags|=128}e=b.memoizedState;null!==e&&(e.rendering=null,e.tail=null,e.lastEffect=null);y(F,F.current);if(d)break;else return null;case 22:case 23:return b.lanes=0,pi(a,b,c)}return Qa(a,b,c)}function Dc(a,b){if(!D)switch(a.tailMode){case "hidden":b=a.tail;for(var c=null;null!== +b;)null!==b.alternate&&(c=b),b=b.sibling;null===c?a.tail=null:c.sibling=null;break;case "collapsed":c=a.tail;for(var d=null;null!==c;)null!==c.alternate&&(d=c),c=c.sibling;null===d?b||null===a.tail?a.tail=null:a.tail.sibling=null:d.sibling=null}}function W(a){var b=null!==a.alternate&&a.alternate.child===a.child,c=0,d=0;if(b)for(var e=a.child;null!==e;)c|=e.lanes|e.childLanes,d|=e.subtreeFlags&14680064,d|=e.flags&14680064,e.return=a,e=e.sibling;else for(e=a.child;null!==e;)c|=e.lanes|e.childLanes, +d|=e.subtreeFlags,d|=e.flags,e.return=a,e=e.sibling;a.subtreeFlags|=d;a.childLanes=c;return b}function xk(a,b,c){var d=b.pendingProps;Ve(b);switch(b.tag){case 2:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return W(b),null;case 1:return ea(b.type)&&(v(S),v(J)),W(b),null;case 3:d=b.stateNode;Tb();v(S);v(J);jf();d.pendingContext&&(d.context=d.pendingContext,d.pendingContext=null);if(null===a||null===a.child)pd(b)?b.flags|=4:null===a||a.memoizedState.isDehydrated&&0===(b.flags& +256)||(b.flags|=1024,null!==wa&&(Gf(wa),wa=null));xi(a,b);W(b);return null;case 5:hf(b);var e=ub(xc.current);c=b.type;if(null!==a&&null!=b.stateNode)yk(a,b,c,d,e),a.ref!==b.ref&&(b.flags|=512,b.flags|=2097152);else{if(!d){if(null===b.stateNode)throw Error(m(166));W(b);return null}a=ub(Ea.current);if(pd(b)){d=b.stateNode;c=b.type;var f=b.memoizedProps;d[Da]=b;d[uc]=f;a=0!==(b.mode&1);switch(c){case "dialog":B("cancel",d);B("close",d);break;case "iframe":case "object":case "embed":B("load",d);break; +case "video":case "audio":for(e=0;e\x3c/script>",a=a.removeChild(a.firstChild)):"string"===typeof d.is?a=g.createElement(c,{is:d.is}):(a=g.createElement(c),"select"===c&&(g=a,d.multiple?g.multiple=!0:d.size&&(g.size=d.size))):a=g.createElementNS(a,c);a[Da]=b;a[uc]=d;zk(a,b,!1,!1);b.stateNode=a;a:{g=qe(c,d);switch(c){case "dialog":B("cancel",a);B("close",a);e=d;break;case "iframe":case "object":case "embed":B("load",a);e=d;break; +case "video":case "audio":for(e=0;eHf&&(b.flags|=128,d=!0,Dc(f,!1),b.lanes=4194304)}else{if(!d)if(a=xd(g),null!==a){if(b.flags|=128,d=!0,c=a.updateQueue,null!==c&&(b.updateQueue=c,b.flags|=4),Dc(f,!0),null===f.tail&&"hidden"===f.tailMode&&!g.alternate&&!D)return W(b),null}else 2*P()-f.renderingStartTime>Hf&&1073741824!==c&&(b.flags|= +128,d=!0,Dc(f,!1),b.lanes=4194304);f.isBackwards?(g.sibling=b.child,b.child=g):(c=f.last,null!==c?c.sibling=g:b.child=g,f.last=g)}if(null!==f.tail)return b=f.tail,f.rendering=b,f.tail=b.sibling,f.renderingStartTime=P(),b.sibling=null,c=F.current,y(F,d?c&1|2:c&1),b;W(b);return null;case 22:case 23:return ba=Ga.current,v(Ga),d=null!==b.memoizedState,null!==a&&null!==a.memoizedState!==d&&(b.flags|=8192),d&&0!==(b.mode&1)?0!==(ba&1073741824)&&(W(b),b.subtreeFlags&6&&(b.flags|=8192)):W(b),null;case 24:return null; +case 25:return null}throw Error(m(156,b.tag));}function Bk(a,b,c){Ve(b);switch(b.tag){case 1:return ea(b.type)&&(v(S),v(J)),a=b.flags,a&65536?(b.flags=a&-65537|128,b):null;case 3:return Tb(),v(S),v(J),jf(),a=b.flags,0!==(a&65536)&&0===(a&128)?(b.flags=a&-65537|128,b):null;case 5:return hf(b),null;case 13:v(F);a=b.memoizedState;if(null!==a&&null!==a.dehydrated){if(null===b.alternate)throw Error(m(340));Qb()}a=b.flags;return a&65536?(b.flags=a&-65537|128,b):null;case 19:return v(F),null;case 4:return Tb(), +null;case 10:return cf(b.type._context),null;case 22:case 23:return ba=Ga.current,v(Ga),null;case 24:return null;default:return null}}function Wb(a,b){var c=a.ref;if(null!==c)if("function"===typeof c)try{c(null)}catch(d){G(a,b,d)}else c.current=null}function If(a,b,c){try{c()}catch(d){G(a,b,d)}}function Ck(a,b){Jf=Zc;a=ch();if(Ie(a)){if("selectionStart"in a)var c={start:a.selectionStart,end:a.selectionEnd};else a:{c=(c=a.ownerDocument)&&c.defaultView||window;var d=c.getSelection&&c.getSelection(); +if(d&&0!==d.rangeCount){c=d.anchorNode;var e=d.anchorOffset,f=d.focusNode;d=d.focusOffset;try{c.nodeType,f.nodeType}catch(M){c=null;break a}var g=0,h=-1,k=-1,n=0,q=0,u=a,r=null;b:for(;;){for(var p;;){u!==c||0!==e&&3!==u.nodeType||(h=g+e);u!==f||0!==d&&3!==u.nodeType||(k=g+d);3===u.nodeType&&(g+=u.nodeValue.length);if(null===(p=u.firstChild))break;r=u;u=p}for(;;){if(u===a)break b;r===c&&++n===e&&(h=g);r===f&&++q===d&&(k=g);if(null!==(p=u.nextSibling))break;u=r;r=u.parentNode}u=p}c=-1===h||-1===k?null: +{start:h,end:k}}else c=null}c=c||{start:0,end:0}}else c=null;Kf={focusedElem:a,selectionRange:c};Zc=!1;for(l=b;null!==l;)if(b=l,a=b.child,0!==(b.subtreeFlags&1028)&&null!==a)a.return=b,l=a;else for(;null!==l;){b=l;try{var x=b.alternate;if(0!==(b.flags&1024))switch(b.tag){case 0:case 11:case 15:break;case 1:if(null!==x){var v=x.memoizedProps,z=x.memoizedState,w=b.stateNode,A=w.getSnapshotBeforeUpdate(b.elementType===b.type?v:ya(b.type,v),z);w.__reactInternalSnapshotBeforeUpdate=A}break;case 3:var t= +b.stateNode.containerInfo;1===t.nodeType?t.textContent="":9===t.nodeType&&t.documentElement&&t.removeChild(t.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(m(163));}}catch(M){G(b,b.return,M)}a=b.sibling;if(null!==a){a.return=b.return;l=a;break}l=b.return}x=zi;zi=!1;return x}function Gc(a,b,c){var d=b.updateQueue;d=null!==d?d.lastEffect:null;if(null!==d){var e=d=d.next;do{if((e.tag&a)===a){var f=e.destroy;e.destroy=void 0;void 0!==f&&If(b,c,f)}e=e.next}while(e!==d)}} +function Id(a,b){b=b.updateQueue;b=null!==b?b.lastEffect:null;if(null!==b){var c=b=b.next;do{if((c.tag&a)===a){var d=c.create;c.destroy=d()}c=c.next}while(c!==b)}}function Lf(a){var b=a.ref;if(null!==b){var c=a.stateNode;switch(a.tag){case 5:a=c;break;default:a=c}"function"===typeof b?b(a):b.current=a}}function Ai(a){var b=a.alternate;null!==b&&(a.alternate=null,Ai(b));a.child=null;a.deletions=null;a.sibling=null;5===a.tag&&(b=a.stateNode,null!==b&&(delete b[Da],delete b[uc],delete b[Me],delete b[Dk], +delete b[Ek]));a.stateNode=null;a.return=null;a.dependencies=null;a.memoizedProps=null;a.memoizedState=null;a.pendingProps=null;a.stateNode=null;a.updateQueue=null}function Bi(a){return 5===a.tag||3===a.tag||4===a.tag}function Ci(a){a:for(;;){for(;null===a.sibling;){if(null===a.return||Bi(a.return))return null;a=a.return}a.sibling.return=a.return;for(a=a.sibling;5!==a.tag&&6!==a.tag&&18!==a.tag;){if(a.flags&2)continue a;if(null===a.child||4===a.tag)continue a;else a.child.return=a,a=a.child}if(!(a.flags& +2))return a.stateNode}}function Mf(a,b,c){var d=a.tag;if(5===d||6===d)a=a.stateNode,b?8===c.nodeType?c.parentNode.insertBefore(a,b):c.insertBefore(a,b):(8===c.nodeType?(b=c.parentNode,b.insertBefore(a,c)):(b=c,b.appendChild(a)),c=c._reactRootContainer,null!==c&&void 0!==c||null!==b.onclick||(b.onclick=kd));else if(4!==d&&(a=a.child,null!==a))for(Mf(a,b,c),a=a.sibling;null!==a;)Mf(a,b,c),a=a.sibling}function Nf(a,b,c){var d=a.tag;if(5===d||6===d)a=a.stateNode,b?c.insertBefore(a,b):c.appendChild(a); +else if(4!==d&&(a=a.child,null!==a))for(Nf(a,b,c),a=a.sibling;null!==a;)Nf(a,b,c),a=a.sibling}function jb(a,b,c){for(c=c.child;null!==c;)Di(a,b,c),c=c.sibling}function Di(a,b,c){if(Ca&&"function"===typeof Ca.onCommitFiberUnmount)try{Ca.onCommitFiberUnmount(Uc,c)}catch(h){}switch(c.tag){case 5:X||Wb(c,b);case 6:var d=T,e=za;T=null;jb(a,b,c);T=d;za=e;null!==T&&(za?(a=T,c=c.stateNode,8===a.nodeType?a.parentNode.removeChild(c):a.removeChild(c)):T.removeChild(c.stateNode));break;case 18:null!==T&&(za? +(a=T,c=c.stateNode,8===a.nodeType?Re(a.parentNode,c):1===a.nodeType&&Re(a,c),nc(a)):Re(T,c.stateNode));break;case 4:d=T;e=za;T=c.stateNode.containerInfo;za=!0;jb(a,b,c);T=d;za=e;break;case 0:case 11:case 14:case 15:if(!X&&(d=c.updateQueue,null!==d&&(d=d.lastEffect,null!==d))){e=d=d.next;do{var f=e,g=f.destroy;f=f.tag;void 0!==g&&(0!==(f&2)?If(c,b,g):0!==(f&4)&&If(c,b,g));e=e.next}while(e!==d)}jb(a,b,c);break;case 1:if(!X&&(Wb(c,b),d=c.stateNode,"function"===typeof d.componentWillUnmount))try{d.props= +c.memoizedProps,d.state=c.memoizedState,d.componentWillUnmount()}catch(h){G(c,b,h)}jb(a,b,c);break;case 21:jb(a,b,c);break;case 22:c.mode&1?(X=(d=X)||null!==c.memoizedState,jb(a,b,c),X=d):jb(a,b,c);break;default:jb(a,b,c)}}function Ei(a){var b=a.updateQueue;if(null!==b){a.updateQueue=null;var c=a.stateNode;null===c&&(c=a.stateNode=new Fk);b.forEach(function(b){var d=Gk.bind(null,a,b);c.has(b)||(c.add(b),b.then(d,d))})}}function Aa(a,b,c){c=b.deletions;if(null!==c)for(var d=0;de&&(e=g);d&=~f}d=e;d=P()-d;d=(120>d?120:480>d?480:1080>d?1080:1920>d?1920:3E3>d?3E3:4320>d?4320:1960*Mk(d/1960))-d;if(10a?16:a;if(null===lb)var d=!1;else{a=lb;lb=null;Qd=0;if(0!==(p&6))throw Error(m(331));var e=p;p|=4;for(l=a.current;null!==l;){var f=l,g=f.child;if(0!==(l.flags&16)){var h=f.deletions;if(null!==h){for(var k=0;kP()-Of?wb(a,0):Sf|=c);ia(a,b)}function Ti(a,b){0===b&&(0===(a.mode&1)?b=1:(b=Rd,Rd<<=1,0===(Rd&130023424)&&(Rd=4194304)));var c=Z();a=Oa(a,b);null!==a&&(ic(a,b,c),ia(a,c))}function vk(a){var b=a.memoizedState,c=0;null!==b&&(c=b.retryLane);Ti(a,c)}function Gk(a,b){var c=0;switch(a.tag){case 13:var d=a.stateNode;var e=a.memoizedState;null!==e&&(c=e.retryLane); +break;case 19:d=a.stateNode;break;default:throw Error(m(314));}null!==d&&d.delete(b);Ti(a,c)}function Mi(a,b){return xh(a,b)}function Tk(a,b,c,d){this.tag=a;this.key=c;this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null;this.index=0;this.ref=null;this.pendingProps=b;this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null;this.mode=d;this.subtreeFlags=this.flags=0;this.deletions=null;this.childLanes=this.lanes=0;this.alternate=null}function yf(a){a= +a.prototype;return!(!a||!a.isReactComponent)}function Uk(a){if("function"===typeof a)return yf(a)?1:0;if(void 0!==a&&null!==a){a=a.$$typeof;if(a===ie)return 11;if(a===je)return 14}return 2}function eb(a,b){var c=a.alternate;null===c?(c=pa(a.tag,b,a.key,a.mode),c.elementType=a.elementType,c.type=a.type,c.stateNode=a.stateNode,c.alternate=a,a.alternate=c):(c.pendingProps=b,c.type=a.type,c.flags=0,c.subtreeFlags=0,c.deletions=null);c.flags=a.flags&14680064;c.childLanes=a.childLanes;c.lanes=a.lanes;c.child= +a.child;c.memoizedProps=a.memoizedProps;c.memoizedState=a.memoizedState;c.updateQueue=a.updateQueue;b=a.dependencies;c.dependencies=null===b?null:{lanes:b.lanes,firstContext:b.firstContext};c.sibling=a.sibling;c.index=a.index;c.ref=a.ref;return c}function rd(a,b,c,d,e,f){var g=2;d=a;if("function"===typeof a)yf(a)&&(g=1);else if("string"===typeof a)g=5;else a:switch(a){case Bb:return sb(c.children,e,f,b);case fe:g=8;e|=8;break;case ee:return a=pa(12,c,b,e|2),a.elementType=ee,a.lanes=f,a;case ge:return a= +pa(13,c,b,e),a.elementType=ge,a.lanes=f,a;case he:return a=pa(19,c,b,e),a.elementType=he,a.lanes=f,a;case Ui:return Gd(c,e,f,b);default:if("object"===typeof a&&null!==a)switch(a.$$typeof){case hg:g=10;break a;case gg:g=9;break a;case ie:g=11;break a;case je:g=14;break a;case Ta:g=16;d=null;break a}throw Error(m(130,null==a?a:typeof a,""));}b=pa(g,c,b,e);b.elementType=a;b.type=d;b.lanes=f;return b}function sb(a,b,c,d){a=pa(7,a,d,b);a.lanes=c;return a}function Gd(a,b,c,d){a=pa(22,a,d,b);a.elementType= +Ui;a.lanes=c;a.stateNode={isHidden:!1};return a}function Ze(a,b,c){a=pa(6,a,null,b);a.lanes=c;return a}function $e(a,b,c){b=pa(4,null!==a.children?a.children:[],a.key,b);b.lanes=c;b.stateNode={containerInfo:a.containerInfo,pendingChildren:null,implementation:a.implementation};return b}function Vk(a,b,c,d,e){this.tag=b;this.containerInfo=a;this.finishedWork=this.pingCache=this.current=this.pendingChildren=null;this.timeoutHandle=-1;this.callbackNode=this.pendingContext=this.context=null;this.callbackPriority= +0;this.eventTimes=we(0);this.expirationTimes=we(-1);this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0;this.entanglements=we(0);this.identifierPrefix=d;this.onRecoverableError=e;this.mutableSourceEagerHydrationData=null}function Vf(a,b,c,d,e,f,g,h,k,l){a=new Vk(a,b,c,h,k);1===b?(b=1,!0===f&&(b|=8)):b=0;f=pa(3,null,null,b);a.current=f;f.stateNode=a;f.memoizedState={element:d,isDehydrated:c,cache:null,transitions:null, +pendingSuspenseBoundaries:null};ff(f);return a}function Wk(a,b,c){var d=3