diff --git a/INSTALLATION.md b/INSTALLATION.md index fed288a958..20e9e9c278 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -39,19 +39,83 @@ configuration reference. ## Desktop installer From a source checkout, run `cargo desktop --help` for the commands and -`cargo desktop install --dry-run` to preview installation. This checkout has -the installer helper but does not yet build the desktop app executable, so -full installation cannot complete. Uninstall keeps settings, history, and -recovery copies. +`cargo desktop install --dry-run` to preview installation. Run +`cargo desktop install` to build and install the app and server. + +The app opens setup on its first launch before creating app and server config files. +Existing app settings or a default server config skip setup, including after +reinstall. Settings lets you change the selected files or remove app preferences. +On Linux, open the installed app with `~/.switchyard/bin/switchyard-desktop --tui`. +See [Desktop setup](crates/switchyard-desktop/SETUP.md) for these steps. +Uninstall keeps settings, history, and recovery copies. + +## Server Keys + +In a server config, each LLM client and decision client names its API key +variable with `api_key_env`. A server you start from a terminal reads those +variables from your shell, as it always has. The installed server does not: on +macOS launchd starts it, on Linux systemd, and on Windows Task Scheduler. None +of them runs your shell, so the installed server never sees the variables that +`~/.zshrc` or `~/.bashrc` exports. + +The installed server reads each key from the system credential store instead: +macOS Keychain, Windows Credential Manager, or the Linux Secret Service (GNOME +Keyring on Ubuntu and GNOME, KDE Wallet on Plasma). A key is saved under its +variable name, such as `NVIDIA_API_KEY`. Before each start, the installed +helper reads the keys that the config names and passes them to the server. A +saved key replaces any value the service manager passes. If a variable has no +saved key, the server does not start, and the server log names the variable. + +To save, replace, or remove a key in the desktop app, open **Settings → Server +keys**. Type or paste the key, or choose **Import from shell** to read the +variable once from your login shell. From a terminal on macOS or Linux, run the +installed helper: -The installer requires the repository's Rust toolchain. macOS uses LaunchAgents; -Linux requires a systemd user session. Windows requires MSVC build tools, -WebView2, and a Task Scheduler user session. +```bash +~/.switchyard/bin/switchyard-desktop-install key status --config ~/.switchyard/composite.toml +~/.switchyard/bin/switchyard-desktop-install key set NVIDIA_API_KEY # prompts without echo +~/.switchyard/bin/switchyard-desktop-install key import NVIDIA_API_KEY # reads it from your login shell +~/.switchyard/bin/switchyard-desktop-install key remove NVIDIA_API_KEY +``` -On Windows, run install and uninstall from the source checkout with Cargo. -The installed helper rejects changes to its own installation because Windows -locks running executables. The app's Update command uses a temporary helper -copy. The installed helper still supports `uninstall --dry-run`. +On Windows, the installer puts the helper in +`%LOCALAPPDATA%\Programs\Switchyard`. There `key set` reads the key from +standard input without a prompt, and `key import` reads the variable from your +Windows environment variables. From PowerShell: + +```powershell +$helper = "$env:LOCALAPPDATA\Programs\Switchyard\switchyard-desktop-install.exe" +& $helper key status --config "$env:USERPROFILE\.switchyard\composite.toml" +[Net.NetworkCredential]::new('', (Read-Host 'Key' -AsSecureString)).Password | & $helper key set NVIDIA_API_KEY +& $helper key import NVIDIA_API_KEY +& $helper key remove NVIDIA_API_KEY +``` + +Restart the server after you change a key. If you rename a variable in +`api_key_env`, save a key under the new name. The old key stays saved until you +remove it in Keychain Access, Credential Manager, or Seahorse. + +`cargo desktop install` never saves, reads, or changes a key, even when your +terminal sets the variable. It checks the config without keys, then lists each +variable that has no saved key. On a first install, the server starts after you +save those keys in **Settings → Server keys** and restart it. In the terminal +app, press `7` to open the Settings page. There `k` lists the variables that the +config names and whether each has a saved key, `s` saves a key, and `i` imports +it from your login shell. Press `b` and then `y` to restart the server. +Uninstalling keeps the saved keys, the config, and history. Switchyard never +writes keys to app settings, service definitions, or logs. Model-list keys that +you enter in Routes are stored separately. + +On macOS, Keychain ties each key to the helper that saved it. Keychain treats a +rebuilt helper as a new program, so after `cargo desktop install` macOS may ask +once per key whether to allow `switchyard-desktop-install`. Choose **Always +Allow**. + +On Linux, the login keyring must be unlocked. A desktop login normally unlocks +it. When the server service starts after login, it waits up to a minute for the +keyring to unlock. Over SSH without a desktop session, or with a window manager +that does not unlock the keyring, the server does not start and its log says +the keyring is locked. ## Linux Codex Service @@ -63,13 +127,16 @@ port while the service is stopped and receive your login and prompts. From a checkout, preview or install the service: ```bash -make install-linux-dry-run -make install-linux +cargo desktop install --dry-run +cargo desktop install systemctl --user status switchyard codex -p sy ``` -The installer builds and installs `~/.switchyard/bin/switchyard-server`. +The installer builds and installs `~/.switchyard/bin/switchyard-server` and +`~/.switchyard/bin/switchyard-desktop`. Run the same command to reinstall from +the current checkout; update the checkout first to install newer code. +Run `~/.switchyard/bin/switchyard-desktop --tui` to open the terminal app. It creates `~/.switchyard/composite.toml` if missing and keeps existing edits. It replaces `~/.config/systemd/user/switchyard.service` and restarts the service. It writes `~/.codex/sy.config.toml`, backing up a changed profile first. @@ -79,20 +146,61 @@ The profile format requires [Codex 0.134.0 or newer](https://developers.openai.c Remove any old `[profiles.sy]` table from `~/.codex/config.toml` before using it. Set `SY_HOME` or `SY_PORT` to change the server directory or port (default 4123). -`SY_HOME` must not contain whitespace, control characters, or a trailing -backslash; `SY_PORT` must contain only digits. `XDG_CONFIG_HOME` and `CODEX_HOME` +`SY_HOME` must be an absolute UTF-8 path without ASCII control characters. +`SY_PORT` must be a number from 1 to 65535. `XDG_CONFIG_HOME` and `CODEX_HOME` set the systemd and Codex config directories. Read server logs with `journalctl --user -u switchyard`. Routing records are stored in `~/.switchyard/routing.jsonl`. Use `systemctl --user edit switchyard` for service changes that survive reinstalling. -Remove the service and profile with `make uninstall-linux`. This also removes +Remove the service and profile with `cargo desktop uninstall`. This also removes marked Codex aliases left by older installs from existing `.bashrc` and `.zshrc` files. If upgrading an older install, uninstall first and run `unalias codex` in any open shell. The server directory, routing records, -profile backups, and systemd drop-in files stay in place. Use the same path -overrides when installing and uninstalling. +profile backups, and systemd drop-in files stay in place. Uninstall uses the +recorded installation. Conflicting environment overrides require +`cargo desktop uninstall --settings FILE` to select it explicitly. + +## Windows Desktop App + +From PowerShell in the repository, run: + +```powershell +cargo desktop install --dry-run +cargo desktop install +``` + +Windows needs the Rust MSVC toolchain, Visual Studio C++ build tools, and +[WebView2](https://v2.tauri.app/start/prerequisites/#windows). +The installer uses Task Scheduler for the current logged-in user. It installs +executables in `%LOCALAPPDATA%\Programs\Switchyard`, falling back to +`%USERPROFILE%\AppData\Local\Programs\Switchyard` when `LOCALAPPDATA` is unset. +It saves that location and adds a Start menu shortcut. Two independent tasks +start the server and desktop app after login with ordinary user privileges. +Quitting the app leaves the server running. + +Run `cargo desktop install` again to build and install the current checkout. +This Cargo command waits for installation to finish. The app's source update +uses the saved checkout and runs from a temporary Rust executable so Windows +can release and replace the installed files; read `SY_HOME\logs\update.log` for +its progress and errors. The installed installer cannot install, update, or +uninstall its own installation, because Windows keeps a running program from +being replaced. Use `cargo desktop` from the source checkout instead. +The installer builds and validates before stopping the tasks. If a binary +replacement fails, it tries to restore the previous binaries. If restoration +also fails, the error names the directory containing recovery files. +Rerun installation if a later task or settings operation fails; the complete +installation is not one transaction. + +Settings, server config, accounts, and routing history stay in +`%USERPROFILE%\.switchyard` unless `SY_HOME` changes the directory. Server and app +logs are in `SY_HOME\logs`. Model-list keys entered in Routes are saved in the +current user's Windows Credential Manager. Coding tools keep their own login files. + +Use `cargo desktop uninstall` to stop and remove both tasks, the shortcut, and +the installed executables. Uninstall preserves settings, accounts, and history, +and makes a recovery copy of the Codex profile before removing it. ## Rust Libraries @@ -123,3 +231,6 @@ uv run pytest tests/ -v The `dev` dependency group contains testing and linting tools and is not exposed in the published wheel metadata. + +See the [desktop and terminal quickstart](crates/switchyard-desktop/README.md) for +first-run setup, reviewed settings changes, usage, recovery, and safe uninstall. diff --git a/Makefile b/Makefile deleted file mode 100644 index eadc87cff6..0000000000 --- a/Makefile +++ /dev/null @@ -1,22 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -.DEFAULT_GOAL := help -.PHONY: help install-linux install-linux-dry-run uninstall-linux - -help: - @echo "install-linux Install the systemd user service and Codex profile" - @echo "install-linux-dry-run Preview installation without changes" - @echo "uninstall-linux Remove the service and Codex profile" - -## Install the Switchyard background server as a systemd user service. -install-linux: - @scripts/linux/install.sh - -## Print what install-linux would do, without changing anything. -install-linux-dry-run: - @scripts/linux/install.sh --dry-run - -## Remove the systemd user service, the sy Codex profile, and the codex alias. -uninstall-linux: - @scripts/linux/uninstall.sh diff --git a/README.md b/README.md index da365228f7..aed6d37064 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,9 @@ The standalone server is provided only for demos and local evaluation. It is not [Evaluate routing locally](docs/operations/local_evaluation.md) with the standalone server and your own model pool, then [verify routing is active](docs/operations/local_evaluation.md#verify-routing-is-active). +The [desktop and terminal quickstart](crates/switchyard-desktop/README.md) covers +local installation, coding-tool connections, sessions, usage, recovery, and uninstall. + Agent-specific guides are available for [pi](docs/integrations/pi.md) and [Oh My Pi](docs/integrations/oh_my_pi.md). diff --git a/crates/switchyard-desktop/Cargo.toml b/crates/switchyard-desktop/Cargo.toml index 55d38d0bdd..64043d1241 100644 --- a/crates/switchyard-desktop/Cargo.toml +++ b/crates/switchyard-desktop/Cargo.toml @@ -55,7 +55,7 @@ windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_ [target.'cfg(any(target_os = "macos", windows))'.dependencies] rfd = { version = "0.17", default-features = false } -tauri = { version = "2", features = ["tray-icon", "custom-protocol"] } +tauri = { version = "2", features = ["tray-icon", "custom-protocol", "macos-private-api"] } [target.'cfg(any(target_os = "macos", windows))'.build-dependencies] tauri-build = "2" diff --git a/crates/switchyard-desktop/README.md b/crates/switchyard-desktop/README.md new file mode 100644 index 0000000000..575c0e7b12 --- /dev/null +++ b/crates/switchyard-desktop/README.md @@ -0,0 +1,590 @@ +# Switchyard desktop and terminal apps + +The macOS and Windows desktop apps and terminal app show usage, edit routes, install +harness settings, and open sessions in separate Git worktrees. The server +handles routing and continues when the app quits. The desktop app uses Tauri 2; +the terminal app uses Ratatui. + +## Install and update + +Install the app from the repository root with the repository's pinned Rust +toolchain (currently Rust 1.99). Install Rust through rustup and keep Cargo on +`PATH`. Native dependencies also need a C compiler and linker: Xcode Command +Line Tools on macOS, or GCC/Clang and the system development libraries on Linux. +Cargo downloads dependencies on the first build. The default Codex profile +requires Codex CLI 0.134.0 or newer and a ChatGPT login. Sign in with Codex +before using it. + +```sh +cargo desktop install +``` + +Run the same command to replace an existing installation. The command builds +the current checkout; update the checkout first to install newer code. + +On macOS, the Rust installer builds the desktop app and server, installs +`~/Applications/Switchyard.app`, and loads two per-user LaunchAgents. It signs +the app ad hoc for local use. The app has no Developer ID signature or +notarization. Reinstalling keeps existing server settings, prices, accounts, and +log history. + +The installer builds the binaries and validates the server config before +stopping services. On macOS, it also signs the replacement app before stopping +services. Each file replacement is atomic, but the complete installation is not. +If installation fails after services stop, fix the reported error and run `cargo +desktop install` again. Install and uninstall share a lock for your home +directory. If another installation holds it, wait for that command to finish and +retry. + +By default, the source installer writes `~/.codex/sy.config.toml` for +`codex -p sy` and keeps `~/.codex/config.toml` unchanged. Set `SY_PROFILE` to +choose another profile name. The installer writes `$SY_PROFILE.config.toml` in +the Codex config directory. For example: + +```sh +export SY_PROFILE=composite-gpt-6-sol-gpt-6-luna +cargo desktop install +codex -p "$SY_PROFILE" +``` + +Remove the app and recorded profile with `cargo desktop uninstall`. Uninstall +uses the saved installation location and profile. Conflicting shell overrides +require an explicit `uninstall --settings FILE` selection. Uninstall keeps an +exact recovery copy of the selected profile before removal. Restore harness +routing from **Connect tools** first; custom and account settings remain and +may still point at the removed server. Preview removal of a selected installation +without changing files or services: + +```sh +cargo desktop uninstall --settings FILE --dry-run +``` + +Set `SY_MODEL` to choose an existing public route ID. New installs use +`composite-gpt-6-sol-gpt-6-luna`. Existing server configs stay saved, so set +`SY_MODEL` to one of their routes when installing with a custom config. The +profile leaves `approval_policy` and `sandbox_mode` to your existing Codex +settings. Reinstalling backs up the profile before updating its managed keys. It +preserves unrelated profile settings. Updates from Settings preserve the entire +profile. `SY_PROFILE` must start with an ASCII letter or digit, use only ASCII +letters, digits, underscores, or hyphens, and contain at most 128 characters. +For example, the installer rejects `SY_PROFILE=team.dev` before creating app or +config files. + +Open the app from Finder or Spotlight. Closing the window removes the running +app from the Dock and keeps the tray menu. Opening the window from the tray +restores the Dock icon. A shortcut you pinned to the Dock stays there. +Left-click the tray icon to see whether the server responds, today's and this +week's requests, tokens, spending, and savings, and each model's share of this +week's tokens. A note explains missing savings, such as a baseline model without +rates. On macOS the popup opens like a status item menu: its left edge follows +the icon, its top sits just below the menu bar, and it uses the system font and +the system light or dark appearance. The popup rereads the routing log every +`refresh_seconds` (at least 5) while it is open. **Restart server** and **Open +server config…** run from the popup and show their result there. **Settings…**, +**Connect tools…**, and **Open Switchyard** open the corresponding desktop view +and close the popup. **Quit Switchyard** quits the app. Escape or a click outside +the popup dismisses it. Clicking the tray icon again also closes it. + +**Settings → Appearance** chooses **System**, **Dark**, or **Light** for the app +window. System follows the device's light or dark appearance. Existing saved +Canary preferences select Dark. + +On macOS, the navigation sidebar shows native regular glass where supported, +with the native sidebar material as a fallback. The popup uses the same glass +effect with a popover material fallback. The page header, editors, charts, and +tables use solid backgrounds. The app checks macOS Reduce Transparency and Reduce +Motion settings every second in both windows. Reduce Transparency covers the +native material with opaque backgrounds; a failed accessibility check also +restores opaque backgrounds. Windows uses opaque backgrounds. + +Right-click the tray icon for the same summary as text rows, followed by +**Restart server**, **Open server config…**, **Settings…**, **Open +Switchyard**, **Connect tools…**, and **Quit Switchyard**. The menu rereads the +routing log every `refresh_seconds` (at least 5), like the earlier menu bar app. +A failed restart opens the app window with the error. Choose +**Open Switchyard** to return or **Quit Switchyard** to exit. Quit waits for a +queued or running operation to finish. The server continues running after the +app quits. + +**Settings → Review update…** shows the checkout and planned changes. **Build +and reinstall** starts the Rust installer from the checkout used for this +installation. It builds that checkout's current commit and preserves the current +Codex profile. Review the checkout, local changes, and planned actions before +starting. Settings shows the update stage and outcome. The updater keeps running +when installation stops the desktop app. Only one update runs at a time. +Progress and errors go to `~/.switchyard/logs/update.log` (under `SY_HOME` when +customized). Keep the checkout and a Rust toolchain available. Update the +checkout first to choose the code you want to build. + +Preview installation with `cargo desktop install --dry-run`. On Linux, the same +command installs the terminal app and a systemd user service for the server. +Linux installation requires an active systemd user session. Containers, +SSH sessions without a user service manager, and distributions without systemd need +separate service setup. The repository targets x86-64-v3 CPUs on Linux x86_64 +and Neoverse N1 CPUs on Linux aarch64; older CPUs may not run the binaries. +The desktop GUI runs on macOS and Windows. Windows requires the MSVC build tools, WebView2, and a Task Scheduler user session. +On Linux, login and session launch open the terminal program named by `TERMINAL`, +or a detected terminal program. Open-file actions use `xdg-open`. Model discovery +needs `curl`; session creation needs Git and the selected harness. +With a custom Linux `SY_HOME`, pass the settings file explicitly: + +```sh +"$SY_HOME/bin/switchyard-desktop" "$SY_HOME/desktop.toml" --tui +``` + +Run the terminal app with: + +```sh +~/.switchyard/bin/switchyard-desktop --tui +# This command runs the terminal app from a checkout. +cargo run -p switchyard-desktop -- --tui +``` + +The terminal app runs every action that the desktop app sends. It has seven +pages: Overview, Calls, Models, Routes, Harnesses, Sessions, and Settings. When +the full page names do not fit, the tab bar shows the first three letters of +each name. Overview shows the server, today's and the past seven days' calls, +spending, and savings, calls per hour, and the top models; it has no keys of its +own. The status bar shows the current page's keys, and `?` opens a window that +lists the keys of every page: + +| Page | Key | What it does | +|---|---|---| +| Every page | `1`–`7`, Tab, Shift-Tab, ←, → | Switch pages | +| Every page | `r`, F5 | Reload usage, calls, and server health | +| Every page | `?` | Show or hide the list of keys | +| Every page | `q`, Ctrl-C | Quit the app; the server keeps running | +| Calls | ↑, ↓, `j`, `k`, PgUp, PgDn, `g`, `G` | Select a call | +| Calls | `/` | Filter by model, route, session, or turn | +| Calls | `s` | Show one session at a time, then all sessions again | +| Calls | Enter | Open the selected call's details | +| Calls | Esc | Clear the filter and the session | +| Models | `p`, `]`, `[` | Switch between today, the past 7 days, and the whole log | +| Routes | ↑, ↓, `j`, PgUp, PgDn, `g`, `G` | Select a route | +| Routes | `/` | Filter by public model ID, config name, algorithm, endpoint, or model | +| Routes | Esc | Clear the filter | +| Routes | `e` | Edit the route's algorithm and models | +| Routes | `a` | Create a route | +| Routes | `n` | Rename the route | +| Routes | `c` | Duplicate the route | +| Routes | `d` | Delete the route | +| Routes | `m` | Load the model IDs that an endpoint offers | +| Routes | `k` | Save an endpoint's model-list key, then load its model IDs | +| Harnesses | ↑, ↓, `j`, `k`, PgUp, PgDn, `g`, `G` | Choose the route to install | +| Harnesses | `t` | Select the next harness | +| Harnesses | `a` | Select the next saved account, then the harness's own settings again | +| Harnesses | `x` | Use a custom settings file; leave it empty to use the detected settings | +| Harnesses | `p` | Preview the settings diff | +| Harnesses | `i` | Review and apply routing to the harness | +| Harnesses | `u` | Review and restore the original settings | +| Harnesses | `n` | Sign in to another subscription account | +| Harnesses | `l` | Sign in to the selected account again | +| Harnesses | `o` | Open the selected account folder in the editor | +| Harnesses | `d` | Archive the selected account | +| Sessions | ↑, ↓, `j`, `k`, PgUp, PgDn, `g`, `G` | Choose the route for a new session | +| Sessions | `t` | Select the next harness for a new session | +| Sessions | `a` | Select the next saved account for a new session | +| Sessions | `s` | Select the next recorded session | +| Sessions | `l` | Review and launch a worktree session | +| Sessions | `n` | Sign in to another subscription account | +| Sessions | `o` | Open the selected session folder in the editor | +| Sessions | `d` | Review and remove the selected session | +| Settings | ↑, ↓, `j`, `g`, `G` | Select an action | +| Settings | Enter | Run the selected action | +| Settings | `o` | Open the server config | +| Settings | `c` | Check the server config | +| Settings | `b` | Restart the server | +| Settings | `a` | Open the app settings | +| Settings | `e` | Change the baseline model and refresh interval | +| Settings | `p` | Open the model prices | +| Settings | `k` | Check the saved server keys | +| Settings | `s` | Save a server key | +| Settings | `i` | Import a server key from your login shell | +| Settings | `x` | Remove a saved server key | +| Settings | `u` | Review an update from source | +| Settings | `l` | Open the update log | +| Settings | `v` | Open the server logs folder | + +The Harnesses and Sessions pages share the selected harness, account, and +route, and the Routes filter limits the routes that they offer. The Harnesses +page shows the settings preview only while the harness, account, and route +still match it. + +Actions that change files ask you to type a confirmation, and most show a +review first. Editing, creating, renaming, and duplicating a route ask for +`SAVE`. Deleting a route asks for its public model ID, and archiving an account +asks for the account name. Applying routing asks for `INSTALL`, restoring +settings asks for `RESTORE`, launching a session asks for `LAUNCH`, removing a +session asks for `REMOVE`, and source updates ask for `UPDATE`. Restarting the +server asks you to press `y`. In a review, ↑ and ↓ scroll the preview, Enter +confirms the typed word, and Esc cancels. + +While you filter calls or routes, Enter keeps the filter, Esc clears it, Ctrl-U +erases the text, and Ctrl-W erases the last word. Short messages appear in the +status bar, and Esc dismisses them. Errors and warnings stay until you dismiss +them. A warning means an action did only part of its work, such as saving a +route when the server could not restart. Long messages and results with details +open a window that ↑ and ↓ scroll and Esc, Enter, or `q` closes. + +In forms, Tab and Shift-Tab change fields, ↑ and ↓ choose algorithms and +endpoints, and Enter submits. Esc cancels, and Ctrl-U clears a field. The left +and right arrow keys, Home, End, Backspace, Delete, and paste edit text. Key +fields show one bullet per character. A failed submission keeps the form and +its values. The terminal restores its screen and input mode on exit. `--print` +prints the usage summary without opening either app. `--tui` cannot be combined +with `--print` or `--validate-toml`. + +## Routes + +**Create route…** asks for the public model ID that harnesses request, the config +name for the route's table, the routing algorithm, and one endpoint and model for +each model the algorithm uses. For example, **Stage router** asks for a capable +and an efficient model, and **Random split** starts with two models and can add +more. The config name follows the public model ID until you edit it. Switching +algorithms keeps the models that both algorithms use. + +Every choice field in the app is a searchable list. Type any letters of a name in +order, such as `g6l` for `gpt-6-luna`, and separate words to match each one. +Arrow keys move through the list, Enter picks, and Escape closes it. Model fields +also accept an ID that the list does not show. + +The route library searches public model IDs, algorithms, endpoints, and model +choices. Select a route to edit its algorithm and model roles. The app opens one +editor at a time, even when the config contains hundreds of routes. **Refresh +suggestions** loads the endpoint's list; you can also type an unlisted model ID. +ChatGPT's private Codex endpoint uses the saved Codex model list and configured +targets. The diagram shows the selected algorithm's model roles; click a role +to focus its endpoint field. At narrow widths, **Route library** and **Selected +route** switch between the route list and selected editor. **Review changes…** +shows the changes before **Save and restart** applies them. The app keeps drafts +during the app session until saving or +**Discard unsaved edits…**. Discarding changes only resets the editor. **Delete +route…** removes the selected route after confirmation, preserves its targets +and endpoint settings, and uses the same validation, backup, and restart as +saving. Refresh keeps drafts and scroll and restores focus to matching form fields. +Drafts belong to their config file. Selecting another file keeps the drafts for +the original file. Switching algorithms preserves each algorithm's full set of choices. + +**Save and restart** asks the installed helper to check the complete edited file +with the installed server's `--dry-run`. The check uses the saved server keys, +which are the keys the server starts with. If the edited file names a key that +is not saved, the check fails. See [Server Keys](../../INSTALLATION.md#server-keys). +The app preserves comments and unrelated tables, saves a backup +beside the config, and restarts the server. A failed check leaves the file +unchanged. A restart failure after saving is reported separately. The app re-reads +the config and rejects concurrent changes detected before replacement. A draft +from a different server config file is rejected before saving. + +Switching algorithms writes the new algorithm's default settings and removes +settings specific to the old algorithm. Targets keep their existing model +settings, such as `extra_body` and `omit_body_fields`; the review names those +settings before saving when a model family changes. Check them if the new +provider rejects requests. Shared targets are copied when an edit would change +another route. + +API keys entered for model lists stay in macOS Keychain or Windows Credential Manager. The app sends keys to +curl through stdin and omits them from frontend snapshots. An endpoint must be +configured to use authentication before it accepts a model-list key. An entered +key applies only to that endpoint's exact `base_url`. On macOS and Windows, submitting a key +always requests a fresh model list; a rejected key is not saved and leaves +the cached list unchanged. Model-list keys do not replace the server's +`api_key_env` keys, which you save under **Settings → Server keys**. The app and +server LaunchAgents do not load shell startup files. + +On other platforms, the app rejects entered model-list keys; set the environment +variable named by the endpoint's `api_key_env` and make it available to the app +instead. + +## Server keys + +**Settings → Server keys** lists each variable that the server config names +with `api_key_env` and says whether a key is saved for it. The installed server +reads only these saved keys, never your shell's variables. To save a key, type +or paste it in the hidden field and press Enter. Select **Show key** to check +what you typed, or choose **Import from shell** to read the variable once from +your login shell. **Remove…** deletes a saved key after you confirm. The +installed helper saves each key in macOS Keychain or Windows Credential Manager. +The app passes the key to the helper through standard input and never stores +it. Restart the server to use a changed key. After a change, the section shows +**Restart server now**. + +## Install harness settings + +**Connect tools** has one harness selector for Codex CLI, Codex app, Claude +Code, and Pi. A harness is an app or CLI that sends model requests through +Switchyard. macOS finds the Codex app by its registered bundle identity, +including copies renamed to `ChatGPT.app`. Windows reads the current user's +`OpenAI.Codex` MSIX registration to find the app. Choose a route and settings location +to compare the current and proposed settings and their exact file paths. +Credential values stay hidden. The preview changes no files. **Apply routing…** +shows a confirmation before saving. Apply compares the reviewed file contents +with the current files and requires a new review if they changed. Progress +appears while the action runs. One dismissible notification shows the result, +with backup paths and diagnostics in **Details**. If the settings already match, +the button reads **Routing already applied**. An optional absolute file path +selects settings in another location. For Pi, choose `models.json`; the app also +uses `settings.json` beside it. A custom file cannot be combined with a saved +account. + +The app's **Connect tools** page defaults to `sy.config.toml` for Codex CLI and +`codex -p sy`. The source installer's `SY_PROFILE` choice does not change this +destination. Select a custom file to update another profile. With the normal +user settings location, the Codex app option changes shared defaults in +`config.toml` for both the app and CLI. A custom file or saved account changes +only that destination; the harness must load those settings separately. +Claude Code gets environment settings in `settings.json`. Pi gets a custom +provider in `models.json` and defaults in `settings.json`. Unrelated settings +remain. + +Codex compacts a thread when its usage nears the model's context window, but it +does not know a Switchyard route ID. When the route sets `context_window` in the +server config, the Codex settings get `model_context_window` with that value, and +Pi's `contextWindow` uses it too. Without it, Codex never compacts on the route, +and Pi uses 128,000. A `model_catalog_json` entry for the route takes precedence +over `model_context_window`, so a catalog that lists the route with the wrong +window makes Codex compact at that size. Correct the catalog entry instead. + +The app backs up each original file as `.switchyard-original`. If a +file did not exist, `.switchyard-original-missing` records its +absence. **Review restore…** lists the files and original backups. **Restore +settings** keeps a copy of each current file, then restores its backup or +removes the file if it was originally absent. The result lists the recovery +copies. A successful restore retires the original backup, so the next +installation backs up your current settings. If backup cleanup fails, retry +Restore before installing another route. + +Install and Restore check every selected file before replacing any file. +Malformed settings and symlink settings files are rejected before writes. Each +replacement is atomic. If a later replacement fails, the app tries to restore +earlier files. A crash can leave Pi's two files out of sync, and rollback can +fail. Another process running as the same user can also change a file after the +app checks it. A failed replacement keeps original backups for manual recovery. +If backup cleanup fails after restoration, the remaining receipts keep the +restored contents available for a retry. Recovery copies remain until you remove +them. + +Caller login routes require a matching harness and provider: Codex for +ChatGPT or Claude Code for Anthropic. Pi requires a route whose API credentials +the server owns. Configure those credentials on the server before installing +the route. Claude settings with a user API key or bearer token must be cleared +before installing a subscription route. Switching from an API route installed +by Switchyard removes its placeholder credentials only when the existing model +settings and local endpoint match that installation. If the local endpoint has +changed, restore the original settings first. Restart the harness after +changing its defaults. Project settings, shell variables, and command-line +options can override them; check Claude's `/status` for shell overrides. + +## Start sessions with separate logins and worktrees + +Codex CLI and Claude Code support named accounts. Expand **Sign in to another +subscription account…** and enter a name. **Open login in Terminal…** opens the +harness’s own login command. Complete the login, refresh, and select the account. +Switchyard keeps account directories under `~/.switchyard/accounts`; the harness +owns their login files. The selected account determines where **Connect +tools** saves settings and which login a new session uses. Existing sessions +keep their login. Switchyard does not read, copy, or rotate OAuth tokens or +combine subscription quotas. + +In **Sessions**, choose a route and login, enter an absolute Git project path, +and click **Review session**, then **Open Terminal…**. Each session starts from +that checkout's HEAD on a new branch in `~/.switchyard/worktrees` and opens in +Terminal. Uncommitted files stay in the original checkout. Each launched session +has its own model settings; launching another session does not change user +defaults. Codex app uses its own workspace controls; choose Codex CLI for +worktree sessions. + +Codex and Claude launches send a session ID for usage correlation. Pi stores its +session under the private agent directory beside the worktree. Its requests +need an integration that sends session and turn IDs to appear as a correlated +session. Otherwise, Usage lists its calls under **All sessions**. + +Use `git worktree list` and `git worktree remove` to manage checkouts after +saving or discarding changes. Successful launches keep their branches and +private settings directories until you remove them. Named account directories +also remain until you remove them. If launch setup fails, the app tries to +remove the worktree, branch, and private settings it created; cleanup can fail. + +The app does not show provider reset windows or manage task dependencies, +session resume, remote workspaces, or review comments. Provider limits still apply. + +## Continue a session in another harness + +A session can continue in another harness, for example from Codex CLI to +Claude Code, without replaying its transcript or sharing a login. In +**Sessions**, the **Switch harness** card has one dropdown per harness, newest +session first, read from the harness's own transcripts: pick a Codex session +and click **Switch to Claude Code**, or pick a Claude Code session and click +**Switch to Codex**. The session continues in its own directory with the first +route the destination can use and your current login; **Options** chooses +another route or a saved account for each destination. Recorded sessions have +the same button. Sessions whose directory is not on this computer, such as +remote sessions, are left out and counted under the dropdowns. The menu bar +popup lists the newest sessions with the same action; choosing one opens the +window on the review. + +The review shows the hand-off text that the new session reads: the goal, the +harness's own compaction summary when it wrote one, the last eight turns, the +files and commands it touched, and the last error, cut to 32 KiB. Tool output +is left out. Nothing is written until you confirm. Then Switchyard writes that +text to `~/.switchyard/handoffs//HANDOFF.md`, readable only by you, and +opens the destination harness in the session's directory with a short +instruction to read the file, summarize the state in five lines, and continue. +Codex CLI receives the instruction as `developer_instructions`; Claude Code +receives it as `--append-system-prompt`, with `--add-dir` for the hand-off +directory. The destination model does the summarizing under its own login. +Switchyard makes no model call, uploads nothing, and never copies login files. + +A hand-off from a recorded session keeps its session ID, so Usage lists both +harnesses under one session, and the server's per-session routing state carries +over. A hand-off from a transcript file starts a new session ID in the +directory that the transcript records. The session's receipt lists every +launch. Hand-off files are removed seven days after they are written; the new +session's own transcript stays with its harness. Switchyard reads transcripts +on this computer only, and only when you ask for a review or a listing. + +In the terminal app, `c` continues the selected recorded session, `x` continues +a transcript file, and `h` lists the sessions of the selected harness and +account. Each review ends with the whole hand-off text; type `HANDOFF` to write +it and open the harness. + +From a shell, `switchyard-desktop handoff --to claude` continues the session +that was last recorded in the current directory, in that same terminal. It +prints the review, asks for confirmation, writes the hand-off, and then starts +the destination harness in place of itself. `--to codex` goes the other way. +`--transcript FILE` or `--session ID` names the source, `--route` and +`--account` choose the destination's route and saved account, `--show` prints +the hand-off text, and `--yes` skips the confirmation. Run +`switchyard-desktop handoff --help` for every option. + +## Usage + +Usage displays up to 100 calls per page. **Previous calls** and **Next calls** move +through the filtered results. **Copy filtered calls** copies all matching calls, +including calls on other pages. Sidebar navigation stays available while the app +refreshes the displayed data. + +**Usage** lists the actual model, public route, timestamp, input, cached, and +output tokens, and recorded session and turn IDs for each completed model call. +Filter by session or search for a turn, model, or route. A user turn may contain +several calls. Classifier calls are marked as routing overhead. Missing IDs +appear as **Session not recorded** or **Turn not recorded** in the desktop app +and **Not recorded** on the terminal app's Calls page. Switchyard does not +infer them from prompts. + +Recent history reads the last 8 MiB of the log plus one byte to check whether the +first record is complete. It keeps at most 5,000 complete records and excludes +partial records. Usage labels a limited view and counts unreadable records. +Overview starts with model totals. Use **Period** to select today, the past +seven local calendar days, or all retained history. **Compare** switches between +models, routes, and sessions; **Measure** switches between tokens and calls. +The chart shows the ten largest totals, and the table lists every matching +name with exact counts. Click a name to see its recent calls with the same +period filter. The daily chart shows usage by model for the past seven days. + +Model, route, and daily totals use the incremental full-log reader. Session +comparisons and per-turn details use the bounded recent history and label its +limits. Replacing or truncating the log resets full-log totals. The calls-per-hour +chart on the terminal app's Overview page also uses recent history. + +Charts use Apache ECharts 6.1.0, bundled in `ui/vendor` with its Apache license +and NOTICE. They load locally and require no CDN or frontend build tool. +The minified dependency adds about 1.1 MB before compression. Tooltips render +on the canvas, and tables provide readable counts and keyboard links. + +Routing history stores no prompt or response text. +Observed tokens do not report provider quotas, reset windows, or authoritative +billing. Both apps refresh usage at the configured `refresh_seconds` interval. + +## Settings + +The app reads `~/.switchyard/desktop.toml` on refresh and before each operation: + +```toml +server_url = "http://127.0.0.1:4123" +routing_log = "~/.switchyard/routing.jsonl" +config_file = "~/.switchyard/composite.toml" +# This optional file replaces the bundled price table. +# pricing_file = "~/.switchyard/prices.toml" +launchd_label = "com.nvidia.switchyard.server" +baseline_model = "gpt-6-sol" +refresh_seconds = 30 +``` + +Use **Settings → Open server config** to edit advanced routing options and +**Settings → Open model price override** to edit a configured price file. Without an +override, **About model prices** explains how to configure one. +Model lists are cached beside the app settings in `model-lists.json`. +Usage remains readable when the route config is missing or malformed. + +## How costs and savings are computed + +The server writes one JSONL record per call, with the model that answered, its +token counts, and its cost. The server and app use the same +[bundled rates](../switchyard-costs/prices.toml) by default. To replace the table, +pass `--pricing-file PATH` to the server and set `pricing_file` to the same TOML or JSON +file in app settings. The [Pi cost guide](../../docs/integrations/pi.md#estimate-the-cost) +describes the file format. If an explicitly configured file is missing or invalid, +the server and app report an error instead of using bundled rates. + +For each answer, the server records the charge that the provider reports, from +`usage.cost` in the response or, for a buffered LiteLLM response, from the +`x-litellm-response-cost` header. Otherwise it records an estimate from those +rates. The server cannot see the headers of classifier and judge calls, so +those calls use `usage.cost` or the estimate. The app adds up these recorded +costs and does not price calls itself. The log covers the calls that the server +observed; a request cancelled before its answer finished may have no record. + +- **Cost** is every recorded call, including Switchyard's own classifier calls. +- **Baseline** is the caller-facing calls only, with their tokens priced at the + `baseline_model` rates from the selected price table. Classifier calls have no + baseline counterpart, because without Switchyard they would not happen. + +Savings are the difference, so routing overhead counts against the figure and a +bad day shows a negative number. A period shows savings only when every call in +it has a recorded cost, every answer has token counts, and the selected table has +rates for `baseline_model`, including `cache_write_1h` when answers wrote to a +one-hour cache. Calls recorded before the server priced calls have no cost, and +neither do calls to a model that has no rates and no provider charge. Overview counts these calls and +explains costs under **How costs work**. + +The server loads rates when it starts, so restart it after editing an override +file. Bundled rates change only when you install a version with updated rates. +Each record keeps the cost from when its call finished, so editing rates does +not change past totals. + +The bundled table includes OpenAI and Claude text-token rates in USD per million +tokens. For GPT-5.6 and newer, `cache_write` uses OpenAI's 30-minute rate, which +is 1.25 times the input rate. Older OpenAI models use the ordinary input rate +for separately counted writes, with no extra write fee. Claude rates distinguish +five-minute writes (`cache_write`) from one-hour writes (`cache_write_1h`). +Estimates exclude long-context and service-tier adjustments, OpenAI's 12-hour +cache-write pricing, tool fees, and time-based cache storage. Entries with provider +prefixes use list-rate estimates; billed charges may differ. + +Installation uses bundled rates without creating a price file and preserves +existing custom price files. +For older `[prices]` settings, remove the table to use bundled rates, or convert +it to the shared TOML or JSON rate format and select it with `pricing_file`. The +installer does not migrate old settings. + +Codex's "Approve for me" reviews count too. The server config that the +installer writes sends them to `codex-auto-review` on the ChatGPT backend. With +a ChatGPT login, Codex sends these reviews to `codex-auto-review` even without +Switchyard, so the bundled rates price that model at the `gpt-6-sol` +rates. Each review then adds the same amount to the cost and to the baseline, +so reviews do not change the dollar amount saved. They do lower the percentage +a little, because the baseline grows. If you change `baseline_model`, use a +custom replacement table with matching `codex-auto-review` rates. + +The server needs rates for the model ID that the routing log records for +reviews, which is the reviewer target's `id`. Without them, a review that the +provider does not price has no recorded cost, and the app hides the savings for +every period that includes it. The installer does not overwrite an existing +`composite.toml` or an existing price override. Older installations may need the +review route added by hand. The bundled table includes `codex-auto-review`; a +custom replacement table must include its own reviewer rates. If a reviewer +uses another model ID, add rates for that ID. + +These are list-price estimates unless the provider reports a charge. On a +ChatGPT login there is no per-token bill, so read them as "what this traffic +would have cost at API rates". diff --git a/crates/switchyard-desktop/SETUP.md b/crates/switchyard-desktop/SETUP.md new file mode 100644 index 0000000000..f8c577adc6 --- /dev/null +++ b/crates/switchyard-desktop/SETUP.md @@ -0,0 +1,38 @@ +# Desktop setup + +Run `cargo desktop install` from the source checkout to build and install the +desktop app and server. On Linux, open the installed app with +`~/.switchyard/bin/switchyard-desktop --tui`. If you set `SY_HOME`, use its `bin` +directory instead. + +Setup opens when neither the app settings file nor its default server config +exists. Existing files skip setup, including after reinstall. Invalid existing +files show an error so you can repair them in Settings. Routing logs and price +files alone do not skip setup. + +Choose an existing server config or create one from bundled defaults. Select +the server address, routing log, optional price file, baseline model, and refresh +interval, then save. Use absolute file paths and an `http://` server address. +Creating a server config never replaces an existing file. +The server waits for saved app settings before starting. Save any required API +keys in **Settings → Server keys**. Use **Check config** in Settings, then restart +the server. The managed server listens on `127.0.0.1`; changing the address does +not expose it to the network. + +**Settings → Configuration** uses the same form. Saving preserves comments in +the app settings file. Restart the server after changing its selected files or +port. The installed server reads those selections when it starts. + +**Run setup again** reopens the form. **Remove app preferences** shows the exact +settings file before removal and checks that its contents have not changed +since review. Removal keeps a recovery copy and preserves server configs, saved +keys, history, accounts, and coding-tool settings. Use **Connect tools** to +restore a coding tool's settings. + +In the terminal app, open Settings with `7`. Press `f` to edit configuration or +`d` to review removal of app preferences. + +A fresh installation leaves the Codex profile unchanged. After setup saves a +server config, reinstalling creates or updates the Codex profile, which defaults +to `~/.codex/sy.config.toml`. The installer keeps a recovery copy of a changed +profile. diff --git a/crates/switchyard-desktop/capabilities/main-chrome.json b/crates/switchyard-desktop/capabilities/main-chrome.json new file mode 100644 index 0000000000..fa51ccd02d --- /dev/null +++ b/crates/switchyard-desktop/capabilities/main-chrome.json @@ -0,0 +1,9 @@ +{ + "identifier": "main-chrome", + "description": "The desktop titlebar supports dragging and double-click maximization.", + "windows": ["main"], + "permissions": [ + "core:window:allow-start-dragging", + "core:window:allow-internal-toggle-maximize" + ] +} diff --git a/crates/switchyard-desktop/src/accounts.rs b/crates/switchyard-desktop/src/accounts.rs new file mode 100644 index 0000000000..3ec510baba --- /dev/null +++ b/crates/switchyard-desktop/src/accounts.rs @@ -0,0 +1,232 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Named login directories use the harness's own authentication commands. + +use crate::harness::{self, Harness}; +use std::path::{Path, PathBuf}; + +// PendingAccount removes only an empty directory if login setup fails. +// The harness may already have saved login files, which must stay. +struct PendingAccount(Option); + +impl Drop for PendingAccount { + fn drop(&mut self) { + if let Some(path) = &self.0 { + let _ = std::fs::remove_dir(path); + } + } +} + +fn root(tool: Harness) -> Result { + let name = match tool { + Harness::CodexCli => "codex", + Harness::Claude => "claude", + _ => return Err("Named accounts are available for Codex CLI and Claude Code.".into()), + }; + let home = crate::config::expand_home(Path::new("~"))?; + let mut path = home; + for component in [".switchyard", "accounts", name] { + path.push(component); + if std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_symlink()) { + return Err(format!( + "{} is a symlink. Edit its target yourself.", + path.display() + )); + } + } + Ok(path) +} + +pub fn validate(tool: Harness, path: &Path) -> Result<(), String> { + let root = root(tool)?; + if path.parent() != Some(root.as_path()) + || !std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir() && !m.file_type().is_symlink()) + { + return Err("Choose a supported harness.".into()); + } + Ok(()) +} + +pub fn discover(tool: Harness) -> Result, String> { + if !matches!(tool, Harness::CodexCli | Harness::Claude) { + return Ok(Vec::new()); + } + let root = root(tool)?; + let entries = match std::fs::read_dir(&root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(format!( + "Could not list accounts in {}: {error}", + root.display() + )); + } + }; + let mut accounts = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| error.to_string())?; + if !entry + .file_name() + .to_string_lossy() + .starts_with(".archived-") + && entry + .file_type() + .map_err(|error| error.to_string())? + .is_dir() + && let Ok(name) = entry.file_name().into_string() + { + accounts.push((name, entry.path())); + } + } + accounts.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(accounts) +} + +pub fn list(tool: Harness) -> Vec<(String, PathBuf)> { + discover(tool).unwrap_or_default() +} + +pub fn config_paths(tool: Harness, account: Option<&Path>) -> Result, String> { + let Some(account) = account else { + return harness::paths(tool); + }; + match tool { + Harness::CodexCli => Ok(vec![account.join("sy.config.toml")]), + Harness::Claude => Ok(vec![account.join("settings.json")]), + _ => harness::paths(tool), + } +} + +pub fn add(tool: Harness, name: &str) -> Result { + if name.is_empty() + || name.len() > 64 + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return Err("Account names need 1–64 letters, numbers, hyphens, or underscores.".into()); + } + let root = root(tool)?; + harness::binary(tool).ok_or("Install the selected harness first.")?; + std::fs::create_dir_all(&root).map_err(|e| e.to_string())?; + let path = root.join(name); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new() + .mode(0o700) + .create(&path) + .map_err(|e| { + format!( + "Create account: {e}. \ + Pick an existing account after Refresh, or choose a new name." + ) + })?; + } + #[cfg(not(unix))] + std::fs::create_dir(&path).map_err(|e| e.to_string())?; + let mut pending = PendingAccount(Some(path.clone())); + login(tool, &path, name)?; + pending.0 = None; + Ok(format!( + "Opened the harness's login in Terminal for account {name}. Complete login, \ + click Refresh, and select this account. Switchyard does not read the login tokens. \ + The selected account applies to new sessions only. Account directory: {}", + path.display() + )) +} + +pub fn login(tool: Harness, path: &Path, name: &str) -> Result { + validate(tool, path)?; + let binary = harness::binary(tool).ok_or("Install the selected harness first.")?; + let variable = match tool { + Harness::CodexCli => "CODEX_HOME", + Harness::Claude => "CLAUDE_CONFIG_DIR", + _ => return Err("Choose a supported harness.".into()), + }; + #[cfg(windows)] + crate::windows::open_terminal(crate::windows::TerminalJob { + binary: binary.clone(), + args: match tool { + Harness::CodexCli => vec!["login".into()], + _ => vec!["auth".into(), "login".into()], + }, + directory: None, + environment: vec![(variable.into(), path.display().to_string())], + remove_environment: vec![], + })?; + #[cfg(not(windows))] + { + let login = match tool { + Harness::CodexCli => "login", + _ => "auth login", + }; + let script = format!( + "#!/usr/bin/env bash\n\ + rm -- \"$0\"\n\ + export PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\n\ + export {variable}={}\n\ + {} {login}", + crate::sessions::quote(&path.display().to_string()), + crate::sessions::quote(&binary.display().to_string()) + ); + crate::server::open_terminal_script("switchyard-login-", &script)?; + } + Ok(format!( + "Opened login in Terminal for account {name}. Finish login in the harness. \ + Login status remains unknown until the harness confirms it." + )) +} + +pub fn archive(tool: Harness, path: &Path) -> Result { + validate(tool, path)?; + let archived = path.with_file_name(format!(".archived-{}", uuid::Uuid::now_v7())); + std::fs::rename(path, &archived).map_err(|error| error.to_string())?; + Ok(format!( + "Archived the account folder at {}. Login files are preserved. \ + Existing sessions may still use the old directory and need a new login.", + archived.display() + )) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + // The subprocess isolates HOME so parallel tests keep their own settings. + #[cfg(unix)] + #[test] + fn symlinked_account_roots_are_not_listed_or_used_for_login() { + if std::env::var_os("SWITCHYARD_ACCOUNT_FIXTURE").is_some() { + assert!(list(Harness::CodexCli).is_empty()); + assert!(discover(Harness::CodexCli).is_err()); + assert!(add(Harness::CodexCli, "new").is_err()); + let path = crate::config::expand_home(Path::new("~/.switchyard/accounts/codex/named")) + .expect("home"); + assert!(validate(Harness::CodexCli, &path).is_err()); + return; + } + let dir = tempfile::tempdir().expect("fixture"); + let home = dir.path().join("home"); + let target = dir.path().join("target"); + std::fs::create_dir(&home).expect("home"); + std::fs::create_dir_all(target.join("accounts/codex/named")).expect("accounts"); + std::os::unix::fs::symlink(&target, home.join(".switchyard")).expect("symlink"); + let output = std::process::Command::new(std::env::current_exe().expect("runner")) + .args([ + "--exact", + "accounts::tests::symlinked_account_roots_are_not_listed_or_used_for_login", + ]) + .env("SWITCHYARD_ACCOUNT_FIXTURE", "1") + .env("HOME", &home) + .output() + .expect("child"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + assert!(!target.join("accounts/codex/new").exists()); + } +} diff --git a/crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs b/crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs index 42ed3b7a56..4c7ed5ad5d 100644 --- a/crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs +++ b/crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs @@ -149,8 +149,16 @@ fn key_remove(name: &str) -> Result<(), String> { fn serve(file: &Path) -> Result<(), String> { use std::os::unix::process::CommandExt; let s = Settings::load(file)?; - let config = s.sy_home.join("composite.toml"); - let mut server = s.server_command(); + // The installed helper waits until setup atomically saves the app settings. + while !s + .desktop_settings() + .try_exists() + .map_err(|error| error.to_string())? + { + std::thread::sleep(std::time::Duration::from_millis(250)); + } + let config = switchyard_desktop::config::Config::load(&s.desktop_settings())?.config_file; + let mut server = s.server_command()?; #[cfg(target_os = "linux")] credentials::wait_for_keyring()?; credentials::configure(&mut server, &config)?; diff --git a/crates/switchyard-desktop/src/config.rs b/crates/switchyard-desktop/src/config.rs index 5b558b1edc..44afc94c3c 100644 --- a/crates/switchyard-desktop/src/config.rs +++ b/crates/switchyard-desktop/src/config.rs @@ -84,13 +84,17 @@ impl Config { pub fn load(path: &Path) -> Result { let defaults = Self::defaults_for(path)?; let mut values = toml::Table::try_from(defaults).map_err(|e| e.to_string())?; + // A dangling settings link needs repair instead of silently using defaults. match std::fs::read_to_string(path) { Ok(text) => { let overrides: toml::Table = toml::from_str(&text) .map_err(|error| format!("parse {}: {error}", path.display()))?; values.extend(overrides); } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && std::fs::symlink_metadata(path) + .is_err_and(|lookup| lookup.kind() == std::io::ErrorKind::NotFound) => {} Err(error) => return Err(format!("read {}: {error}", path.display())), } let moved = if values.contains_key("prices") { @@ -127,14 +131,126 @@ impl Config { } } -pub fn save_preferences(path: &Path, baseline: &str, refresh: u64) -> Result<(), String> { - use std::io::Write; +/// Setup is required only when neither the app settings nor its default server config exists. +/// Existing files, including broken symlinks, skip setup and remain available for repair in Settings. +pub fn needs_setup(path: &Path) -> Result { + fn present(path: &Path) -> Result { + match std::fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!("Read {}: {error}", path.display())), + } + } + Ok(!present(path)? && !present(&Config::defaults_for(path)?.config_file)?) +} + +/// This function saves the complete form while retaining comments and unrelated settings. +pub fn save_configuration(path: &Path, config: &Config, create_server: bool) -> Result<(), String> { + crate::health::validate_url(&config.server_url)?; + for file in [&config.config_file, &config.routing_log] + .into_iter() + .chain(config.prices_file.iter()) + { + if !file.is_absolute() + || file + .as_os_str() + .to_string_lossy() + .chars() + .any(char::is_control) + { + return Err("Choose absolute file paths without control characters.".into()); + } + } + if config.launchd_label.is_empty() || config.launchd_label.chars().any(char::is_control) { + return Err("Choose a server service label without control characters.".into()); + } + let paths = [ + Some(path), + Some(config.config_file.as_path()), + Some(config.routing_log.as_path()), + config.prices_file.as_deref(), + ]; + let resolved = paths + .into_iter() + .flatten() + .map(|path| resolved_destination(path, 0)) + .collect::, _>>()?; + let distinct: std::collections::HashSet<_> = resolved.iter().collect(); + if distinct.len() != resolved.len() { + return Err("App settings, server config, routing log, and price override must use different files.".into()); + } + validate_preferences(&config.baseline_model, config.refresh_seconds)?; + let mut values = toml::Table::try_from(config).map_err(|error| error.to_string())?; + // An absent price file removes a previous override and restores bundled prices. + if config.prices_file.is_none() { + values.insert("pricing_file".into(), toml::Value::String(String::new())); + } + if let Some(prices) = &config.prices_file { + switchyard_costs::Pricing::load(prices).map_err(|error| error.to_string())?; + } + if !create_server { + let text = std::fs::read_to_string(&config.config_file) + .map_err(|error| format!("Read server config: {error}"))?; + switchyard_runner::api_key_variables(&text).map_err(|error| error.to_string())?; + } + edit_settings( + path, + values, + create_server.then_some(config.config_file.as_path()), + ) +} + +// Canonical paths also catch existing symlinks and aliases to a file that setup will create. +fn resolved_destination(path: &Path, depth: usize) -> Result { + if depth > 40 { + return Err("A selected file has too many symbolic links or parent folders.".into()); + } + match std::fs::canonicalize(path) { + Ok(path) => return Ok(path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.to_string()), + } + if let Ok(target) = std::fs::read_link(path) { + return resolved_destination( + &path.parent().ok_or("Missing file folder.")?.join(target), + depth + 1, + ); + } + let name = path + .file_name() + .ok_or("Choose a file path without unresolved parent components.")?; + Ok(resolved_destination(path.parent().ok_or("Missing file folder.")?, depth + 1)?.join(name)) +} + +fn validate_preferences(baseline: &str, refresh: u64) -> Result<(), String> { if baseline.trim().is_empty() || baseline.contains(['\n', '\r']) { return Err("Choose a nonempty baseline model ID without line breaks.".into()); } if refresh == 0 { return Err("Refresh interval must be at least one second.".into()); } + Ok(()) +} + +pub fn save_preferences(path: &Path, baseline: &str, refresh: u64) -> Result<(), String> { + validate_preferences(baseline, refresh)?; + let mut edits = toml::Table::new(); + edits.insert("baseline_model".into(), baseline.trim().into()); + edits.insert( + "refresh_seconds".into(), + i64::try_from(refresh) + .map_err(|error| error.to_string())? + .into(), + ); + edit_settings(path, edits, None) +} + +fn edit_settings( + path: &Path, + edits: toml::Table, + create_server: Option<&Path>, +) -> Result<(), String> { + use std::io::Write; let original = match std::fs::read_to_string(path) { Ok(text) => Some(text), Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, @@ -145,9 +261,17 @@ pub fn save_preferences(path: &Path, baseline: &str, refresh: u64) -> Result<(), .unwrap_or("") .parse::() .map_err(|error| error.to_string())?; - doc["baseline_model"] = toml_edit::value(baseline.trim()); - doc["refresh_seconds"] = - toml_edit::value(i64::try_from(refresh).map_err(|error| error.to_string())?); + for (key, value) in edits { + if key == "pricing_file" && value.as_str() == Some("") { + doc.remove(&key); + } else { + doc[&key] = match value { + toml::Value::String(value) => toml_edit::value(value), + toml::Value::Integer(value) => toml_edit::value(value), + _ => return Err(format!("Unsupported app setting: {key}")), + }; + } + } let text = doc.to_string(); // Validation fills missing fields with installation defaults, just as Config::load does. // The saved text retains comments and changes only the requested preferences. @@ -182,8 +306,36 @@ pub fn save_preferences(path: &Path, baseline: &str, refresh: u64) -> Result<(), if current != original { return Err("App settings changed while saving. Reload them and try again.".into()); } - temp.persist(destination) - .map_err(|error| error.to_string())?; + let created = if let Some(server) = create_server { + let parent = server + .parent() + .ok_or("Server config needs a parent folder.")?; + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + let mut staged = + tempfile::NamedTempFile::new_in(parent).map_err(|error| error.to_string())?; + staged + .write_all(crate::install::BUNDLED_CONFIG.as_bytes()) + .and_then(|()| staged.as_file().sync_all()) + .map_err(|error| error.to_string())?; + staged.persist_noclobber(server).map_err(|error| { + format!( + "Create {}: {error}. Choose an existing config without creating a new file.", + server.display() + ) + })?; + Some(server) + } else { + None + }; + if let Err(error) = temp.persist(destination) { + if let Some(server) = created + && std::fs::read(server).ok().as_deref() + == Some(crate::install::BUNDLED_CONFIG.as_bytes()) + { + let _ = std::fs::remove_file(server); + } + return Err(error.to_string()); + } Ok(()) } @@ -203,9 +355,68 @@ pub fn expand_home(path: &Path) -> Result { } } -#[cfg(all(test, unix))] +#[cfg(test)] mod tests { - use super::*; + use super::{Config, needs_setup, save_configuration, save_preferences}; + + #[test] + fn existing_primary_files_skip_setup_even_when_they_need_repair() { + let dir = tempfile::tempdir().expect("fixture"); + let path = dir.path().join("desktop.toml"); + assert!(needs_setup(&path).expect("fresh")); + std::fs::write(dir.path().join("routing.jsonl"), "").expect("log"); + std::fs::write(dir.path().join("prices.json"), "{}").expect("prices"); + assert!(needs_setup(&path).expect("optional files")); + std::fs::write(dir.path().join("composite.toml"), "invalid").expect("server config"); + assert!(!needs_setup(&path).expect("server file")); + std::fs::remove_file(dir.path().join("composite.toml")).expect("remove server"); + std::fs::write(&path, "invalid [").expect("broken settings"); + assert!(!needs_setup(&path).expect("existing settings")); + } + + #[test] + fn setup_saves_selected_files_and_rejected_edits_have_no_file_side_effects() { + let dir = tempfile::tempdir().expect("fixture"); + let path = dir.path().join("desktop.toml"); + let mut config = Config::defaults_for(&path).expect("defaults"); + config.config_file = dir.path().join("chosen.toml"); + config.routing_log = dir.path().join("chosen.jsonl"); + save_configuration(&path, &config, true).expect("setup"); + assert!(!needs_setup(&path).expect("completed")); + assert_eq!( + Config::load(&path).expect("load").config_file, + config.config_file + ); + assert_eq!( + std::fs::read_to_string(&config.config_file).expect("template"), + crate::install::BUNDLED_CONFIG + ); + let original = std::fs::read(&path).expect("settings"); + assert!( + save_configuration(&path, &config, true).is_err(), + "creating must not replace a file" + ); + assert_eq!(std::fs::read(&path).expect("unchanged"), original); + for (url, file) in [ + ("https://invalid.test", dir.path().join("absent.toml")), + ("http://localhost", path.clone()), + ("http://localhost", config.routing_log.clone()), + ] { + let mut invalid = config.clone(); + invalid.server_url = url.into(); + invalid.config_file = file.clone(); + assert!(save_configuration(&path, &invalid, true).is_err()); + assert_eq!(std::fs::read(&path).expect("unchanged"), original); + assert!(!dir.path().join("absent.toml").exists()); + assert!(!config.routing_log.exists()); + } + std::fs::write(&path, "# keep my comment\nunknown_key=true\n").expect("unknown field"); + let before = std::fs::read(&path).expect("raw settings"); + config.config_file = dir.path().join("absent.toml"); + assert!(save_configuration(&path, &config, true).is_err()); + assert_eq!(std::fs::read(&path).expect("raw unchanged"), before); + assert!(!config.config_file.exists()); + } #[cfg(unix)] #[test] @@ -214,6 +425,8 @@ mod tests { let path = dir.path().join("desktop.toml"); let target = dir.path().join("missing.toml"); std::os::unix::fs::symlink(&target, &path).expect("symlink"); + assert!(!needs_setup(&path).expect("existing settings link")); + assert!(Config::load(&path).is_err()); assert!(save_preferences(&path, "baseline", 30).is_err()); assert_eq!( std::fs::read_link(&path).expect("preserved symlink"), diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 567da30ce8..953b455ee2 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -4,15 +4,179 @@ //! The terminal controller reloads settings and combines usage and server health into snapshots. //! It retains the log reader and health probe across refreshes. -use crate::{config::Config, history, pricing, rollup, server}; +use crate::{ + accounts, + config::Config, + handoffs, + harness::{self, Harness}, + history, models, pricing, rollup, server, + server_config::{ALGORITHMS, Choice, Route, ServerConfig}, + sessions, +}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use std::{collections::BTreeMap, path::PathBuf}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; +use switchyard_desktop::transcripts; #[derive(Clone, Debug, Deserialize, strum::EnumDiscriminants)] #[strum_discriminants(name(ActionKind), derive(strum::EnumIter))] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum Action { + Editor { + generation: u64, + route: String, + algorithm: String, + }, + Apply { + generation: u64, + route: String, + algorithm: String, + choices: Vec, + revision: Option, + }, + Remove { + generation: u64, + route: String, + id: String, + revision: Option, + }, + PreviewRoute { + generation: u64, + route: String, + algorithm: String, + choices: Vec, + revision: Option, + }, + PreviewRouteChange { + generation: u64, + operation: String, + route: String, + name: String, + id: String, + algorithm: Option, + choices: Vec, + }, + RouteChange { + generation: u64, + operation: String, + route: String, + name: String, + id: String, + algorithm: Option, + choices: Vec, + preview_token: Option, + }, + Models { + client: String, + refresh: bool, + key: Option, + }, + PreviewInstall { + tool: Harness, + account: Option, + route: String, + id: String, + settings_file: Option, + }, + Install { + tool: Harness, + account: Option, + route: String, + id: String, + settings_file: Option, + preview_token: Option, + }, + PreviewRestore { + tool: Harness, + account: Option, + settings_file: Option, + }, + Restore { + tool: Harness, + account: Option, + settings_file: Option, + preview_token: Option, + }, + AddAccount { + tool: Harness, + name: String, + }, + Launch { + preview_token: Option, + tool: Harness, + account: Option, + route: String, + id: String, + project: String, + }, + PreviewLaunch { + tool: Harness, + account: Option, + route: String, + id: String, + project: String, + }, + /// This shows the hand-off that another harness reads to continue `source`: the text, + /// the destination, the warnings, and the command. `tool`, `account`, and `route` select + /// the destination. + PreviewHandoff { + source: HandoffSource, + tool: Harness, + account: Option, + route: String, + id: String, + }, + /// This writes the reviewed hand-off and opens the destination harness in the session's + /// directory. With `attach`, it returns the harness command instead of opening a terminal, + /// for the `handoff` subcommand, which runs the harness in its own terminal. + Handoff { + source: HandoffSource, + tool: Harness, + account: Option, + route: String, + id: String, + preview_token: Option, + #[serde(default)] + attach: bool, + }, + /// This lists recent sessions from harness transcripts, newest first: those of one harness + /// and account, or of both command line harnesses and every saved account when `tool` is + /// absent. Each session names the harness that continues it by default. The list is read on + /// demand because it opens every transcript. + RecentSessions { + tool: Option, + account: Option, + }, + LoginAccount { + tool: Harness, + name: String, + }, + PreviewAccountArchive { + tool: Harness, + name: String, + }, + ArchiveAccount { + tool: Harness, + name: String, + preview_token: Option, + }, + OpenAccount { + tool: Harness, + name: String, + }, + OpenSession { + id: String, + }, + PreviewSessionCleanup { + id: String, + }, + RemoveSession { + id: String, + preview_token: Option, + }, Restart {}, PreviewUpdate {}, Update { @@ -35,19 +199,57 @@ pub enum Action { RemoveServerKey { name: String, }, + SaveConfiguration { + config: Config, + create_server: bool, + }, + PreviewResetSettings {}, + ResetSettings { + preview_token: Option, + }, SavePreferences { baseline_model: String, refresh_seconds: u64, }, } +/// HandoffSource names the session that a hand-off continues. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum HandoffSource { + /// A session that the app launched, by its `sy-` ID. The hand-off reads the transcript of + /// the session's latest launch and keeps the session ID. + Managed { id: String }, + /// A transcript file that the user chose: a Claude Code `.jsonl` or a Codex rollout. + /// The hand-off continues in the directory the transcript records, under a new session ID. + Transcript { path: String }, +} + /// Snapshot holds the data shown by the apps. The terminal app destructures every field, /// so adding a field requires an update to `draw`. #[derive(Clone, Default, Serialize)] pub struct Snapshot { + pub needs_setup: bool, + pub configuration_error: Option, pub update_status: Value, + /// Each value is the `session.json` file that `sessions::launch` wrote for a managed + /// worktree session, plus `process_status`. The snapshot passes these files through as JSON. + pub session_inventory: Vec, + /// This field holds the error that stopped the inventory read. `session_inventory` is empty + /// when this field is set. + pub session_inventory_error: Option, + /// Each value is the `handoff.json` record of a hand-off, newest first. Records older than + /// `handoffs::RETENTION_DAYS` are removed on the first snapshot after the app starts. + pub handoffs: Vec, pub settings: Option, pub data_state: DataState, + /// The controller increases this number when the app settings select another server + /// config file. Route actions send it back, so a draft cannot change the new file. + pub generation: u64, + pub routes: Vec, + pub clients: Vec, + pub tools: Vec, + pub algorithms: Vec, pub metrics: Metrics, pub today: chrono::NaiveDate, pub analytics: rollup::Usage, @@ -77,6 +279,99 @@ pub struct AppSettings { pub prices_file: Option, } +/// RouteInfo describes one entry under `[routes]` in the server config. +#[derive(Clone, Default, Serialize)] +pub struct RouteInfo { + /// This field names the route's table in the server config. + pub key: String, + /// This field holds the public model ID that harnesses send. + pub id: String, + pub kind: String, + /// Route edits send this token back. The controller rejects it after the file changes. + pub revision: String, + pub label: String, + pub choices: Vec, + /// This field lists the tier of each role in role order. It is `None` when the apps + /// cannot edit the route's algorithm. + pub tiers: Option>>, + pub editable: bool, + /// This field holds the marker of the generated block that contains the route. The tool + /// that wrote the block replaces the route the next time it runs. + pub generated: Option, + /// This field maps each harness to the reason it cannot use the route, or to `None` + /// when it can. + pub compatibility: BTreeMap>, + /// This field lists the harness settings files that select the route's public model ID. + pub dependents: Vec, +} + +/// Dependent names harness settings that select a route, so deleting the route breaks them. +#[derive(Clone, Serialize)] +pub struct Dependent { + pub tool: Harness, + pub label: &'static str, + /// This field is `None` for the harness's own settings and names a saved account otherwise. + pub account: Option, + pub files: Vec, +} + +/// ToolInfo describes one harness whose settings the apps can change. +#[derive(Clone, Serialize)] +pub struct ToolInfo { + pub tool: Harness, + pub label: &'static str, + /// This field is true when the settings files resolve and the harness program is installed. + pub available: bool, + pub files: Vec, + /// This field summarizes the current settings, or holds the error that stopped the read. + pub status: String, + pub accounts: Vec, + pub account_details: Vec, + pub discovery_error: Option, +} + +/// AccountInfo describes one saved account folder of a harness. +#[derive(Clone, Serialize)] +pub struct AccountInfo { + pub name: String, + pub path: PathBuf, + /// The apps cannot ask a harness whether a login finished, so this field is `unknown`. + pub login_status: &'static str, + /// This field summarizes the account's settings, or holds the error that stopped the read. + pub status: String, +} + +/// ClientInfo describes one endpoint under `[llm_clients]` in the server config. +#[derive(Clone, Default, Serialize)] +pub struct ClientInfo { + pub name: String, + pub host: String, + /// This field lists the model IDs that targets in the server config name on this endpoint. + pub models: Vec, + /// This field is true when the app cannot ask the endpoint for its model list. + pub unlisted: bool, + pub accepts_key: bool, + /// This field explains why the endpoint cannot list models without a saved key. + pub note: Option, +} + +/// AlgorithmInfo describes one routing algorithm that the apps can write and the roles +/// that a new route of that algorithm needs. +#[derive(Clone, Default, Serialize)] +pub struct AlgorithmInfo { + pub kind: &'static str, + pub title: &'static str, + pub summary: &'static str, + pub roles: Vec, +} + +#[derive(Clone, Default, Serialize)] +pub struct RoleInfo { + pub label: String, + pub hint: &'static str, + pub tier: Option, +} + #[derive(Clone, Default, Serialize)] pub struct DataState { pub usage: Source, @@ -122,6 +417,13 @@ pub struct Reply { pub data: Value, } impl Reply { + fn saved_edit(result: server::SaveResult) -> Self { + Self { + message: result.message, + data: json!({"warning": result.warning, "details": result.details}), + } + } + fn message(message: String) -> Self { Self { message, @@ -130,12 +432,117 @@ impl Reply { } } +// Review keeps exact file bytes in the controller so later edits require a new review. +// Only the UUID token goes to the frontend; reviewed files can contain credentials. +struct Review { + token: String, + inputs: Value, + files: Vec, +} +impl Review { + fn capture(inputs: Value, paths: Vec) -> Result { + let files = paths + .into_iter() + .map(|path| { + let bytes = match std::fs::read(&path) { + Ok(bytes) => Some(std::sync::Arc::<[u8]>::from(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(format!("Read {}: {error}", path.display())), + }; + Ok((path, bytes)) + }) + .collect::, String>>()?; + Ok(Self { + token: uuid::Uuid::now_v7().to_string(), + inputs, + files, + }) + } + fn original(&self) -> Result<&str, String> { + std::str::from_utf8( + self.files + .first() + .and_then(|(_, bytes)| bytes.as_deref()) + .ok_or("The reviewed server config is missing.")?, + ) + .map_err(|error| error.to_string()) + } + // Decoded settings omit comments and unrelated fields, so this check compares raw bytes. + fn check(&self, token: Option<&str>, inputs: &Value) -> Result<(), String> { + if token != Some(self.token.as_str()) || inputs != &self.inputs { + return Err("Review these changes again before applying them.".into()); + } + for (path, bytes) in &self.files { + let current = match std::fs::read(path) { + Ok(bytes) => Some(std::sync::Arc::<[u8]>::from(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.to_string()), + }; + if ¤t != bytes { + return Err(format!( + "{} changed since your review. Review it again; your draft is preserved.", + path.display() + )); + } + } + Ok(()) + } +} + +// Each Controller retains one launch review until the next preview or a selected config change. +// Review keeps raw settings private; Launch uses these values and the reviewed Git commit. +struct ReviewedLaunch { + review: Review, + project: Value, + model: String, + server_url: String, + login: bool, + account: Option, +} + +// The hand-off review keeps the rendered text, so Handoff writes exactly what the user read. +// The transcript is a live file, so the review remembers its size and modification time +// rather than its bytes. +struct ReviewedHandoff { + review: Review, + plan: HandoffPlan, +} +struct HandoffPlan { + id: String, + markdown: String, + kind: transcripts::Kind, + transcript: PathBuf, + fingerprint: transcripts::Fingerprint, + source: Value, + managed: Option, + tool: Harness, + model: String, + root: String, + login: bool, + account: Option, + directory: PathBuf, + session_id: String, + native_session_id: Option, +} + pub struct Controller { settings: PathBuf, config: Config, reader: rollup::Reader, - health_probe: crate::health::Probe, + health: crate::health::Probe, + // The generation ties a draft to the server config file selected by the settings. + generation: u64, + route_reviews: std::collections::HashMap, + tool_review: Option, + session_cleanup: Option<(String, String, Value)>, + account_archive: Option<(Harness, String, String)>, + route_change: Option, update_review: Option<(String, Value)>, + launch_review: Option, + handoff_review: Option, + settings_review: Option, + /// Old hand-offs are removed once per run, on the first snapshot. + purged_handoffs: bool, } impl Controller { pub fn new(config: Config, settings: PathBuf) -> Self { @@ -143,12 +550,38 @@ impl Controller { config, settings, reader: rollup::Reader::default(), - health_probe: crate::health::Probe::default(), + health: crate::health::Probe::default(), + generation: 0, + route_reviews: Default::default(), + tool_review: None, + session_cleanup: None, + account_archive: None, + route_change: None, update_review: None, + launch_review: None, + handoff_review: None, + settings_review: None, + purged_handoffs: false, } } fn reload(&mut self) -> Result<(), String> { - self.config = Config::load(&self.settings)?; + let config = Config::load(&self.settings)?; + if config.config_file != self.config.config_file { + self.generation += 1; + self.route_reviews.clear(); + self.tool_review = None; + self.route_change = None; + self.launch_review = None; + self.handoff_review = None; + } + self.config = config; + + Ok(()) + } + fn check_generation(&self, generation: u64) -> Result<(), String> { + if generation != self.generation { + return Err("The route changed or was removed. Refresh and choose it again.".into()); + } Ok(()) } // Key actions accept only names that the server config declares. @@ -163,10 +596,28 @@ impl Controller { )) } } + fn routes(&self) -> Result { + let text = std::fs::read_to_string(&self.config.config_file).map_err(|e| e.to_string())?; + ServerConfig::parse(&text).map_err(|_| { + "The server config contains invalid TOML. Open it in Settings to fix it.".into() + }) + } + // Unchanged bytes keep the draft revision when the editor switches algorithms. + fn remember_route(&mut self, route: &str, mut review: Review) -> String { + if let Some(previous) = self.route_reviews.get(route) + && previous.files == review.files + { + review.token = previous.token.clone(); + } + let revision = review.token.clone(); + self.route_reviews.insert(route.to_string(), review); + revision + } pub fn snapshot(&mut self) -> Result { - self.reload()?; - let mut errors = Vec::new(); - // One local date keeps the daily and weekly usage totals aligned across midnight. + let configuration_error = self.reload().err(); + let mut errors: Vec = configuration_error.iter().cloned().collect(); + let needs_setup = crate::config::needs_setup(&self.settings)?; + // One local date keeps aggregate totals and frontend filters aligned across midnight. let today = chrono::Local::now().date_naive(); let mut usage_state = Availability::Available; let usage = self @@ -195,7 +646,7 @@ impl Controller { }) .collect(); let checked_at = chrono::Utc::now().to_rfc3339(); - let mut health = self.health_probe.details(&self.config.server_url); + let mut health = self.health.details(&self.config.server_url); health["checked_at"] = json!(checked_at); health["url"] = json!(self.config.server_url); let server_status = if health["state"] == "running" { @@ -203,6 +654,80 @@ impl Controller { } else { crate::health::ServerStatus::Stopped }; + let mut routes = Vec::new(); + let mut clients = Vec::new(); + let route_review = Review::capture(json!({}), vec![self.config.config_file.clone()]); + let route_config = route_review + .as_ref() + .map_err(Clone::clone) + .and_then(|review| { + ServerConfig::parse(review.original()?).map_err(|_| { + "The server config contains invalid TOML. Open it in Settings to fix it." + .to_string() + }) + }); + match route_config { + Ok(config) => { + let listed = config.routes(); + self.route_reviews + .retain(|key, _| listed.iter().any(|route| &route.key == key)); + for route in listed { + let original = route_review.as_ref().map_err(Clone::clone)?; + let review = Review { + token: uuid::Uuid::now_v7().to_string(), + inputs: json!({"route":route.key}), + files: original.files.clone(), + }; + let revision = self.remember_route(&route.key, review); + let choices = config.choices(&route.key); + let models = choices + .iter() + .map(|c| format!("{} / {}", c.client, c.model)) + .collect::>() + .join(" → "); + let algorithm = config.algorithm(&route.key); + routes.push(RouteInfo { + label: format!("{} · {} · {}", route.id, route.kind, models), + tiers: algorithm.map(|a| { + config + .roles(&route.key, a) + .iter() + .map(|r| r.tier.map(|t| format!("{t:?}"))) + .collect() + }), + compatibility: harness::HARNESSES + .iter() + .map(|&(tool, _)| (tool, login_mode(tool, &config, &route).err())) + .collect(), + dependents: known_dependents(&route.id), + editable: algorithm.is_some(), + generated: config + .generated_by("routes", &route.key) + .map(str::to_string), + key: route.key, + id: route.id, + kind: route.kind, + revision, + choices, + }); + } + clients = config + .clients() + .iter() + .map(|c| ClientInfo { + name: c.name.clone(), + host: c.host().to_string(), + models: config.models_on(&c.name), + unlisted: models::unlisted(c), + accepts_key: cfg!(any(target_os = "macos", windows)) + && !models::unlisted(c) + && (c.forward_auth || c.api_key_env.is_some()), + note: models::missing_env(c).map(models::missing_env_note), + }) + .collect(); + } + Err(e) => errors.push(e), + } let mut history_state = Availability::Available; let history = history::load(&self.config.routing_log).unwrap_or_else(|error| { history_state = Availability::Unavailable; @@ -210,6 +735,56 @@ impl Controller { history::History::default() }); let activity = history::hourly_calls(&history.entries, chrono::Utc::now()); + let tools = harness::HARNESSES + .iter() + .map(|&(tool, label)| { + let discovered = accounts::discover(tool); + let discovery_error = discovered.as_ref().err().cloned(); + let discovered = discovered.unwrap_or_default(); + let account_details = discovered + .iter() + .map(|(name, path)| AccountInfo { + name: name.clone(), + path: path.clone(), + login_status: "unknown", + status: accounts::config_paths(tool, Some(path)) + .and_then(|files| harness::inspect(tool, &files)) + .unwrap_or_else(|error| error), + }) + .collect(); + // A path error stays in status; files remains an array and the tool is unavailable. + let files = harness::paths(tool); + let status = files + .as_ref() + .map_err(Clone::clone) + .and_then(|files| harness::inspect(tool, files)) + .unwrap_or_else(|error| error); + ToolInfo { + tool, + label, + available: files.is_ok() && harness::binary(tool).is_some(), + files: files.unwrap_or_default(), + status, + accounts: discovered.into_iter().map(|(name, _)| name).collect(), + account_details, + discovery_error, + } + }) + .collect(); + let (session_inventory, session_inventory_error) = match sessions::inventory() { + Ok(inventory) => (inventory, None), + Err(error) => (Vec::new(), Some(error)), + }; + if !self.purged_handoffs { + self.purged_handoffs = true; + if let Err(error) = handoffs::purge(chrono::Utc::now()) { + errors.push(format!("Could not remove old hand-offs: {error}")); + } + } + let handoffs = handoffs::list().unwrap_or_else(|error| { + errors.push(format!("Could not read hand-offs: {error}")); + Vec::new() + }); let Config { server_url, routing_log, @@ -223,6 +798,8 @@ impl Controller { .and_then(|path| switchyard_desktop::install::update_status(&path)) .unwrap_or_else(|error| json!({"state":"unavailable","message":error})); Ok(Snapshot { + needs_setup, + configuration_error, update_status, settings: Some(AppSettings { path: self.settings.clone(), @@ -234,6 +811,9 @@ impl Controller { baseline_model: baseline_model.clone(), prices_file, }), + session_inventory, + session_inventory_error, + handoffs, data_state: DataState { usage: Source { state: usage_state, @@ -247,6 +827,19 @@ impl Controller { }, health, }, + generation: self.generation, + routes, + clients, + tools, + algorithms: ALGORITHMS + .iter() + .map(|a| AlgorithmInfo { + kind: a.kind, + title: a.title, + summary: a.summary, + roles: a.new_route_roles().iter().map(role_info).collect(), + }) + .collect(), metrics: Metrics { server_url: server_url.clone(), running: server_status == crate::health::ServerStatus::Running, @@ -272,7 +865,7 @@ impl Controller { refresh_seconds: refresh_seconds.max(1), }) } - /// The TUI uses these file paths to run an editor in the foreground. + /// The TUI uses these file and folder paths to run an editor in the foreground. pub fn editor_target(&mut self, action: &Action) -> Result, String> { match action { Action::OpenSettings {} => Ok(Some(self.settings.clone())), @@ -288,6 +881,16 @@ impl Controller { } Action::OpenUpdateLog {} => self.update_log().map(Some), Action::OpenLogs {} => logs_dir().map(Some), + // Account and session folders open in the editor too, because a terminal may run + // over SSH, where `open` would start a file manager on another screen. + Action::OpenAccount { tool, name } => account_path(*tool, Some(name)), + Action::OpenSession { id } => { + let receipt = sessions::managed_session(id)?; + let worktree = receipt["worktree"] + .as_str() + .ok_or("Missing session directory.")?; + Ok(Some(PathBuf::from(worktree))) + } _ => Ok(None), } } @@ -301,13 +904,884 @@ impl Controller { server::command("open", &[&self.settings.display().to_string()])?; return Ok(Reply::message("Opened app settings.".into())); } + // Configuration actions remain available while a saved file needs repair. + match &action { + Action::SaveConfiguration { + config, + create_server, + } => { + crate::config::save_configuration(&self.settings, config, *create_server)?; + self.reload()?; + return Ok(Reply::message("Saved configuration. The installed server loads these files when it starts. Check Server keys before restarting.".into())); + } + Action::PreviewResetSettings {} => { + let review = Review::capture(json!({}), vec![self.settings.clone()])?; + let data = json!({"preview_token":review.token,"file":self.settings,"message":"This removes only the app preferences. A recovery copy is kept. Server configs, keys, history, accounts, and tool settings are preserved."}); + self.settings_review = Some(review); + return Ok(Reply { + message: String::new(), + data, + }); + } + Action::ResetSettings { preview_token } => { + let review = self + .settings_review + .as_ref() + .ok_or("Review app preference removal first.")?; + review.check(preview_token.as_deref(), &json!({}))?; + let backup = self + .settings + .with_extension(format!("removed-{}.toml", uuid::Uuid::now_v7())); + match self.settings.symlink_metadata() { + Ok(_) => std::fs::rename(&self.settings, &backup) + .map_err(|error| error.to_string())?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.to_string()), + } + self.settings_review = None; + self.config = Config::defaults_for(&self.settings)?; + self.generation += 1; + self.route_reviews.clear(); + self.route_change = None; + self.tool_review = None; + self.launch_review = None; + return Ok(Reply::message(format!( + "Removed app preferences. Recovery copy: {}. Server files are preserved.", + backup.display() + ))); + } + _ => {} + } self.reload()?; match action { + Action::SaveConfiguration { .. } + | Action::PreviewResetSettings {} + | Action::ResetSettings { .. } => unreachable!(), Action::OpenLogs {} => { server::command("open", &[&logs_dir()?.display().to_string()])?; Ok(Reply::message("Opened Switchyard logs.".into())) } - Action::Restart {} => server::restart_checked(&self.config).map(Reply::message), + Action::Editor { + generation, + route, + algorithm, + } => { + self.check_generation(generation)?; + let review = Review::capture( + json!({"route":route}), + vec![self.config.config_file.clone()], + )?; + let config = self.routes()?; + review.check(Some(&review.token), &review.inputs)?; + current_route(&config, &route, None)?; + config + .algorithm(&route) + .ok_or("This route must be edited in the server config file.")?; + let algorithm = algorithm_by_id(&algorithm)?; + let roles = config.roles(&route, algorithm); + let revision = self.remember_route(&route, review); + let data = json!({"revision":revision,"roles": roles.iter().map(role_info).collect::>(),"choices":config.choices(&route)}); + Ok(Reply { + message: algorithm.summary.into(), + data, + }) + } + Action::Apply { + generation, + route, + algorithm, + choices, + revision, + } => { + self.check_generation(generation)?; + let config = self.routes()?; + current_route(&config, &route, None)?; + config + .algorithm(&route) + .ok_or("This route must be edited in the server config file.")?; + let algorithm = algorithm_by_id(&algorithm)?; + self.route_reviews + .get(&route) + .ok_or("Open the route editor before saving.")? + .check(revision.as_deref(), &json!({"route":route}))?; + server::apply( + &self.config, + &self.health, + &route, + algorithm, + &choices, + self.route_reviews[&route].original()?, + ) + .map(Reply::saved_edit) + } + Action::Remove { + generation, + route, + id, + revision, + } => { + self.check_generation(generation)?; + current_route(&self.routes()?, &route, Some(&id))?; + self.route_reviews + .get(&route) + .ok_or("Open the route editor before removing it.")? + .check(revision.as_deref(), &json!({"route":route}))?; + server::remove( + &self.config, + &self.health, + &route, + &id, + self.route_reviews[&route].original()?, + ) + .map(Reply::saved_edit) + } + Action::PreviewRouteChange { + generation, + operation, + route, + name, + id, + algorithm, + choices, + } => { + self.check_generation(generation)?; + let inputs = json!({"operation":operation,"route":route,"name":name,"id":id,"algorithm":algorithm,"choices":choices}); + let review = Review::capture(inputs, vec![self.config.config_file.clone()])?; + let edited = self.routes()?.change_route( + &operation, + &route, + &name, + &id, + algorithm.as_deref().map(algorithm_by_id).transpose()?, + &choices, + )?; + review.check(Some(&review.token), &review.inputs)?; + let data = json!({"notes":edited.notes,"file":self.config.config_file,"preview_token":review.token}); + self.route_change = Some(review); + Ok(Reply { + message: String::new(), + data, + }) + } + Action::RouteChange { + generation, + operation, + route, + name, + id, + algorithm, + choices, + preview_token, + } => { + self.check_generation(generation)?; + let inputs = json!({"operation":operation,"route":route,"name":name,"id":id,"algorithm":algorithm,"choices":choices}); + let review = self + .route_change + .as_ref() + .ok_or("Review the route change first.")?; + review.check(preview_token.as_deref(), &inputs)?; + let edited = self.routes()?.change_route( + &operation, + &route, + &name, + &id, + algorithm.as_deref().map(algorithm_by_id).transpose()?, + &choices, + )?; + server::save_route_change(&self.config, &self.health, review.original()?, edited) + .map(Reply::saved_edit) + } + Action::Models { + client, + refresh, + key, + } => { + let config = self.routes()?; + let client = config + .clients() + .into_iter() + .find(|c| c.name == client) + .ok_or("Unknown endpoint.")?; + if let Some(key) = &key { + if key.trim().is_empty() || models::has_line_break(key) { + return Err("Enter a nonempty key without line breaks.".into()); + } + if models::unlisted(&client) + || !(client.forward_auth || client.api_key_env.is_some()) + { + return Err("This endpoint does not accept a model-list key. \ + Choose an endpoint configured for model-list authentication." + .into()); + } + #[cfg(not(any(target_os = "macos", windows)))] + models::save_key(&client.base_url, key)?; + } + let cache = self.settings.with_file_name(models::CACHE_FILE); + let loaded = + models::load(&cache, &client, refresh || key.is_some(), key.as_deref()); + if let Some(key) = &key { + match &loaded.error { + None | Some(models::ListError::NotCached(_)) => { + models::save_key(&client.base_url, key)? + } + Some(error) => return Err(error.reason()), + } + } + // Fetch and cache-write failures need a warning even when a model list remains usable. + let warning = loaded.error.is_some(); + let fetched_at = loaded.list.as_ref().map(|list| list.fetched_at); + let source = loaded.list.as_ref().map_or("config", |_| loaded.source); + let message = loaded + .error + .map(|e| match e.advice() { + "" => e.reason(), + advice => format!("{} {advice}", e.reason()), + }) + .unwrap_or_else(|| "Model list loaded.".into()); + let models = loaded + .list + .map(|l| l.models) + .unwrap_or_else(|| config.models_on(&client.name)); + Ok(Reply { + message, + data: json!({"client":client.name,"count":models.len(),"models":models,"warning":warning,"source":source,"fetched_at":fetched_at}), + }) + } + Action::PreviewRoute { + generation, + route, + algorithm, + choices, + revision, + } => { + self.check_generation(generation)?; + self.route_reviews + .get(&route) + .ok_or("Open the route editor before reviewing it.")? + .check(revision.as_deref(), &json!({"route":route}))?; + let edited = self + .routes()? + .edit(&route, algorithm_by_id(&algorithm)?, &choices)?; + Ok(Reply { + message: String::new(), + data: json!({"notes":edited.notes,"revision":revision,"file":self.config.config_file}), + }) + } + Action::PreviewInstall { + tool, + account, + route, + id, + settings_file, + } => { + let files = install_paths(tool, account.as_deref(), settings_file.as_deref())?; + let inputs = json!({"tool":tool,"account":account,"route":route,"id":id,"settings_file":settings_file,"server_url":self.config.server_url}); + let mut paths = harness::review_files(&files); + paths.push(self.config.config_file.clone()); + let review = Review::capture(inputs, paths)?; + let config = self.routes()?; + let selected = current_route(&config, &route, Some(&id))?; + let mut data = harness::preview( + tool, + &files, + &self.config.server_url, + &selected.id, + config.context_window(&selected.key), + login_mode(tool, &config, &selected)?, + )?; + review.check(Some(&review.token), &review.inputs)?; + data["preview_token"] = json!(review.token); + self.tool_review = Some(review); + Ok(Reply { + message: String::new(), + data, + }) + } + Action::Install { + tool, + account, + route, + id, + settings_file, + preview_token, + } => { + let files = install_paths(tool, account.as_deref(), settings_file.as_deref())?; + let inputs = json!({"tool":tool,"account":account,"route":route,"id":id,"settings_file":settings_file,"server_url":self.config.server_url}); + self.tool_review + .as_ref() + .ok_or("Preview these settings before applying them.")? + .check(preview_token.as_deref(), &inputs)?; + let config = self.routes()?; + let route = current_route(&config, &route, Some(&id))?; + harness::install_reviewed( + tool, + &files, + &self.config.server_url, + &route.id, + config.context_window(&route.key), + login_mode(tool, &config, &route)?, + self.tool_review + .as_ref() + .map(|review| review.files.as_slice()), + ) + .map(Reply::message) + } + Action::PreviewRestore { + tool, + account, + settings_file, + } => { + let files = install_paths(tool, account.as_deref(), settings_file.as_deref())?; + let review = Review::capture( + json!({"restore":true,"tool":tool,"account":account,"settings_file":settings_file}), + harness::review_files(&files), + )?; + let mut data = harness::preview_restore(tool, &files)?; + review.check(Some(&review.token), &review.inputs)?; + data["preview_token"] = json!(review.token); + self.tool_review = Some(review); + Ok(Reply { + message: String::new(), + data, + }) + } + Action::Restore { + tool, + account, + settings_file, + preview_token, + } => { + let files = install_paths(tool, account.as_deref(), settings_file.as_deref())?; + self.tool_review.as_ref().ok_or("Preview the restore before applying it.")?.check(preview_token.as_deref(),&json!({"restore":true,"tool":tool,"account":account,"settings_file":settings_file}))?; + match harness::restore_reviewed( + &files, + self.tool_review + .as_ref() + .map(|review| review.files.as_slice()), + ) { + Ok(message) => Ok(Reply::message(message)), + Err(error) if error.starts_with("Restored settings, but") => Ok(Reply { + message: error, + data: json!({"warning":true}), + }), + Err(error) => Err(error), + } + } + Action::AddAccount { tool, name } => { + accounts::add(tool, name.trim()).map(Reply::message) + } + Action::Launch { + tool, + account, + route, + id, + project, + preview_token, + } => { + let inputs = + json!({"tool":tool,"account":account,"route":route,"id":id,"project":project}); + let plan = self + .launch_review + .as_ref() + .ok_or("Review the session before opening Terminal.")?; + plan.review.check(preview_token.as_deref(), &inputs)?; + let current = sessions::preview(std::path::Path::new(&project))?; + if current != plan.project { + return Err( + "The project changed since review. Review the session again.".into(), + ); + } + sessions::launch( + tool, + std::path::Path::new( + plan.project["project"] + .as_str() + .ok_or("Missing reviewed project.")?, + ), + &plan.model, + &plan.server_url, + plan.login, + plan.account.as_deref(), + plan.project["revision"] + .as_str() + .ok_or("Missing reviewed commit.")?, + ) + .map(Reply::message) + } + Action::PreviewLaunch { + tool, + account, + route, + id, + project, + } => { + if tool == Harness::CodexApp { + return Err("Use Codex app’s workspace controls or choose Codex CLI.".into()); + } + if harness::binary(tool).is_none() { + return Err("Install the selected harness first.".into()); + } + let inputs = + json!({"tool":tool,"account":account,"route":route,"id":id,"project":project}); + let review = Review::capture( + inputs.clone(), + vec![self.config.config_file.clone(), self.settings.clone()], + )?; + let app_config = Config::load(&self.settings)?; + review.check(Some(&review.token), &inputs)?; + if app_config.config_file != self.config.config_file { + return Err("Review these changes again before applying them.".into()); + } + let config = ServerConfig::parse(review.original()?).map_err( + |_| "The server config contains invalid TOML. Open it in Settings to fix it.", + )?; + let route = current_route(&config, &route, Some(&id))?; + let login = login_mode(tool, &config, &route)?; + let account = account_path(tool, account.as_deref())?; + let project_review = sessions::preview(std::path::Path::new(&project))?; + let mut data = project_review.clone(); + data["tool"] = json!(tool); + data["route"] = json!(route.id); + data["account_directory"] = json!(account); + data["server_url"] = json!(app_config.server_url); + data["login"] = json!(login); + if tool == Harness::Pi { + data["settings_notice"] = json!( + "Pi uses isolated session settings. \ + Personal settings and extensions are not copied." + ); + } + review.check(Some(&review.token), &inputs)?; + data["preview_token"] = json!(review.token); + self.launch_review = Some(ReviewedLaunch { + review, + project: project_review, + model: route.id, + server_url: app_config.server_url, + login, + account, + }); + Ok(Reply { + message: String::new(), + data, + }) + } + Action::PreviewHandoff { + source, + tool, + account, + route, + id, + } => { + let inputs = + json!({"source":source,"tool":tool,"account":account,"route":route,"id":id}); + if matches!(tool, Harness::CodexApp | Harness::Pi) { + return Err("Choose Codex CLI or Claude Code to continue a session.".into()); + } + let binary = harness::binary(tool).ok_or("Install the selected harness first.")?; + let review = Review::capture( + inputs.clone(), + vec![self.config.config_file.clone(), self.settings.clone()], + )?; + let app_config = Config::load(&self.settings)?; + if app_config.config_file != self.config.config_file { + return Err("Review these changes again before applying them.".into()); + } + let config = ServerConfig::parse(review.original()?).map_err( + |_| "The server config contains invalid TOML. Open it in Settings to fix it.", + )?; + let route = current_route(&config, &route, Some(&id))?; + let login = login_mode(tool, &config, &route)?; + let root = harness::validate(tool, &app_config.server_url, &route.id, login)?; + let account = account_path(tool, account.as_deref())?; + let mut warnings = Vec::new(); + let (kind, transcript, managed, directory, session_id) = match &source { + HandoffSource::Managed { id } => { + let receipt = sessions::managed_session(id)?; + let (kind, transcript) = managed_transcript(&receipt)?; + let directory = session_directory(&receipt)?; + warnings.push(format!( + "The new session keeps the session ID {id}, so the server's \ + per-session routing state carries over and Usage lists both \ + harnesses under it." + )); + ( + kind, + transcript, + Some(id.clone()), + Some(directory), + id.clone(), + ) + } + HandoffSource::Transcript { path } => { + let path = PathBuf::from(path); + if !path.is_absolute() + || !path.is_file() + || path.extension().is_none_or(|e| e != "jsonl") + { + return Err( + "Choose a .jsonl transcript file by its absolute path.".into() + ); + } + let kind = transcripts::detect(&path)?; + let session_id = format!("sy-{}", uuid::Uuid::now_v7()); + (kind, path, None, None, session_id) + } + }; + let limits = transcripts::Limits::default(); + let excerpt = transcripts::excerpt(kind, &transcript, &limits)?; + let markdown = transcripts::render(&excerpt, &limits); + let directory = match directory { + Some(directory) => directory, + None => excerpt.session.cwd.clone().ok_or( + "The transcript does not record a working directory, so the app \ + cannot tell where to continue. Hand off a managed session instead.", + )?, + }; + if !directory.is_dir() { + return Err(format!( + "The session directory {} is not on this computer. The transcript may \ + come from a remote session or from a project that was removed.", + directory.display() + )); + } + if excerpt.truncated { + warnings.push( + "Parts of the transcript were cut to keep the hand-off short.".into(), + ); + } + if let Some(branch) = &excerpt.session.branch + && let Some(current) = current_branch(&directory) + && ¤t != branch + { + warnings.push(format!( + "The transcript records branch {branch}, but {} is on {current}.", + directory.display() + )); + } + if chrono::Utc::now().timestamp_millis() - excerpt.fingerprint.modified_ms + < RECENT_MS + { + warnings.push( + "The transcript changed in the last two minutes, so the source session \ + may still be running. Finish or quit it before continuing elsewhere." + .into(), + ); + } + if kind == transcripts::Kind::Claude + && claude_open(&transcript, &excerpt.session.native_id) + { + warnings.push("Claude Code lists this session as open.".into()); + } + let handoff_id = handoffs::new_id(); + let dir = handoffs::directory(&handoff_id)?; + let file = dir.join(handoffs::FILE); + let native_session_id = (tool == Harness::Claude).then(sessions::claude_session_id); + let script_account = sessions::script_account(tool, account.as_deref())?; + let files = vec![dir.join("settings.json")]; + let instructions = pointer(kind, &file); + let first = first_prompt(&file); + let command = sessions::display_command(&sessions::OpenRequest { + tool, + binary, + directory: directory.clone(), + model: &route.id, + root: &root, + login, + account: script_account.as_deref(), + session_id: &session_id, + files: &files, + private: &dir, + native_session_id: native_session_id.as_deref(), + instructions: Some(&instructions), + read_dirs: std::slice::from_ref(&dir), + first_prompt: Some(&first), + })?; + let mut source_info = + serde_json::to_value(&excerpt.session).map_err(|error| error.to_string())?; + source_info["tool"] = json!(source_harness(kind)); + source_info["label"] = json!(kind.label()); + source_info["managed_id"] = json!(managed); + let data = json!({ + "preview_token": review.token, + "handoff": markdown, + "bytes": markdown.len(), + "truncated": excerpt.truncated, + "turns": excerpt.turns.len(), + "files_touched": excerpt.files.len(), + "commands": excerpt.commands.len(), + "source": source_info, + "destination": { + "tool": tool, + "label": harness_name(tool), + "route": route.id, + "account_directory": account, + "directory": directory, + "session_id": session_id, + "native_session_id": native_session_id, + "login": login, + "server_url": app_config.server_url, + }, + "handoff_file": file, + "settings_file": (tool == Harness::Claude).then(|| files[0].clone()), + "command": command, + "warnings": warnings, + "notice": HANDOFF_NOTICE, + }); + self.handoff_review = Some(ReviewedHandoff { + review, + plan: HandoffPlan { + id: handoff_id, + markdown, + kind, + transcript, + fingerprint: excerpt.fingerprint, + source: source_info, + managed, + tool, + model: route.id, + root, + login, + account, + directory, + session_id, + native_session_id, + }, + }); + Ok(Reply { + message: String::new(), + data, + }) + } + Action::Handoff { + source, + tool, + account, + route, + id, + preview_token, + attach, + } => { + let inputs = + json!({"source":source,"tool":tool,"account":account,"route":route,"id":id}); + let reviewed = self + .handoff_review + .as_ref() + .ok_or("Review the hand-off before opening Terminal.")?; + reviewed.review.check(preview_token.as_deref(), &inputs)?; + if transcripts::fingerprint(&reviewed.plan.transcript)? != reviewed.plan.fingerprint + { + return Err( + "The transcript changed since your review. Review the hand-off again." + .into(), + ); + } + let binary = harness::binary(tool).ok_or("Install the selected harness first.")?; + // A review opens one session. Another hand-off needs another review. + let plan = self + .handoff_review + .take() + .ok_or("Review the hand-off before opening Terminal.")? + .plan; + let started_at = chrono::Utc::now().to_rfc3339(); + let dir = handoffs::directory(&plan.id)?; + let file = dir.join(handoffs::FILE); + let mut record = json!({ + "id": plan.id, + "created_at": started_at, + "file": file, + "source": plan.source, + "destination": { + "tool": plan.tool, + "label": harness_name(plan.tool), + "route": plan.model, + "account_directory": plan.account, + "directory": plan.directory, + "session_id": plan.session_id, + "native_session_id": plan.native_session_id, + }, + "bytes": plan.markdown.len(), + "opened": false, + }); + handoffs::create(&plan.id, &plan.markdown, &record)?; + let files = vec![dir.join("settings.json")]; + let instructions = pointer(plan.kind, &file); + let first = first_prompt(&file); + let opened = sessions::script_account(plan.tool, plan.account.as_deref()).and_then( + |script_account| { + if plan.tool == Harness::Claude { + harness::install( + plan.tool, + &files, + &plan.root, + &plan.model, + None, + plan.login, + )?; + } + let request = sessions::OpenRequest { + tool: plan.tool, + binary: binary.clone(), + directory: plan.directory.clone(), + model: &plan.model, + root: &plan.root, + login: plan.login, + account: script_account.as_deref(), + session_id: &plan.session_id, + files: &files, + private: &dir, + native_session_id: plan.native_session_id.as_deref(), + instructions: Some(&instructions), + read_dirs: std::slice::from_ref(&dir), + first_prompt: Some(&first), + }; + if attach { + sessions::invocation(&request).map(Some) + } else { + sessions::open_harness(&request).map(|()| None) + } + }, + ); + // A hand-off that never opened holds only an unread excerpt, so it goes away. + let invocation = match opened { + Ok(invocation) => invocation, + Err(error) => { + let _ = std::fs::remove_dir_all(&dir); + return Err(error); + } + }; + record["opened"] = json!(true); + handoffs::update(&plan.id, &record)?; + if let Some(managed) = &plan.managed { + sessions::append_launch( + managed, + &json!({ + "tool": plan.tool, + "route": plan.model, + "account_directory": plan.account, + "settings_directory": dir, + "started_at": started_at, + "native_session_id": plan.native_session_id, + "handoff": { + "id": plan.id, + "from_tool": plan.source["tool"], + "from_native_id": plan.source["native_id"], + "file": file, + }, + }), + )?; + } + let message = format!( + "{} {}.\n\ + Hand-off: {}\n\ + Directory: {}\n\ + Session ID: {}\n\ + The new session reads the hand-off under its own login. Hand-offs are \ + removed after {} days.", + if attach { + "Continuing the session here in" + } else { + "Opened Terminal to continue the session in" + }, + harness_name(plan.tool), + file.display(), + plan.directory.display(), + plan.session_id, + handoffs::RETENTION_DAYS + ); + let data = match invocation { + Some(invocation) => json!({"invocation": { + "program": binary, + "directory": plan.directory, + "args": invocation.args, + "environment": invocation.environment, + "remove_environment": invocation.remove_environment, + }}), + None => Value::Null, + }; + Ok(Reply { message, data }) + } + Action::RecentSessions { tool, account } => Ok(Reply { + message: String::new(), + data: recent_sessions(&self.config.config_file, tool, account.as_deref())?, + }), + Action::LoginAccount { tool, name } => { + let path = account_path(tool, Some(&name))?.ok_or("Choose an account.")?; + accounts::login(tool, &path, &name).map(Reply::message) + } + Action::PreviewAccountArchive { tool, name } => { + let path = account_path(tool, Some(&name))?.ok_or("Choose an account.")?; + let token = uuid::Uuid::now_v7().to_string(); + self.account_archive = Some((tool, name.clone(), token.clone())); + Ok(Reply { + message: String::new(), + data: json!({ + "name": name, + "path": path, + "preview_token": token, + "notice": "Archive moves this account out of the picker and preserves all \ + login files. Existing sessions using its old directory may \ + need a new login." + }), + }) + } + Action::ArchiveAccount { + tool, + name, + preview_token, + } => { + let (expected_tool, expected_name, token) = self + .account_archive + .as_ref() + .ok_or("Review account archive first.")?; + if tool != *expected_tool + || name != *expected_name + || preview_token.as_deref() != Some(token.as_str()) + { + return Err("Review account archive again.".into()); + } + let path = account_path(tool, Some(&name))?.ok_or("Choose an account.")?; + accounts::archive(tool, &path).map(Reply::message) + } + Action::OpenAccount { tool, name } => { + let path = account_path(tool, Some(&name))?.ok_or("Choose an account.")?; + server::command("open", &[&path.display().to_string()])?; + Ok(Reply::message("Opened the account folder.".into())) + } + Action::OpenSession { id } => { + let receipt = sessions::managed_session(&id)?; + server::command( + "open", + &[receipt["worktree"] + .as_str() + .ok_or("Missing session directory.")?], + )?; + Ok(Reply::message("Opened the session folder.".into())) + } + Action::PreviewSessionCleanup { id } => { + let data = sessions::cleanup_preview(&id)?; + let token = uuid::Uuid::now_v7().to_string(); + self.session_cleanup = Some((id, token.clone(), data.clone())); + let mut data = data; + data["preview_token"] = json!(token); + Ok(Reply { + message: String::new(), + data, + }) + } + Action::RemoveSession { id, preview_token } => { + let (expected_id, token, data) = self + .session_cleanup + .as_ref() + .ok_or("Review session cleanup first.")?; + if &id != expected_id || preview_token.as_deref() != Some(token.as_str()) { + return Err("Review session cleanup again.".into()); + } + sessions::cleanup(&id, data).map(Reply::message) + } + Action::Restart {} => { + server::restart_checked(&self.config, &self.health).map(Reply::message) + } Action::CheckConfig {} => server::check_config(&self.config) .map(|()| Reply::message("Server config check passed.".into())), Action::ServerKeys {} => Ok(Reply { @@ -365,9 +1839,11 @@ impl Controller { }) } Action::Update { preview_token } => { + // Each update attempt uses up the reviewed preview, so a second Update needs a new + // PreviewUpdate even when the checkout has not changed. let (token, reviewed) = self .update_review - .as_ref() + .take() .ok_or("Review the source update first.")?; let mut current = switchyard_desktop::install::preview_update(&install_metadata( &self.settings, @@ -376,7 +1852,7 @@ impl Controller { .as_object_mut() .ok_or("Invalid update preview.")? .remove("status"); - if preview_token.as_deref() != Some(token.as_str()) || ¤t != reviewed { + if preview_token != Some(token) || current != reviewed { return Err( "The source checkout or installation changed. Review the update again." .into(), @@ -412,6 +1888,460 @@ impl Controller { fn logs_dir() -> Result { crate::config::expand_home(std::path::Path::new("~/.switchyard/logs")) } +fn known_dependents(id: &str) -> Vec { + let mut dependents = Vec::new(); + for &(tool, label) in harness::HARNESSES { + let mut destinations = vec![(None, harness::paths(tool))]; + destinations.extend( + accounts::list(tool) + .into_iter() + .map(|(name, path)| (Some(name), accounts::config_paths(tool, Some(&path)))), + ); + for (account, files) in destinations { + let Ok(files) = files else { + continue; + }; + if harness::inspect(tool, &files) + .is_ok_and(|status| status.lines().next() == Some(format!("Model: {id}").as_str())) + { + dependents.push(Dependent { + tool, + label, + account, + files, + }); + } + } + } + dependents +} + +fn role_info(role: &crate::server_config::Role) -> RoleInfo { + RoleInfo { + label: role.label.clone(), + hint: role.hint, + tier: role.tier.map(|t| format!("{t:?}")), + } +} + +fn algorithm_by_id(kind: &str) -> Result<&'static crate::server_config::Algorithm, String> { + ALGORITHMS + .iter() + .find(|a| a.kind == kind) + .ok_or_else(|| "Unknown routing algorithm.".into()) +} +fn current_route(config: &ServerConfig, key: &str, id: Option<&str>) -> Result { + config + .routes() + .into_iter() + .find(|r| r.key == key && id.is_none_or(|id| id == r.id)) + .ok_or_else(|| "The route changed or was removed. Refresh and choose it again.".into()) +} +// A custom file selects one settings location; saved accounts select their own folders. +fn install_paths( + tool: Harness, + account: Option<&str>, + file: Option<&str>, +) -> Result, String> { + if let Some(file) = file { + if account.is_some() { + return Err("Choose a saved account or a custom settings file, not both.".into()); + } + let path = PathBuf::from(file); + if !path.is_absolute() || file.trim().is_empty() || file.contains(['\n', '\r']) { + return Err("Enter an absolute settings file path without line breaks.".into()); + } + if path.is_dir() { + return Err("Choose a settings file, not a folder.".into()); + } + if tool == Harness::Pi { + if path.file_name().is_none_or(|name| name != "models.json") { + return Err("Choose Pi’s models.json; settings.json is read beside it.".into()); + } + return Ok(vec![path.clone(), path.with_file_name("settings.json")]); + } + return Ok(vec![path]); + } + let account = account_path(tool, account)?; + accounts::config_paths(tool, account.as_deref()) +} + +fn account_path(tool: Harness, name: Option<&str>) -> Result, String> { + let Some(name) = name else { + return Ok(None); + }; + let path = accounts::list(tool) + .into_iter() + .find(|(n, _)| n == name) + .map(|(_, p)| p) + .ok_or("Unknown account. Refresh and choose an existing account.")?; + accounts::validate(tool, &path)?; + Ok(Some(path)) +} +fn login_mode( + tool: Harness, + config: &ServerConfig, + route: &crate::server_config::Route, +) -> Result { + let clients = config.clients(); + let choices = config.choices(&route.key); + if choices.is_empty() { + return Err( + "This route cannot be installed by the app. Choose a route with models.".into(), + ); + } + let mut login = false; + for choice in choices { + let client = clients + .iter() + .find(|c| c.name == choice.client) + .ok_or("The route has an unknown endpoint.")?; + if client.forward_auth { + let url = url::Url::parse(&client.base_url) + .map_err(|_| "The route has an invalid endpoint URL.")?; + let host = url + .host_str() + .ok_or("The route has an invalid endpoint URL.")?; + let supported = url.scheme() == "https" + && url.port().is_none_or(|port| port == 443) + && match tool { + Harness::CodexCli | Harness::CodexApp => host == "chatgpt.com", + Harness::Claude => host == "api.anthropic.com", + Harness::Pi => false, + }; + if !supported { + return Err(format!( + "This route forwards the caller's login to {host}. \ + Pick a matching harness or a route with server API credentials." + )); + } + login = true; + } + } + Ok(login) +} + +/// A transcript changed this recently suggests that its session is still running. +const RECENT_MS: i64 = 2 * 60 * 1000; +/// `HarnessSessions` lists at most this many sessions, newest first. +const LISTED_SESSIONS: usize = 200; +const HANDOFF_NOTICE: &str = "Switchyard writes the hand-off text above to a private file and opens the harness in the \ + session directory. The new session reads the file under its own login and summarizes it; \ + nothing is uploaded, and no login is shared."; + +/// The short harness name that hand-offs use. `HARNESSES` labels describe settings files. +pub(crate) fn harness_name(tool: Harness) -> &'static str { + match tool { + Harness::CodexCli => "Codex CLI", + Harness::CodexApp => "Codex app", + Harness::Claude => "Claude Code", + Harness::Pi => "Pi", + } +} + +fn source_harness(kind: transcripts::Kind) -> Harness { + match kind { + transcripts::Kind::Claude => Harness::Claude, + transcripts::Kind::Codex => Harness::CodexCli, + } +} + +/// The transcripts that a harness writes. Codex app and Codex CLI share one session store. +fn transcript_kind(tool: Harness) -> Result { + match tool { + Harness::Claude => Ok(transcripts::Kind::Claude), + Harness::CodexCli | Harness::CodexApp => Ok(transcripts::Kind::Codex), + Harness::Pi => Err("Pi sessions cannot be continued in another harness yet.".into()), + } +} + +/// The directory that holds a harness's transcripts: the account directory, or the harness's +/// current config directory. +fn transcript_root(tool: Harness, account: Option<&Path>) -> Result { + if let Some(account) = account { + return Ok(account.to_path_buf()); + } + harness::paths(tool)?[0] + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| "The harness settings have no parent directory.".into()) +} + +/// The harness that continues a session by default: the other command line harness. +pub(crate) fn other_harness(tool: Harness) -> Harness { + match tool { + Harness::Claude => Harness::CodexCli, + _ => Harness::Claude, + } +} + +/// This lists sessions from harness transcripts, newest first: those of one harness and +/// account, or of both command line harnesses and every saved account when `tool` is `None`. +/// Each session names the harness that continues it by default, with the first route that +/// harness can use, so a caller can offer one action per session. +pub(crate) fn recent_sessions( + config_file: &Path, + tool: Option, + account: Option<&str>, +) -> Result { + let mut roots = Vec::new(); + match tool { + Some(tool) => { + let path = account_path(tool, account)?; + roots.push(( + tool, + account.map(str::to_owned), + transcript_root(tool, path.as_deref())?, + )); + } + None => { + for tool in [Harness::CodexCli, Harness::Claude] { + if let Ok(root) = transcript_root(tool, None) { + roots.push((tool, None, root)); + } + for (name, path) in accounts::list(tool) { + roots.push((tool, Some(name), path)); + } + } + } + } + let text = std::fs::read_to_string(config_file) + .map_err(|error| format!("Read {}: {error}", config_file.display()))?; + let config = ServerConfig::parse(&text) + .map_err(|_| "The server config contains invalid TOML. Open it in Settings to fix it.")?; + let routes = config.routes(); + let destination = |tool: Harness| { + let name = harness_name(tool); + let mut usable = None; + let mut rejected = None; + for route in &routes { + match login_mode(tool, &config, route) { + Ok(_) => { + usable = Some((route.key.clone(), route.id.clone())); + break; + } + Err(reason) => { + rejected.get_or_insert(reason); + } + } + } + let available = harness::binary(tool).is_some(); + let reason = if !available { + Some(format!( + "{name} is not detected. Install it or add it to PATH." + )) + } else if usable.is_none() { + Some(match rejected { + Some(why) => format!("No route in the server config can serve {name}. {why}"), + None => format!("Add a route that {name} can use to the server config."), + }) + } else { + None + }; + json!({ + "tool": tool, + "label": name, + "available": available, + "route": usable.as_ref().map(|(key, _)| key.clone()), + "id": usable.as_ref().map(|(_, id)| id.clone()), + "reason": reason, + }) + }; + let destinations: BTreeMap = [Harness::CodexCli, Harness::Claude] + .into_iter() + .map(|tool| (tool, destination(tool))) + .collect(); + // A session in a managed worktree keeps its session ID when it continues. + let managed: Vec<(PathBuf, String)> = sessions::inventory() + .unwrap_or_default() + .iter() + .filter_map(|receipt| { + Some(( + canonical(Path::new(receipt["worktree"].as_str()?)), + receipt["id"].as_str()?.to_owned(), + )) + }) + .collect(); + let mut listed = Vec::new(); + let mut errors = Vec::new(); + let mut found = Vec::new(); + let mut skipped = 0; + let mut elsewhere = 0; + for (tool, account, root) in &roots { + let kind = transcript_kind(*tool)?; + listed.push(json!({ + "tool": tool, + "label": kind.label(), + "account": account, + "transcripts": kind.transcript_dir(root), + })); + let listing = match transcripts::list(kind, root) { + Ok(listing) => listing, + Err(error) => { + errors.push(format!("{}: {error}", kind.transcript_dir(root).display())); + continue; + } + }; + skipped += listing.skipped; + for session in &listing.sessions { + // A session continues in its own directory, so one recorded on another computer, + // such as a remote session, or in a folder that is gone, is counted and left out. + if !session.cwd.as_deref().is_some_and(Path::is_dir) { + elsewhere += 1; + continue; + } + let mut value = serde_json::to_value(session).map_err(|error| error.to_string())?; + let managed_id = session.cwd.as_deref().map(canonical).and_then(|cwd| { + managed + .iter() + .find(|(worktree, _)| cwd.starts_with(worktree)) + .map(|(_, id)| id.clone()) + }); + value["tool"] = json!(tool); + value["label"] = json!(kind.label()); + value["account"] = json!(account); + value["managed_id"] = json!(managed_id); + value["destination"] = destinations[&other_harness(*tool)].clone(); + found.push(value); + } + } + found.sort_by(|left, right| { + right["last_activity"] + .as_str() + .cmp(&left["last_activity"].as_str()) + }); + let total = found.len(); + found.truncate(LISTED_SESSIONS); + Ok(json!({ + "sessions": found, + "total": total, + "skipped": skipped, + "elsewhere": elsewhere, + "roots": listed, + "errors": errors, + "limit": LISTED_SESSIONS, + })) +} + +// Harnesses record the resolved directory, which differs from the recorded one through +// symlinks such as macOS's /var → /private/var. +pub(crate) fn canonical(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +fn parse_time(text: &str) -> Option> { + chrono::DateTime::parse_from_rfc3339(text) + .ok() + .map(|time| time.with_timezone(&chrono::Utc)) +} + +/// This finds the transcript of a managed session's latest launch. Claude launches record +/// their session UUID; Codex names its thread itself, so the newest rollout started in the +/// session directory after the launch stands in for it. +fn managed_transcript(receipt: &Value) -> Result<(transcripts::Kind, PathBuf), String> { + let launch = receipt["launches"] + .as_array() + .and_then(|launches| launches.last()) + .unwrap_or(receipt); + let tool: Harness = serde_json::from_value(launch["tool"].clone()) + .map_err(|_| "The session receipt names an unknown harness.")?; + let kind = transcript_kind(tool)?; + let account = launch["account_directory"].as_str().map(PathBuf::from); + let root = transcript_root(tool, account.as_deref())?; + if let Some(native) = launch["native_session_id"].as_str() { + return Ok((kind, transcripts::locate(kind, &root, native)?)); + } + let directory = canonical(&session_directory(receipt)?); + let launched = launch["started_at"].as_str().and_then(parse_time); + let listing = transcripts::list(kind, &root)?; + listing + .sessions + .into_iter() + .filter(|session| { + session + .cwd + .as_deref() + .is_some_and(|cwd| canonical(cwd) == directory) + }) + .find(|session| { + match (launched, session.started.as_deref().and_then(parse_time)) { + // A rollout's own timestamp can precede the launch record by the time the + // harness took to start. + (Some(launched), Some(begun)) => begun + chrono::Duration::minutes(1) >= launched, + _ => true, + } + }) + .map(|session| (kind, session.transcript)) + .ok_or_else(|| { + format!( + "No {} transcript started in {} after the launch. Send one message in that \ + session first, or choose its transcript file.", + kind.label(), + directory.display() + ) + }) +} + +/// The directory a managed session's harness starts in: the worktree, or the recorded folder +/// within it. +fn session_directory(receipt: &Value) -> Result { + let worktree = PathBuf::from( + receipt["worktree"] + .as_str() + .ok_or("Missing session worktree.")?, + ); + Ok(match receipt["relative_directory"].as_str() { + Some(relative) if !relative.is_empty() => worktree.join(relative), + _ => worktree, + }) +} + +fn current_branch(directory: &Path) -> Option { + let output = std::process::Command::new("git") + .arg("-C") + .arg(directory) + .args(["branch", "--show-current"]) + .output() + .ok()?; + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (output.status.success() && !branch.is_empty()).then_some(branch) +} + +/// Claude Code registers each open session under `/sessions`. The transcript +/// lives at `/projects//.jsonl`. +fn claude_open(transcript: &Path, native_id: &str) -> bool { + let Some(config_dir) = transcript.ancestors().nth(3) else { + return false; + }; + let Ok(entries) = std::fs::read_dir(config_dir.join("sessions")) else { + return false; + }; + entries.flatten().any(|entry| { + std::fs::read(entry.path()) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .is_some_and(|record| record["sessionId"] == native_id) + }) +} + +/// The instructions that point the destination at the hand-off. The excerpt itself never goes +/// on the command line. +fn pointer(kind: transcripts::Kind, file: &Path) -> String { + format!( + "Switchyard handed a {} session to you. Its state is in the file {}. Read that file \ + before anything else. Then summarize the state in at most five lines, confirm the \ + next step, and continue the work. Do not redo completed work.", + kind.label(), + file.display() + ) +} + +fn first_prompt(file: &Path) -> String { + format!( + "Read the hand-off file {} and continue from where the previous session stopped.", + file.display() + ) +} fn install_metadata(settings: &std::path::Path) -> Result { let executable = std::env::current_exe().map_err(|e| e.to_string())?; @@ -439,7 +2369,7 @@ fn update(settings: &std::path::Path) -> Result { #[cfg(test)] mod tests { - use super::{Config, Controller}; + use super::*; #[test] fn recent_call_read_errors_name_the_source_and_keep_the_io_details() { @@ -467,4 +2397,1194 @@ mod tests { ); assert_eq!(snapshot.activity, [0; 24]); } + + // An update attempt uses up the reviewed preview, so the same token cannot start a second one. + #[test] + fn update_preview_tokens_work_once() { + let directory = tempfile::tempdir().expect("directory"); + let settings = directory.path().join("desktop.toml"); + std::fs::write(&settings, "server_url = 'http://127.0.0.1:9'\n").expect("settings"); + let config = Config::load(&settings).expect("config"); + let mut controller = Controller::new(config, settings); + // The temporary directory has no install.toml, so the first attempt fails after it takes + // the review. + controller.update_review = Some(("token".into(), serde_json::json!({}))); + let update = || Action::Update { + preview_token: Some("token".into()), + }; + let first = controller.dispatch(update()).err().expect("no install"); + assert_ne!(first, "Review the source update first."); + assert_eq!( + controller.dispatch(update()).err().as_deref(), + Some("Review the source update first.") + ); + } + + #[test] + fn setup_and_settings_share_saves_and_removal_requires_the_reviewed_bytes() { + let dir = tempfile::tempdir().expect("fixture"); + let path = dir.path().join("desktop.toml"); + let mut config = Config::defaults_for(&path).expect("defaults"); + config.server_url = "http://127.0.0.1:1".into(); + let mut controller = Controller::new(config.clone(), path.clone()); + assert!(controller.snapshot().expect("initial snapshot").needs_setup); + let mut unknown = serde_json::to_value(&config).expect("config"); + unknown["unknown_file"] = json!("ignored.toml"); + assert!( + serde_json::from_value::( + json!({"kind":"save_configuration","config":unknown,"create_server":true}) + ) + .is_err() + ); + assert!(!path.exists()); + controller + .dispatch(Action::SaveConfiguration { + config: config.clone(), + create_server: true, + }) + .expect("setup save"); + assert!(!controller.snapshot().expect("saved snapshot").needs_setup); + let server_bytes = std::fs::read(&config.config_file).expect("server"); + let selected = dir.path().join("selected.toml"); + std::fs::write(&selected, &server_bytes).expect("another server config"); + config.config_file = selected; + config.routing_log = dir.path().join("selected.jsonl"); + controller + .dispatch(Action::SaveConfiguration { + config: config.clone(), + create_server: false, + }) + .expect("settings save"); + let snapshot = controller.snapshot().expect("changed snapshot"); + assert_eq!( + snapshot.settings.expect("settings").config_file, + config.config_file + ); + assert_eq!( + snapshot.generation, 1, + "selecting a different config invalidates route drafts" + ); + let preview = controller + .dispatch(Action::PreviewResetSettings {}) + .expect("preview"); + let original = std::fs::read_to_string(&path).expect("preferences"); + std::fs::write(&path, format!("{original}# external change\n")).expect("external edit"); + assert!( + controller + .dispatch(Action::ResetSettings { + preview_token: preview.data["preview_token"].as_str().map(str::to_owned) + }) + .is_err() + ); + assert!(path.exists()); + let preview = controller + .dispatch(Action::PreviewResetSettings {}) + .expect("new preview"); + controller + .dispatch(Action::ResetSettings { + preview_token: preview.data["preview_token"].as_str().map(str::to_owned), + }) + .expect("remove preferences"); + assert!(!path.exists()); + assert_eq!( + std::fs::read(&config.config_file).expect("server preserved"), + server_bytes + ); + assert!( + std::fs::read_dir(dir.path()) + .expect("files") + .flatten() + .any(|entry| entry.file_name().to_string_lossy().contains("removed-")) + ); + #[cfg(unix)] + { + std::os::unix::fs::symlink(dir.path().join("missing-settings.toml"), &path) + .expect("broken settings link"); + let snapshot = controller.snapshot().expect("repair snapshot"); + assert!(!snapshot.needs_setup); + assert!(snapshot.configuration_error.is_some()); + assert!(controller.dispatch(Action::Restart {}).is_err()); + assert!( + path.symlink_metadata() + .expect("preserved link") + .is_symlink() + ); + } + } + + // The subprocess isolates harness discovery and every launch destination. + #[cfg(unix)] + #[test] + fn session_launch_requires_an_unchanged_review_before_creating_files() { + use std::os::unix::fs::PermissionsExt; + use std::{fs, process::Command}; + if let Some(home) = std::env::var_os("SWITCHYARD_LAUNCH_REVIEW_FIXTURE") { + let home = PathBuf::from(home); + let project = home.join("project"); + let git = |args: &[&str]| { + let result = Command::new("/usr/bin/git") + .arg("-C") + .arg(&project) + .args(args) + .output() + .expect("git"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + }; + fs::create_dir(&project).expect("project"); + git(&["init"]); + git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "initial", + ]); + let settings = home.join("desktop.toml"); + let server = home.join("server.toml"); + let text = "[llm_clients.c]\n\ + base_url='http://localhost:1234'\n\ + [targets.m]\n\ + id='actual'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n"; + fs::write(&server, text).expect("server"); + fs::write( + &settings, + format!( + "config_file={server:?}\nrouting_log={:?}\n", + home.join("routing.jsonl") + ), + ) + .expect("settings"); + let mut controller = + Controller::new(Config::load(&settings).expect("settings"), settings.clone()); + let preview = |controller: &mut Controller| { + controller + .dispatch(Action::PreviewLaunch { + tool: Harness::CodexCli, + account: None, + route: "r".into(), + id: "public".into(), + project: project.display().to_string(), + }) + .expect("preview") + }; + let launch = |token: Option, path: String| Action::Launch { + tool: Harness::CodexCli, + account: None, + route: "r".into(), + id: "public".into(), + project: path, + preview_token: token, + }; + let reviewed = preview(&mut controller); + let token = reviewed.data["preview_token"] + .as_str() + .expect("token") + .to_string(); + for rejected in [ + launch(None, project.display().to_string()), + launch(Some("wrong".into()), project.display().to_string()), + launch(Some(token.clone()), home.display().to_string()), + ] { + assert!(controller.dispatch(rejected).is_err()); + } + fs::write(&server, format!("{text}# external edit\n")).expect("edit"); + assert!( + controller + .dispatch(launch(Some(token), project.display().to_string())) + .is_err() + ); + assert_eq!( + fs::read_to_string(&server).expect("preserved"), + format!("{text}# external edit\n") + ); + let reviewed = preview(&mut controller); + let token = reviewed.data["preview_token"] + .as_str() + .expect("token") + .to_string(); + let original = fs::read_to_string(&settings).expect("settings"); + fs::write(&settings, format!("{original}# external settings\n")).expect("edit"); + assert!( + controller + .dispatch(launch(Some(token), project.display().to_string())) + .is_err() + ); + let reviewed = preview(&mut controller); + let token = reviewed.data["preview_token"] + .as_str() + .expect("token") + .to_string(); + git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "changed", + ]); + assert!( + controller + .dispatch(launch(Some(token), project.display().to_string())) + .err() + .expect("changed HEAD") + .contains("project changed") + ); + assert!(!home.join(".switchyard/worktrees").exists()); + assert!(!home.join("terminal-called").exists()); + assert_eq!(fs::read_dir(&home).expect("files").count(), 4); + return; + } + let home = tempfile::tempdir().expect("fixture"); + let bin = home.path().join("bin"); + fs::create_dir(&bin).expect("bin"); + for name in ["codex", "open", "osascript"] { + let path = bin.join(name); + fs::write( + &path, + "#!/bin/sh\ntouch \"$HOME/terminal-called\"\nexit 0\n", + ) + .expect("stub"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("executable"); + } + let output=Command::new(std::env::current_exe().expect("test binary")) + .args(["--exact", "controller::tests::session_launch_requires_an_unchanged_review_before_creating_files"]) + .env("SWITCHYARD_LAUNCH_REVIEW_FIXTURE",home.path()).env("HOME",home.path()) + .env("PATH",format!("{}:/usr/bin:/bin",bin.display())).output().expect("child"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + // The subprocess isolates harness discovery, transcripts, hand-off files, and the terminal. + // The stubs record their arguments and environment, so the test sees what the destination + // harness receives. + #[cfg(unix)] + #[test] + fn session_handoff_requires_an_unchanged_review_and_opens_the_other_harness() { + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + use std::{fs, process::Command}; + if let Some(home) = std::env::var_os("SWITCHYARD_HANDOFF_REVIEW_FIXTURE") { + let home = PathBuf::from(home); + let project = home.join("project"); + let git = |args: &[&str]| { + let result = Command::new("/usr/bin/git") + .arg("-C") + .arg(&project) + .args(args) + .output() + .expect("git"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + }; + fs::create_dir(&project).expect("project"); + git(&["init"]); + git(&["symbolic-ref", "HEAD", "refs/heads/main"]); + git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "initial", + ]); + let settings = home.join("desktop.toml"); + let server = home.join("server.toml"); + fs::write( + &server, + "[llm_clients.c]\n\ + base_url='http://localhost:1234'\n\ + [targets.m]\n\ + id='actual'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n", + ) + .expect("server"); + fs::write( + &settings, + format!( + "config_file={server:?}\nrouting_log={:?}\n", + home.join("routing.jsonl") + ), + ) + .expect("settings"); + let mut controller = + Controller::new(Config::load(&settings).expect("settings"), settings.clone()); + let revision = sessions::preview(&project).expect("preview")["revision"] + .as_str() + .expect("revision") + .to_string(); + let server_url = controller.config.server_url.clone(); + sessions::launch( + Harness::CodexCli, + &project, + "public", + &server_url, + false, + None, + &revision, + ) + .expect("launch"); + let receipt = &sessions::inventory().expect("inventory")[0]; + let id = receipt["id"].as_str().expect("id").to_string(); + let worktree = PathBuf::from(receipt["worktree"].as_str().expect("worktree")); + // Codex names its thread itself, so a rollout started in the worktree after the + // launch stands in for the session. + let codex_home = PathBuf::from(std::env::var_os("CODEX_HOME").expect("CODEX_HOME")); + let day = codex_home.join("sessions/2026/10/26"); + fs::create_dir_all(&day).expect("rollouts"); + let thread = uuid::Uuid::now_v7().to_string(); + let rollout = day.join(format!("rollout-2026-10-26T10-00-00-{thread}.jsonl")); + let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + let line = |record: Value| format!("{record}\n"); + fs::write( + &rollout, + [ + line( + json!({"timestamp": now, "type": "session_meta", "payload": { + "id": thread, "cwd": worktree, "timestamp": now, "git": {"branch": id}}}), + ), + line(json!({"timestamp": now, "type": "event_msg", "payload": { + "type": "user_message", "message": "Fix the flaky login test"}})), + line(json!({"timestamp": now, "type": "event_msg", "payload": { + "type": "agent_message", + "message": "I changed auth.rs and the test passes now."}})), + ] + .concat(), + ) + .expect("rollout"); + let preview = |controller: &mut Controller, source: HandoffSource, tool: Harness| { + controller.dispatch(Action::PreviewHandoff { + source, + tool, + account: None, + route: "r".into(), + id: "public".into(), + }) + }; + let handoff = + |source: HandoffSource, tool: Harness, token: Option| Action::Handoff { + source, + tool, + account: None, + route: "r".into(), + id: "public".into(), + preview_token: token, + attach: false, + }; + let managed = HandoffSource::Managed { id: id.clone() }; + assert!(preview(&mut controller, managed.clone(), Harness::Pi).is_err()); + assert!(preview(&mut controller, managed.clone(), Harness::CodexApp).is_err()); + let reviewed = + preview(&mut controller, managed.clone(), Harness::Claude).expect("preview"); + let data = &reviewed.data; + let token = data["preview_token"].as_str().expect("token").to_string(); + let text = data["handoff"].as_str().expect("hand-off text"); + assert!(text.starts_with("# Hand-off from Codex"), "{text}"); + assert!(text.contains("Fix the flaky login test") && text.contains("auth.rs")); + assert_eq!(data["destination"]["directory"], json!(worktree)); + assert_eq!(data["destination"]["session_id"], json!(id)); + assert_eq!(data["source"]["tool"], "codex_cli"); + assert_eq!(data["source"]["native_id"], json!(thread)); + assert_eq!(data["source"]["managed_id"], json!(id)); + let warnings = data["warnings"].to_string(); + assert!(warnings.contains("keeps the session ID"), "{warnings}"); + assert!(warnings.contains("may still be running"), "{warnings}"); + assert!(!warnings.contains("records branch"), "{warnings}"); + let command = data["command"].as_str().expect("command"); + for flag in [ + "--append-system-prompt", + "--add-dir", + "--session-id", + "--settings", + ] { + assert!(command.contains(flag), "{command}"); + } + // The excerpt stays out of the command line. + assert!(!command.contains("Fix the flaky"), "{command}"); + assert!(!home.join(".switchyard/handoffs").exists()); + for rejected in [ + handoff(managed.clone(), Harness::Claude, None), + handoff(managed.clone(), Harness::Claude, Some("wrong".into())), + handoff(managed.clone(), Harness::CodexCli, Some(token.clone())), + ] { + assert!(controller.dispatch(rejected).is_err()); + } + assert!(!home.join(".switchyard/handoffs").exists()); + // The transcript grew after the review. + let mut file = fs::OpenOptions::new() + .append(true) + .open(&rollout) + .expect("append"); + writeln!( + file, + "{}", + json!({"timestamp": now, "type": "event_msg", "payload": { + "type": "user_message", "message": "later"}}) + ) + .expect("write"); + drop(file); + let error = controller + .dispatch(handoff(managed.clone(), Harness::Claude, Some(token))) + .err() + .expect("stale review"); + assert!(error.contains("changed since"), "{error}"); + assert!(!home.join(".switchyard/handoffs").exists()); + let reviewed = + preview(&mut controller, managed.clone(), Harness::Claude).expect("second preview"); + let token = reviewed.data["preview_token"] + .as_str() + .expect("token") + .to_string(); + let file = PathBuf::from(reviewed.data["handoff_file"].as_str().expect("file")); + let done = controller + .dispatch(handoff( + managed.clone(), + Harness::Claude, + Some(token.clone()), + )) + .expect("hand-off"); + assert!(done.message.contains("Claude Code"), "{}", done.message); + // One review opens one session. + assert!( + controller + .dispatch(handoff(managed.clone(), Harness::Claude, Some(token))) + .is_err() + ); + let dir = file.parent().expect("hand-off directory"); + assert_eq!( + fs::metadata(dir).expect("dir").permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(&file).expect("file").permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::read_to_string(&file).expect("hand-off"), + reviewed.data["handoff"].as_str().expect("text") + ); + assert!(dir.join("settings.json").is_file()); + let record: Value = + serde_json::from_slice(&fs::read(dir.join("handoff.json")).expect("record")) + .expect("json"); + assert_eq!(record["opened"], true); + assert_eq!(record["file"], json!(file)); + assert_eq!(record["destination"]["session_id"], json!(id)); + assert_eq!(record["source"]["native_id"], json!(thread)); + let receipt = sessions::managed_session(&id).expect("receipt"); + let launches = receipt["launches"].as_array().expect("launches"); + assert_eq!(launches.len(), 2); + assert_eq!(launches[1]["tool"], "claude"); + assert_eq!(launches[1]["route"], "public"); + assert_eq!(launches[1]["handoff"]["from_tool"], "codex_cli"); + assert_eq!(launches[1]["handoff"]["from_native_id"], json!(thread)); + assert_eq!(launches[1]["handoff"]["file"], json!(file)); + assert_eq!(launches[1]["settings_directory"], json!(dir)); + assert!( + uuid::Uuid::parse_str( + launches[1]["native_session_id"] + .as_str() + .expect("claude session") + ) + .is_ok() + ); + assert_eq!(receipt["tool"], "claude"); + assert!(sessions::cleanup_preview(&id).is_ok()); + let arguments = wait_for(&home.join("claude-arguments"), |_| true); + assert!( + arguments.contains(&format!("--add-dir\n{}\n", dir.display())), + "{arguments}" + ); + assert!( + arguments.contains(&format!( + "--settings\n{}\n", + dir.join("settings.json").display() + )), + "{arguments}" + ); + assert!( + arguments.contains("--append-system-prompt\nSwitchyard handed a Codex session"), + "{arguments}" + ); + assert!( + arguments.contains(&format!("x-switchyard-session-id: {id}\n")), + "{arguments}" + ); + assert!( + arguments.contains(&format!("Read the hand-off file {}", file.display())), + "{arguments}" + ); + assert!(!arguments.contains("Fix the flaky login test")); + let snapshot = controller.snapshot().expect("snapshot"); + assert_eq!(snapshot.handoffs.len(), 1); + assert_eq!(snapshot.handoffs[0]["id"], record["id"]); + + // A transcript that the app did not start continues in its own directory under a + // new session ID, and no receipt is written for it. + let claude_dir = + PathBuf::from(std::env::var_os("CLAUDE_CONFIG_DIR").expect("CLAUDE_CONFIG_DIR")); + let session = sessions::claude_session_id(); + let slug = claude_dir.join("projects/-fixture-project"); + fs::create_dir_all(&slug).expect("projects"); + let transcript = slug.join(format!("{session}.jsonl")); + fs::write( + &transcript, + [ + line(json!({"type": "user", "cwd": project, "sessionId": session, + "gitBranch": "main", "timestamp": now, + "message": {"role": "user", "content": "Add retries to the uploader"}})), + line( + json!({"type": "assistant", "cwd": project, "sessionId": session, + "timestamp": now, "message": {"id": "m1", "role": "assistant", + "model": "claude-x", + "content": [{"type": "text", "text": "Added retries in uploader.rs."}]}}), + ), + ] + .concat(), + ) + .expect("transcript"); + // A transcript from another computer records a directory that is not here, so it + // is counted and left out of the listing. + let remote_slug = claude_dir.join("projects/-remote-box-home"); + fs::create_dir_all(&remote_slug).expect("projects"); + let remote = sessions::claude_session_id(); + fs::write( + remote_slug.join(format!("{remote}.jsonl")), + line( + json!({"type": "user", "cwd": home.join("not-on-this-computer"), + "sessionId": remote, "timestamp": now, + "message": {"role": "user", "content": "Remote work"}}), + ), + ) + .expect("remote transcript"); + let listing = controller + .dispatch(Action::RecentSessions { + tool: Some(Harness::Claude), + account: None, + }) + .expect("listing"); + assert_eq!(listing.data["total"], 1); + assert_eq!(listing.data["elsewhere"], 1); + assert_eq!(listing.data["sessions"][0]["transcript"], json!(transcript)); + assert_eq!(listing.data["sessions"][0]["cwd"], json!(project)); + // Without a harness, both harnesses list together, and each session names the + // other harness with the first route it can use. + let merged = controller + .dispatch(Action::RecentSessions { + tool: None, + account: None, + }) + .expect("merged listing"); + let all = merged.data["sessions"].as_array().expect("sessions"); + assert_eq!(all.len(), 2, "{all:?}"); + assert_eq!(merged.data["elsewhere"], 1); + let claude = all + .iter() + .find(|s| s["tool"] == "claude") + .expect("claude session"); + let codex = all + .iter() + .find(|s| s["tool"] == "codex_cli") + .expect("codex session"); + assert_eq!(claude["destination"]["tool"], "codex_cli"); + assert_eq!(codex["destination"]["tool"], "claude"); + assert_eq!(codex["destination"]["route"], "r"); + assert_eq!(codex["destination"]["id"], "public"); + assert_eq!(codex["destination"]["available"], true); + assert!(codex["destination"]["reason"].is_null(), "{codex}"); + assert_eq!(codex["managed_id"], json!(id)); + assert!(claude["managed_id"].is_null()); + assert_eq!(merged.data["roots"].as_array().map(Vec::len), Some(2)); + assert!( + preview( + &mut controller, + HandoffSource::Transcript { + path: settings.display().to_string() + }, + Harness::CodexCli + ) + .is_err() + ); + let source = HandoffSource::Transcript { + path: transcript.display().to_string(), + }; + let reviewed = preview(&mut controller, source.clone(), Harness::CodexCli) + .expect("transcript preview"); + let new_id = reviewed.data["destination"]["session_id"] + .as_str() + .expect("session id") + .to_string(); + assert!(new_id.starts_with("sy-") && new_id != id); + assert_eq!(reviewed.data["destination"]["directory"], json!(project)); + assert_eq!(reviewed.data["source"]["tool"], "claude"); + assert!(reviewed.data["source"]["managed_id"].is_null()); + let command = reviewed.data["command"].as_str().expect("command"); + assert!(command.contains("developer_instructions="), "{command}"); + assert!(command.contains("model_providers.sy="), "{command}"); + let token = reviewed.data["preview_token"] + .as_str() + .expect("token") + .to_string(); + controller + .dispatch(handoff(source, Harness::CodexCli, Some(token))) + .expect("transcript hand-off"); + assert_eq!(sessions::inventory().expect("inventory").len(), 1); + assert!(sessions::managed_session(&new_id).is_err()); + let arguments = wait_for(&home.join("codex-arguments"), |text| { + text.contains("developer_instructions") + }); + assert!(arguments.contains(&new_id) && !arguments.contains(&id)); + assert!(arguments.contains("Read the hand-off file"), "{arguments}"); + assert!(!arguments.contains("Add retries to the uploader")); + assert_eq!(controller.snapshot().expect("snapshot").handoffs.len(), 2); + return; + } + let home = tempfile::tempdir().expect("fixture"); + let bin = home.path().join("bin"); + fs::create_dir(&bin).expect("bin"); + for name in ["codex", "claude"] { + let path = bin.join(name); + fs::write( + &path, + format!( + "#!/bin/bash\n\ + printf '%s\\n' \"${{CODEX_HOME:-}}\" \"${{CLAUDE_CONFIG_DIR:-}}\" \ + \"${{ANTHROPIC_CUSTOM_HEADERS:-}}\" \"$@\" > \"$HOME/{name}-arguments.partial\" \ + && mv \"$HOME/{name}-arguments.partial\" \"$HOME/{name}-arguments\"\n" + ), + ) + .expect("stub"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("executable"); + } + // macOS runs the script through `open`; Linux runs it through the program in TERMINAL, + // which receives `-e` before the script path. + let terminal = bin.join(if cfg!(target_os = "linux") { + "terminal" + } else { + "open" + }); + fs::write( + &terminal, + "#!/bin/bash\n[ \"$1\" = -e ] && shift\nbash \"$1\"\n", + ) + .expect("terminal"); + fs::set_permissions(&terminal, fs::Permissions::from_mode(0o755)).expect("executable"); + let output = Command::new(std::env::current_exe().expect("test binary")) + .args([ + "--exact", + "controller::tests::session_handoff_requires_an_unchanged_review_and_opens_the_other_harness", + ]) + .env("SWITCHYARD_HANDOFF_REVIEW_FIXTURE", home.path()) + .env("HOME", home.path()) + .env("CODEX_HOME", home.path().join("codex-home")) + .env("CLAUDE_CONFIG_DIR", home.path().join("claude-config")) + .env("PATH", format!("{}:/usr/bin:/bin", bin.display())) + .env("TERMINAL", &terminal) + .output() + .expect("child"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + /// The terminal starts apart from the hand-off, so a stub's record can arrive after the + /// action returns. This waits for the file to hold what `ready` expects. + #[cfg(unix)] + fn wait_for(path: &std::path::Path, ready: impl Fn(&str) -> bool) -> String { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + if let Ok(text) = std::fs::read_to_string(path) + && ready(&text) + { + return text; + } + assert!( + std::time::Instant::now() < deadline, + "{} was not written in time", + path.display() + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + + #[test] + // An algorithm switch must not give stale choices a revision for externally edited bytes. + fn algorithm_switches_keep_the_first_revision_and_external_edits_reject_it() { + let dir = tempfile::tempdir().expect("fixture"); + let settings = dir.path().join("desktop.toml"); + let config = dir.path().join("server.toml"); + let text = "[llm_clients.c]\n\ + base_url='http://localhost:1234'\n\ + [targets.m]\n\ + id='actual'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n"; + std::fs::write(&config, text).expect("config"); + std::fs::write( + &settings, + format!( + "config_file={config:?}\nrouting_log={:?}", + dir.path().join("log") + ), + ) + .expect("settings"); + let mut controller = Controller::new(Config::load(&settings).expect("load"), settings); + let first = controller + .dispatch(Action::Editor { + generation: 0, + route: "r".into(), + algorithm: "passthrough".into(), + }) + .expect("editor"); + let second = controller + .dispatch(Action::Editor { + generation: 0, + route: "r".into(), + algorithm: "random".into(), + }) + .expect("switch roles"); + assert_eq!(first.data["revision"], second.data["revision"]); + assert_eq!( + controller.snapshot().expect("snapshot").routes[0].revision, + first.data["revision"] + ); + std::fs::write(&config, format!("{text}# external edit\n")).expect("external edit"); + let latest = controller + .dispatch(Action::Editor { + generation: 0, + route: "r".into(), + algorithm: "passthrough".into(), + }) + .expect("new editor"); + assert_ne!(latest.data["revision"], first.data["revision"]); + let rejected = controller.dispatch(Action::PreviewRoute { + generation: 0, + route: "r".into(), + algorithm: "passthrough".into(), + choices: vec![Choice { + client: "c".into(), + model: "replacement".into(), + }], + revision: first.data["revision"].as_str().map(str::to_string), + }); + assert!(rejected.is_err()); + assert_eq!( + std::fs::read_to_string(&config).expect("config"), + format!("{text}# external edit\n") + ); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 2); + } + + #[test] + // A custom destination must use the same files for preview, installation, backup, and restore. + fn custom_settings_are_validated_and_restored() { + let dir = tempfile::tempdir().expect("directory"); + for (tool, _) in harness::HARNESSES { + let file = dir.path().join(if *tool == Harness::Pi { + "models.json" + } else { + "custom-settings" + }); + assert_eq!( + install_paths(*tool, None, Some(file.to_str().expect("path"))).expect("custom")[0], + file + ); + } + for (account, file) in [ + (None, "relative.json"), + (None, ""), + (Some("account"), "/tmp/settings.json"), + (None, "/tmp/settings\n.json"), + ] { + assert!(install_paths(Harness::Claude, account, Some(file)).is_err()); + } + assert!(install_paths(Harness::Pi, None, Some("/tmp/settings.json")).is_err()); + assert!(install_paths(Harness::Claude, None, dir.path().to_str()).is_err()); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 0); + let settings = dir.path().join("app.toml"); + let config = dir.path().join("server.toml"); + std::fs::write( + &config, + "[llm_clients.c]\n\ + base_url='https://example.com/v1'\n\ + format='openai_chat'\n\ + [targets.m]\n\ + id='actual'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n", + ) + .expect("config"); + std::fs::write( + &settings, + format!( + "config_file={config:?}\nrouting_log={:?}", + dir.path().join("log") + ), + ) + .expect("settings"); + let custom = dir.path().join("custom.json"); + std::fs::write(&custom, "{\"user\":true}").expect("custom"); + let mut controller = Controller::new(Config::load(&settings).expect("settings"), settings); + let file = Some(custom.display().to_string()); + let preview = controller + .dispatch(Action::PreviewInstall { + tool: Harness::Claude, + account: None, + settings_file: file.clone(), + route: "r".into(), + id: "public".into(), + }) + .expect("preview"); + assert!( + preview.data["changes"] + .as_array() + .expect("diff") + .iter() + .any(|change| change["file"] == custom.display().to_string()) + ); + std::fs::write(&custom, "{\"external\":true}").expect("external edit"); + let rejected = controller.dispatch(Action::Install { + tool: Harness::Claude, + account: None, + settings_file: file.clone(), + route: "r".into(), + id: "public".into(), + preview_token: preview.data["preview_token"].as_str().map(str::to_string), + }); + assert!(rejected.is_err()); + assert_eq!( + std::fs::read_to_string(&custom).expect("custom"), + "{\"external\":true}" + ); + assert!( + !custom + .with_file_name("custom.json.switchyard-original") + .exists() + ); + std::fs::write(&custom, "{\"user\":true}").expect("restore fixture"); + controller + .dispatch(Action::Install { + tool: Harness::Claude, + account: None, + settings_file: file.clone(), + route: "r".into(), + id: "public".into(), + preview_token: preview.data["preview_token"].as_str().map(str::to_string), + }) + .expect("install"); + assert_eq!( + preview.data["proposed"], + harness::inspect(Harness::Claude, std::slice::from_ref(&custom)).expect("inspect") + ); + let restore_preview = controller + .dispatch(Action::PreviewRestore { + tool: Harness::Claude, + account: None, + settings_file: file.clone(), + }) + .expect("restore preview"); + controller + .dispatch(Action::Restore { + tool: Harness::Claude, + account: None, + settings_file: file, + preview_token: restore_preview.data["preview_token"] + .as_str() + .map(str::to_string), + }) + .expect("restore"); + assert_eq!( + std::fs::read_to_string(custom).expect("custom"), + "{\"user\":true}" + ); + } + + #[cfg(any(target_os = "macos", windows))] + #[test] + // A cached list must not let an untested key reach Keychain. + fn submitting_a_key_checks_upstream_even_when_refresh_is_false() { + use std::io::{Read, Write}; + let dir = tempfile::tempdir().expect("directory"); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener"); + listener.set_nonblocking(true).expect("nonblocking"); + let url = format!("http://{}/v1", listener.local_addr().expect("address")); + #[cfg(windows)] + assert_eq!( + switchyard_desktop::install::windows::saved_key(&url).expect("no saved key"), + None + ); + let worker = std::thread::spawn(move || { + for _ in 0..200 { + if let Ok((mut stream, _)) = listener.accept() { + stream.set_nonblocking(false).expect("blocking request"); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(1))) + .expect("timeout"); + let mut request = Vec::new(); + let mut chunk = [0; 1024]; + for _ in 0..4 { + let n = stream.read(&mut chunk).expect("request"); + request.extend_from_slice(&chunk[..n]); + if n == 0 || request.windows(4).any(|bytes| bytes == b"\r\n\r\n") { + break; + } + } + stream + .write_all( + b"HTTP/1.1 401 Unauthorized\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\ + \r\n", + ) + .expect("response"); + return String::from_utf8_lossy(&request).into_owned(); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + String::new() + }); + let settings = dir.path().join("settings.toml"); + let routes = dir.path().join("routes.toml"); + std::fs::write( + &routes, + format!( + "[llm_clients.c]\n\ + base_url={url:?}\n\ + format='openai_responses'\n\ + forward_auth=true\n\ + [targets.m]\n\ + id='actual'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n" + ), + ) + .expect("routes"); + std::fs::write( + &settings, + format!( + "config_file={routes:?}\nrouting_log={:?}", + dir.path().join("log") + ), + ) + .expect("settings"); + let cached = + json!({format!("{url}/models"): {"models":["cached"],"fetched_at":models::now()}}) + .to_string(); + let cache = dir.path().join(models::CACHE_FILE); + std::fs::write(&cache, &cached).expect("cache"); + let mut controller = Controller::new(Config::load(&settings).expect("settings"), settings); + let cached_reply = controller + .dispatch(Action::Models { + client: "c".into(), + refresh: false, + key: None, + }) + .expect("cached list"); + assert_eq!(cached_reply.data["warning"], false); + assert_eq!(cached_reply.data["source"], "cache"); + // A failed refresh must not report the cached list as a successful upstream fetch. + let failed_refresh = controller + .dispatch(Action::Models { + client: "c".into(), + refresh: true, + key: None, + }) + .expect("cached fallback"); + assert_eq!(failed_refresh.data["warning"], true); + assert_eq!(failed_refresh.data["source"], "cache"); + assert_eq!(failed_refresh.data["models"], json!(["cached"])); + let result = controller.dispatch(Action::Models { + client: "c".into(), + refresh: false, + key: Some("rejected-fixture-key".into()), + }); + let request = worker.join().expect("worker"); + assert!(result.err().expect("rejected key").contains("401")); + assert!(request.starts_with("GET /v1/models ")); + assert!(request.contains("Authorization: Bearer rejected-fixture-key")); + assert_eq!(std::fs::read_to_string(cache).expect("cache"), cached); + #[cfg(windows)] + assert_eq!( + switchyard_desktop::install::windows::saved_key(&url).expect("rejected key not saved"), + None + ); + std::fs::write( + &routes, + "[llm_clients.c]\n\ + base_url='http://127.0.0.1:9/v1'\n\ + format='openai_responses'\n\ + [targets.m]\n\ + id='actual'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n", + ) + .expect("routes"); + let result = controller.dispatch(Action::Models { + client: "c".into(), + refresh: false, + key: Some("unused-fixture-key".into()), + }); + assert!( + result + .err() + .expect("unused key") + .contains("does not accept a model-list key") + ); + } + #[test] + // Matching route names in two files must not let an old draft edit the newly selected file. + fn stale_editor_rejects_after_settings_change_without_writes() { + let dir = tempfile::tempdir().expect("directory"); + let settings = dir.path().join("settings.toml"); + let first = dir.path().join("first.toml"); + let second = dir.path().join("second.toml"); + let text = "[llm_clients.c]\n\ + base_url='https://example.com/v1'\n\ + format='openai_responses'\n\ + [targets.m]\n\ + id='actual'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n"; + std::fs::write(&first, text).expect("first"); + std::fs::write(&second, text).expect("second"); + let write_settings = |path: &std::path::Path| { + std::fs::write( + &settings, + format!( + "config_file={path:?}\nrouting_log={:?}", + dir.path().join("log") + ), + ) + .expect("settings"); + }; + write_settings(&first); + let mut controller = + Controller::new(Config::load(&settings).expect("settings"), settings.clone()); + let snapshot = controller.snapshot().expect("snapshot"); + assert_eq!( + snapshot.routes[0].tiers, + Some(vec![Some("Capable".to_string())]) + ); + let generation = snapshot.generation; + write_settings(&second); + for action in [ + Action::Editor { + generation, + route: "r".into(), + algorithm: "passthrough".into(), + }, + Action::Apply { + generation, + route: "r".into(), + algorithm: "passthrough".into(), + choices: vec![Choice { + client: "c".into(), + model: "changed".into(), + }], + revision: None, + }, + ] { + assert!(controller.dispatch(action).is_err()); + } + assert_eq!(std::fs::read_to_string(&second).expect("second"), text); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 3); + assert_eq!( + controller.snapshot().expect("snapshot").generation, + generation + 1 + ); + } + + #[test] + fn unsupported_routes_are_read_only_before_any_write() { + let dir = tempfile::tempdir().expect("directory"); + let config = dir.path().join("server.toml"); + let settings = dir.path().join("app.toml"); + std::fs::write( + &settings, + format!("config_file={config:?}\nserver_url='http://127.0.0.1:0'"), + ) + .expect("settings"); + let mut controller = Controller::new(Config::load(&settings).expect("settings"), settings); + // These fixtures use valid server algorithms that the app cannot edit. + for fields in [ + "type='noop'", + "type='prefill_router'\ntargets=['answer']\ncheckpoint='router.pt'", + "type='llm_classifier'\n\ + mode='custom'\n\ + models={judge=['judge'],any=['answer']}\n\ + default_target='any'\n\ + prompt='Select a target.'\n\ + response_schema='{\"type\":\"object\"}'\n\ + policy={type='target_selector',selector='/target'}", + ] { + let _: switchyard_runner::AlgorithmSpec = + toml::from_str(fields).expect("server route schema"); + let original = format!("[routes.r]\nid='public'\n{fields}\n"); + std::fs::write(&config, &original).expect("config"); + assert!(!controller.snapshot().expect("snapshot").routes[0].editable); + for kind in ["editor", "apply"] { + let mut raw = + json!({"kind":kind,"generation":0,"route":"r","algorithm":"passthrough"}); + if kind == "apply" { + raw["choices"] = json!([]); + } + let action = serde_json::from_value(raw).expect("action"); + assert_eq!( + controller.dispatch(action).err().expect("read only"), + "This route must be edited in the server config file." + ); + } + assert_eq!(std::fs::read_to_string(&config).expect("config"), original); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 2); + } + } } diff --git a/crates/switchyard-desktop/src/gui.rs b/crates/switchyard-desktop/src/gui.rs index ce5f445c3e..380dcb6745 100644 --- a/crates/switchyard-desktop/src/gui.rs +++ b/crates/switchyard-desktop/src/gui.rs @@ -1,153 +1,824 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! The desktop window shows server health and recorded request and token counts. +//! Tauri manages the window and tray, and runs blocking operations on worker threads. +#[cfg(target_os = "macos")] +mod panel; + +use crate::controller::{Action, Controller, Reply, Snapshot}; +use crate::summary::Overview; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use switchyard_desktop::{config::Config, health::Probe, rollup}; -use tauri::State; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicU8, Ordering}, +}; +use std::time::Duration; +use tauri::{ + Manager, State, + menu::{Menu, MenuItem, PredefinedMenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, +}; -/// The window retains the probe and reader so refreshes reuse the health worker and log offset. -struct Desktop { - settings: PathBuf, - health: Probe, - reader: rollup::Reader, +struct Shared { + controller: Arc>, + // The state is idle (0), busy (1), closing (2), or busy with Quit queued (3). + // The idle state accepts new operations. + operation: Arc, + // This lock serializes saved appearance changes from the main window and popup. + appearance: Mutex<()>, } +/// TRAY_ID names the tray icon so refreshes can replace its menu. +const TRAY_ID: &str = "main"; -#[derive(serde::Serialize)] -struct Counts { - requests: u64, - tokens: u64, +/// TrayUsage reads the routing log for the tray menu and popup. It loads the settings +/// and keeps its own log reader, so the tray never waits for an app operation and the +/// app never waits for the tray. +struct TrayUsage { + settings: PathBuf, + reader: Mutex, + health: switchyard_desktop::health::Probe, + // The menu is rebuilt only when these rows change, so an open menu does not flicker. + rows: Mutex>, + // Session rows open every transcript, so they are re-read at most every 30 seconds. + sessions: Mutex)>>, } +/// TrayView holds what the tray shows: the usage overview, or the errors that hide it. #[derive(serde::Serialize)] -struct Snapshot { - server_url: String, - health: serde_json::Value, - today: Option, - week: Option, +struct TrayView { + overview: Option, errors: Vec, refresh_seconds: u64, + /// The newest sessions, each with the harness that continues it by default. + sessions: Vec, +} + +impl TrayUsage { + /// This function reads new log entries, probes the server, and builds the overview. + fn read(&self) -> TrayView { + let config = match switchyard_desktop::config::Config::load(&self.settings) { + Ok(config) => config, + Err(error) => { + return TrayView { + overview: None, + errors: vec![format!("Cannot read app settings: {error}")], + refresh_seconds: MIN_TRAY_REFRESH, + sessions: Vec::new(), + }; + } + }; + let refresh_seconds = config.refresh_seconds.max(MIN_TRAY_REFRESH); + let today = chrono::Local::now().date_naive(); + let usage = match self.reader.lock() { + Ok(mut reader) => reader.read(&config.routing_log, today), + Err(_) => Err(std::io::Error::other( + "the log reader stopped after an earlier failure", + )), + }; + let usage = match usage { + Ok(usage) => usage, + Err(error) => { + return TrayView { + overview: None, + errors: vec![format!("Cannot read routing log: {error}")], + refresh_seconds, + sessions: Vec::new(), + }; + } + }; + let mut errors = Vec::new(); + // An invalid price file hides savings but keeps traffic and spending visible. + let rates = crate::pricing::load(config.prices_file.as_deref()).unwrap_or_else(|error| { + errors.push(format!("Could not read model prices: {error}")); + Default::default() + }); + let status = self.health.probe(&config.server_url); + let sessions = self.recent_sessions(&config.config_file, &mut errors); + TrayView { + overview: Some(Overview::new(status, &usage, &config, &rates)), + errors, + refresh_seconds, + sessions, + } + } + + /// The newest sessions for the popup's Continue rows. The listing opens every transcript, + /// so one result serves the popup for TRAY_SESSIONS_SECONDS. + fn recent_sessions( + &self, + config_file: &std::path::Path, + errors: &mut Vec, + ) -> Vec { + let Ok(mut cache) = self.sessions.lock() else { + return Vec::new(); + }; + if let Some((read_at, sessions)) = cache.as_ref() + && read_at.elapsed() < Duration::from_secs(TRAY_SESSIONS_SECONDS) + { + return sessions.clone(); + } + match crate::controller::recent_sessions(config_file, None, None) { + Ok(listing) => { + let sessions: Vec = listing["sessions"] + .as_array() + .into_iter() + .flatten() + .take(TRAY_SESSIONS) + .cloned() + .collect(); + *cache = Some((std::time::Instant::now(), sessions.clone())); + sessions + } + Err(error) => { + errors.push(format!("Could not list sessions: {error}")); + Vec::new() + } + } + } +} + +/// MIN_TRAY_REFRESH keeps the tray from probing the server more than once every 5 seconds. +const MIN_TRAY_REFRESH: u64 = 5; +/// The popup shows this many sessions, re-read at most every TRAY_SESSIONS_SECONDS. +const TRAY_SESSIONS: usize = 3; +const TRAY_SESSIONS_SECONDS: u64 = 30; + +/// This function reads the tray usage and replaces the tray menu when its rows changed. +fn refresh_tray(app: &tauri::AppHandle) -> TrayView { + let usage = app.state::(); + let view = usage.read(); + let mut rows = view + .overview + .as_ref() + .map(Overview::rows) + .unwrap_or_default(); + if !view.errors.is_empty() && !rows.is_empty() { + rows.push(String::new()); + } + rows.extend(view.errors.iter().cloned()); + let Ok(mut shown) = usage.rows.lock() else { + return view; + }; + if *shown != rows { + let result = tray_menu(app, &rows).and_then(|menu| match app.tray_by_id(TRAY_ID) { + Some(tray) => tray.set_menu(Some(menu)), + None => Ok(()), + }); + match result { + Ok(()) => *shown = rows, + Err(error) => eprintln!("switchyard-desktop: could not update the tray menu: {error}"), + } + } + view +} + +/// This function builds the tray menu: the usage rows, then the server and app actions. +fn tray_menu(app: &tauri::AppHandle, rows: &[String]) -> tauri::Result> { + let menu = Menu::new(app)?; + for row in rows { + if row.is_empty() { + menu.append(&PredefinedMenuItem::separator(app)?)?; + } else { + // Rows are labels, not commands, so they are disabled. + menu.append(&MenuItem::new(app, row, false, None::<&str>)?)?; + } + } + let groups: [&[(&str, &str)]; 3] = [ + &[ + ("restart", "Restart server"), + ("open-config", "Open server config…"), + ("settings", "Settings…"), + ], + &[("open", "Open Switchyard"), ("install", "Connect tools…")], + &[("quit", "Quit Switchyard")], + ]; + for group in groups { + menu.append(&PredefinedMenuItem::separator(app)?)?; + for (id, text) in group { + menu.append(&MenuItem::with_id(app, *id, *text, true, None::<&str>)?)?; + } + } + Ok(menu) } -type Shared = Arc>; +/// This function runs a tray menu action and reports a failure in the app window. +fn run_tray_action(app: &tauri::AppHandle, action: Action) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let result = dispatch(app.clone(), action).await; + let refresh = app.clone(); + // The refresh probes the server, so it runs on a worker thread. + let _ = tauri::async_runtime::spawn_blocking(move || refresh_tray(&refresh)).await; + if let Err(error) = result { + eprintln!("switchyard-desktop: {error}"); + notice(&app, &error); + } + }); +} + +/// This function shows a message in the app window and brings the window forward. +fn notice(app: &tauri::AppHandle, message: &str) { + if let Some(window) = app.get_webview_window("main") { + let detail = serde_json::json!(message); + let _ = window.eval(format!( + "window.dispatchEvent(new CustomEvent('switchyard-notice',{{detail:{detail}}}))" + )); + } + show(app); +} + +/// This function quits now, or after the running operation when one is running. +fn quit(app: &tauri::AppHandle) { + if app.state::().quit() { + app.exit(0); + } else { + notice( + app, + "Switchyard will quit when the current operation finishes.", + ); + } +} +// Operation keeps the app busy from queueing through worker completion. +// Drop returns a busy app to idle or closes it when Quit is queued. +struct Operation(Arc, Option); +impl Drop for Operation { + fn drop(&mut self) { + if self + .0 + .compare_exchange(1, 0, Ordering::AcqRel, Ordering::Acquire) + == Err(3) + { + self.0.store(2, Ordering::Release); + if let Some(app) = &self.1 { + app.exit(0); + } + } + } +} +impl Shared { + fn begin(&self) -> Result { + self.operation + .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| "An operation is running. Try again when it finishes.".to_string())?; + Ok(Operation(self.operation.clone(), None)) + } + fn quit(&self) -> bool { + if self + .operation + .compare_exchange(1, 3, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + return false; + } + self.operation + .compare_exchange(0, 2, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + || self.operation.load(Ordering::Acquire) == 2 + } +} -/// The worker reads health and usage while the mutex prevents overlapping refreshes. #[tauri::command] -async fn snapshot(state: State<'_, Shared>) -> Result { - let shared = state.inner().clone(); +async fn snapshot(app: tauri::AppHandle, state: State<'_, Shared>) -> Result { + let mut operation = state.begin()?; + operation.1 = Some(app.clone()); + let shared = state.controller.clone(); tauri::async_runtime::spawn_blocking(move || { - let mut desktop = shared + let _operation = operation; + shared .try_lock() - .map_err(|_| "A refresh is running. Try again when it finishes.".to_string())?; - let config = Config::load(&desktop.settings)?; - let health = desktop.health.details(&config.server_url); - let mut errors = Vec::new(); - // A failed read leaves counts absent so the window cannot show incomplete usage as zero. - let usage = desktop - .reader - .read(&config.routing_log, chrono::Local::now().date_naive()) - .map_err(|error| errors.push(format!("Could not read usage: {error}"))) - .ok(); - let counts = |totals: &rollup::Totals| Counts { - requests: totals.requests(), - tokens: totals.tokens(), - }; - Ok(Snapshot { - server_url: config.server_url, - health, - today: usage.as_ref().map(|usage| counts(&usage.today)), - week: usage.as_ref().map(|usage| counts(&usage.week)), - errors, - refresh_seconds: config.refresh_seconds.max(1), - }) + .map_err(|_| "An operation is running. Try again when it finishes.".to_string())? + .snapshot() }) .await - .map_err(|error| error.to_string())? + .map_err(|e| e.to_string())? } - -#[derive(serde::Deserialize)] -#[serde(rename_all = "snake_case")] -enum FileKind { - Config, - Logs, +async fn dispatch(app: tauri::AppHandle, action: Action) -> Result { + let (operation, shared) = { + let state = app.state::(); + let mut operation = state.begin()?; + operation.1 = Some(app.clone()); + (operation, state.controller.clone()) + }; + tauri::async_runtime::spawn_blocking(move || { + let _operation = operation; + shared + .try_lock() + .map_err(|_| "An operation is running. Try again when it finishes.".to_string())? + .dispatch(action) + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +async fn action(app: tauri::AppHandle, action: Action) -> Result { + dispatch(app, action).await } - -/// The worker waits for any refresh, then opens the configured file after releasing the mutex. #[tauri::command] -async fn open_file(state: State<'_, Shared>, kind: FileKind) -> Result<(), String> { - let shared = state.inner().clone(); +async fn tray_overview(app: tauri::AppHandle) -> Result { + // The overview probes the server, so it runs on a worker thread. + tauri::async_runtime::spawn_blocking(move || refresh_tray(&app)) + .await + .map_err(|e| e.to_string()) +} +#[tauri::command] +fn quit_app(app: tauri::AppHandle) { + quit(&app); +} +#[tauri::command] +async fn choose_path(directory: bool) -> Result, String> { + // The blocking worker waits for the picker; rfd opens macOS panels on the main thread. tauri::async_runtime::spawn_blocking(move || { - let config = { - let desktop = shared - .lock() - .map_err(|_| "Could not access desktop state after a worker failed.".to_string())?; - Config::load(&desktop.settings)? - }; - let path = match kind { - FileKind::Config => config.config_file, - FileKind::Logs => config.routing_log, + let dialog = rfd::FileDialog::new().set_title(if directory { + "Choose a Git project folder" + } else { + "Choose harness settings" + }); + let path = if directory { + dialog.pick_folder() + } else { + dialog.pick_file() }; - #[cfg(target_os = "macos")] + #[cfg(windows)] { - let output = std::process::Command::new("open") - .arg("--") - .arg(&path) - .output() - .map_err(|error| format!("Could not open {}: {error}", path.display()))?; - if !output.status.success() { - return Err(format!( - "Could not open {}: {}", - path.display(), - String::from_utf8_lossy(&output.stderr).trim() - )); - } - Ok(()) + Ok(path.map(|path| path.display().to_string())) } - #[cfg(windows)] + #[cfg(target_os = "macos")] { - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::UI::{ - Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOWNORMAL, - }; - let path: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); - let status = unsafe { - ShellExecuteW( - std::ptr::null_mut(), - std::ptr::null(), - path.as_ptr(), - std::ptr::null(), - std::ptr::null(), - SW_SHOWNORMAL, - ) - } as isize; - if status > 32 { - Ok(()) - } else { - Err(format!("Windows could not open the file (error {status}).")) - } + path.map(|path| { + path.into_os_string().into_string().map_err(|_| { + "The selected path cannot be represented as UTF-8. \ + Enter a different path in the field." + .to_string() + }) + }) + .transpose() } }) .await .map_err(|error| error.to_string())? } -/// Quitting closes the window and stops the application. +#[derive(Clone, Copy, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +enum AppearanceMode { + #[default] + System, + // Dark is saved as "canary". + Canary, + Light, +} + #[tauri::command] -fn quit_app(app: tauri::AppHandle) { - app.exit(0); +fn appearance( + app: tauri::AppHandle, + window: tauri::WebviewWindow, + state: State<'_, Shared>, + mode: Option, +) -> Result { + let _lock = state.appearance.lock().map_err(|e| e.to_string())?; + let system = window.theme().map_err(|e| e.to_string())?; + let path = app + .path() + .app_config_dir() + .map_err(|e| e.to_string())? + .join("appearance.json"); + let selected = if let Some(mode) = mode { + let directory = path + .parent() + .ok_or("Could not find the appearance settings folder.")?; + std::fs::create_dir_all(directory).map_err(|e| e.to_string())?; + use std::io::Write; + let mut temporary = + tempfile::NamedTempFile::new_in(directory).map_err(|e| e.to_string())?; + temporary + .write_all(&serde_json::to_vec(&mode).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + temporary.as_file().sync_all().map_err(|e| e.to_string())?; + temporary.persist(&path).map_err(|e| e.to_string())?; + mode + } else { + match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|e| format!("Could not read appearance settings: {e}"))?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => AppearanceMode::default(), + Err(e) => return Err(e.to_string()), + } + }; + let result = serde_json::json!({"mode": selected, "system": system}); + if mode.is_some() { + for window in app.webview_windows().values() { + window + .eval(format!( + "window.dispatchEvent(new CustomEvent('switchyard-appearance', \ + {{detail:{result}}}))" + )) + .map_err(|e| e.to_string())?; + } + } + Ok(result) +} + +#[cfg(target_os = "macos")] +fn material( + fallback: tauri::window::Effect, + radius: Option, + state: tauri::window::EffectState, +) -> tauri::utils::config::WindowEffectsConfig { + tauri::utils::config::WindowEffectsConfig { + effects: vec![tauri::window::Effect::LiquidGlassRegular, fallback], + state: Some(state), + radius, + color: None, + interactive: false, + } +} + +#[tauri::command] +fn accessibility() -> serde_json::Value { + #[cfg(target_os = "macos")] + { + let workspace = objc2_app_kit::NSWorkspace::sharedWorkspace(); + serde_json::json!({ + "native_material": true, + "reduce_transparency": workspace.accessibilityDisplayShouldReduceTransparency(), + "reduce_motion": workspace.accessibilityDisplayShouldReduceMotion(), + }) + } + #[cfg(not(target_os = "macos"))] + serde_json::json!({"native_material": false, "reduce_transparency": true, "reduce_motion": false}) +} + +/// POPUP_WIDTH matches the width of macOS status item menus such as Wi-Fi and Sound. +const POPUP_WIDTH: f64 = 320.0; + +// The webview measures popup height; invalid sizes must not reach AppKit. +fn popup_height(height: f64) -> Result { + if !height.is_finite() || height <= 0.0 { + return Err("The popup height must be a positive number.".into()); + } + Ok(height.clamp(120.0, 620.0)) +} + +#[tauri::command] +fn tray_size(app: tauri::AppHandle, height: f64) -> Result<(), String> { + let height = popup_height(height)?; + #[cfg(target_os = "macos")] + app.state::().resize(height); + #[cfg(windows)] + if let Some(window) = app.get_webview_window("tray") { + let height = window + .current_monitor() + .map_err(|e| e.to_string())? + .map(|monitor| { + height.min(f64::from(monitor.work_area().size.height) / monitor.scale_factor()) + }) + .unwrap_or(height); + window + .set_size(tauri::LogicalSize::new(POPUP_WIDTH, height)) + .map_err(|e| e.to_string())?; + } + Ok(()) } -/// The application owns one health probe and log reader for the lifetime of the window. -pub fn run(settings: PathBuf) -> Result<(), String> { - tauri::Builder::default() - .manage(Arc::new(Mutex::new(Desktop { +#[tauri::command] +fn tray_page(app: tauri::AppHandle, page: String) -> Result<(), String> { + if !matches!(page.as_str(), "overview" | "install" | "settings" | "setup") { + return Err("Choose Overview, Connect tools, or Settings.".into()); + } + open_page(&app, &page); + dismiss_tray(app) +} +#[tauri::command] +fn tray_continue(app: tauri::AppHandle, request: serde_json::Value) -> Result<(), String> { + // The popup forwards a session row. The window reviews it like any other hand-off, so the + // hand-off text is shown before anything is written. + let source = request["source"]["kind"].as_str(); + let tool = request["destination"]["tool"].as_str(); + if !matches!(source, Some("managed" | "transcript")) + || !matches!(tool, Some("codex_cli" | "claude")) + { + return Err("Choose a session row.".into()); + } + if let Some(window) = app.get_webview_window("main") { + let script = format!( + "window.dispatchEvent(new CustomEvent('switchyard-continue', {{detail:{request}}}))" + ); + if let Err(error) = window.eval(&script) { + eprintln!("switchyard-desktop: could not open the hand-off review: {error}"); + } + } + show(&app); + dismiss_tray(app) +} +#[tauri::command] +fn dismiss_tray(app: tauri::AppHandle) -> Result<(), String> { + #[cfg(target_os = "macos")] + app.state::().dismiss(); + #[cfg(windows)] + if let Some(window) = app.get_webview_window("tray") { + window.hide().map_err(|error| error.to_string())?; + } + Ok(()) +} +// Windows uses physical coordinates to keep the popup within the target monitor's work area. +#[cfg(windows)] +fn popup_geometry( + rect: tauri::Rect, + area: &tauri::PhysicalRect, + scale: f64, +) -> (tauri::PhysicalPosition, tauri::PhysicalSize) { + let origin = rect.position.to_physical::(scale); + let icon = rect.size.to_physical::(scale); + let size = tauri::PhysicalSize::new( + (POPUP_WIDTH * scale) + .min(f64::from(area.size.width)) + .round() as u32, + (620.0 * scale).min(f64::from(area.size.height)).round() as u32, + ); + let x = (origin.x + icon.width / 2.0 - f64::from(size.width) / 2.0).clamp( + f64::from(area.position.x), + f64::from(area.position.x) + f64::from(area.size.width - size.width), + ); + let y = (origin.y + icon.height + 6.0 * scale).clamp( + f64::from(area.position.y), + f64::from(area.position.y) + f64::from(area.size.height - size.height), + ); + ( + tauri::PhysicalPosition::new(x.round() as i32, y.round() as i32), + size, + ) +} + +fn toggle_tray( + tray: &tauri::tray::TrayIcon, + position: tauri::PhysicalPosition, + rect: tauri::Rect, +) -> tauri::Result<()> { + let app = tray.app_handle(); + #[cfg(target_os = "macos")] + { + let _ = (position, rect); + // AppKit frames avoid converting Tauri's physical, top-left coordinates back to points. + let Some((anchor, area)) = panel::status_item_frames(tray) else { + return Ok(()); + }; + if app.state::().toggle(anchor, area) + && let Some(window) = app.get_webview_window("tray") + { + window.eval("window.dispatchEvent(new Event('switchyard-tray-open'))")?; + } + Ok(()) + } + #[cfg(windows)] + { + let Some(window) = app.get_webview_window("tray") else { + return Ok(()); + }; + if window.is_visible()? { + return window.hide(); + } + if let Some(monitor) = window.available_monitors()?.into_iter().find(|monitor| { + let origin = monitor.position(); + let size = monitor.size(); + position.x >= f64::from(origin.x) + && position.x < f64::from(origin.x) + f64::from(size.width) + && position.y >= f64::from(origin.y) + && position.y < f64::from(origin.y) + f64::from(size.height) + }) { + let (position, size) = + popup_geometry(rect, monitor.work_area(), monitor.scale_factor()); + window.set_position(position)?; + window.set_size(size)?; + } + window.show()?; + window.set_focus()?; + window.eval("window.dispatchEvent(new Event('switchyard-tray-open'))")?; + Ok(()) + } +} +fn open_page(app: &tauri::AppHandle, page: &str) { + if let Some(window) = app.get_webview_window("main") { + // Only the fixed page names from the tray menu reach this script. + let script = format!( + "window.dispatchEvent(new CustomEvent('switchyard-page', {{detail:{page:?}}}))" + ); + if let Err(error) = window.eval(&script) { + eprintln!("switchyard-desktop: could not select the page: {error}"); + } + } + show(app); +} +fn activation(app: &tauri::AppHandle, visible: bool) -> tauri::Result<()> { + #[cfg(target_os = "macos")] + { + app.set_activation_policy(if visible { + tauri::ActivationPolicy::Regular + } else { + tauri::ActivationPolicy::Accessory + }) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (app, visible); + Ok(()) + } +} +fn show(app: &tauri::AppHandle) { + if let Err(error) = dismiss_tray(app.clone()) { + eprintln!("switchyard-desktop: could not hide the preview: {error}"); + } + if let Some(window) = app.get_webview_window("main") + && let Err(e) = activation(app, true) + .and_then(|()| window.show()) + .and_then(|()| window.set_focus()) + { + eprintln!("switchyard-desktop: could not open the window: {e}"); + } +} +pub fn run(controller: Controller, settings: PathBuf) -> Result<(), String> { + let app = tauri::Builder::default() + .manage(Shared { + controller: Arc::new(Mutex::new(controller)), + operation: Arc::new(AtomicU8::new(0)), + appearance: Mutex::new(()), + }) + .manage(TrayUsage { settings, - health: Probe::default(), - reader: rollup::Reader::default(), - }))) - .invoke_handler(tauri::generate_handler![snapshot, open_file, quit_app]) - .run(tauri::generate_context!()) - .map_err(|error| error.to_string()) + reader: Mutex::new(crate::rollup::Reader::default()), + health: switchyard_desktop::health::Probe::default(), + rows: Mutex::new(Vec::new()), + sessions: Mutex::new(None), + }) + .invoke_handler(tauri::generate_handler![ + snapshot, + action, + choose_path, + appearance, + accessibility, + tray_size, + tray_page, + tray_continue, + tray_overview, + dismiss_tray, + quit_app + ]) + .setup(|app| { + #[cfg(target_os = "macos")] + if let Some(window) = app.get_webview_window("main") { + window.set_effects(material( + tauri::window::Effect::Sidebar, + None, + tauri::window::EffectState::FollowsWindowActiveState, + ))?; + } + let tray_window = tauri::WebviewWindowBuilder::new( + app, + "tray", + tauri::WebviewUrl::App("tray.html".into()), + ) + .title("Switchyard usage") + .inner_size(POPUP_WIDTH, 620.0) + .transparent(cfg!(target_os = "macos")) + .decorations(false) + .resizable(false) + .always_on_top(true) + .skip_taskbar(true) + .visible(false) + .focused(false) + .build()?; + #[cfg(target_os = "macos")] + { + // Like a menu, the popup keeps the active material although it never activates + // the app. + tray_window.set_effects(material( + tauri::window::Effect::Popover, + Some(panel::CORNER_RADIUS), + tauri::window::EffectState::Active, + ))?; + app.manage(panel::TrayPanel::new(&tray_window).map_err(std::io::Error::other)?); + } + #[cfg(windows)] + let _ = tray_window; + + let tray_pixels = include_bytes!("../icons/tray.rgba"); + TrayIconBuilder::with_id(TRAY_ID) + .icon(tauri::image::Image::new_owned(tray_pixels.to_vec(), 22, 22)) + .icon_as_template(false) + .tooltip("Switchyard — routes and usage") + .menu(&tray_menu(app.handle(), &["Reading usage…".into()])?) + .show_menu_on_left_click(false) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + position, + rect, + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + && let Err(error) = toggle_tray(tray, position, rect) + { + eprintln!( + "switchyard-desktop: could not show or hide the usage preview: {error}" + ); + } + }) + .on_menu_event(|app, event| match event.id.as_ref() { + "restart" => run_tray_action(app, Action::Restart {}), + "open-config" => run_tray_action(app, Action::OpenConfig {}), + "settings" => open_page(app, "settings"), + "open" => open_page(app, "overview"), + "install" => open_page(app, "install"), + "quit" => quit(app), + _ => {} + }) + .build(app)?; + // Like the earlier menu bar app, the tray rereads usage on the settings interval. + let handle = app.handle().clone(); + std::thread::spawn(move || { + loop { + let view = refresh_tray(&handle); + std::thread::sleep(Duration::from_secs(view.refresh_seconds)); + } + }); + Ok(()) + }) + .on_window_event(|window, event| { + if let tauri::WindowEvent::ThemeChanged(theme) = event { + let value = serde_json::json!(theme); + for target in window.app_handle().webview_windows().values() { + if let Err(error) = target.eval(format!( + "window.dispatchEvent(new CustomEvent('switchyard-system-theme', \ + {{detail:{value}}}))" + )) { + eprintln!("switchyard-desktop: could not update the appearance: {error}"); + } + } + } + #[cfg(windows)] + if window.label() == "tray" + && matches!(event, tauri::WindowEvent::Focused(false)) + && let Err(error) = window.hide() + { + eprintln!("switchyard-desktop: could not hide the usage preview: {error}"); + } + + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + if window.label() == "tray" { + if let Err(error) = dismiss_tray(window.app_handle().clone()) { + eprintln!("switchyard-desktop: could not hide the usage preview: {error}"); + } + return; + } + // Accessory removes the running Dock entry while keeping the tray menu. + if let Err(e) = window.hide().and_then(|()| { + if window.label() == "main" { + activation(window.app_handle(), false) + } else { + Ok(()) + } + }) { + eprintln!("switchyard-desktop: could not hide the window: {e}"); + } + } + }) + .build(tauri::generate_context!()) + .map_err(|e| e.to_string())?; + app.run(|app, event| match event { + #[cfg(target_os = "macos")] + tauri::RunEvent::Reopen { .. } => show(app), + tauri::RunEvent::ExitRequested { api, .. } if !app.state::().quit() => { + api.prevent_exit() + } + _ => {} + }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + // A queued operation must prevent Quit before its worker starts. + fn quit_blocks_pending_operations_and_stays_closed() { + let directory = tempfile::tempdir().expect("directory"); + let settings = directory.path().join("app.toml"); + let shared = Shared { + controller: Arc::new(Mutex::new(Controller::new( + crate::config::Config::defaults_for(&settings).expect("defaults"), + settings, + ))), + operation: Arc::new(AtomicU8::new(0)), + appearance: Mutex::new(()), + }; + let operation = shared.begin().expect("operation"); + assert!(shared.begin().is_err()); + assert!(!shared.quit()); + drop(operation); + assert!(shared.quit()); + assert!(shared.quit()); + assert!(shared.begin().is_err()); + } } diff --git a/crates/switchyard-desktop/src/gui/panel.rs b/crates/switchyard-desktop/src/gui/panel.rs new file mode 100644 index 0000000000..5b1acd36c1 --- /dev/null +++ b/crates/switchyard-desktop/src/gui/panel.rs @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module configures a nonactivating tray panel to join other apps' fullscreen Spaces. + +use dispatch2::MainThreadBound; +use objc2::{DefinedClass, MainThreadOnly, define_class, msg_send, rc::Retained}; +use objc2_app_kit::{ + NSApplication, NSBackingStoreType, NSColor, NSEvent, NSEventType, NSPanel, + NSPopUpMenuWindowLevel, NSWindow, NSWindowCollectionBehavior, NSWindowStyleMask, +}; +use objc2_foundation::{MainThreadMarker, NSProcessInfo, NSRect}; +use std::cell::Cell; + +/// MENU_GAP matches the space macOS leaves between the menu bar and a status item's menu. +const MENU_GAP: f64 = 5.0; + +/// CORNER_RADIUS matches the corners of macOS 26 menus; the glass and tray.css use it too. +pub(super) const CORNER_RADIUS: f64 = 12.0; + +// Like a status item's menu, the panel's left edge follows the icon and its top edge sits +// just below the menu bar. AppKit supplies points, so the frame needs no scale conversion. +fn popup_frame(anchor: NSRect, area: NSRect, height: f64) -> NSRect { + let mut frame = area; + frame.size.width = frame.size.width.min(super::POPUP_WIDTH); + frame.size.height = frame.size.height.min(height); + frame.origin.x = anchor.origin.x.clamp( + area.origin.x, + area.origin.x + area.size.width - frame.size.width, + ); + let top = anchor.origin.y.min(area.origin.y + area.size.height) - MENU_GAP; + frame.origin.y = (top - frame.size.height).clamp( + area.origin.y, + area.origin.y + area.size.height - frame.size.height, + ); + frame +} + +/// This function returns the status item's frame and its screen's visible frame in points. +pub(super) fn status_item_frames(tray: &tauri::tray::TrayIcon) -> Option<(NSRect, NSRect)> { + tray.with_inner_tray_icon(|inner| { + let mtm = MainThreadMarker::new()?; + let window = inner.ns_status_item()?.button(mtm)?.window()?; + Some((window.frame(), window.screen()?.visibleFrame())) + }) + .ok() + .flatten() +} + +#[derive(Default)] +struct PopupState { + anchor: Cell, + area: Cell, + height: Cell, +} + +define_class!( + // SAFETY: The panel is initialized as an NSPanel, and every method runs on the main thread. + #[unsafe(super = NSPanel)] + #[thread_kind = MainThreadOnly] + #[ivars = PopupState] + struct Popup; + + impl Popup { + #[unsafe(method(canBecomeKeyWindow))] + fn can_become_key(&self) -> bool { true } + + #[unsafe(method(canBecomeMainWindow))] + fn can_become_main(&self) -> bool { false } + + #[unsafe(method(resignKeyWindow))] + fn resign_key(&self) { + // SAFETY: These inherited AppKit methods have no additional requirements. + unsafe { + let _: () = msg_send![super(self), resignKeyWindow]; + let mouse = NSEvent::mouseLocation(); + let anchor = self.ivars().anchor.get(); + // A left-click on the tray icon must reach toggle_tray before focus loss hides the panel. + let tray_click = NSApplication::sharedApplication(self.mtm()).currentEvent() + .is_some_and(|event| matches!(event.r#type(), NSEventType::LeftMouseDown | NSEventType::LeftMouseUp)); + if !tray_click || mouse.x < anchor.origin.x || mouse.x > anchor.origin.x + anchor.size.width + || mouse.y < anchor.origin.y || mouse.y > anchor.origin.y + anchor.size.height { + self.orderOut(None); + } + } + } + } +); + +// MainThreadBound also releases the panel on the main thread during shutdown. +pub(super) struct TrayPanel(MainThreadBound>); + +impl TrayPanel { + pub(super) fn new(window: &tauri::WebviewWindow) -> Result { + let mtm = MainThreadMarker::new().ok_or("The tray panel must start on the main thread.")?; + let pointer = window.ns_window().map_err(|error| error.to_string())?; + // SAFETY: Tauri supplies a live NSWindow; its hidden window retains the webview and IPC registration. + unsafe { + let source = &*pointer.cast::(); + let view = source + .contentView() + .ok_or("The tray webview has no content view.")?; + let responder = source.firstResponder(); + let allocated = Popup::alloc(mtm).set_ivars(PopupState { + anchor: Cell::new(NSRect::default()), + area: Cell::new(source.frame()), + height: Cell::new(620.0), + }); + let panel: Retained = msg_send![super(allocated), + initWithContentRect: source.frame(), + styleMask: NSWindowStyleMask::NonactivatingPanel, + backing: NSBackingStoreType::Buffered, + defer: false + ]; + panel.setReleasedWhenClosed(false); + panel.setOpaque(false); + panel.setBackgroundColor(Some(&NSColor::clearColor())); + panel.setHasShadow(true); + panel.setTitle(&source.title()); + panel.setHidesOnDeactivate(false); + panel.setBecomesKeyOnlyIfNeeded(false); + panel.setLevel(NSPopUpMenuWindowLevel); + let mut behavior = NSWindowCollectionBehavior::CanJoinAllSpaces + | NSWindowCollectionBehavior::FullScreenAuxiliary + | NSWindowCollectionBehavior::Stationary + | NSWindowCollectionBehavior::IgnoresCycle; + if NSProcessInfo::processInfo() + .operatingSystemVersion() + .majorVersion + >= 13 + { + behavior |= NSWindowCollectionBehavior::CanJoinAllApplications; + } + panel.setCollectionBehavior(behavior); + source.setContentView(None); + panel.setContentView(Some(&view)); + // Clipping the glass and the webview to one rounded shape also rounds the shadow. + view.setWantsLayer(true); + if let Some(layer) = view.layer() { + layer.setCornerRadius(CORNER_RADIUS); + layer.setMasksToBounds(true); + } + panel.makeFirstResponder(responder.as_deref()); + Ok(Self(MainThreadBound::new(panel, mtm))) + } + } + + pub(super) fn dismiss(&self) { + self.0.get_on_main(|panel| panel.orderOut(None)); + } + + pub(super) fn resize(&self, height: f64) { + self.0.get_on_main(|panel| { + let state = panel.ivars(); + state.height.set(height); + panel.setFrame_display( + popup_frame(state.anchor.get(), state.area.get(), height), + true, + ); + panel.invalidateShadow(); + }); + } + + /// This function shows the panel below `anchor`, the status item's frame, within `area`, + /// the visible frame of the status item's screen. It returns false when it hid the panel. + pub(super) fn toggle(&self, anchor: NSRect, area: NSRect) -> bool { + self.0.get_on_main(|panel| { + // A panel visible on another Space must be shown here rather than hidden by this click. + if panel.isVisible() && panel.isOnActiveSpace() { + panel.orderOut(None); + return false; + } + let state = panel.ivars(); + state.anchor.set(anchor); + state.area.set(area); + panel.setFrame_display(popup_frame(anchor, area, state.height.get()), true); + panel.orderFrontRegardless(); + panel.makeKeyWindow(); + panel.invalidateShadow(); + true + }) + } +} diff --git a/crates/switchyard-desktop/src/handoff_cli.rs b/crates/switchyard-desktop/src/handoff_cli.rs new file mode 100644 index 0000000000..f0d8f3809c --- /dev/null +++ b/crates/switchyard-desktop/src/handoff_cli.rs @@ -0,0 +1,655 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The `handoff` subcommand continues a session in another harness from the terminal it runs +//! in. It reviews the hand-off the way the apps do, writes it, and then replaces itself with +//! the destination harness in the session's directory. + +use std::io::{IsTerminal, Write}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use serde_json::Value; + +use crate::config::Config; +use crate::controller::{ + Action, Controller, HandoffSource, RouteInfo, canonical, harness_name, other_harness, +}; +use crate::harness::{self, Harness}; + +pub const USAGE: &str = "\ +Usage: switchyard-desktop handoff --to [options] + +This continues a session in another harness, here in this terminal. It reads the +session's transcript, writes a bounded hand-off file that only you can read, and +starts the destination harness in the session's directory with an instruction to +read that file first. Nothing is written until you confirm. + + --to HARNESS The destination: codex or claude. + --from HARNESS The source harness. Default: the other one. + --transcript FILE The source transcript. Default: the newest transcript of + the source harness that was recorded in this directory. + --session ID Continue a session that the app started (sy-…) instead. + --route ROUTE The route the destination uses, by ID. Default: the first + route the destination can use. + --account NAME A saved account for the destination. Default: its login. + --source-account NAME A saved account whose transcripts hold the source session. + --show Print the whole hand-off text in the review. + --yes, -y Skip the confirmation. + --settings FILE Read app settings from FILE. + +Quit the source harness first. Hand-off files are removed after seven days."; + +#[derive(Debug)] +pub struct Options { + pub to: Harness, + pub from: Harness, + pub transcript: Option, + pub session: Option, + pub route: Option, + pub account: Option, + pub source_account: Option, + pub show: bool, + pub yes: bool, + pub settings: Option, +} + +/// The program that replaces this one, as the controller returned it. +#[derive(Debug, serde::Deserialize)] +pub struct Launch { + pub program: PathBuf, + pub directory: PathBuf, + pub args: Vec, + pub environment: Vec<(String, String)>, + pub remove_environment: Vec, +} + +pub fn main(args: &[String]) -> ExitCode { + let options = match parse(args) { + Ok(Some(options)) => options, + Ok(None) => { + println!("{USAGE}"); + return ExitCode::SUCCESS; + } + Err(error) => { + eprintln!("switchyard-desktop handoff: {error}\n\n{USAGE}"); + return ExitCode::from(2); + } + }; + match prepare(&options) { + Ok(Some(launch)) => exec(launch), + Ok(None) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("switchyard-desktop handoff: {error}"); + ExitCode::FAILURE + } + } +} + +/// This reads the options. `Ok(None)` asks for the usage text. +pub fn parse(args: &[String]) -> Result, String> { + let mut to = None; + let mut from = None; + let mut transcript = None; + let mut session = None; + let mut route = None; + let mut account = None; + let mut source_account = None; + let mut show = false; + let mut yes = false; + let mut settings = None; + let mut rest = args.iter(); + while let Some(arg) = rest.next() { + match arg.as_str() { + "-h" | "--help" => return Ok(None), + "--to" => to = Some(parse_harness(&value(&mut rest, arg)?)?), + "--from" => from = Some(parse_harness(&value(&mut rest, arg)?)?), + "--transcript" => transcript = Some(PathBuf::from(value(&mut rest, arg)?)), + "--session" => session = Some(value(&mut rest, arg)?), + "--route" => route = Some(value(&mut rest, arg)?), + "--account" => account = Some(value(&mut rest, arg)?), + "--source-account" => source_account = Some(value(&mut rest, arg)?), + "--settings" => settings = Some(PathBuf::from(value(&mut rest, arg)?)), + "--show" => show = true, + "-y" | "--yes" => yes = true, + other => return Err(format!("Unknown option {other}.")), + } + } + let to = to.ok_or("Pass --to codex or --to claude.")?; + if transcript.is_some() && session.is_some() { + return Err("Pass --transcript or --session, not both.".into()); + } + Ok(Some(Options { + to, + from: from.unwrap_or(other_harness(to)), + transcript, + session, + route, + account, + source_account, + show, + yes, + settings, + })) +} + +fn value(rest: &mut std::slice::Iter<'_, String>, flag: &str) -> Result { + rest.next() + .cloned() + .ok_or_else(|| format!("{flag} needs a value.")) +} + +fn parse_harness(text: &str) -> Result { + match text { + "claude" | "claude-code" => Ok(Harness::Claude), + "codex" | "codex-cli" => Ok(Harness::CodexCli), + other => Err(format!("Unknown harness {other:?}. Use codex or claude.")), + } +} + +/// This reviews the hand-off, asks for confirmation, writes it, and returns the program to +/// run. `Ok(None)` means the user declined, and nothing was written. +pub fn prepare(options: &Options) -> Result, String> { + let settings = match &options.settings { + Some(path) => path.clone(), + None => Config::default_path()?, + }; + let config = Config::load(&settings)?; + let mut controller = Controller::new(config, settings); + let destination = harness_name(options.to); + if harness::binary(options.to).is_none() { + return Err(format!( + "{destination} is not installed, or its program is not on PATH." + )); + } + let snapshot = controller.snapshot()?; + let (route, id) = choose_route(&snapshot.routes, options.to, options.route.as_deref())?; + let source = match (&options.session, &options.transcript) { + (Some(id), _) => HandoffSource::Managed { id: id.clone() }, + (None, Some(path)) => HandoffSource::Transcript { + path: std::path::absolute(path) + .map_err(|error| error.to_string())? + .display() + .to_string(), + }, + (None, None) => newest_here( + &mut controller, + options.from, + options.source_account.clone(), + )?, + }; + let preview = controller.dispatch(Action::PreviewHandoff { + source: source.clone(), + tool: options.to, + account: options.account.clone(), + route: route.clone(), + id: id.clone(), + })?; + let data = preview.data; + let mut out = std::io::stdout().lock(); + let report = |out: &mut std::io::StdoutLock<'_>, text: &str| { + writeln!(out, "{text}") + .and_then(|()| out.flush()) + .map_err(|error| error.to_string()) + }; + report(&mut out, &review_text(&data, options.show))?; + if !options.yes { + if !std::io::stdin().is_terminal() { + return Err("Pass --yes to continue without a prompt.".into()); + } + write!( + out, + "Write the hand-off and start {destination} here? [y/N] " + ) + .and_then(|()| out.flush()) + .map_err(|error| error.to_string())?; + let mut answer = String::new(); + std::io::stdin() + .read_line(&mut answer) + .map_err(|error| error.to_string())?; + if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") { + report(&mut out, "Nothing was written.")?; + return Ok(None); + } + } + let reply = controller.dispatch(Action::Handoff { + source, + tool: options.to, + account: options.account.clone(), + route, + id, + preview_token: data["preview_token"].as_str().map(str::to_owned), + attach: true, + })?; + report(&mut out, &reply.message)?; + serde_json::from_value(reply.data["invocation"].clone()) + .map(Some) + .map_err(|error| format!("The hand-off reply has no command: {error}")) +} + +/// The route the destination uses: the one named, or the first one it can use. +fn choose_route( + routes: &[RouteInfo], + tool: Harness, + wanted: Option<&str>, +) -> Result<(String, String), String> { + let reason = |route: &RouteInfo| route.compatibility.get(&tool).cloned().flatten(); + if let Some(wanted) = wanted { + let route = routes + .iter() + .find(|route| route.id == wanted || route.key == wanted) + .ok_or_else(|| { + let ids: Vec<&str> = routes.iter().map(|route| route.id.as_str()).collect(); + format!("No route {wanted:?}. Routes: {}.", ids.join(", ")) + })?; + if let Some(reason) = reason(route) { + return Err(format!( + "{} cannot use route {}: {reason}", + harness_name(tool), + route.id + )); + } + return Ok((route.key.clone(), route.id.clone())); + } + let route = routes + .iter() + .find(|route| reason(route).is_none()) + .ok_or_else(|| { + let reasons: Vec = routes + .iter() + .filter_map(|route| Some(format!("{}: {}", route.id, reason(route)?))) + .collect(); + format!( + "No route in the server config is usable by {}. {}", + harness_name(tool), + reasons.join(" ") + ) + })?; + Ok((route.key.clone(), route.id.clone())) +} + +/// The newest transcript of `tool` that was recorded in the current directory. +fn newest_here( + controller: &mut Controller, + tool: Harness, + account: Option, +) -> Result { + let here = canonical(&std::env::current_dir().map_err(|error| error.to_string())?); + let reply = controller.dispatch(Action::RecentSessions { + tool: Some(tool), + account, + })?; + let sessions = reply.data["sessions"] + .as_array() + .cloned() + .unwrap_or_default(); + let label = harness_name(tool); + let recorded_here = |session: &Value| { + session["cwd"] + .as_str() + .is_some_and(|cwd| canonical(Path::new(cwd)) == here) + }; + if let Some(session) = sessions.iter().find(|session| recorded_here(session)) { + let path = session["transcript"] + .as_str() + .ok_or("The session listing has no transcript path.")?; + return Ok(HandoffSource::Transcript { + path: path.to_owned(), + }); + } + let mut message = format!("No {label} session was recorded in {}.", here.display()); + if sessions.is_empty() { + message.push_str(&format!( + " No transcripts were found under {}.", + reply.data["roots"][0]["transcripts"] + .as_str() + .unwrap_or("?") + )); + } else { + message.push_str(" Newest sessions:"); + for session in sessions.iter().take(5) { + message.push_str(&format!( + "\n {} {}\n {}", + session["last_activity"].as_str().unwrap_or("?"), + session["cwd"].as_str().unwrap_or("(directory unknown)"), + session["transcript"].as_str().unwrap_or("?") + )); + } + message.push_str("\nRun this in the session's directory, or pass --transcript FILE."); + } + Err(message) +} + +fn review_text(data: &Value, show: bool) -> String { + let text = |value: &Value| value.as_str().unwrap_or("").to_owned(); + let source = &data["source"]; + let destination = &data["destination"]; + let mut lines = vec![ + "Hand-off review".to_owned(), + format!( + "From: {} · {}", + text(&source["label"]), + text(&source["transcript"]) + ), + format!( + "To: {} · route {} · {}", + text(&destination["label"]), + text(&destination["route"]), + destination["account_directory"] + .as_str() + .unwrap_or("current login") + ), + format!("Directory: {}", text(&destination["directory"])), + format!("Session ID: {}", text(&destination["session_id"])), + format!("Hand-off: {}", text(&data["handoff_file"])), + format!( + "Excerpt: {} bytes · {} turns · {} files · {} commands{}", + data["bytes"], + data["turns"], + data["files_touched"], + data["commands"], + if data["truncated"].as_bool().unwrap_or(false) { + " · cut to fit" + } else { + "" + } + ), + ]; + if let Some(warnings) = data["warnings"].as_array().filter(|list| !list.is_empty()) { + lines.push("Warnings:".into()); + lines.extend( + warnings + .iter() + .map(|warning| format!(" - {}", text(warning))), + ); + } + lines.push("Command:".into()); + lines.extend( + text(&data["command"]) + .lines() + .map(|line| format!(" {line}")), + ); + if show { + lines.push(String::new()); + lines.push("---- hand-off text, which the new session reads ----".into()); + lines.push(text(&data["handoff"])); + lines.push("---- end of the hand-off text ----".into()); + } else { + lines.push("Pass --show to print the hand-off text.".into()); + } + lines.push(text(&data["notice"])); + lines.join("\n") +} + +fn command(launch: &Launch) -> std::process::Command { + let mut command = std::process::Command::new(&launch.program); + command.args(&launch.args).current_dir(&launch.directory); + for (name, value) in &launch.environment { + command.env(name, value); + } + for name in &launch.remove_environment { + command.env_remove(name); + } + command +} + +/// This replaces the current process with the harness, so the terminal belongs to it. +#[cfg(unix)] +fn exec(launch: Launch) -> ExitCode { + use std::os::unix::process::CommandExt; + let error = command(&launch).exec(); + eprintln!( + "switchyard-desktop handoff: could not start {}: {error}", + launch.program.display() + ); + ExitCode::FAILURE +} + +/// Windows has no exec, so this waits for the harness and passes on its result. +#[cfg(not(unix))] +fn exec(launch: Launch) -> ExitCode { + match command(&launch).status() { + Ok(status) if status.success() => ExitCode::SUCCESS, + Ok(_) => ExitCode::FAILURE, + Err(error) => { + eprintln!( + "switchyard-desktop handoff: could not start {}: {error}", + launch.program.display() + ); + ExitCode::FAILURE + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn args(list: &[&str]) -> Vec { + list.iter().map(|arg| (*arg).to_owned()).collect() + } + + #[test] + fn options_need_a_destination_and_accept_only_cli_harnesses() { + assert!(parse(&args(&[])).unwrap_err().contains("--to")); + assert!( + parse(&args(&["--to", "pi"])) + .unwrap_err() + .contains("codex or claude") + ); + assert!(parse(&args(&["--to", "claude", "--route"])).is_err()); + assert!(parse(&args(&["--to", "claude", "--bogus"])).is_err()); + assert!( + parse(&args(&[ + "--to", + "claude", + "--transcript", + "a", + "--session", + "b" + ])) + .is_err() + ); + assert!(parse(&args(&["--help"])).unwrap().is_none()); + let options = parse(&args(&["--to", "codex", "-y", "--route", "switchyard"])) + .unwrap() + .unwrap(); + assert_eq!( + (options.to, options.from, options.yes), + (Harness::CodexCli, Harness::Claude, true) + ); + assert_eq!(options.route.as_deref(), Some("switchyard")); + let options = parse(&args(&["--to", "claude"])).unwrap().unwrap(); + assert_eq!(options.from, Harness::CodexCli); + } + + #[test] + fn the_review_names_the_file_the_command_and_the_warnings() { + let data = json!({ + "source": {"label": "Codex", "transcript": "/codex/rollout-1.jsonl"}, + "destination": {"label": "Claude Code", "route": "claude", "directory": "/work", + "session_id": "sy-1", "account_directory": null}, + "handoff_file": "/handoffs/hd-1/HANDOFF.md", + "bytes": 1200, "turns": 3, "files_touched": 2, "commands": 1, "truncated": true, + "warnings": ["The transcript changed in the last two minutes."], + "command": "cd '/work'\n'claude' '--model' 'claude'", + "handoff": "# Hand-off from Codex\n\nFix the flaky login test", + "notice": "Nothing is uploaded.", + }); + let short = review_text(&data, false); + assert!(short.contains("To: Claude Code · route claude · current login")); + assert!(short.contains("cut to fit") && short.contains(" - The transcript changed")); + assert!(short.contains(" 'claude' '--model' 'claude'")); + assert!(!short.contains("Fix the flaky login test")); + assert!(review_text(&data, true).contains("Fix the flaky login test")); + } + + // The child half of this test runs the subcommand against a fixture home and ends inside + // the stub harness, so the parent sees the stub's exit status and its record. + #[cfg(unix)] + #[test] + fn handoff_command_writes_the_file_and_runs_the_destination_here() { + use std::os::unix::fs::PermissionsExt; + use std::{fs, process::Command}; + if let Some(home) = std::env::var_os("SWITCHYARD_HANDOFF_CLI_FIXTURE") { + let home = PathBuf::from(home); + let project = home.join("project"); + fs::create_dir(&project).expect("project"); + let settings = home.join("desktop.toml"); + let server = home.join("server.toml"); + fs::write( + &server, + "[llm_clients.c]\n\ + base_url='http://localhost:1234'\n\ + [targets.m]\n\ + id='actual'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n", + ) + .expect("server"); + fs::write( + &settings, + format!( + "config_file={server:?}\nrouting_log={:?}\n", + home.join("routing.jsonl") + ), + ) + .expect("settings"); + let day = PathBuf::from(std::env::var_os("CODEX_HOME").expect("CODEX_HOME")) + .join("sessions/2026/10/26"); + fs::create_dir_all(&day).expect("rollouts"); + let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + let rollout = |name: &str, cwd: &Path, prompt: &str| { + let thread = uuid::Uuid::now_v7().to_string(); + let lines = [ + json!({"timestamp": now, "type": "session_meta", "payload": { + "id": thread, "cwd": cwd, "timestamp": now}}), + json!({"timestamp": now, "type": "event_msg", "payload": { + "type": "user_message", "message": prompt}}), + json!({"timestamp": now, "type": "event_msg", "payload": { + "type": "agent_message", + "message": "I changed auth.rs and the test passes now."}}), + ]; + let text: String = lines.iter().map(|line| format!("{line}\n")).collect(); + fs::write(day.join(format!("rollout-{name}-{thread}.jsonl")), text) + .expect("rollout"); + }; + // A session from another directory is not the one to continue here. + rollout("2026-10-26T11-00-00", &home, "Add retries to the uploader"); + rollout("2026-10-26T10-00-00", &project, "Fix the flaky login test"); + std::env::set_current_dir(&project).expect("cwd"); + let options = parse(&args(&[ + "--to", + "claude", + "--yes", + "--settings", + settings.to_str().expect("settings path"), + ])) + .expect("options") + .expect("options"); + let launch = prepare(&options).expect("prepare").expect("confirmed"); + assert_eq!(launch.directory, project); + assert!(launch.program.ends_with("claude"), "{launch:?}"); + exec(launch); + panic!("exec returned"); + } + let home = tempfile::tempdir().expect("fixture"); + let bin = home.path().join("bin"); + fs::create_dir(&bin).expect("bin"); + let stub = bin.join("claude"); + fs::write( + &stub, + "#!/bin/bash\n\ + printf '%s\\n' \"$PWD\" \"${CLAUDE_CONFIG_DIR:-}\" \"${ANTHROPIC_CUSTOM_HEADERS:-}\" \ + \"$@\" > \"$HOME/claude-arguments\"\n", + ) + .expect("stub"); + fs::set_permissions(&stub, fs::Permissions::from_mode(0o755)).expect("executable"); + let output = Command::new(std::env::current_exe().expect("test binary")) + .args([ + "--exact", + "handoff_cli::tests::handoff_command_writes_the_file_and_runs_the_destination_here", + ]) + .env("SWITCHYARD_HANDOFF_CLI_FIXTURE", home.path()) + .env("HOME", home.path()) + .env("CODEX_HOME", home.path().join("codex-home")) + .env("CLAUDE_CONFIG_DIR", home.path().join("claude-config")) + .env("PATH", format!("{}:/usr/bin:/bin", bin.display())) + .output() + .expect("child"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "{stdout}\n{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + stdout.contains("Hand-off review") + && stdout.contains("Continuing the session here in Claude Code"), + "{stdout}" + ); + let arguments = + fs::read_to_string(home.path().join("claude-arguments")).expect("the stub ran"); + let project = home.path().join("project"); + let mut lines = arguments.lines(); + assert_eq!( + fs::canonicalize(lines.next().expect("PWD")).expect("PWD"), + fs::canonicalize(&project).expect("project") + ); + assert_eq!( + lines.next().expect("CLAUDE_CONFIG_DIR"), + home.path().join("claude-config").display().to_string() + ); + assert!( + arguments.contains("x-switchyard-session-id: sy-"), + "{arguments}" + ); + assert!( + arguments.contains("x-switchyard-origin: claude"), + "{arguments}" + ); + for flag in [ + "--settings", + "--model\npublic", + "--session-id", + "--append-system-prompt", + "--add-dir", + "Read the hand-off file", + ] { + assert!(arguments.contains(flag), "{flag}\n{arguments}"); + } + // The excerpt stays in the file; the command line only points at it. + assert!( + !arguments.contains("Fix the flaky login test"), + "{arguments}" + ); + let handoffs = home.path().join(".switchyard/handoffs"); + let entries: Vec = fs::read_dir(&handoffs) + .expect("handoffs") + .map(|entry| entry.expect("entry").path()) + .collect(); + assert_eq!(entries.len(), 1, "{entries:?}"); + let file = entries[0].join("HANDOFF.md"); + let text = fs::read_to_string(&file).expect("hand-off"); + assert!(text.contains("Fix the flaky login test") && text.contains("auth.rs")); + assert!(!text.contains("Add retries to the uploader")); + assert_eq!( + fs::metadata(&file).expect("mode").permissions().mode() & 0o777, + 0o600 + ); + assert!( + arguments.contains(file.to_str().expect("path")), + "{arguments}" + ); + let record: Value = + serde_json::from_str(&fs::read_to_string(entries[0].join("handoff.json")).unwrap()) + .expect("record"); + assert_eq!(record["opened"], json!(true)); + assert_eq!(record["destination"]["directory"], json!(project)); + assert_eq!(record["source"]["tool"], json!("codex_cli")); + } +} diff --git a/crates/switchyard-desktop/src/handoffs.rs b/crates/switchyard-desktop/src/handoffs.rs new file mode 100644 index 0000000000..4d12ef2a8f --- /dev/null +++ b/crates/switchyard-desktop/src/handoffs.rs @@ -0,0 +1,240 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! A hand-off is the text a destination harness reads to continue a session that another +//! harness started. Each one lives in its own directory under `~/.switchyard/handoffs`: the +//! Markdown the destination reads, a record of where it came from and where it went, and the +//! destination's private harness settings. Hand-offs hold conversation excerpts, so the files +//! are private to the user and old ones are removed. + +use serde_json::Value; +use std::path::{Path, PathBuf}; + +/// Hand-offs older than this are removed when the app starts. +pub const RETENTION_DAYS: i64 = 7; +pub const FILE: &str = "HANDOFF.md"; +pub const RECORD: &str = "handoff.json"; + +pub fn root() -> Result { + crate::config::expand_home(Path::new("~/.switchyard/handoffs")) +} + +pub fn new_id() -> String { + format!("hd-{}", uuid::Uuid::now_v7()) +} + +fn validate_id(id: &str) -> Result<(), String> { + let suffix = id.strip_prefix("hd-").ok_or("Choose a hand-off.")?; + uuid::Uuid::parse_str(suffix).map_err(|_| "Choose a hand-off.")?; + Ok(()) +} + +/// The directory of one hand-off. It must be a directory the app created, not a symlink. +pub fn directory(id: &str) -> Result { + validate_id(id)?; + let path = root()?.join(id); + if std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_symlink()) { + return Err("Hand-off directories must not be symlinks.".into()); + } + Ok(path) +} + +fn private_dir(path: &Path) -> Result<(), String> { + std::fs::create_dir_all(path).map_err(|error| format!("Create {}: {error}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .map_err(|error| error.to_string())?; + } + Ok(()) +} + +fn private_file(path: &Path, bytes: &[u8]) -> Result<(), String> { + std::fs::write(path, bytes).map_err(|error| format!("Write {}: {error}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|error| error.to_string())?; + } + Ok(()) +} + +/// This writes a hand-off's directory, Markdown, and record, and returns the directory. +pub fn create(id: &str, markdown: &str, record: &Value) -> Result { + let root = root()?; + if std::fs::symlink_metadata(&root).is_ok_and(|m| m.file_type().is_symlink()) { + return Err(format!( + "{} is a symlink. Edit its target yourself.", + root.display() + )); + } + private_dir(&root)?; + let dir = directory(id)?; + private_dir(&dir)?; + private_file(&dir.join(FILE), markdown.as_bytes())?; + update(id, record)?; + Ok(dir) +} + +/// This replaces a hand-off's record. +pub fn update(id: &str, record: &Value) -> Result<(), String> { + let dir = directory(id)?; + private_file( + &dir.join(RECORD), + &serde_json::to_vec_pretty(record).map_err(|error| error.to_string())?, + ) +} + +/// This lists the recorded hand-offs, newest first. Unreadable records are skipped. +pub fn list() -> Result, String> { + let root = root()?; + let entries = match std::fs::read_dir(&root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(format!("Read {}: {error}", root.display())), + }; + let mut records = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| error.to_string())?; + let name = entry.file_name().to_string_lossy().into_owned(); + if validate_id(&name).is_err() || directory(&name).is_err() { + continue; + } + let Ok(bytes) = std::fs::read(entry.path().join(RECORD)) else { + continue; + }; + if let Ok(record) = serde_json::from_slice::(&bytes) + && record.is_object() + && record["id"] == name + { + records.push(record); + } + } + records.sort_by(|left, right| { + right["created_at"] + .as_str() + .cmp(&left["created_at"].as_str()) + }); + Ok(records) +} + +/// This removes hand-offs created more than `RETENTION_DAYS` before `now` and returns how +/// many it removed. A hand-off without a readable record is removed by its directory age. +pub fn purge(now: chrono::DateTime) -> Result { + let root = root()?; + let entries = match std::fs::read_dir(&root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(format!("Read {}: {error}", root.display())), + }; + let cutoff = now - chrono::Duration::days(RETENTION_DAYS); + let mut removed = 0; + for entry in entries { + let entry = entry.map_err(|error| error.to_string())?; + let name = entry.file_name().to_string_lossy().into_owned(); + let Ok(dir) = directory(&name) else { + continue; + }; + let Ok(metadata) = std::fs::symlink_metadata(&dir) else { + continue; + }; + if !metadata.is_dir() { + continue; + } + let created = std::fs::read(dir.join(RECORD)) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|record| { + record["created_at"] + .as_str() + .and_then(|ts| chrono::DateTime::parse_from_rfc3339(ts).ok()) + .map(|ts| ts.with_timezone(&chrono::Utc)) + }) + .or_else(|| { + metadata + .modified() + .ok() + .map(chrono::DateTime::::from) + }); + if created.is_some_and(|created| created < cutoff) { + std::fs::remove_dir_all(&dir).map_err(|error| error.to_string())?; + removed += 1; + } + } + Ok(removed) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // The subprocess isolates HOME so the test never touches the user's hand-offs. + #[cfg(unix)] + #[test] + fn handoffs_are_private_listed_newest_first_and_purged_by_age() { + use std::os::unix::fs::PermissionsExt; + if let Ok(home) = std::env::var("SWITCHYARD_HANDOFF_FIXTURE") { + let home = PathBuf::from(home); + let old = new_id(); + let recent = new_id(); + let created = |ts: &str, id: &str| json!({"id": id, "created_at": ts}); + let dir = create(&old, "# old", &created("2026-10-01T00:00:00Z", &old)).expect("old"); + assert_eq!( + std::fs::metadata(&dir).expect("dir").permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + std::fs::metadata(dir.join(FILE)) + .expect("file") + .permissions() + .mode() + & 0o777, + 0o600 + ); + create( + &recent, + "# recent", + &created("2026-10-25T00:00:00Z", &recent), + ) + .expect("recent"); + std::fs::create_dir(home.join(".switchyard/handoffs/not-a-handoff")).expect("decoy"); + std::fs::write(home.join(".switchyard/handoffs/hd-decoy"), "file").expect("decoy"); + let listed = list().expect("list"); + assert_eq!( + listed + .iter() + .map(|r| r["id"].as_str().unwrap()) + .collect::>(), + vec![recent.as_str(), old.as_str()] + ); + assert!(directory("../etc").is_err()); + let now = chrono::DateTime::parse_from_rfc3339("2026-10-26T00:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc); + assert_eq!(purge(now).expect("purge"), 1); + assert!(!root().unwrap().join(&old).exists()); + assert!(root().unwrap().join(&recent).join(FILE).exists()); + assert!(home.join(".switchyard/handoffs/not-a-handoff").exists()); + return; + } + let home = tempfile::tempdir().expect("home"); + let output = std::process::Command::new(std::env::current_exe().expect("test binary")) + .args([ + "--exact", + "handoffs::tests::handoffs_are_private_listed_newest_first_and_purged_by_age", + ]) + .env("SWITCHYARD_HANDOFF_FIXTURE", home.path()) + .env("HOME", home.path()) + .output() + .expect("child"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } +} diff --git a/crates/switchyard-desktop/src/harness.rs b/crates/switchyard-desktop/src/harness.rs new file mode 100644 index 0000000000..885c3e25ee --- /dev/null +++ b/crates/switchyard-desktop/src/harness.rs @@ -0,0 +1,1209 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module installs and restores user settings for the supported harnesses. + +use serde_json::{Value, json}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use toml_edit::{DocumentMut, value}; + +pub type ReviewedFile = (PathBuf, Option>); + +#[derive( + Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize, +)] +#[serde(rename_all = "snake_case")] +pub enum Harness { + CodexCli, + CodexApp, + Claude, + Pi, +} + +pub const HARNESSES: &[(Harness, &str)] = &[ + (Harness::CodexCli, "Codex CLI profile"), + (Harness::CodexApp, "Codex app and CLI defaults"), + (Harness::Claude, "Claude Code"), + (Harness::Pi, "Pi"), +]; + +/// This function uses the selected harness's environment variable when it is present. +/// This function resolves the home directory only when that variable is absent. +pub fn paths(tool: Harness) -> Result, String> { + let (variable, default, files): (&str, &str, &[&str]) = match tool { + Harness::CodexCli => ("CODEX_HOME", "~/.codex", &["sy.config.toml"]), + Harness::CodexApp => ("CODEX_HOME", "~/.codex", &["config.toml"]), + Harness::Claude => ("CLAUDE_CONFIG_DIR", "~/.claude", &["settings.json"]), + Harness::Pi => ( + "PI_CODING_AGENT_DIR", + "~/.pi/agent", + &["models.json", "settings.json"], + ), + }; + let directory = match std::env::var_os(variable) { + Some(path) => PathBuf::from(path), + None => crate::config::expand_home(Path::new(default))?, + }; + Ok(files.iter().map(|file| directory.join(file)).collect()) +} + +pub fn inspect(tool: Harness, files: &[PathBuf]) -> Result { + let texts = files + .iter() + .map(|path| read(path)) + .collect::, _>>()?; + inspect_settings(tool, files, &texts) +} + +fn inspect_settings(tool: Harness, files: &[PathBuf], texts: &[String]) -> Result { + let text = &texts[0]; + let model; + let endpoint; + match tool { + Harness::CodexCli | Harness::CodexApp => { + let doc = text + .parse::() + .map_err(|e| e.message().to_owned())?; + model = doc + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("Default") + .to_string(); + let provider = doc + .get("model_provider") + .and_then(|v| v.as_str()) + .unwrap_or("openai"); + endpoint = doc + .get("model_providers") + .and_then(|v| v.get(provider)) + .and_then(|v| v.get("base_url")) + .and_then(|v| v.as_str()) + .unwrap_or("Provider default") + .to_string(); + } + Harness::Claude => { + let doc = object(text)?; + model = doc + .pointer("/env/ANTHROPIC_MODEL") + .and_then(Value::as_str) + .or_else(|| doc.get("model").and_then(Value::as_str)) + .unwrap_or("Default") + .to_string(); + endpoint = doc + .pointer("/env/ANTHROPIC_BASE_URL") + .and_then(Value::as_str) + .unwrap_or("Anthropic default") + .to_string(); + } + Harness::Pi => { + let doc = object(&texts[1])?; + model = doc + .get("defaultModel") + .and_then(Value::as_str) + .unwrap_or("Default") + .to_string(); + let provider = doc + .get("defaultProvider") + .and_then(Value::as_str) + .unwrap_or("Default"); + let models = object(text)?; + endpoint = models + .get("providers") + .and_then(|v| v.get(provider)) + .and_then(|v| v.get("baseUrl")) + .and_then(Value::as_str) + .unwrap_or("Provider default") + .to_string(); + } + } + // The display keeps the URL scheme, host, and port to omit credentials and queries. + let endpoint = url::Url::parse(&endpoint) + .ok() + .map(|u| { + format!( + "{}://{}{}", + u.scheme(), + u.host_str().unwrap_or("?"), + u.port().map(|p| format!(":{p}")).unwrap_or_default() + ) + }) + .unwrap_or_else(|| "Provider default".into()); + Ok(format!( + "Model: {model}\nEndpoint: {endpoint}\nSettings: {}", + files + .iter() + .map(|file| file.display().to_string()) + .collect::>() + .join("\n ") + )) +} + +fn read_file(path: &Path) -> Result, String> { + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(format!( + "{} is a symlink. Edit its target yourself.", + path.display() + )); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.to_string()), + _ => {} + } + std::fs::read_to_string(path) + .map(Some) + .map_err(|e| format!("Read {}: {e}", path.display())) +} + +fn read(path: &Path) -> Result { + Ok(read_file(path)?.unwrap_or_default()) +} + +fn object(text: &str) -> Result { + let doc: Value = if text.is_empty() { + json!({}) + } else { + serde_json::from_str(text).map_err(|e| e.to_string())? + }; + if !doc.is_object() { + return Err("Settings must be a JSON object.".into()); + } + Ok(doc) +} + +fn map<'a>(doc: &'a mut Value, key: &str) -> Result<&'a mut Value, String> { + if doc.get(key).is_none() { + doc[key] = json!({}); + } + if !doc[key].is_object() { + return Err(format!("{key} must be a JSON object.")); + } + Ok(&mut doc[key]) +} + +pub fn validate(tool: Harness, root: &str, model: &str, login: bool) -> Result { + let url = url::Url::parse(root).map_err(|e| e.to_string())?; + if url.scheme() != "http" + || !matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")) + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.path() != "/" + { + return Err( + "Install requires a local HTTP server URL, such as http://127.0.0.1:4123.".into(), + ); + } + if model.trim().is_empty() { + return Err("Pick a route first.".into()); + } + if tool == Harness::Pi && login { + return Err( + "Pi's custom provider needs a route with server API credentials. \ + Use Codex or Claude for subscription login forwarding." + .into(), + ); + } + // validate returns the parsed URL without a trailing slash so callers can append API paths. + Ok(url.to_string().trim_end_matches('/').to_owned()) +} + +fn codex_settings( + text: &str, + root: &str, + model: &str, + context_window: Option, + login: bool, +) -> Result { + let mut doc = text + .parse::() + .map_err(|e| e.message().to_owned())?; + if doc + .get("model_providers") + .is_some_and(|item| item.as_table_like().is_none()) + { + return Err("model_providers must be a TOML table.".into()); + } + if doc + .get("model_providers") + .and_then(|v| v.get("sy")) + .is_some_and(|item| item.as_table_like().is_none()) + { + return Err("sy must be a TOML provider table.".into()); + } + doc["model"] = value(model); + doc["model_provider"] = value("sy"); + // Codex does not know a route ID, so it compacts only when the settings name the + // route's window. A window kept from another route would compact at the wrong size. + match context_window.and_then(|window| i64::try_from(window).ok()) { + Some(window) => doc["model_context_window"] = value(window), + None => { + doc.remove("model_context_window"); + } + } + doc["model_providers"]["sy"]["name"] = value("Switchyard"); + doc["model_providers"]["sy"]["base_url"] = value(format!("{root}/v1")); + doc["model_providers"]["sy"]["wire_api"] = value("responses"); + doc["model_providers"]["sy"]["requires_openai_auth"] = value(login); + // A stale env_key would make Codex require a local API key even when the + // server owns the credentials or forwards the harness’s subscription login. + doc["model_providers"]["sy"] + .as_table_like_mut() + .ok_or("sy must be a provider table")? + .remove("env_key"); + Ok(doc.to_string()) +} + +fn claude_settings(text: &str, root: &str, model: &str, login: bool) -> Result { + let mut doc = object(text)?; + let env = map(&mut doc, "env")?; + let role_models = [ + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + ]; + // The complete installation signature distinguishes our placeholder from a user credential. + let prior_switchyard = env["ANTHROPIC_BASE_URL"].as_str() == Some(root) + && env["ANTHROPIC_AUTH_TOKEN"].as_str() == Some("switchyard-local") + && env["ANTHROPIC_API_KEY"].as_str() == Some("") + && env["ANTHROPIC_MODEL"] + .as_str() + .is_some_and(|model| !model.is_empty()) + && role_models + .iter() + .all(|key| env[*key] == env["ANTHROPIC_MODEL"]); + env["ANTHROPIC_BASE_URL"] = json!(root); + env["ANTHROPIC_MODEL"] = json!(model); + // All model roles use the selected public route, including subagents. + for key in role_models { + env[key] = json!(model); + } + if login { + if prior_switchyard { + let values = env.as_object_mut().ok_or("env must be a JSON object.")?; + values.remove("ANTHROPIC_AUTH_TOKEN"); + values.remove("ANTHROPIC_API_KEY"); + } + for key in ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] { + if env.get(key).is_some_and(|value| value.as_str() != Some("")) { + return Err(format!( + "{key} overrides subscription login. \ + Remove it from Claude settings before installing this route." + )); + } + } + } else { + env["ANTHROPIC_AUTH_TOKEN"] = json!("switchyard-local"); + env["ANTHROPIC_API_KEY"] = json!(""); + } + Ok(serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())? + "\n") +} + +// The preview uses the installation transformations without creating files or backups. +pub fn preview( + tool: Harness, + files: &[PathBuf], + root: &str, + model: &str, + context_window: Option, + login: bool, +) -> Result { + let root = validate(tool, root, model, login)?; + for path in files { + if read_file(&restore_pending_path(path))?.is_some() { + return Err("A previous restore still needs backup cleanup. \ + Retry Restore before connecting another route." + .into()); + } + } + let originals = files + .iter() + .map(|path| read_file(path)) + .collect::, _>>()?; + let incoming = configured(tool, &originals, &root, model, context_window, login)?; + let current = originals + .iter() + .map(|text| text.clone().unwrap_or_default()) + .collect::>(); + Ok(json!({ + "changes": settings_changes(tool, files, ¤t, &incoming)?, + "current": inspect_settings(tool, files, ¤t)?, + "proposed": inspect_settings(tool, files, &incoming)?, + "authentication": if login { + "This route uses the harness’s subscription login." + } else { + "This route uses the API credentials configured on the Switchyard server." + }, + "files": files.iter().map(|path| path.display().to_string()).collect::>(), + "file_actions": files.iter().zip(originals.iter().zip(&incoming)).map(|(path,(before,after))|json!({"file":path,"action":if before.as_deref()==Some(after.as_str()) {"preserve"} else if before.is_none() {"create"} else {"update"}})).collect::>() + })) +} + +// PreviewModel and its nested types omit unknown fields that could contain credentials. +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct PreviewModel { + id: Option, + name: Option, + reasoning: Option, + input: Option>, + context_window: Option, + max_tokens: Option, + cost: Option, +} +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "lowercase")] +enum PreviewInput { + Text, + Image, +} +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct PreviewCosts { + input: Option, + output: Option, + cache_read: Option, + cache_write: Option, +} + +// settings_changes compares supported routing fields and hides credential values before display. +fn settings_changes( + tool: Harness, + files: &[PathBuf], + before: &[String], + after: &[String], +) -> Result, String> { + let mut changes = Vec::new(); + for (index, file) in files.iter().enumerate() { + let decode = |text: &str| -> Result { + if matches!(tool, Harness::CodexCli | Harness::CodexApp) { + let doc: toml::Value = toml::from_str(text).map_err(|e| e.to_string())?; + serde_json::to_value(doc).map_err(|e| e.to_string()) + } else { + object(text) + } + }; + let old = decode(&before[index])?; + let new = decode(&after[index])?; + let keys: &[&str] = match tool { + Harness::CodexCli | Harness::CodexApp => &[ + "/model", + "/model_provider", + "/model_context_window", + "/model_providers/sy/name", + "/model_providers/sy/env_key", + "/model_providers/sy/base_url", + "/model_providers/sy/requires_openai_auth", + "/model_providers/sy/wire_api", + ], + Harness::Claude => &[ + "/model", + "/env/ANTHROPIC_MODEL", + "/env/ANTHROPIC_DEFAULT_OPUS_MODEL", + "/env/ANTHROPIC_DEFAULT_SONNET_MODEL", + "/env/ANTHROPIC_DEFAULT_HAIKU_MODEL", + "/env/ANTHROPIC_BASE_URL", + "/env/ANTHROPIC_AUTH_TOKEN", + "/env/ANTHROPIC_API_KEY", + ], + Harness::Pi if index == 0 => &[ + "/providers/switchyard/baseUrl", + "/providers/switchyard/api", + "/providers/switchyard/apiKey", + "/providers/switchyard/headers", + "/providers/switchyard/models", + ], + Harness::Pi => &["/defaultModel", "/defaultProvider"], + }; + for key in keys { + let left = old.pointer(key); + let right = new.pointer(key); + if left == right { + continue; + } + let display = |value: Option<&Value>| -> String { + let Some(value) = value else { + return "(unset)".into(); + }; + match *key { + "/env/ANTHROPIC_AUTH_TOKEN" + | "/env/ANTHROPIC_API_KEY" + | "/providers/switchyard/apiKey" => if value.as_str() == Some("") { + "(empty)" + } else { + "(set; hidden)" + } + .into(), + "/providers/switchyard/headers" => value + .as_object() + .map(|headers| { + headers + .keys() + .map(|name| format!("{name}: (set; hidden)")) + .collect::>() + .join(", ") + }) + .unwrap_or_else(|| "(configured headers; values hidden)".into()), + "/providers/switchyard/models" => value + .as_array() + .map(|models| { + models + .iter() + .map(|model| { + serde_json::from_value::(model.clone()) + .and_then(serde_json::to_value) + .unwrap_or_else(|_| { + json!("(unsupported model metadata; hidden)") + }) + }) + .collect::>() + }) + .map(|models| Value::Array(models).to_string()) + .unwrap_or_default(), + "/env/ANTHROPIC_BASE_URL" + | "/model_providers/sy/base_url" + | "/providers/switchyard/baseUrl" => value + .as_str() + .and_then(|v| url::Url::parse(v).ok()) + .map(|mut url| { + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_query(None); + url.set_fragment(None); + url.to_string() + }) + .unwrap_or_else(|| "(configured endpoint)".into()), + _ => match value { + Value::String(value) => value.clone(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + _ => "(set; hidden)".into(), + }, + } + }; + changes.push(json!({"file":file.display().to_string(),"key":key.trim_start_matches('/').replace('/',"."),"before":display(left),"after":display(right)})); + } + } + Ok(changes) +} + +// Both preview and installation use these transformations so the proposed settings match the saved settings. +fn configured( + tool: Harness, + originals: &[Option], + root: &str, + model: &str, + context_window: Option, + login: bool, +) -> Result, String> { + let mut incoming = Vec::new(); + match tool { + Harness::CodexCli | Harness::CodexApp => incoming.push(codex_settings( + originals[0].as_deref().unwrap_or_default(), + root, + model, + context_window, + login, + )?), + Harness::Claude => incoming.push(claude_settings( + originals[0].as_deref().unwrap_or_default(), + root, + model, + login, + )?), + Harness::Pi => { + let mut doc = object(originals[0].as_deref().unwrap_or_default())?; + let providers = map(&mut doc, "providers")?; + providers["switchyard"] = json!({"baseUrl":format!("{root}/v1"), "api":"openai-completions", "apiKey":"switchyard-local", "headers":{"x-switchyard-origin":"pi"}, "models":[{"id":model,"name":model,"reasoning":true,"input":["text"],"contextWindow":context_window.unwrap_or(128000),"maxTokens":16384,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}}]}); + incoming.push(serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())? + "\n"); + let mut settings = object(originals[1].as_deref().unwrap_or_default())?; + settings["defaultProvider"] = json!("switchyard"); + settings["defaultModel"] = json!(model); + incoming + .push(serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())? + "\n"); + } + } + Ok(incoming) +} + +pub fn install( + tool: Harness, + files: &[PathBuf], + root: &str, + model: &str, + context_window: Option, + login: bool, +) -> Result { + install_reviewed(tool, files, root, model, context_window, login, None) +} + +pub fn install_reviewed( + tool: Harness, + files: &[PathBuf], + root: &str, + model: &str, + context_window: Option, + login: bool, + expected: Option<&[ReviewedFile]>, +) -> Result { + let root = validate(tool, root, model, login)?; + for path in files { + if read_file(&restore_pending_path(path))?.is_some() { + return Err("A previous restore did not finish retiring its backups. \ + Retry Restore before installing another route." + .into()); + } + } + let originals = files + .iter() + .map(|p| read_file(p)) + .collect::, _>>()?; + check_review(expected)?; + let incoming = configured(tool, &originals, &root, model, context_window, login)?; + let incoming: Vec<_> = incoming.into_iter().map(Some).collect(); + for (path, original) in files.iter().zip(&originals) { + if let Some(expected) = expected + .and_then(|files| files.iter().find(|(file, _)| file == path)) + .map(|(_, bytes)| bytes) + && original.as_ref().map(|text| text.as_bytes()) != expected.as_deref() + { + return Err("Harness settings changed after the preview. Review them again.".into()); + } + } + for (path, before) in files.iter().zip(&originals) { + let backup = backup_path(path); + let absent = absent_path(path); + if read_file(&backup)?.is_some() || read_file(&absent)?.is_some() { + continue; + } + let (destination, text) = match before { + Some(text) => (&backup, text.as_str()), + None => (&absent, ""), + }; + prepare(destination, text)? + .persist_noclobber(destination) + .map_err(|e| e.to_string())?; + } + replace(files, &originals, &incoming)?; + let next = match tool { + Harness::CodexCli => { + let profile = files[0] + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_suffix(".config.toml")); + profile + .map(|name| format!(" Start Codex CLI with codex -p {name}.")) + .unwrap_or_else(|| " Restart Codex CLI.".into()) + } + Harness::Pi => " Reload Pi models with /model.".into(), + Harness::CodexApp => " Restart Codex app.".into(), + Harness::Claude => " Restart Claude Code.".into(), + }; + Ok(format!( + "Connected route {model}. Changed {}.{next}", + files + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") + )) +} + +fn backup_path(path: &Path) -> PathBuf { + path.with_file_name(format!( + "{}.switchyard-original", + path.file_name().unwrap_or_default().to_string_lossy() + )) +} + +// The marker distinguishes an originally missing file from an empty file. +// Restore removes installed settings only when the original file was missing. +fn absent_path(path: &Path) -> PathBuf { + backup_path(path).with_extension("switchyard-original-missing") +} + +fn restore_pending_path(path: &Path) -> PathBuf { + backup_path(path).with_extension("switchyard-restore-pending") +} + +pub fn review_files(files: &[PathBuf]) -> Vec { + files + .iter() + .flat_map(|path| { + [ + path.clone(), + backup_path(path), + absent_path(path), + restore_pending_path(path), + ] + }) + .collect() +} + +pub fn preview_restore(tool: Harness, files: &[PathBuf]) -> Result { + let resuming = files.iter().any(|path| restore_pending_path(path).exists()); + let mut plan = Vec::new(); + let mut before = Vec::new(); + let mut after = Vec::new(); + for path in files { + let current = read_file(path)?; + let pending = read_file(&restore_pending_path(path))?; + let original = match pending { + Some(text) => { + serde_json::from_str::>(&text).map_err(|error| error.to_string())? + } + None => match read_file(&backup_path(path))? { + Some(text) => Some(text), + None if read_file(&absent_path(path))?.is_some() => None, + None if resuming => current.clone(), + None => return Err("No original backup is available for this destination.".into()), + }, + }; + let action = if current == original { + "unchanged" + } else { + match (¤t, &original) { + (_, None) => "remove", + (None, _) => "create", + _ => "replace", + } + }; + before.push(current.unwrap_or_default()); + after.push(original.unwrap_or_default()); + plan.push(json!({"file":path,"action":action,"backup":backup_path(path)})); + } + Ok(json!({ + "files": plan, + "changes": settings_changes(tool, files, &before, &after).ok(), + "warning": "Restore replaces each listed file with its original version. \ + Later edits are kept in a recovery copy before replacement." + })) +} + +// Restore receipts keep the original contents or absence available until backup cleanup finishes. +#[cfg(test)] +pub fn restore(files: &[PathBuf]) -> Result { + restore_reviewed(files, None) +} + +pub fn restore_reviewed( + files: &[PathBuf], + expected: Option<&[ReviewedFile]>, +) -> Result { + let pending = files + .iter() + .map(|path| read_file(&restore_pending_path(path))) + .collect::, _>>()?; + let resuming = pending.iter().any(Option::is_some); + let mut restored = Vec::new(); + for (path, pending) in files.iter().zip(pending) { + let original = read_file(&backup_path(path))?; + let absent = read_file(&absent_path(path))?.is_some(); + let original = match pending { + Some(text) => serde_json::from_str::>(&text) + .map_err(|e| format!("Read pending restore: {e}"))?, + None if original.is_none() && !absent => { + if !resuming { + return Err("No original backup is available for this installation.".into()); + } + // A cleared receipt means this file finished restoring, so a retry preserves later edits. + read_file(path)? + } + None => original, + }; + restored.push(original); + } + let current = files + .iter() + .map(|p| read_file(p)) + .collect::, _>>()?; + check_review(expected)?; + for (path, original) in files.iter().zip(¤t) { + if let Some(expected) = expected + .and_then(|files| files.iter().find(|(file, _)| file == path)) + .map(|(_, bytes)| bytes) + && original.as_ref().map(|text| text.as_bytes()) != expected.as_deref() + { + return Err( + "Harness settings changed after the restore preview. Review them again.".into(), + ); + } + } + let mut kept_paths = Vec::new(); + for (path, before) in files.iter().zip(¤t) { + let Some(before) = before else { + continue; + }; + let mut recovery = tempfile::Builder::new() + .prefix("switchyard-before-restore-") + .tempfile_in(path.parent().ok_or("Settings need a parent directory")?) + .map_err(|e| e.to_string())?; + recovery + .write_all(before.as_bytes()) + .map_err(|e| e.to_string())?; + let (_, kept) = recovery.keep().map_err(|e| e.to_string())?; + kept_paths.push(kept.display().to_string()); + } + // Each private receipt stores the original contents or absence and blocks installation during cleanup. + for (path, original) in files.iter().zip(&restored) { + let pending = restore_pending_path(path); + if read_file(&pending)?.is_none() { + let text = serde_json::to_string(original).map_err(|e| e.to_string())?; + prepare(&pending, &text)? + .persist_noclobber(&pending) + .map_err(|e| e.to_string())?; + } + } + let recovery = format!("Previous settings: {}", kept_paths.join(", ")); + replace(files, ¤t, &restored).map_err(|error| format!("{error}. {recovery}"))?; + for path in files { + for retired in [backup_path(path), absent_path(path)] { + match std::fs::remove_file(&retired) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "Restored settings, but could not retire {}: {error}. \ + Retry Restore before installing another route. {recovery}", + retired.display() + )); + } + } + } + } + for path in files { + let pending = restore_pending_path(path); + std::fs::remove_file(&pending).map_err(|error| { + format!( + "Restored settings, but could not clear {}: {error}. {recovery}", + pending.display() + ) + })?; + } + Ok(format!( + "Restored original settings. Restart the harness. {recovery}" + )) +} + +fn check_review(expected: Option<&[ReviewedFile]>) -> Result<(), String> { + if let Some(files) = expected { + for (path, expected) in files { + let current = match std::fs::read(path) { + Ok(bytes) => Some(std::sync::Arc::<[u8]>::from(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.to_string()), + }; + if ¤t != expected { + return Err(format!( + "{} changed after the preview. Review it again.", + path.display() + )); + } + } + } + Ok(()) +} + +fn prepare(path: &Path, text: &str) -> Result { + let parent = path.parent().ok_or("Settings need a parent directory")?; + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; + temp.write_all(text.as_bytes()) + .and_then(|_| temp.as_file().sync_all()) + .map_err(|e| e.to_string())?; + Ok(temp) +} + +// Each file replacement is atomic, but the set of replacements is not. +// A later failure triggers an attempt to restore files already replaced. +fn replace( + files: &[PathBuf], + before: &[Option], + after: &[Option], +) -> Result<(), String> { + let staged = files + .iter() + .zip(after) + .map(|(path, text)| text.as_deref().map(|text| prepare(path, text)).transpose()) + .collect::, _>>()?; + for (path, expected) in files.iter().zip(before) { + if read_file(path)? != *expected { + return Err(format!( + "{} changed while installing. Refresh and try again.", + path.display() + )); + } + } + for (index, (path, temp)) in files.iter().zip(staged).enumerate() { + let result = match temp { + Some(temp) => temp.persist(path).map(|_| ()).map_err(|e| e.to_string()), + None if before[index].is_some() => { + std::fs::remove_file(path).map_err(|e| e.to_string()) + } + None => Ok(()), + }; + if let Err(error) = result { + for (path, original) in files[..index].iter().zip(before) { + match original { + Some(text) => { + prepare(path, text)? + .persist(path) + .map_err(|e| e.to_string())?; + } + None => std::fs::remove_file(path).map_err(|e| e.to_string())?, + } + } + return Err(error); + } + } + Ok(()) +} + +pub fn binary(tool: Harness) -> Option { + if tool == Harness::CodexApp { + #[cfg(windows)] + return crate::windows::codex_app(); + #[cfg(not(windows))] + { + #[cfg(target_os = "macos")] + if let Some(path) = objc2_app_kit::NSWorkspace::sharedWorkspace() + .URLForApplicationWithBundleIdentifier(&objc2_foundation::NSString::from_str( + "com.openai.codex", + )) + .and_then(|url| url.path()) + .map(|path| PathBuf::from(path.to_string())) + .filter(|path| path.is_dir()) + { + return Some(path); + } + return [ + Some(PathBuf::from("/Applications/Codex.app")), + crate::config::expand_home(Path::new("~/Applications/Codex.app")).ok(), + ] + .into_iter() + .flatten() + .find(|p| p.exists()); + } + } + let name = match tool { + Harness::CodexCli => "codex", + Harness::Claude => "claude", + Harness::Pi => "pi", + Harness::CodexApp => return None, + }; + let mut dirs: Vec<_> = std::env::var_os("PATH") + .map(|s| std::env::split_paths(&s).collect()) + .unwrap_or_default(); + dirs.extend([ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + ]); + dirs.extend( + [ + crate::config::expand_home(Path::new("~/.local/bin")).ok(), + crate::config::expand_home(Path::new("~/.npm-global/bin")).ok(), + ] + .into_iter() + .flatten(), + ); + #[cfg(windows)] + { + dirs.extend( + [ + crate::config::expand_home(Path::new("~/AppData/Roaming/npm")).ok(), + crate::config::expand_home(Path::new("~/.cargo/bin")).ok(), + crate::config::expand_home(Path::new("~/.local/bin")).ok(), + ] + .into_iter() + .flatten(), + ); + dirs.into_iter() + .flat_map(|p| [p.join(format!("{name}.exe")), p.join(format!("{name}.cmd"))]) + .find(|p| p.is_file()) + } + #[cfg(not(windows))] + dirs.into_iter().map(|p| p.join(name)).find(|p| p.is_file()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + // Preview must match the installed settings without creating files or backups. + fn preview_matches_installation_without_writes() { + for (tool, _) in HARNESSES { + let dir = tempfile::tempdir().expect("directory"); + let files = if *tool == Harness::Pi { + vec![ + dir.path().join("models.json"), + dir.path().join("settings.json"), + ] + } else { + vec![dir.path().join("config")] + }; + let original = if matches!(tool, Harness::CodexCli | Harness::CodexApp) { + "# user comment\nmodel='before'\nsandbox_mode='workspace-write'\n" + } else { + "{\"custom\":true}" + }; + std::fs::write(&files[0], original).expect("settings"); + let result = preview( + *tool, + &files, + "http://localhost:4123", + "new-route", + None, + false, + ) + .expect("preview"); + assert!( + result["changes"] + .as_array() + .expect("diff") + .iter() + .any(|change| change["after"] + .as_str() + .is_some_and(|value| value.contains("new-route"))) + ); + assert_eq!(read(&files[0]).expect("unchanged"), original); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 1); + install( + *tool, + &files, + "http://localhost:4123", + "new-route", + None, + false, + ) + .expect("install"); + assert_eq!( + result["proposed"], + inspect(*tool, &files).expect("installed") + ); + install( + *tool, + &files, + "http://localhost:4123", + "refreshed-route", + None, + false, + ) + .expect("refresh"); + let installed = read(&files[0]).expect("settings"); + assert!(installed.contains("refreshed-route")); + assert!( + installed.contains(if matches!(tool, Harness::CodexCli | Harness::CodexApp) { + "sandbox_mode" + } else { + "custom" + }) + ); + assert_eq!(read(&backup_path(&files[0])).expect("backup"), original); + restore(&files).expect("restore"); + assert_eq!(read(&files[0]).expect("original"), original); + if *tool == Harness::Pi { + assert!(!files[1].exists()); + } + std::fs::write(&files[0], original.replace("before", "new-user-model")) + .expect("user edit"); + install(*tool, &files, "http://localhost:4123", "again", None, false) + .expect("reinstall"); + restore(&files).expect("restore again"); + assert_eq!( + read(&files[0]).expect("new original"), + original.replace("before", "new-user-model") + ); + assert!( + result["authentication"] + .as_str() + .expect("authentication") + .contains("API credentials") + ); + } + let dir = tempfile::tempdir().expect("directory"); + let files = vec![dir.path().join("settings.json")]; + let original = + r#"{"env":{"ANTHROPIC_API_KEY":"SECRET","ANTHROPIC_MODEL":{"key":"SECRET"}}}"#; + std::fs::write(&files[0], original).expect("settings"); + assert!( + preview( + Harness::Claude, + &files, + "http://localhost:4123", + "route", + None, + true + ) + .is_err() + ); + assert_eq!(read(&files[0]).expect("unchanged"), original); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 1); + let safe = preview( + Harness::Claude, + &files, + "http://localhost:4123", + "route", + None, + false, + ) + .expect("API preview"); + assert!(!safe.to_string().contains("SECRET")); + assert!( + safe["changes"] + .as_array() + .expect("diff") + .iter() + .any(|change| change["key"] == "env.ANTHROPIC_API_KEY" + && change["before"] == "(set; hidden)") + ); + std::fs::write(&files[0], "{}").expect("settings"); + let result = preview( + Harness::Claude, + &files, + "http://localhost:4123", + "route", + None, + true, + ) + .expect("login preview"); + assert!( + result["authentication"] + .as_str() + .expect("authentication") + .contains("subscription login") + ); + } + + #[test] + fn a_pending_restore_blocks_install_and_retires_stale_backups_on_retry() { + let dir = tempfile::tempdir().expect("directory"); + let path = dir.path().join("settings.json"); + let files = std::slice::from_ref(&path); + std::fs::write(&path, "{}").expect("settings"); + install( + Harness::Claude, + files, + "http://localhost:4123", + "api", + None, + false, + ) + .expect("install"); + let pending = restore_pending_path(&path); + std::fs::write( + &pending, + serde_json::to_string(&Some("{}".to_string())).expect("receipt"), + ) + .expect("pending retirement"); + let installed = read(&path).expect("installed"); + assert!( + install( + Harness::Claude, + files, + "http://localhost:4123", + "another", + None, + false + ) + .is_err() + ); + assert_eq!(read(&path).expect("unchanged"), installed); + // The backup was already retired, so retry uses the saved restoration receipt. + std::fs::remove_file(backup_path(&path)).expect("retired backup"); + restore(files).expect("retry restore"); + assert_eq!(read(&path).expect("restored"), "{}"); + assert!(!pending.exists()); + } + + #[test] + fn subscription_install_removes_only_switchyard_credentials() { + let dir = tempfile::tempdir().expect("directory"); + let path = dir.path().join("settings.json"); + let files = std::slice::from_ref(&path); + std::fs::write(&path, "{}").expect("settings"); + install( + Harness::Claude, + files, + "http://localhost:4123", + "api-route", + None, + false, + ) + .expect("API install"); + install( + Harness::Claude, + files, + "http://localhost:4123", + "subscription", + None, + true, + ) + .expect("subscription install"); + let doc = object(&read(&path).expect("settings")).expect("JSON"); + assert!(doc["env"].get("ANTHROPIC_AUTH_TOKEN").is_none()); + assert!(doc["env"].get("ANTHROPIC_API_KEY").is_none()); + assert_eq!(doc["env"]["ANTHROPIC_MODEL"], "subscription"); + for key in ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] { + for value in [ + json!("user-secret"), + json!("switchyard-local"), + json!(null), + json!(false), + json!(123), + json!([]), + json!({}), + ] { + let original = json!({"env":{key:value}}).to_string(); + std::fs::write(&path, &original).expect("settings"); + let before = std::fs::read_dir(dir.path()).expect("files").count(); + assert!( + install( + Harness::Claude, + files, + "http://localhost:4123", + "subscription", + None, + true + ) + .is_err() + ); + assert_eq!(read(&path).expect("unchanged"), original); + assert_eq!( + std::fs::read_dir(dir.path()).expect("files").count(), + before + ); + } + } + } + + #[test] + fn replacement_prepares_every_file_and_rejects_changed_inputs() { + let dir = tempfile::tempdir().expect("directory"); + let first = dir.path().join("first"); + let blocked = dir.path().join("blocked"); + std::fs::write(&first, "original").expect("first"); + std::fs::write(&blocked, "not a directory").expect("blocked"); + let files = vec![first.clone(), blocked.join("second")]; + assert!( + replace( + &files, + &[Some("original".into()), None], + &[Some("new".into()), Some("new".into())] + ) + .is_err() + ); + assert_eq!(read(&first).expect("read"), "original"); + assert!( + replace( + std::slice::from_ref(&first), + &[Some("stale".into())], + &[Some("new".into())] + ) + .is_err() + ); + assert_eq!(read(&first).expect("read"), "original"); + } +} diff --git a/crates/switchyard-desktop/src/history.rs b/crates/switchyard-desktop/src/history.rs index c498e4e0b1..049188e710 100644 --- a/crates/switchyard-desktop/src/history.rs +++ b/crates/switchyard-desktop/src/history.rs @@ -19,6 +19,9 @@ pub struct Entry { pub session_id: Option, #[serde(default)] pub turn_id: Option, + /// The caller-set origin header, such as `codex` or `claude`, when the harness sent one. + #[serde(default)] + pub origin: Option, #[serde(default)] pub route_id: String, #[serde(default)] diff --git a/crates/switchyard-desktop/src/install.rs b/crates/switchyard-desktop/src/install.rs index 4e4bd7837d..380f3d359f 100644 --- a/crates/switchyard-desktop/src/install.rs +++ b/crates/switchyard-desktop/src/install.rs @@ -40,7 +40,7 @@ pub fn executable_name(name: &str) -> String { format!("{name}{}", std::env::consts::EXE_SUFFIX) } -const BUNDLED_CONFIG: &str = include_str!("../../../scripts/config/composite.toml"); +pub const BUNDLED_CONFIG: &str = include_str!("../../../scripts/config/composite.toml"); const UPDATE_LOCK: &str = ".switchyard-desktop-update.lock"; const PORT_ERROR: &str = "SY_PORT must be a number from 1 to 65535."; @@ -210,23 +210,24 @@ impl Settings { } /// This method builds the installed server command with any existing price override. /// The caller configures and starts the process. - pub fn server_command(&self) -> Command { + pub fn server_command(&self) -> Result { + let config = Config::load(&self.desktop_settings())?; let mut command = Command::new(self.binary("switchyard-server")); - command - .arg("--config") - .arg(self.sy_home.join(SERVER_CONFIG)); + command.arg("--config").arg(&config.config_file); + let url = crate::health::validate_url(&config.server_url)?; command.args(["--host", "127.0.0.1"]); - command.arg("--port").arg(self.port.to_string()); - command - .arg("--routing-log-file") - .arg(self.sy_home.join(ROUTING_LOG)); - - let prices = self.prices_file(); - if prices.exists() { + command.arg("--port").arg( + url.port_or_known_default() + .ok_or("Missing server port.")? + .to_string(), + ); + command.arg("--routing-log-file").arg(&config.routing_log); + if let Some(prices) = &config.prices_file { command.arg("--pricing-file").arg(prices); } - command + Ok(command) } + fn profile_file(&self) -> PathBuf { self.codex_home .join(format!("{}.config.toml", self.profile)) @@ -450,6 +451,7 @@ pub fn preview_update(settings_file: &Path) -> Result } fn installation_plan(s: &Settings, action: &str) -> String { let removing = action == "uninstall"; + let needs_setup = !removing && matches!(config::needs_setup(&s.desktop_settings()), Ok(true)); let pick = |remove, install| if removing { remove } else { install }; let app = if cfg!(target_os = "macos") { "macOS desktop app" @@ -461,6 +463,8 @@ fn installation_plan(s: &Settings, action: &str) -> String { let profile = s.profile_file(); let profile_action = if removing { "Preserve recovery copy, then remove" + } else if needs_setup { + "Defer until after setup:" } else if !profile.exists() { "Create" } else if action == "update" { @@ -486,7 +490,11 @@ fn installation_plan(s: &Settings, action: &str) -> String { "{} service definitions in {}. The server {}.", pick("Remove", "Replace"), s.service_dir.display(), - pick("will stop", "will restart after build and validation") + if needs_setup { + "will wait for setup before starting" + } else { + pick("will stop", "will restart after build and validation") + } ), ]; for (path, label) in [ @@ -494,7 +502,9 @@ fn installation_plan(s: &Settings, action: &str) -> String { (s.sy_home.join(SERVER_CONFIG), "server config"), (s.sy_home.join(ROUTING_LOG), "usage history"), ] { - let verb = if removing || path.exists() { + let verb = if needs_setup { + "Defer until setup" + } else if removing || path.exists() { "Preserve" } else { "Create" @@ -590,8 +600,32 @@ fn wait_for_health(port: u16) -> bool { ) .is_ok() { - let mut response = String::new(); - if stream.take(8192).read_to_string(&mut response).is_ok() + let mut bytes = [0; 8192]; + let mut used = 0; + let mut complete = false; + while used < bytes.len() { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + break; + }; + if remaining.is_zero() + || stream + .set_read_timeout(Some(remaining.min(Duration::from_millis(300)))) + .is_err() + { + break; + } + match stream.read(&mut bytes[used..]) { + Ok(0) => { + complete = true; + break; + } + Ok(count) => used += count, + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + if (complete || used == bytes.len()) + && let Ok(response) = std::str::from_utf8(&bytes[..used]) && let Some((headers, body)) = response.split_once("\r\n\r\n") && headers .lines() @@ -700,6 +734,7 @@ fn remove(path: &Path) -> Result<(), String> { /// What an install gathers and checks before it changes the installation. struct Prepared { + needs_setup: bool, /// The Codex profile to save. profile: String, /// App settings rewritten for a new port, when the port changes. @@ -719,8 +754,17 @@ fn install(s: &Settings, preserve_profile: bool) -> Result, Strin "install", "Build and config validation passed. Replacing the installation.", )?; - replace_installation(s, prepared.desktop_settings.as_deref())?; - platform::install(s, prepared.missing_keys.is_empty())?; + replace_installation( + s, + prepared.desktop_settings.as_deref(), + prepared.needs_setup, + )?; + platform::install(s, !prepared.needs_setup && prepared.missing_keys.is_empty())?; + if prepared.needs_setup { + let message = "Installed. Open Switchyard to choose your configuration. Existing files will skip setup on later installs.".to_string(); + println!("{message}"); + return Ok(Some(message)); + } if !preserve_profile || !present(&s.profile_file())? { save_profile(s, &prepared.profile)?; } @@ -728,24 +772,49 @@ fn install(s: &Settings, preserve_profile: bool) -> Result, Strin } fn prepare(s: &Settings, preserve_profile: bool) -> Result { + let needs_setup = config::needs_setup(&s.desktop_settings())?; let profile = s.profile_file(); - let context_window = route_context_window(s)?; - let profile_text = if profile.exists() && !preserve_profile { - let current = fs::read_to_string(&profile).map_err(err)?; - merge_codex_profile(s, ¤t, context_window)? + let desktop_settings = reconcile_desktop_port(s)?; + let server_url = if desktop_settings.is_some() || !present(&s.desktop_settings())? { + config::server_url(s.port) } else { - codex_profile(s, context_window) + Config::load(&s.desktop_settings())?.server_url + }; + let profile_text = if needs_setup { + String::new() + } else { + let context_window = route_context_window(s)?; + if profile.exists() && !preserve_profile { + let current = fs::read_to_string(&profile).map_err(err)?; + merge_codex_profile(s, ¤t, context_window, &server_url)? + } else { + codex_profile(s, context_window, &server_url) + } }; #[cfg(not(windows))] platform::preflight()?; - let desktop_settings = reconcile_desktop_port(s)?; - let prices = s.prices_file(); - if present(&prices)? { + let prices = if needs_setup { + None + } else { + Config::load(&s.desktop_settings())? + .prices_file + .or_else(|| { + // An installation with only a server config still imports its adjacent price override. + (!s.desktop_settings().exists() && s.prices_file().exists()) + .then(|| s.prices_file()) + }) + }; + if let Some(prices) = prices { Pricing::load(&prices).map_err(|e| format!("{e}. Fix this file before installing."))?; } build(s)?; - let missing_keys = check_server_config(s, !preserve_profile || !profile.exists())?; + let missing_keys = if needs_setup { + Vec::new() + } else { + check_server_config(s, !preserve_profile || !profile.exists())? + }; Ok(Prepared { + needs_setup, profile: profile_text, desktop_settings, missing_keys, @@ -776,7 +845,7 @@ fn build(s: &Settings) -> Result<(), String> { /// Checks the server config that the install keeps, or the bundled one on a first install, and /// returns the API key variables that have no saved key. fn check_server_config(s: &Settings, require_route: bool) -> Result, String> { - let config = s.sy_home.join(SERVER_CONFIG); + let config = Config::load(&s.desktop_settings())?.config_file; let existing = present(&config)?; let staged = tempfile::NamedTempFile::new().map_err(err)?; let checked = if existing { @@ -806,7 +875,11 @@ fn check_server_config(s: &Settings, require_route: bool) -> Result, /// Stops the services and replaces the app files. The macOS app bundle is staged and signed /// before the services stop. -fn replace_installation(s: &Settings, desktop_settings: Option<&str>) -> Result<(), String> { +fn replace_installation( + s: &Settings, + desktop_settings: Option<&str>, + needs_setup: bool, +) -> Result<(), String> { let metadata = toml::to_string(s).map_err(err)?; #[cfg(target_os = "macos")] let bundle = platform::stage_bundle(s, &metadata)?; @@ -830,11 +903,12 @@ fn replace_installation(s: &Settings, desktop_settings: Option<&str>) -> Result< let bytes = fs::read(s.release(binary)).map_err(err)?; write(&s.binary(binary), &bytes, true)?; } - write_once(&s.sy_home.join(SERVER_CONFIG), BUNDLED_CONFIG)?; - if let Some(text) = desktop_settings { - write(&s.desktop_settings(), text.as_bytes(), false)?; - } else { - write_once(&s.desktop_settings(), &desktop_config(s)?)?; + if !needs_setup { + if let Some(text) = desktop_settings { + write(&s.desktop_settings(), text.as_bytes(), false)?; + } else { + write_once(&s.desktop_settings(), &desktop_config(s)?)?; + } } for record in [s.sy_home.join("install.toml"), s.install_record()] { write(&record, metadata.as_bytes(), false)?; @@ -860,9 +934,10 @@ fn save_profile(s: &Settings, text: &str) -> Result<(), String> { /// Returns the warning to record when the server cannot start yet or does not answer /health. fn report_readiness(s: &Settings, missing_keys: &[String]) -> Result, String> { + let config = Config::load(&s.desktop_settings())?; println!( - "Installed. Server: http://127.0.0.1:{}\nSettings: {}\nUse: codex -p {}", - s.port, + "Installed. Server: {}\nSettings: {}\nUse: codex -p {}", + config.server_url, s.desktop_settings().display(), s.profile ); @@ -891,7 +966,10 @@ fn report_readiness(s: &Settings, missing_keys: &[String]) -> Result Result, String> { // config or, on a new install, the bundled one. A missing route returns `None` here; // check_public_route rejects it later with the list of valid route IDs. fn route_context_window(s: &Settings) -> Result, String> { - let path = s.sy_home.join(SERVER_CONFIG); + let path = Config::load(&s.desktop_settings())?.config_file; let text = read_optional(&path).map_err(|e| format!("Read {}: {e}", path.display()))?; let document: toml::Value = toml::from_str(text.as_deref().unwrap_or(BUNDLED_CONFIG)) .map_err(|e| format!("Parse {}: {e}", path.display()))?; @@ -992,7 +1070,7 @@ fn route_context_window(s: &Settings) -> Result, String> { // Codex compacts a thread when its usage nears the model's context window. Codex does not // know a Switchyard route ID, so the profile names the route's window. Without it, Codex // never compacts on the route, and a long thread fails when the upstream rejects it. -fn codex_profile(s: &Settings, context_window: Option) -> String { +fn codex_profile(s: &Settings, context_window: Option, server_url: &str) -> String { let mut value: toml::Value = toml::from_str( include_str!("../../../scripts/config/codex.sy.toml") .replace("@SY_PORT@", &s.port.to_string()) @@ -1000,6 +1078,8 @@ fn codex_profile(s: &Settings, context_window: Option) -> String { ) .expect("bundled profile is valid TOML"); value["model"] = s.model.clone().into(); + value["model_providers"]["sy"]["base_url"] = + format!("{}/v1", server_url.trim_end_matches('/')).into(); if let (Some(window), Some(table)) = (context_window, value.as_table_mut()) { table.insert("model_context_window".into(), window.into()); } @@ -1009,11 +1089,12 @@ fn merge_codex_profile( s: &Settings, current: &str, context_window: Option, + server_url: &str, ) -> Result { let mut document: toml_edit::DocumentMut = current .parse() .map_err(|e| format!("Read Codex profile: {e}"))?; - let template: toml_edit::DocumentMut = codex_profile(s, context_window) + let template: toml_edit::DocumentMut = codex_profile(s, context_window, server_url) .parse() .expect("generated profile is valid TOML"); for key in ["model", "model_provider"] { diff --git a/crates/switchyard-desktop/src/install/windows.rs b/crates/switchyard-desktop/src/install/windows.rs index 9a6429026a..648e8845b4 100644 --- a/crates/switchyard-desktop/src/install/windows.rs +++ b/crates/switchyard-desktop/src/install/windows.rs @@ -384,13 +384,21 @@ pub fn temporary_updater(source: &Path) -> Result { } /// This function waits for its child so Task Scheduler tracks the app or server's lifetime. pub fn launch_managed(s: &Settings, desktop: bool) -> Result<(), String> { + while !desktop + && !s + .desktop_settings() + .try_exists() + .map_err(|error| error.to_string())? + { + std::thread::sleep(std::time::Duration::from_millis(250)); + } let (mut command, log) = if desktop { ( Command::new(s.binary("switchyard-desktop")), "logs/desktop.log", ) } else { - (s.server_command(), "logs/server.log") + (s.server_command()?, "logs/server.log") }; fs::create_dir_all(s.sy_home.join("logs")).map_err(|e| e.to_string())?; let log = fs::OpenOptions::new() @@ -408,7 +416,7 @@ pub fn launch_managed(s: &Settings, desktop: bool) -> Result<(), String> { if desktop { command.arg(s.desktop_settings()); } else { - let config = s.sy_home.join("composite.toml"); + let config = crate::config::Config::load(&s.desktop_settings())?.config_file; // The helper has no console, so a missing key is reported in the server log. credentials::configure(&mut command, &config).inspect_err(|error| { let _ = writeln!(&log, "switchyard-desktop-install: {error}"); @@ -473,6 +481,15 @@ pub(crate) fn delete_credential(target: &str) -> Result<(), String> { Ok(()) } +/// The app's model list saves each upstream base URL's key under its own target. +pub fn saved_key(base_url: &str) -> Result, String> { + read_credential(&format!("Switchyard:model-list:{base_url}")) +} + +pub fn save_key(base_url: &str, key: &str) -> Result<(), String> { + write_credential(&format!("Switchyard:model-list:{base_url}"), key.trim()) +} + pub fn restart(s: &Settings) -> Result<(), String> { stop_task(s, false)?; stop_images(&[s.binary("switchyard-server")])?; diff --git a/crates/switchyard-desktop/src/lib.rs b/crates/switchyard-desktop/src/lib.rs index 0d83621d92..f9712768ba 100644 --- a/crates/switchyard-desktop/src/lib.rs +++ b/crates/switchyard-desktop/src/lib.rs @@ -1,10 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Desktop settings, server checks, and installation support. +//! Desktop settings, server checks, installation support, and harness transcript reading. pub mod config; pub mod health; pub mod history; pub mod install; pub mod rollup; +pub mod transcripts; diff --git a/crates/switchyard-desktop/src/main.rs b/crates/switchyard-desktop/src/main.rs index 6e344401cc..06dfd87193 100644 --- a/crates/switchyard-desktop/src/main.rs +++ b/crates/switchyard-desktop/src/main.rs @@ -1,18 +1,25 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! The desktop app shows a local Switchyard server's status and recorded usage. +//! The desktop and terminal apps manage a local Switchyard server. //! //! The printed summary compares list-price estimates with the configured baseline model. #![cfg_attr(windows, windows_subsystem = "windows")] use switchyard_desktop::{config, health, history, rollup}; +mod accounts; mod controller; #[cfg(any(target_os = "macos", windows))] mod gui; +mod handoff_cli; +mod handoffs; +mod harness; +mod models; mod pricing; mod server; +mod server_config; +mod sessions; mod summary; mod tui; #[cfg(windows)] @@ -25,6 +32,7 @@ use config::Config; const USAGE: &str = "\ Usage: switchyard-desktop [--tui | --print | --validate-toml] [FILE] + switchyard-desktop handoff --to [options] This app opens the desktop window on macOS and Windows. Use --tui for the terminal app or --print for a usage summary. @@ -33,6 +41,8 @@ Use --tui for the terminal app or --print for a usage summary. --tui Run the terminal app. --print Print the current summary and exit. --validate-toml Parse FILE as TOML and exit. + handoff Continue this directory's session in another harness, here in + this terminal. Run `switchyard-desktop handoff --help`. Without FILE, the app uses bundled installation metadata to find desktop.toml. Without that metadata, it uses $SY_HOME/desktop.toml, or ~/.switchyard/desktop.toml @@ -49,13 +59,28 @@ Recorded spending remains visible when savings are unknown."; /// The command selects the graphical app, terminal app, printed summary, or TOML validation. fn main() -> ExitCode { + #[cfg(windows)] + if let Some(result) = windows::terminal_job() { + return match result { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("switchyard-desktop: {error}"); + ExitCode::FAILURE + } + }; + } + #[cfg(windows)] windows::attach_console(); + let arguments: Vec = std::env::args().skip(1).collect(); + if arguments.first().map(String::as_str) == Some("handoff") { + return handoff_cli::main(&arguments[1..]); + } let mut settings = None; let mut print_only = false; let mut validate_toml = false; let mut terminal = false; - for arg in std::env::args().skip(1) { + for arg in arguments { match arg.as_str() { "-h" | "--help" => { println!("{USAGE}"); @@ -100,27 +125,15 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - if !terminal && !print_only { - #[cfg(any(target_os = "macos", windows))] - return match gui::run(settings) { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("switchyard-desktop: {error}"); - ExitCode::FAILURE - } - }; - #[cfg(not(any(target_os = "macos", windows)))] - { - eprintln!("The desktop window supports macOS and Windows. Use --print or --tui."); - return ExitCode::FAILURE; - } + if !terminal && !print_only && !cfg!(any(target_os = "macos", windows)) { + eprintln!("The desktop window supports macOS and Windows. Use --print or --tui."); + return ExitCode::FAILURE; } - let config = match Config::load(&settings) { Ok(config) => config, Err(error) => { eprintln!("switchyard-desktop: {error}"); - if !terminal { + if print_only || (!terminal && !cfg!(any(target_os = "macos", windows))) { return ExitCode::FAILURE; } match Config::defaults_for(&settings) { @@ -159,12 +172,26 @@ fn main() -> ExitCode { return ExitCode::SUCCESS; } - let controller = controller::Controller::new(config, settings); - #[cfg(windows)] - windows::allocate_console(); - if let Err(error) = tui::run(controller) { + if !settings.try_exists().unwrap_or(true) + && matches!(config::needs_setup(&settings), Ok(false)) + && let Err(error) = config::save_configuration(&settings, &config, false) + { eprintln!("switchyard-desktop: {error}"); - return ExitCode::FAILURE; + } + let controller = controller::Controller::new(config, settings.clone()); + if terminal { + #[cfg(windows)] + windows::allocate_console(); + if let Err(error) = tui::run(controller) { + eprintln!("switchyard-desktop: {error}"); + return ExitCode::FAILURE; + } + } else { + #[cfg(any(target_os = "macos", windows))] + if let Err(error) = gui::run(controller, settings) { + eprintln!("switchyard-desktop: {error}"); + return ExitCode::FAILURE; + } } ExitCode::SUCCESS } diff --git a/crates/switchyard-desktop/src/models.rs b/crates/switchyard-desktop/src/models.rs new file mode 100644 index 0000000000..688aa7a96f --- /dev/null +++ b/crates/switchyard-desktop/src/models.rs @@ -0,0 +1,726 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module lists the models an LLM client offers from its `GET /models` endpoint, +//! and keeps each fetched list in a cache file. +//! +//! The request goes through the system `curl`, the same way the app runs +//! `launchctl`, so the app needs no HTTP or TLS stack of its own. The key is +//! written to curl's stdin, never to its command line, where other processes +//! could read it. +//! +//! The cache file holds model IDs and fetch times, never a key. The app uses +//! a list from the cache file until the user refreshes it, so it fetches a +//! list on its own only when the file does not have it. + +use std::collections::BTreeMap; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{Mutex, PoisonError}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::config::expand_home; +use crate::server_config::Client; + +/// CACHE_FILE names the cache file. It sits next to the settings file that the +/// app was started with. +pub const CACHE_FILE: &str = "model-lists.json"; + +/// KEYCHAIN_SERVICE identifies keys saved from Routes. Each +/// item's account is the client's `base_url`, so clients that share a +/// gateway share its key. +#[cfg(target_os = "macos")] +const KEYCHAIN_SERVICE: &str = "Switchyard model list"; + +/// TIMEOUT_SECONDS limits how long curl may spend listing models. +const TIMEOUT_SECONDS: &str = "20"; + +/// CACHE_WRITE stays locked while a thread rewrites the cache file, so threads that +/// finish at the same time do not drop each other's lists. +static CACHE_WRITE: Mutex<()> = Mutex::new(()); + +/// ModelList stores one fetched model list in the cache file. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ModelList { + /// This field stores sorted model IDs. + pub models: Vec, + /// This field records the fetch time in seconds since 1970-01-01 UTC. + pub fetched_at: u64, +} + +/// Loaded stores the model list and fetch result for one URL. +#[derive(Clone, Debug, PartialEq)] +pub struct Loaded { + /// This field identifies an endpoint list as `endpoint` or a Switchyard cache list as `cache`. + /// A Codex cache list has the source `local`; `error` separately reports fetch or cache-write failures. + pub source: &'static str, + /// This field names the URL that serves the list. + pub url: String, + /// This field stores the newest list fetched or read from the cache file. + pub list: Option, + /// This field records a fetch or cache-write error, if either occurred. + pub error: Option, +} + +/// ListError describes why a client's models could not be listed. +#[derive(Clone, Debug, PartialEq)] +pub enum ListError { + /// The client needs a key, and none is set or saved. + NoKey, + /// The client reads its key from this `api_key_env` variable, which this + /// process does not have, and no key is saved. + NoEnv(String), + /// The platform's credential store did not return the saved key. + Keychain(String), + /// The models endpoint answered 401 or 403. + Rejected(u16), + /// The request failed, or the answer was not a model list. + Failed(String), + /// The list was fetched, but the cache file could not be written. + NotCached(String), +} + +impl ListError { + /// This function says what went wrong, in one sentence. + pub fn reason(&self) -> String { + match self { + Self::NoKey => { + "This endpoint needs an API key to list models, and the app does not have one." + .to_string() + } + Self::NoEnv(variable) => missing_env_note(variable), + Self::Keychain(error) => { + format!("Could not read the saved key from secure credential storage: {error}.") + } + Self::Rejected(status) => { + format!("The models endpoint rejected the key (HTTP {status}).") + } + Self::Failed(error) => format!("Could not list models: {error}."), + Self::NotCached(error) => format!("Could not save the list: {error}."), + } + } + + /// This function says what the user can do while the window has no list to show. The + /// window accepts a typed model ID, so most answers offer that. A missing + /// variable also stops Apply, so that answer offers only the key field. + /// Only macOS and Windows can save a key, so elsewhere the answers offer + /// only a typed model ID. [`missing_env_note`] already says that, so a + /// missing variable gets no advice there. + pub fn advice(&self) -> &'static str { + let saves_keys = cfg!(any(target_os = "macos", windows)); + match self { + Self::NoEnv(_) if saves_keys => "Or paste the key below to list models.", + Self::NoEnv(_) => "", + _ if self.needs_key() && saves_keys => { + "Enter a valid API key and choose Save key and load models, or type a model ID." + } + _ if self.needs_key() => "Type a model ID instead.", + _ => "Click Refresh models to try again, or type a model ID.", + } + } + + /// This function returns whether saving a key could fix this error. + pub fn needs_key(&self) -> bool { + matches!( + self, + Self::NoKey | Self::NoEnv(_) | Self::Keychain(_) | Self::Rejected(_) + ) + } +} + +/// This function explains why model suggestions lack a key. Model-list keys stay separate from +/// the server keys that the installed helper keeps. +pub fn missing_env_note(variable: &str) -> String { + if cfg!(any(target_os = "macos", windows)) { + format!( + "Switchyard does not read {variable} from your shell. \ + To load model suggestions, save a model-list key below. \ + The server's own key is under Settings → Server keys." + ) + } else { + format!( + "{variable} is not set in this terminal, so this endpoint cannot list models. \ + Type the model ID instead." + ) + } +} + +/// This function returns the client's `api_key_env` variable when this process does not +/// have it set. +pub fn missing_env(client: &Client) -> Option<&str> { + client + .api_key_env + .as_deref() + .filter(|_| env_key(client).is_none()) +} + +/// This function loads one endpoint's models and retains the cached list when refresh fails. +/// The selected endpoint alone receives the entered key. +pub fn load(cache: &Path, client: &Client, refresh: bool, typed_key: Option<&str>) -> Loaded { + let url = list_url(client); + let old = read_cache(cache).remove(&url); + if unlisted(client) { + // ChatGPT has no list to fetch, so nothing goes in the cache file. + return Loaded { + source: "local", + url, + list: codex_models(), + error: None, + }; + } + if old.is_some() && !refresh { + return Loaded { + source: "cache", + url, + list: old, + error: None, + }; + } + match key(client, typed_key).and_then(|key| list(client, key.as_deref())) { + Ok(models) => { + let new = ModelList { + models, + fetched_at: now(), + }; + let error = write_cache(cache, &url, &new) + .err() + .map(ListError::NotCached); + Loaded { + source: "endpoint", + url, + list: Some(new), + error, + } + } + Err(error) => Loaded { + source: "cache", + url, + list: old, + error: Some(error), + }, + } +} + +/// This function returns the URL that serves the client's model list. It is also the +/// list's key in the cache file and in [`Loaded::url`]. +pub fn list_url(client: &Client) -> String { + models_url(&client.format, &client.base_url) +} + +/// This function returns whether the client's endpoint has no model list that this app can +/// ask for. The ChatGPT Codex endpoint answers only a ChatGPT login, never an +/// API key, and the app does not hold the login. +pub fn unlisted(client: &Client) -> bool { + client.host() == "chatgpt.com" +} + +/// This function reads the model list that Codex saved in `$CODEX_HOME/models_cache.json`, +/// or in `~/.codex/models_cache.json` when `CODEX_HOME` is unset. Codex +/// fetched that list with the user's ChatGPT login. For a ChatGPT client this +/// file is the only list of its models that the app can read. +fn codex_models() -> Option { + let home = std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .or_else(|| expand_home(Path::new("~/.codex")).ok())?; + read_codex_models(&home.join("models_cache.json")) +} + +/// This function parses a Codex model list file. Returns every model's `slug`, sorted and +/// without duplicates, and the time Codex fetched the file. +fn read_codex_models(path: &Path) -> Option { + #[derive(Deserialize)] + struct Saved { + fetched_at: String, + models: Vec, + } + #[derive(Deserialize)] + struct Entry { + slug: String, + } + + let saved: Saved = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?; + let fetched_at = chrono::DateTime::parse_from_rfc3339(&saved.fetched_at).ok()?; + let mut models: Vec = saved.models.into_iter().map(|entry| entry.slug).collect(); + models.sort(); + models.dedup(); + Some(ModelList { + models, + fetched_at: u64::try_from(fetched_at.timestamp()).ok()?, + }) +} + +/// This function lists the client's model IDs, sorted. When the client needs a key and +/// `key` is `None`, returns [`ListError::NoEnv`] or [`ListError::NoKey`] +/// without sending a request. +fn list(client: &Client, key: Option<&str>) -> Result, ListError> { + match (key, &client.api_key_env) { + (None, Some(variable)) => Err(ListError::NoEnv(variable.clone())), + (None, None) if client.forward_auth => Err(ListError::NoKey), + _ => fetch(&list_url(client), &client.format, key), + } +} + +/// This function returns the models endpoint for a client. It builds the URL the way the +/// server builds its request URLs. OpenAI clients drop a trailing +/// `/chat/completions` or `/responses`. Anthropic clients drop a trailing +/// `/v1/messages` to `/v1`, and add `/v1` to any other path. +/// Anthropic returns 20 models unless the URL sets `limit`, so the URL adds +/// `limit=1000`, the most that Anthropic allows. +fn models_url(format: &str, base_url: &str) -> String { + let (base, query) = match base_url.split_once('?') { + Some((base, query)) => (base, format!("?{query}")), + None => (base_url, String::new()), + }; + let base = base.trim_end_matches('/'); + let path = if format == "anthropic_messages" { + let root = base + .strip_suffix("/messages") + .filter(|root| root.ends_with("/v1")) + .unwrap_or(base); + if root.ends_with("/v1") { + format!("{root}/models") + } else { + format!("{root}/v1/models") + } + } else { + let root = base + .strip_suffix("/chat/completions") + .or_else(|| base.strip_suffix("/responses")) + .unwrap_or(base); + format!("{root}/models") + }; + let query = if format == "anthropic_messages" && query.is_empty() { + "?limit=1000".to_string() + } else if format == "anthropic_messages" { + format!("{query}&limit=1000") + } else { + query + }; + format!("{path}{query}") +} + +/// This function returns whether the server sends this client a key. If it does, listing +/// the client's models needs a key too. +fn sends_key(client: &Client) -> bool { + client.forward_auth || client.api_key_env.is_some() +} + +/// This function returns the key to list the client's models with, or `None` when the +/// client needs no key or none is available. +/// +/// The entered key takes precedence over the environment and saved credentials. +fn key(client: &Client, typed_key: Option<&str>) -> Result, ListError> { + if !sends_key(client) { + return Ok(None); + } + if let Some(key) = typed_key.map(str::to_string).or_else(|| env_key(client)) { + return Ok(Some(key)); + } + saved_key(&client.base_url).map_err(ListError::Keychain) +} + +fn env_key(client: &Client) -> Option { + client + .api_key_env + .as_deref() + .and_then(|variable| std::env::var(variable).ok()) + .filter(|key| !key.trim().is_empty()) +} + +/// This function returns the key saved in the Keychain for `base_url`, or `None` when no +/// key is saved. +#[cfg(target_os = "macos")] +fn saved_key(base_url: &str) -> Result, String> { + /// NOT_FOUND matches the Keychain's `errSecItemNotFound` status. + const NOT_FOUND: i32 = -25300; + match security_framework::passwords::get_generic_password(KEYCHAIN_SERVICE, base_url) { + Ok(bytes) => String::from_utf8(bytes) + .map(|key| Some(key).filter(|key| !key.trim().is_empty())) + .map_err(|_| "the saved key is not text".to_string()), + Err(error) if error.code() == NOT_FOUND => Ok(None), + Err(error) => Err(keychain_error(&error)), + } +} + +#[cfg(not(any(target_os = "macos", windows)))] +fn saved_key(_base_url: &str) -> Result, String> { + Ok(None) +} + +/// This function saves a key in the login Keychain for every client with this `base_url`. +#[cfg(target_os = "macos")] +pub fn save_key(base_url: &str, key: &str) -> Result<(), String> { + security_framework::passwords::set_generic_password( + KEYCHAIN_SERVICE, + base_url, + key.trim().as_bytes(), + ) + .map_err(|error| keychain_error(&error)) +} + +#[cfg(not(any(target_os = "macos", windows)))] +/// This function rejects saved keys on platforms without Keychain or Credential Manager support. +pub fn save_key(_base_url: &str, _key: &str) -> Result<(), String> { + Err( + "Saving model-list keys requires macOS Keychain or Windows Credential Manager. \ + On this platform, set the environment variable named by api_key_env \ + and make it available to the app." + .into(), + ) +} + +#[cfg(windows)] +/// This function reads a saved key for this exact base_url from Windows Credential Manager. +fn saved_key(base_url: &str) -> Result, String> { + switchyard_desktop::install::windows::saved_key(base_url) +} +#[cfg(windows)] +/// This function saves the key for this exact base_url in Windows Credential Manager. +pub fn save_key(base_url: &str, key: &str) -> Result<(), String> { + switchyard_desktop::install::windows::save_key(base_url, key) +} + +/// This function returns the Keychain's message without its final period, so the window +/// can end its own sentence after it. +#[cfg(target_os = "macos")] +fn keychain_error(error: &security_framework::base::Error) -> String { + error.to_string().trim_end_matches('.').to_string() +} + +/// This function returns whether a key has a line break, which would end the header that +/// carries the key and start another one. +pub fn has_line_break(key: &str) -> bool { + key.contains(['\r', '\n']) +} + +/// This function reads the cache file. A missing or unreadable file reads as empty, so the +/// app fetches its lists again and writes a new file. +fn read_cache(path: &Path) -> BTreeMap { + std::fs::read_to_string(path) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_default() +} + +/// This function adds the list at `url` to the cache file. The list replaces the one at +/// the same URL unless that one is newer: two loads can overlap, and the +/// load that fetched a list first can write it last. The text goes to a +/// temporary file that is renamed over the cache file, so a reader never +/// sees half a file. +fn write_cache(path: &Path, url: &str, list: &ModelList) -> Result<(), String> { + let _writing = CACHE_WRITE.lock().unwrap_or_else(PoisonError::into_inner); + let mut all = read_cache(path); + if all + .get(url) + .is_some_and(|cached| cached.fetched_at > list.fetched_at) + { + return Ok(()); + } + all.insert(url.to_string(), list.clone()); + let text = serde_json::to_string_pretty(&all) + .map_err(|error| format!("write {}: {error}", path.display()))?; + let dir = path + .parent() + .filter(|dir| !dir.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let mut file = tempfile::NamedTempFile::new_in(dir) + .map_err(|error| format!("create a file in {}: {error}", dir.display()))?; + file.write_all(text.as_bytes()) + .and_then(|()| file.as_file().sync_all()) + .map_err(|error| format!("write {}: {error}", path.display()))?; + file.persist(path) + .map_err(|error| format!("replace {}: {}", path.display(), error.error))?; + Ok(()) +} + +/// This function returns the current time in seconds since 1970-01-01 UTC. +pub fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_secs()) +} + +fn fetch(url: &str, format: &str, key: Option<&str>) -> Result, ListError> { + let failed = |error: String| ListError::Failed(error); + if key.is_some_and(has_line_break) { + return Err(failed( + "the key has a line break, so the app did not send it".to_string(), + )); + } + let mut command = Command::new("curl"); + #[cfg(windows)] + switchyard_desktop::install::windows::hide_console(&mut command); + let mut child = command + // curl honors `-q` only as its first argument. `-q` makes curl ignore + // ~/.curlrc, where a `verbose` line would print the key into the + // error text that the window shows. + .args([ + "-q", + "--silent", + "--show-error", + "--max-time", + TIMEOUT_SECONDS, + ]) + // curl reads request headers from stdin, one per line. + .args(["--header", "@-"]) + // `--url` keeps a URL that starts with `-` from being read as an + // option. + .args(["--write-out", "\n%{http_code}", "--url", url]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| failed(format!("run curl: {error}")))?; + if let Some(mut stdin) = child.stdin.take() { + let headers = match (key, format) { + (Some(key), "anthropic_messages") => { + format!("x-api-key: {key}\nanthropic-version: 2023-06-01\n") + } + (Some(key), _) => format!("Authorization: Bearer {key}\n"), + (None, _) => String::new(), + }; + let sent = stdin.write_all(headers.as_bytes()); + drop(stdin); + if let Err(error) = sent { + let _ = child.kill(); + let _ = child.wait(); + return Err(failed(format!("send headers to curl: {error}"))); + } + } + let output = child + .wait_with_output() + .map_err(|error| failed(format!("run curl: {error}")))?; + if !output.status.success() { + return Err(failed( + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let (body, status) = stdout.rsplit_once('\n').unwrap_or(("", &stdout)); + match status.trim().parse::() { + Ok(200) => parse_ids(body).map_err(failed), + Ok(status @ (401 | 403)) => Err(ListError::Rejected(status)), + Ok(status) => Err(failed(format!("{url} answered HTTP {status}"))), + Err(_) => Err(failed(format!("{url} sent no HTTP status"))), + } +} + +/// This function parses the model IDs. OpenAI and Anthropic both answer with +/// `{"data": [{"id": ...}, ...]}`. +fn parse_ids(body: &str) -> Result, String> { + #[derive(Deserialize)] + struct Listing { + data: Vec, + } + #[derive(Deserialize)] + struct Entry { + id: String, + } + + let listing: Listing = serde_json::from_str(body) + .map_err(|error| format!("the model list is not the expected JSON: {error}"))?; + let mut ids: Vec = listing.data.into_iter().map(|entry| entry.id).collect(); + ids.sort(); + ids.dedup(); + Ok(ids) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{BufRead, BufReader}; + use std::net::{SocketAddr, TcpListener, TcpStream}; + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + + /// Stub returns the configured response and records each request header. + /// Drop wakes its listener and joins the worker so a failed assertion cannot leave it running. + struct Stub { + /// This field sets the client base URL used to list models from this server. + url: String, + requests: Arc>>>, + address: SocketAddr, + stopping: Arc, + worker: Option>, + } + + impl Stub { + fn start(response: String) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let address = listener.local_addr().expect("addr"); + let url = format!("http://{address}/v1"); + let requests = Arc::new(Mutex::new(Vec::new())); + let stopping = Arc::new(AtomicBool::new(false)); + let stop = Arc::clone(&stopping); + let record = Arc::clone(&requests); + let worker = std::thread::spawn(move || { + for mut stream in listener.incoming().map_while(Result::ok) { + if stop.load(Ordering::Acquire) { + break; + } + let mut head = Vec::new(); + let mut reader = BufReader::new(stream.try_clone().expect("clone stream")); + let mut line = String::new(); + while reader.read_line(&mut line).is_ok_and(|read| read > 0) && line != "\r\n" { + head.push(line.trim_end().to_string()); + line.clear(); + } + record.lock().expect("lock").push(head); + let _ = stream.write_all(response.as_bytes()); + } + }); + Self { + url, + requests, + address, + stopping, + worker: Some(worker), + } + } + + fn requests(&self) -> Vec> { + self.requests.lock().expect("lock").clone() + } + } + + impl Drop for Stub { + fn drop(&mut self) { + self.stopping.store(true, Ordering::Release); + let _ = TcpStream::connect(self.address); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } + } + + fn listing(models: &[&str]) -> String { + let data: Vec<_> = models + .iter() + .map(|id| serde_json::json!({ "id": id })) + .collect(); + format!( + "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n{}", + serde_json::json!({ "data": data }) + ) + } + + fn client(name: &str, format: &str, base_url: &str) -> Client { + Client { + name: name.to_string(), + format: format.to_string(), + base_url: base_url.to_string(), + api_key_env: None, + forward_auth: true, + } + } + + // The subprocess isolates PATH; its curl fixture exits before reading the oversized header. + #[cfg(unix)] + #[test] + fn failed_header_write_reaps_the_curl_process() { + use std::os::unix::fs::PermissionsExt; + if let Ok(record) = std::env::var("SWITCHYARD_CURL_FIXTURE") { + let error = fetch( + "http://localhost/models", + "openai_chat", + Some(&"k".repeat(1024 * 1024)), + ) + .expect_err("header write fails"); + assert!( + matches!(error, ListError::Failed(message) if message.starts_with("send headers to curl:")) + ); + let pid = std::fs::read_to_string(record).expect("curl PID"); + assert!( + !Command::new("/bin/kill") + .args(["-0", pid.trim()]) + .status() + .expect("process status") + .success() + ); + return; + } + let dir = tempfile::tempdir().expect("fixture"); + let record = dir.path().join("pid"); + let curl = dir.path().join("curl"); + std::fs::write( + &curl, + "#!/bin/sh\nprintf '%s' \"$$\" > \"$SWITCHYARD_CURL_FIXTURE\"\nexit 1\n", + ) + .expect("curl fixture"); + std::fs::set_permissions(&curl, std::fs::Permissions::from_mode(0o700)) + .expect("permissions"); + let output = Command::new(std::env::current_exe().expect("runner")) + .args([ + "--exact", + "models::tests::failed_header_write_reaps_the_curl_process", + ]) + .env("SWITCHYARD_CURL_FIXTURE", &record) + .env("PATH", dir.path()) + .output() + .expect("child"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + } + + #[test] + fn entered_key_is_sent_only_to_its_selected_base_url() { + let selected = Stub::start(listing(&["selected"])); + let unrelated = Stub::start(listing(&["unrelated"])); + let unauthenticated = Stub::start(listing(&["public"])); + let dir = tempfile::tempdir().expect("fixture"); + let clients = [ + client("selected", "openai_chat", &selected.url), + Client { + api_key_env: Some("SWITCHYARD_MENUBAR_TEST_UNSET_KEY".into()), + ..client("unrelated", "openai_chat", &unrelated.url) + }, + Client { + forward_auth: false, + ..client("public", "openai_chat", &unauthenticated.url) + }, + ]; + let loaded: Vec<_> = clients + .iter() + .enumerate() + .map(|(index, client)| { + load( + &dir.path().join(CACHE_FILE), + client, + true, + (index == 0).then_some("selected-secret"), + ) + }) + .collect(); + assert_eq!(loaded[0].error, None); + assert_eq!(loaded[0].source, "endpoint"); + assert!( + !std::fs::read_to_string(dir.path().join(CACHE_FILE)) + .expect("cache") + .contains("selected-secret") + ); + assert_eq!(selected.requests().len(), 1); + assert!(selected.requests()[0].contains(&"Authorization: Bearer selected-secret".into())); + assert_eq!( + loaded[1].error, + Some(ListError::NoEnv("SWITCHYARD_MENUBAR_TEST_UNSET_KEY".into())) + ); + assert!(unrelated.requests().is_empty()); + assert_eq!(loaded[2].error, None); + assert_eq!(loaded[2].source, "endpoint"); + assert_eq!(unauthenticated.requests().len(), 1); + assert!( + unauthenticated.requests()[0] + .iter() + .all(|header| !header.starts_with("Authorization:")) + ); + } +} diff --git a/crates/switchyard-desktop/src/server.rs b/crates/switchyard-desktop/src/server.rs index e289dd9520..9d46a74ae0 100644 --- a/crates/switchyard-desktop/src/server.rs +++ b/crates/switchyard-desktop/src/server.rs @@ -1,21 +1,155 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! This module checks server configs and saved key status and opens files. +//! This module checks and saves server configs, checks saved key status, and opens files. -use std::io::Write; +use std::fs::OpenOptions; +use std::io::{ErrorKind, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; +use chrono::Local; use switchyard_desktop::install::credentials::KeyStatus; use crate::config::Config; use crate::health::{Probe, ServerStatus}; +use crate::server_config::{Algorithm, Choice, ServerConfig}; /// RESTART_WAIT limits how long the app waits for the restarted server to answer `/health`. const RESTART_WAIT: Duration = Duration::from_secs(10); +/// BACKUPS_PER_SECOND limits the number of backups for one file within one second. +const BACKUPS_PER_SECOND: u32 = 100; + +/// SaveResult distinguishes a saved config from a failed restart or health check. +pub struct SaveResult { + pub message: String, + pub warning: bool, + pub details: Vec, +} + +/// This function switches a route to new models, checks the result with the server's +/// `--dry-run`, saves it, and restarts the server. +/// +/// The result names the changed file and reports whether the restart succeeded. +/// The details contain the backup path and edit notes. An error means the file was not changed. +pub fn apply( + settings: &Config, + health: &Probe, + route: &str, + algorithm: &Algorithm, + choices: &[Choice], + expected: &str, +) -> Result { + let path = &settings.config_file; + // Read the file again so an edit made while the window was open is kept. + let text = std::fs::read_to_string(path).map_err(|error| { + format!( + "Could not read {}: {error}. Check config_file in the app settings.", + path.display() + ) + })?; + check_original(&text, expected)?; + let config = ServerConfig::parse(&text)?; + let edited = config.edit(route, algorithm, choices)?; + save_edit(settings, health, &text, edited, "Updated the route.") +} + +pub fn remove( + settings: &Config, + health: &Probe, + route: &str, + id: &str, + expected: &str, +) -> Result { + let text = std::fs::read_to_string(&settings.config_file).map_err(|error| error.to_string())?; + check_original(&text, expected)?; + let config = ServerConfig::parse(&text)?; + if !config.routes().iter().any(|r| r.key == route && r.id == id) { + return Err("The route changed or was removed. Refresh and choose it again.".into()); + } + let edited = config.remove(route)?; + save_edit(settings, health, &text, edited, "Removed the route.") +} + +pub fn save_route_change( + settings: &Config, + health: &Probe, + expected: &str, + edited: crate::server_config::Edited, +) -> Result { + let text = std::fs::read_to_string(&settings.config_file).map_err(|error| error.to_string())?; + check_original(&text, expected)?; + save_edit(settings, health, &text, edited, "Changed the routes.") +} + +fn check_original(text: &str, expected: &str) -> Result<(), String> { + if text != expected { + return Err("The server config changed since the editor loaded. \ + Review it again; your draft is preserved." + .into()); + } + Ok(()) +} + +fn save_edit( + settings: &Config, + health: &Probe, + text: &str, + edited: crate::server_config::Edited, + summary: &str, +) -> Result { + let path = &settings.config_file; + if edited.text == text { + return Ok(SaveResult { + message: "Nothing to save. The route already uses this algorithm and these models." + .into(), + warning: false, + details: Vec::new(), + }); + } + + let binary = server_binary()?; + let backup = save_checked(&binary, path, text, &edited.text)?; + let saved = format!("{summary} Changed {}.", path.display()); + let health_url = format!("{}/health", settings.server_url); + let (message, warning) = match restart(&settings.launchd_label) { + Err(error) => ( + format!( + "{saved} Switchyard could not restart: {error}. \ + Use Restart server in Settings to try again." + ), + true, + ), + Ok(()) if wait_for_health(health, &settings.server_url) => { + (format!("{saved} Restarted Switchyard."), false) + } + Ok(()) => ( + format!( + "{saved} Switchyard restarted but did not answer {health_url} within {} seconds. \ + Check the server log in ~/.switchyard/logs/.", + RESTART_WAIT.as_secs() + ), + true, + ), + }; + let mut details = vec![format!("Backup: {}", backup.display())]; + details.extend(edited.notes); + if warning { + details.push(format!( + "To undo, copy {} over {} and use Restart server in Settings.", + backup.display(), + path.display() + )); + } + Ok(SaveResult { + message, + warning, + details, + }) +} + /// This function finds the installed helper, which is the only program that reads or writes /// server keys. fn helper() -> Result { @@ -108,6 +242,120 @@ fn server_binary() -> Result { ) } +/// This function checks `text` with `switchyard-server --dry-run`, then replaces `path` +/// with it, keeping `original`, the text that `path` held, as a timestamped +/// backup. Returns the backup's path. +/// +/// The real file is `path`, or the file it points to when `path` is a +/// symlink. The new text goes to a temporary file next to the real file. The +/// temporary file is synced to disk and then renamed over the real file, so a +/// reader never sees a half-written config. On Unix, the directory is synced +/// after the rename, so a power loss keeps both the new file and the backup. +/// When the check fails, or when the real file no longer holds `original` +/// after the backup is written, the temporary file and the new backup are +/// removed, `path` does not change, and the error is returned. +fn save_checked(binary: &Path, path: &Path, original: &str, text: &str) -> Result { + // Resolve a symlink and replace the file it points to, so the link stays. + let file = std::fs::canonicalize(path) + .map_err(|error| format!("Could not find {}: {error}", path.display()))?; + let dir = file.parent().unwrap_or(Path::new("/")); + let mut candidate = tempfile::Builder::new() + .prefix(".switchyard-picker-") + .suffix(".toml") + .tempfile_in(dir) + .map_err(|error| format!("Could not create a file in {}: {error}", dir.display()))?; + candidate + .write_all(text.as_bytes()) + .and_then(|()| candidate.as_file().sync_all()) + .map_err(|error| format!("Could not write {}: {error}", candidate.path().display()))?; + if let Ok(metadata) = std::fs::metadata(&file) { + candidate + .as_file() + .set_permissions(metadata.permissions()) + .map_err(|error| { + format!( + "Could not copy the permissions of {}: {error}", + path.display() + ) + })?; + } + + // Name the real file in errors, not the temporary one. + check(binary, candidate.path()).map_err(|error| { + error.replace( + &candidate.path().display().to_string(), + &path.display().to_string(), + ) + })?; + + let backup = back_up(&file, original) + .map_err(|error| format!("Could not back up {}: {error}", path.display()))?; + // The check and the backup take a moment. Keep an edit that another + // program saved in the meantime. Compare right before the rename, because + // the rename replaces the file whatever it holds. + if std::fs::read_to_string(&file).ok().as_deref() != Some(original) { + let _ = std::fs::remove_file(&backup); + return Err(format!( + "Could not replace {}, because it changed on disk while Apply was checking it. \ + Click Apply again to apply your choices to the new file.", + path.display() + )); + } + candidate + .persist(&file) + .map_err(|error| format!("Could not replace {}: {}", path.display(), error.error))?; + // The rename has already saved the file, so a failed directory sync does not + // return an error. Windows has no directory sync. + #[cfg(unix)] + if let Ok(directory) = std::fs::File::open(dir) { + let _ = directory.sync_all(); + } + Ok(backup) +} + +/// This function writes `text` to a new backup of `file` and returns its path. The backup +/// is `.switchyard-backup.`, the name the installer uses, +/// with `-2`, `-3`, and so on added when that name is taken, so a backup +/// never replaces another one. The backup gets the file's permissions before +/// it gets the text. +fn back_up(file: &Path, text: &str) -> Result { + let permissions = std::fs::metadata(file)?.permissions(); + let stamp = Local::now().format("%Y%m%d%H%M%S"); + for number in 1..=BACKUPS_PER_SECOND { + let suffix = if number == 1 { + String::new() + } else { + format!("-{number}") + }; + let backup = PathBuf::from(format!( + "{}.switchyard-backup.{stamp}{suffix}", + file.display() + )); + let mut out = match OpenOptions::new() + .write(true) + .create_new(true) + .open(&backup) + { + Ok(out) => out, + Err(error) if error.kind() == ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + }; + let written = out + .set_permissions(permissions.clone()) + .and_then(|()| out.write_all(text.as_bytes())) + .and_then(|()| out.sync_all()); + if let Err(error) = written { + let _ = std::fs::remove_file(&backup); + return Err(error); + } + return Ok(backup); + } + Err(std::io::Error::new( + ErrorKind::AlreadyExists, + format!("{BACKUPS_PER_SECOND} backups from this second already exist"), + )) +} + /// This function asks the installed helper to run the server's dry-run on `config` with the /// saved server keys, the same keys the installed server starts with. fn check(binary: &Path, config: &Path) -> Result<(), String> { @@ -142,10 +390,10 @@ pub fn check_config(settings: &Config) -> Result<(), String> { check(&server_binary()?, &settings.config_file) } -pub fn restart_checked(settings: &Config) -> Result { +pub fn restart_checked(settings: &Config, health: &Probe) -> Result { check_config(settings)?; restart(&settings.launchd_label)?; - if !wait_for_health(&settings.server_url) { + if !wait_for_health(health, &settings.server_url) { return Err(format!( "Switchyard did not answer {}/health within {} seconds. Open the server log and retry.", settings.server_url.trim_end_matches('/'), @@ -177,9 +425,8 @@ pub fn restart(launchd_label: &str) -> Result<(), String> { } } -pub(crate) fn wait_for_health(server_url: &str) -> bool { +pub(crate) fn wait_for_health(probe: &Probe, server_url: &str) -> bool { let deadline = Instant::now() + RESTART_WAIT; - let probe = Probe::default(); loop { if probe.probe(server_url) == ServerStatus::Running { return true; @@ -220,3 +467,194 @@ pub fn command(program: &str, args: &[&str]) -> Result { } Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } + +// This function saves the script before opening a terminal so a save error cannot launch it. +// If no terminal starts, this function removes the script; callers supply scripts that remove themselves on startup. +#[cfg(not(windows))] +pub fn open_terminal_script(prefix: &str, text: &str) -> Result<(), String> { + let mut script = tempfile::Builder::new() + .prefix(prefix) + .suffix(".command") + .tempfile() + .map_err(|e| e.to_string())?; + writeln!(script, "{text}").map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + script + .as_file() + .set_permissions(std::fs::Permissions::from_mode(0o700)) + .map_err(|e| e.to_string())?; + } + let (_, path) = script.keep().map_err(|e| e.to_string())?; + if let Err(error) = run_in_terminal(&path) { + let _ = std::fs::remove_file(path); + return Err(error); + } + Ok(()) +} + +// macOS `open` runs a `.command` file in Terminal. +#[cfg(not(any(windows, target_os = "linux")))] +fn run_in_terminal(path: &Path) -> Result<(), String> { + command("open", &[&path.display().to_string()]).map(drop) +} + +// xdg-open may open the script in an editor, so this function starts a terminal program that runs it. +// It tries $TERMINAL first and then common terminal programs, and uses the first one that starts. +#[cfg(target_os = "linux")] +fn run_in_terminal(path: &Path) -> Result<(), String> { + let preferred = std::env::var("TERMINAL") + .ok() + .filter(|name| !name.is_empty()); + let terminals = preferred + .as_deref() + .map(|name| (name, "-e")) + .into_iter() + .chain([ + ("x-terminal-emulator", "-e"), + ("gnome-terminal", "--"), + ("konsole", "-e"), + ("xterm", "-e"), + ]); + for (program, flag) in terminals { + let started = Command::new(program) + .arg(flag) + .arg(path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + if let Ok(mut terminal) = started { + // A thread waits for the terminal so the exited process does not stay a zombie. + std::thread::spawn(move || terminal.wait()); + return Ok(()); + } + } + Err( + "Could not start a terminal. Switchyard tried $TERMINAL, x-terminal-emulator, \ + gnome-terminal, konsole, and xterm. Set the TERMINAL environment variable to your \ + terminal program, for example TERMINAL=xterm, and try again." + .into(), + ) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + const OLD: &str = "schema_version=1\n[targets]\n[routes]\n#old\n"; + const NEW: &str = "schema_version=1\n[targets]\n[routes]\n#new\n"; + const NEWER: &str = "schema_version=1\n[targets]\n[routes]\n#newer\n"; + use std::os::unix::fs::PermissionsExt; + + /// The fixture forwards the helper's validation command to a stand-in server that runs `body`. + fn fake_server(dir: &Path, body: &str) -> PathBuf { + let path = dir.join("switchyard-server"); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write script"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) + .expect("make script executable"); + let helper = dir.join("switchyard-desktop-install"); + std::fs::write(&helper, r#"#!/bin/sh +test "$#" -eq 5 && test "$1" = validate && test "$2" = --server && test "$4" = --config && test -f "$5" || exit 1 +exec "$3" --config "$5" --dry-run +"#).expect("write helper"); + std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o755)) + .expect("make helper executable"); + path + } + + fn backups(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .expect("read dir") + .map(|entry| entry.expect("entry").path()) + .filter(|path| path.display().to_string().contains(".switchyard-backup.")) + .collect() + } + + #[test] + fn a_config_that_fails_the_check_is_not_saved() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = dir.path().join("composite.toml"); + std::fs::write(&config, OLD).expect("write config"); + // The real server names the file it was given; the error must name + // the user's file instead of the temporary copy. + let binary = fake_server( + dir.path(), + r#"echo "invalid server config $2: route gateway cannot forward both" >&2; exit 1"#, + ); + + let error = save_checked(&binary, &config, OLD, NEW).expect_err("check fails"); + + assert_eq!( + error, + format!( + "invalid server config {}: route gateway cannot forward both", + config.display() + ) + ); + assert_eq!(std::fs::read_to_string(&config).expect("read"), OLD); + assert!(backups(dir.path()).is_empty()); + assert_eq!( + std::fs::read_dir(dir.path()).expect("read dir").count(), + 3, + "the temporary file is removed" + ); + } + + #[test] + fn a_config_that_passes_the_check_replaces_the_file_and_keeps_a_backup() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = dir.path().join("composite.toml"); + std::fs::write(&config, OLD).expect("write config"); + std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o640)) + .expect("set permissions"); + let binary = fake_server( + dir.path(), + r#"grep -q new "$2" && echo "server OK: gateway""#, + ); + + let backup = save_checked(&binary, &config, OLD, NEW).expect("saved"); + + assert_eq!(std::fs::read_to_string(&config).expect("read"), NEW); + let mode = |path: &Path| { + std::fs::metadata(path) + .expect("metadata") + .permissions() + .mode() + & 0o777 + }; + assert_eq!(mode(&config), 0o640, "the file keeps its permissions"); + assert_eq!(backups(dir.path()).len(), 1); + assert_eq!(std::fs::read_to_string(&backup).expect("read"), OLD); + assert_eq!(mode(&backup), 0o640, "the backup gets the same permissions"); + save_checked(&binary, &config, NEW, NEWER).expect("saved again"); + let mut kept: Vec<_> = backups(dir.path()) + .iter() + .map(|path| std::fs::read_to_string(path).expect("backup")) + .collect(); + kept.sort(); + assert_eq!(kept, [NEW, OLD]); + assert_eq!(std::fs::read_to_string(&config).expect("read"), NEWER); + } + + #[test] + fn a_file_that_changes_during_the_check_is_not_replaced() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = dir.path().join("composite.toml"); + std::fs::write(&config, OLD).expect("write config"); + // Another program saves the file while the check runs. + let binary = fake_server( + dir.path(), + &format!("echo edited-elsewhere > '{}'", config.display()), + ); + + let error = save_checked(&binary, &config, OLD, NEW).expect_err("file changed"); + + assert!(error.contains(&config.display().to_string()), "{error}"); + assert_eq!( + std::fs::read_to_string(&config).expect("read"), + "edited-elsewhere\n" + ); + assert!(backups(dir.path()).is_empty()); + } +} diff --git a/crates/switchyard-desktop/src/server_config.rs b/crates/switchyard-desktop/src/server_config.rs new file mode 100644 index 0000000000..a944f71d29 --- /dev/null +++ b/crates/switchyard-desktop/src/server_config.rs @@ -0,0 +1,1556 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module reads the server config and rewrites one route's algorithm and models. +//! +//! Edits go through `toml_edit`, so comments, formatting, and every table the +//! edit does not touch stay as the user wrote them. The server's own +//! `--dry-run` decides whether the result is valid; this module only builds it. + +use std::collections::HashMap; + +use toml_edit::{DocumentMut, Item, Table, TableLike, Value}; + +/// Tier identifies the part a model plays in a route. A choice made for one algorithm carries +/// over to the role with the same tier when the user switches algorithms. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum Tier { + Judge, + Capable, + Efficient, +} + +/// Algorithm describes a route `type` the apps can write. +#[derive(Debug)] +pub struct Algorithm { + pub kind: &'static str, + /// This field sets the name shown in both apps. + pub title: &'static str, + pub summary: &'static str, + /// This field lists roles in display order. The list is empty for + /// `random`, whose roles are the entries of its `targets` list. + roles: &'static [RoleSpec], + /// This field lists settings written when a route switches to this type. They + /// match the values the routing docs use in their examples. + settings: &'static [(&'static [&'static str], Setting)], + /// This field records whether the type accepts a nested `subagents` policy. + subagents: bool, +} + +/// RoleSpec describes one role of an algorithm. +#[derive(Debug)] +struct RoleSpec { + label: &'static str, + /// This field explains what the role does. + hint: &'static str, + tier: Tier, + /// This field names the key path to the role's target under the route. + path: &'static [&'static str], +} + +#[derive(Debug)] +enum Setting { + Text(&'static str), + Number(f64), +} + +impl Setting { + fn value(&self) -> Value { + match self { + Self::Text(text) => Value::from(*text), + Self::Number(number) => Value::from(*number), + } + } +} + +const RANDOM: &str = "random"; + +/// ALGORITHMS lists supported route types in display order. +/// `noop` and the experimental `prefill_router` are left out: one calls no +/// model, and the other needs a checkpoint file rather than models. +pub const ALGORITHMS: &[Algorithm] = &[ + Algorithm { + kind: "passthrough", + title: "Single model", + summary: "This algorithm sends every request to one model.", + roles: &[RoleSpec { + label: "Model", + hint: "This model answers every request.", + tier: Tier::Capable, + path: &["target"], + }], + settings: &[], + subagents: true, + }, + Algorithm { + kind: RANDOM, + title: "Random split", + summary: "This algorithm splits requests at random between models \ + for A/B tests and baselines.", + roles: &[], + settings: &[], + subagents: false, + }, + Algorithm { + kind: "llm_classifier", + title: "Judge per request", + summary: "A judge model reads each request and picks the capable or the efficient model.", + roles: &[ + RoleSpec { + label: "Judge", + hint: "The judge reads each request and picks a model.", + tier: Tier::Judge, + path: &["classifier_target"], + }, + RoleSpec { + label: "Capable", + hint: "The capable model answers the hard requests.", + tier: Tier::Capable, + path: &["strong_target"], + }, + RoleSpec { + label: "Efficient", + hint: "The efficient model answers the easy requests.", + tier: Tier::Efficient, + path: &["weak_target"], + }, + ], + settings: &[ + (&["mode"], Setting::Text("capability")), + (&["base_threshold"], Setting::Number(0.5)), + ], + subagents: false, + }, + Algorithm { + kind: "composite", + title: "Judge plus stage router", + summary: "A judge picks the starting model on each user turn, and tool results move \ + requests between the capable and the efficient model.", + roles: &[ + RoleSpec { + label: "Judge", + hint: "The judge picks the starting model on each user turn.", + tier: Tier::Judge, + path: &["classifier", "target"], + }, + RoleSpec { + label: "Capable", + hint: "The capable model takes the hard steps.", + tier: Tier::Capable, + path: &["stage", "capable_target"], + }, + RoleSpec { + label: "Efficient", + hint: "The efficient model takes the easy steps.", + tier: Tier::Efficient, + path: &["stage", "efficient_target"], + }, + ], + settings: &[ + (&["classifier", "base_threshold"], Setting::Number(0.5)), + ( + &["classifier", "classify_trigger"], + Setting::Text("user_turn"), + ), + (&["stage", "confidence_threshold"], Setting::Number(0.5)), + ], + subagents: true, + }, + Algorithm { + kind: "stage_router", + title: "Stage router", + summary: "Tool results and agent progress move each request between the capable and \ + the efficient model.", + roles: &[ + RoleSpec { + label: "Capable", + hint: "The capable model takes the hard steps.", + tier: Tier::Capable, + path: &["capable_target"], + }, + RoleSpec { + label: "Efficient", + hint: "The efficient model takes the easy steps.", + tier: Tier::Efficient, + path: &["efficient_target"], + }, + ], + settings: &[ + (&["picker"], Setting::Text("efficient_first")), + (&["confidence_threshold"], Setting::Number(0.5)), + ], + subagents: true, + }, + Algorithm { + kind: "advisor", + title: "Executor and advisor", + summary: "The executor answers every turn, and a stronger advisor reviews its final \ + answers.", + roles: &[ + RoleSpec { + label: "Executor", + hint: "The executor answers every turn.", + tier: Tier::Efficient, + path: &["executor_target"], + }, + RoleSpec { + label: "Advisor", + hint: "The advisor reviews the executor's final answers.", + tier: Tier::Capable, + path: &["advisor_target"], + }, + ], + settings: &[], + subagents: false, + }, + Algorithm { + kind: "plan_execute", + title: "Plan, then execute", + summary: "The capable model inspects and plans. The efficient model takes over after \ + the first file edit.", + roles: &[ + RoleSpec { + label: "Capable", + hint: "The capable model inspects the code and plans.", + tier: Tier::Capable, + path: &["capable_target"], + }, + RoleSpec { + label: "Efficient", + hint: "The efficient model takes over after the first edit.", + tier: Tier::Efficient, + path: &["efficient_target"], + }, + ], + settings: &[], + subagents: false, + }, + Algorithm { + kind: "auto", + title: "Recommended preset", + summary: "This stage router starts each request on the efficient model.", + roles: &[ + RoleSpec { + label: "Capable", + hint: "The capable model takes the hard steps.", + tier: Tier::Capable, + path: &["capable_target"], + }, + RoleSpec { + label: "Efficient", + hint: "The efficient model starts each request.", + tier: Tier::Efficient, + path: &["efficient_target"], + }, + ], + settings: &[], + subagents: false, + }, +]; + +/// COMMON_ROUTE_KEYS lists keys that every route type accepts. Switching types keeps these and removes +/// the rest, so settings of the old type cannot fail the new type's checks. +const COMMON_ROUTE_KEYS: [&str; 6] = [ + "id", + "type", + "context_window", + "tool_calling", + "reasoning", + "vision", +]; + +/// REQUEST_SETTINGS lists target settings that change the request body. The server keeps one +/// target per model on a client, so it rejects two targets that name the +/// same model on the same client with different values for these. +const REQUEST_SETTINGS: [&str; 3] = ["omit_body_fields", "reasoning_effort", "extra_body"]; + +/// TARGET_SETTINGS lists settings that a route takes on when it uses the target. +const TARGET_SETTINGS: [&str; 4] = [ + "system_prompt", + "reasoning_effort", + "extra_body", + "omit_body_fields", +]; + +/// Route describes one entry under `[routes]`. +#[derive(Clone, Debug, PartialEq)] +pub struct Route { + /// This field names the route's table in this file. Callers never send it. + pub key: String, + /// This field sets the public model ID that callers send. + pub id: String, + pub kind: String, +} + +/// Client describes one entry under `[llm_clients]`. +#[derive(Clone, Debug, PartialEq)] +pub struct Client { + pub name: String, + pub format: String, + pub base_url: String, + pub api_key_env: Option, + pub forward_auth: bool, +} + +impl Client { + /// This function returns the host in `base_url`, such as `chatgpt.com`. A port stays. + pub fn host(&self) -> &str { + let rest = self + .base_url + .split_once("://") + .map_or(self.base_url.as_str(), |(_, rest)| rest); + let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest); + authority.rsplit('@').next().unwrap_or(authority) + } +} + +/// Choice identifies a model and the client that serves it. +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Choice { + pub client: String, + pub model: String, +} + +/// Role describes a model the algorithm needs, such as the judge. +#[derive(Clone, Debug, PartialEq)] +pub struct Role { + pub label: String, + /// This field explains what the role does. + pub hint: &'static str, + pub tier: Option, + slot: Slot, +} + +/// Slot identifies where a route names a role's target. +#[derive(Clone, Debug, PartialEq)] +enum Slot { + /// Key identifies a key path, such as `stage.capable_target`. + Key(&'static [&'static str]), + /// Listed identifies an entry of a random route's `targets` list. + Listed(usize), +} + +impl Slot { + /// This function returns the slot's key path under the route, such as + /// `stage.capable_target`. + fn path(&self) -> String { + match self { + Self::Key(path) => path.join("."), + Self::Listed(_) => "targets".to_string(), + } + } +} + +impl Algorithm { + /// This function returns the roles that a new route of this type needs. A new + /// random route starts with two models. + pub fn new_route_roles(&self) -> Vec { + self.roles_for(0) + } + + /// This function returns the roles of this type. `listed` is the number of entries + /// in a random route's `targets` list; other types ignore it. + fn roles_for(&self, listed: usize) -> Vec { + if self.kind != RANDOM { + return self + .roles + .iter() + .map(|spec| Role { + label: spec.label.to_string(), + hint: spec.hint, + tier: Some(spec.tier), + slot: Slot::Key(spec.path), + }) + .collect(); + } + (0..listed.max(2)) + .map(|index| Role { + label: format!("Model {}", index + 1), + hint: "This model gets a share of the requests.", + tier: [Tier::Capable, Tier::Efficient].get(index).copied(), + slot: Slot::Listed(index), + }) + .collect() + } +} + +/// Edited stores the result of [`ServerConfig::edit`]. +#[derive(Debug)] +pub struct Edited { + /// This field stores the new config text. + pub text: String, + /// This field stores user-facing notes about the edit. + pub notes: Vec, +} + +/// ServerConfig keeps the parsed config and the original text's layout. +#[derive(Clone, Debug)] +pub struct ServerConfig { + doc: DocumentMut, + /// This field maps tables inside generated blocks to each block's first marker. + generated: HashMap, +} + +impl ServerConfig { + pub fn parse(text: &str) -> Result { + text.parse::() + .map(|doc| Self { + doc, + generated: generated_tables(text), + }) + .map_err(|error| { + format!( + "Could not parse the server config. Fix this TOML error and try again:\n\ + {error}" + ) + }) + } + + pub fn routes(&self) -> Vec { + entries(self.table("routes")) + .map(|(key, route)| Route { + key: key.to_string(), + id: text(route, "id").unwrap_or(key).to_string(), + kind: text(route, "type").unwrap_or_default().to_string(), + }) + .collect() + } + + pub fn remove(&self, route: &str) -> Result { + let mut doc = self.doc.clone(); + let routes = doc + .get_mut("routes") + .and_then(Item::as_table_like_mut) + .ok_or("No routes are configured.")?; + let removed = routes + .remove(route) + .ok_or("The route was already removed. Refresh and choose it again.")?; + retain_block_markers(&mut doc, &removed); + Ok(Edited { + text: doc.to_string(), + notes: vec![ + "Removed the route. Its targets and endpoint settings are preserved.".into(), + ], + }) + } + + pub fn change_route( + &self, + operation: &str, + route: &str, + name: &str, + id: &str, + algorithm: Option<&Algorithm>, + choices: &[Choice], + ) -> Result { + if id.trim().is_empty() || id.contains(['\n', '\r']) { + return Err("Enter a nonempty public route ID without line breaks.".into()); + } + if self + .routes() + .iter() + .any(|existing| existing.id == id && !(operation == "rename" && existing.key == route)) + { + return Err("That public route ID is already configured.".into()); + } + let mut doc = self.doc.clone(); + if operation == "rename" { + let table = doc + .get_mut("routes") + .and_then(Item::as_table_like_mut) + .and_then(|routes| routes.get_mut(route)) + .and_then(Item::as_table_like_mut) + .ok_or("Choose an existing route.")?; + table.insert("id", toml_edit::value(id)); + let mut notes = vec![ + "Harnesses that select the old public route ID must choose the new ID.".into(), + ]; + notes.extend(self.generated_note(route, &[])); + return Ok(Edited { + text: doc.to_string(), + notes, + }); + } + if name.is_empty() + || name.len() > 64 + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err("Route names need 1–64 letters, numbers, hyphens, or underscores.".into()); + } + if self.route(name).is_some() { + return Err("That route name is already configured.".into()); + } + let mut table = match operation { + "duplicate" => self + .table("routes") + .and_then(|routes| routes.get(route)) + .cloned() + .ok_or("Choose an existing route.")?, + "create" => { + let mut table = toml_edit::Table::new(); + table.insert("type", toml_edit::value("passthrough")); + table.insert("target", toml_edit::value("")); + Item::Table(table) + } + _ => return Err("Choose Create, Duplicate, or Rename.".into()), + }; + let mut copied = vec![&mut table]; + while let Some(item) = copied.pop() { + if let Some(table) = item.as_table_mut() { + table.set_position(Some(-1)); + if let Some(prefix) = table.decor().prefix().and_then(|prefix| prefix.as_str()) { + let prefix = prefix + .split_inclusive('\n') + .filter(|line| { + !line.trim().starts_with("# >>>") && !line.trim().starts_with("# <<<") + }) + .collect::(); + table.decor_mut().set_prefix(prefix); + } + copied.extend(table.iter_mut().map(|(_, item)| item)); + } + } + table + .as_table_like_mut() + .ok_or("Route must be a table.")? + .insert("id", toml_edit::value(id)); + if doc.get("routes").is_none() { + doc["routes"] = Item::Table(toml_edit::Table::new()); + } + doc["routes"] + .as_table_like_mut() + .ok_or("Routes must be a table.")? + .insert(name, table); + let candidate = ServerConfig::parse(&doc.to_string())?; + if operation == "create" { + candidate.edit( + name, + algorithm.ok_or("Choose a routing algorithm.")?, + choices, + ) + } else { + Ok(Edited { + text: doc.to_string(), + notes: vec![ + "The duplicate initially shares targets and endpoint settings \ + with the source route." + .into(), + ], + }) + } + } + + pub fn clients(&self) -> Vec { + entries(self.table("llm_clients")) + .map(|(name, client)| Client { + name: name.to_string(), + format: text(client, "format").unwrap_or_default().to_string(), + base_url: text(client, "base_url").unwrap_or_default().to_string(), + api_key_env: text(client, "api_key_env").map(str::to_string), + forward_auth: client + .get("forward_auth") + .and_then(Item::as_bool) + .unwrap_or(false), + }) + .collect() + } + + /// This function returns the text of the marker that opens the block another tool + /// writes around `[table.key]`, when the table sits inside one. That tool + /// overwrites the table the next time it runs. + pub fn generated_by(&self, table: &str, key: &str) -> Option<&str> { + self.generated + .get(&format!("{table}.{key}")) + .map(String::as_str) + } + + /// This function returns the model IDs that targets name on `client`, sorted, each once. + pub fn models_on(&self, client: &str) -> Vec { + let mut models: Vec = entries(self.table("targets")) + .filter(|(_, target)| text(*target, "llm_client") == Some(client)) + .filter_map(|(_, target)| text(target, "id").map(str::to_string)) + .collect(); + models.sort(); + models.dedup(); + models + } + + /// This function returns the algorithm a route uses now, or `None` when the picker + /// cannot show it, such as a custom-mode classifier whose groups are + /// free-form. + pub fn algorithm(&self, route: &str) -> Option<&'static Algorithm> { + let route = self.route(route)?; + if text(route, "mode") == Some("custom") { + return None; + } + let kind = text(route, "type")?; + ALGORITHMS.iter().find(|algorithm| algorithm.kind == kind) + } + + /// This function returns the route's `context_window`, the largest request in tokens + /// that the route accepts, or `None` when the route does not set it. + pub fn context_window(&self, route: &str) -> Option { + self.route(route)? + .get("context_window")? + .as_integer() + .and_then(|window| u64::try_from(window).ok()) + } + + /// This function returns the roles that `algorithm` needs on `route`. A random route + /// keeps its number of targets, and has at least two. + pub fn roles(&self, route: &str, algorithm: &Algorithm) -> Vec { + let listed = self + .route(route) + .filter(|route| algorithm.kind == RANDOM && text(*route, "type") == Some(RANDOM)) + .and_then(|route| { + route + .get("targets")? + .as_array() + .map(|targets| targets.len()) + }) + .unwrap_or(0); + algorithm.roles_for(listed) + } + + /// This function returns the model that each role of the route's current algorithm + /// uses. A role whose target is missing gets an empty choice. + pub fn choices(&self, route: &str) -> Vec { + let Some(algorithm) = self.algorithm(route) else { + return Vec::new(); + }; + self.roles(route, algorithm) + .iter() + .map(|role| { + self.target_at(route, &role.slot) + .and_then(|name| self.target(name)) + .unwrap_or_default() + }) + .collect() + } + + /// This function returns the config text with `route` switched to `algorithm`, using + /// one choice per role, and notes about the edit. + /// + /// Each role keeps its target if it already names the selected model. + /// Otherwise, this function reuses a matching target with equivalent settings, + /// or with request settings that prevent the server from accepting a duplicate. + /// If neither exists, this function edits an unshared target or copies a shared one. + /// Roles earlier in this edit also count as users of a target. A changed or copied target keeps settings + /// such as `extra_body` and `omit_body_fields`, including settings meant + /// for the old model. Targets no route uses any more are left in the file. + pub fn edit( + &self, + route: &str, + algorithm: &Algorithm, + choices: &[Choice], + ) -> Result { + if self.route(route).is_none() { + return Err(format!("The config has no [routes.{route}] table.")); + } + let current = self + .algorithm(route) + .ok_or("This route must be edited in the server config file.")?; + let roles = if algorithm.kind == RANDOM { + if choices.len() < 2 { + return Err("Random routing needs at least two models.".into()); + } + algorithm.roles_for(choices.len()) + } else { + self.roles(route, algorithm) + }; + if roles.len() != choices.len() { + return Err(format!( + "{} needs {} models, not {}.", + algorithm.kind, + roles.len(), + choices.len() + )); + } + let clients = self.clients(); + for (role, choice) in roles.iter().zip(choices) { + if choice.model.trim().is_empty() { + return Err(format!("Pick a model for {}.", role.label)); + } + if choice.client.is_empty() { + return Err(format!("Pick an endpoint for {}.", role.label)); + } + if !clients.iter().any(|client| client.name == choice.client) { + return Err(format!( + "The config has no LLM client named \"{}\".", + choice.client + )); + } + } + + let same_type = current.kind == algorithm.kind; + let old_roles = self.roles(route, current); + // When the type changes, each new role starts from the old role with + // the same tier, so a composite's capable target stays the capable one. + let by_tier: Vec<(Option, &str)> = old_roles + .iter() + .filter_map(|role| Some((role.tier, self.target_at(route, &role.slot)?))) + .collect(); + let mut users = self.target_users(); + let place = self.new_target_position(); + + let mut doc = self.doc.clone(); + let mut names = Vec::with_capacity(roles.len()); + let mut notes = Vec::new(); + for (role, choice) in roles.iter().zip(choices) { + let existing = if same_type { + self.target_at(route, &role.slot) + } else { + role.tier.and_then(|tier| { + by_tier + .iter() + .find(|(old, _)| *old == Some(tier)) + .map(|(_, name)| *name) + }) + }; + let choice = Choice { + client: choice.client.clone(), + model: choice.model.trim().to_string(), + }; + let (name, moved_from) = + choose_target(&mut doc, &mut users, route, role, &choice, existing, place)?; + if let Some(old) = moved_from + && let Some(note) = kept_settings_note(&doc, &clients, &name, &old, &choice) + { + notes.push(note); + } + let mut others: Vec<&str> = users + .get(name.as_str()) + .into_iter() + .flatten() + .copied() + .filter(|other| *other != route) + .collect(); + others.dedup(); + if Some(name.as_str()) != existing && !others.is_empty() { + let plural = if others.len() == 1 { "" } else { "s" }; + notes.push(format!( + "{} now shares [targets.{name}] with route{plural} {}.", + role.label, + others.join(", ") + )); + } + names.push(name); + } + notes.extend(self.login_note(route, choices)); + notes.extend(self.generated_note(route, &names)); + + let table = doc + .get_mut("routes") + .and_then(Item::as_table_like_mut) + .and_then(|routes| routes.get_mut(route)) + .and_then(Item::as_table_like_mut) + .ok_or_else(|| format!("The config has no [routes.{route}] table."))?; + if !same_type { + let old_slots: Vec = old_roles.iter().map(|role| role.slot.path()).collect(); + let removed = reset_route(table, algorithm, &old_slots); + if !removed.is_empty() { + notes.push(format!( + "Switching to {} removed these settings from [routes.{route}]: {}. The \ + backup still has them.", + algorithm.kind, + removed.join(", ") + )); + } + } + if algorithm.kind == RANDOM { + table.insert( + "targets", + Item::Value(toml_edit::Value::Array(toml_edit::Array::new())), + ); + } + for (role, name) in roles.iter().zip(&names) { + set_slot(table, &role.slot, name); + } + if !same_type { + for (path, setting) in algorithm.settings { + set_at(table, path, setting.value()); + } + } + Ok(Edited { + text: doc.to_string(), + notes, + }) + } + + fn table(&self, name: &str) -> Option<&dyn TableLike> { + self.doc.get(name)?.as_table_like() + } + + fn route(&self, route: &str) -> Option<&dyn TableLike> { + self.table("routes")?.get(route)?.as_table_like() + } + + fn target(&self, name: &str) -> Option { + let target = self.table("targets")?.get(name)?.as_table_like()?; + Some(Choice { + client: text(target, "llm_client")?.to_string(), + model: text(target, "id")?.to_string(), + }) + } + + fn target_at(&self, route: &str, slot: &Slot) -> Option<&str> { + let route = self.route(route)?; + match slot { + Slot::Key(path) => { + let (last, parents) = path.split_last()?; + let mut table = route; + for key in parents { + table = table.get(key)?.as_table_like()?; + } + text(table, last) + } + Slot::Listed(index) => route.get("targets")?.as_array()?.get(*index)?.as_str(), + } + } + + /// This function returns each target name with the routes that use it. + /// Each mention adds one entry, including mentions in nested policies. + /// A target with one entry is used in one role. + fn target_users(&self) -> HashMap<&str, Vec<&str>> { + let mut users: HashMap<&str, Vec<&str>> = entries(self.table("targets")) + .map(|(name, _)| (name, Vec::new())) + .collect(); + for (route, item) in self + .table("routes") + .into_iter() + .flat_map(|routes| routes.iter()) + { + visit_strings(item, &mut |text| { + if let Some(routes) = users.get_mut(text) { + routes.push(route); + } + }); + } + users + } + + /// This function returns the file position for a new `[targets.*]` table. The table + /// goes after the last target outside every generated block, so that the + /// next rewrite of a block cannot delete it. When every target sits + /// inside a block, any other place is next to a generated target, so the + /// table goes before the first table of the file. + fn new_target_position(&self) -> Option { + self.table("targets")? + .iter() + .filter(|(key, _)| self.generated_by("targets", key).is_none()) + .filter_map(|(_, item)| item.as_table()?.position()) + .max() + .or_else(|| { + self.generated + .keys() + .any(|key| key.starts_with("targets.")) + .then_some(-1) + }) + } + + /// This function says so when the edit sends the callers' logins to another host. A + /// client with `forward_auth` sends each caller's own login, and a caller + /// that has none for the new host gets HTTP 401. + fn login_note(&self, route: &str, choices: &[Choice]) -> Option { + let clients = self.clients(); + let before = login_hosts(&clients, &self.choices(route)); + let after = login_hosts(&clients, choices); + if after.iter().all(|host| before.contains(host)) { + return None; + } + let was = if before.is_empty() { + String::new() + } else { + format!(" It sent them to {} before.", before.join(" and ")) + }; + Some(format!( + "[routes.{route}] now sends each caller's own login to {}.{was} A caller that has \ + no login for the new host gets HTTP 401.", + after.join(" and ") + )) + } + + /// This function returns a warning when the route sits inside a block that another tool + /// writes, which overwrites the route, or when the route uses targets + /// inside such a block, which that tool can change or remove. + fn generated_note(&self, route: &str, targets: &[String]) -> Option { + if let Some(block) = self.generated_by("routes", route) { + return Some(format!( + "[routes.{route}] sits inside a block that another tool writes. That tool \ + overwrites this change the next time it runs. Block marker: \"{block}\"." + )); + } + let inside: Vec = targets + .iter() + .filter(|name| self.generated_by("targets", name).is_some()) + .map(|name| format!("[targets.{name}]")) + .collect(); + let (verb, pronoun) = if inside.len() == 1 { + ("sits", "it") + } else { + ("sit", "them") + }; + (!inside.is_empty()).then(|| { + format!( + "[routes.{route}] uses {}, which {verb} inside a block that another tool \ + writes. The next time that tool runs, it can change or remove {pronoun}.", + inside.join(", ") + ) + }) + } +} + +/// This function picks or writes the target that serves `choice` for one role. Returns its +/// name, and the model that the target named before when the edit changed +/// the target in place or copied it. Reusing a target adds the route to the +/// target's users, so a later role in the same edit copies that target +/// instead of changing it in place. +/// A new target goes at file position `place`, where the next rewrite of a +/// generated block cannot delete it. +fn choose_target<'a>( + doc: &mut DocumentMut, + users: &mut HashMap<&'a str, Vec<&'a str>>, + route: &'a str, + role: &Role, + choice: &Choice, + existing: Option<&str>, + place: Option, +) -> Result<(String, Option), String> { + if doc.get("targets").is_none() { + doc["targets"] = Item::Table(Table::new()); + } + let targets = doc + .get_mut("targets") + .and_then(Item::as_table_like_mut) + .ok_or("Targets must be a table.")?; + + let current = existing + .and_then(|name| targets.get(name)) + .and_then(Item::as_table_like); + if let Some(name) = existing + && current.is_some_and(|target| names_choice(target, choice)) + { + return Ok((name.to_string(), None)); + } + let old = current.and_then(|target| { + Some(Choice { + client: text(target, "llm_client")?.to_string(), + model: text(target, "id")?.to_string(), + }) + }); + if let Some(name) = reusable(&*targets, current, choice).map(str::to_string) { + if let Some(routes) = users.get_mut(name.as_str()) { + routes.push(route); + } + return Ok((name, None)); + } + if let Some(name) = existing + && users.get(name).is_some_and(|routes| routes.len() == 1) + && let Some(target) = targets.get_mut(name).and_then(Item::as_table_like_mut) + { + set_value(target, "id", Value::from(choice.model.as_str())); + set_value(target, "llm_client", Value::from(choice.client.as_str())); + return Ok((name.to_string(), old)); + } + + let mut table = Table::new(); + if let Some(source) = existing + .and_then(|name| targets.get(name)) + .and_then(Item::as_table_like) + { + for (key, item) in source.iter() { + let mut item = item.clone(); + unplace(&mut item); + table.insert(key, item); + } + } + set_value(&mut table, "id", Value::from(choice.model.as_str())); + set_value( + &mut table, + "llm_client", + Value::from(choice.client.as_str()), + ); + let base = format!("{route}_{}", role.label.to_lowercase().replace(' ', "_")); + let mut name = base.clone(); + let mut suffix = 1; + while targets.contains_key(&name) { + suffix += 1; + name = format!("{base}_{suffix}"); + } + // A table with no position is written after the last [targets.*] table in + // the file, which can sit inside a block that another tool writes. + table.set_position(place); + targets.insert(&name, Item::Table(table)); + Ok((name, old)) +} + +/// This function returns a note about the request settings that a target kept when the +/// edit moved it from `old` to `new`, a model of another family or on a +/// client with another request format. A setting meant for the old model can +/// make the provider reject the new model's requests, and `--dry-run` does +/// not catch that. So the note lists the kept settings, or says that the +/// target has none, and the user decides. +fn kept_settings_note( + doc: &DocumentMut, + clients: &[Client], + name: &str, + old: &Choice, + new: &Choice, +) -> Option { + let format = |name: &str| { + clients + .iter() + .find(|client| client.name == name) + .map(|client| client.format.as_str()) + }; + if family(&old.model) == family(&new.model) && format(&old.client) == format(&new.client) { + return None; + } + let target = doc + .get("targets")? + .as_table_like()? + .get(name)? + .as_table_like()?; + let kept: Vec = REQUEST_SETTINGS + .iter() + .filter_map(|key| Some(format!("{key} = {}", setting(target, key)?))) + .collect(); + Some(if kept.is_empty() { + format!( + "[targets.{name}] now names {} and has no omit_body_fields, reasoning_effort, or \ + extra_body. Check whether the new model needs one of them.", + new.model + ) + } else { + format!( + "[targets.{name}] now names {} and kept {} from {}. Check that the new model \ + accepts these settings.", + new.model, + kept.join(", "), + old.model + ) + }) +} + +/// This function returns a model's family: the first word of the last part of its ID, +/// such as `gpt` for `openai/gpt-5.6-sol`. +fn family(model: &str) -> String { + let name = model.rsplit('/').next().unwrap_or(model); + name.split(['-', '.', '_', ':']) + .next() + .unwrap_or(name) + .to_lowercase() +} + +/// This function returns another target that names `choice` and that a role whose target +/// is `current` may use. The role takes on that target's settings. So it +/// uses a target with the same settings as `current`, or else a target whose +/// request settings differ from `current`, because the server would reject a +/// second target for this model with other request settings. +fn reusable<'t>( + targets: &'t dyn TableLike, + current: Option<&dyn TableLike>, + choice: &Choice, +) -> Option<&'t str> { + let same = |target: &dyn TableLike, keys: &[&str]| { + keys.iter() + .all(|key| setting(target, key) == current.and_then(|current| setting(current, key))) + }; + let candidates: Vec<(&str, &dyn TableLike)> = targets + .iter() + .filter_map(|(name, target)| Some((name, target.as_table_like()?))) + .filter(|(_, target)| names_choice(*target, choice)) + .collect(); + candidates + .iter() + .find(|(_, target)| same(*target, &TARGET_SETTINGS)) + .or_else(|| { + candidates + .iter() + .find(|(_, target)| !same(*target, &REQUEST_SETTINGS)) + }) + .map(|(name, _)| *name) +} + +/// This function returns a target's setting as plain data, so the same value written in +/// another layout compares equal. +fn setting(target: &dyn TableLike, key: &str) -> Option { + let mut table = Table::new(); + table.insert(key, target.get(key)?.clone()); + DocumentMut::from(table) + .to_string() + .parse::() + .ok()? + .remove(key) +} + +/// This function clears the file position of every table in `item`. A copied sub-table +/// then follows the header of the table it is copied into, not the header +/// of the table it came from. +fn unplace(item: &mut Item) { + if let Some(table) = item.as_table_mut() { + table.set_position(None); + for (_, child) in table.iter_mut() { + unplace(child); + } + } +} + +/// This function returns whether a target names exactly this model on this client. +fn names_choice(target: &dyn TableLike, choice: &Choice) -> bool { + text(target, "llm_client") == Some(choice.client.as_str()) + && text(target, "id") == Some(choice.model.as_str()) +} + +/// This function removes the old type's settings, sets the new `type`, and returns the +/// removed settings as key paths, such as `classifier.base_threshold`. The +/// list leaves out `old_slots`, the old roles' targets, which the new roles +/// replace. +fn reset_route( + route: &mut dyn TableLike, + algorithm: &Algorithm, + old_slots: &[String], +) -> Vec { + let kept = + |key: &str| COMMON_ROUTE_KEYS.contains(&key) || (algorithm.subagents && key == "subagents"); + let stale: Vec = route + .iter() + .map(|(key, _)| key) + .filter(|key| !kept(key)) + .map(str::to_string) + .collect(); + let mut removed = Vec::new(); + for key in stale { + if let Some(item) = route.remove(&key) { + key_paths(&key, &item, &mut removed); + } + } + removed.retain(|path| !old_slots.contains(path)); + set_value(route, "type", Value::from(algorithm.kind)); + removed +} + +/// This function adds the key path of every value in `item` to `paths`. +fn key_paths(path: &str, item: &Item, paths: &mut Vec) { + match item.as_table_like() { + Some(table) => { + for (key, child) in table.iter() { + key_paths(&format!("{path}.{key}"), child, paths); + } + } + None => paths.push(path.to_string()), + } +} + +fn set_slot(route: &mut dyn TableLike, slot: &Slot, name: &str) { + match slot { + Slot::Key(path) => set_at(route, path, Value::from(name)), + Slot::Listed(index) => { + if !route.get("targets").is_some_and(Item::is_array) { + route.insert("targets", toml_edit::value(toml_edit::Array::new())); + } + if let Some(targets) = route.get_mut("targets").and_then(Item::as_array_mut) { + if *index < targets.len() { + targets.replace(*index, name); + } else { + targets.push(name); + } + } + } + } +} + +/// This function sets a value at a key path, creating missing tables on the way. +fn set_at(table: &mut dyn TableLike, path: &[&str], value: Value) { + match path { + [] => {} + [key] => set_value(table, key, value), + [key, rest @ ..] => { + if !table.get(key).is_some_and(Item::is_table_like) { + table.insert(key, Item::Table(Table::new())); + } + if let Some(child) = table.get_mut(key).and_then(Item::as_table_like_mut) { + set_at(child, rest, value); + } + } + } +} + +/// This function replaces a value, keeping the spacing and trailing comment around it. +fn set_value(table: &mut dyn TableLike, key: &str, mut value: Value) { + if let Some(old) = table.get_mut(key).and_then(Item::as_value_mut) { + *value.decor_mut() = old.decor().clone(); + *old = value; + } else { + table.insert(key, Item::Value(value)); + } +} + +fn entries(table: Option<&dyn TableLike>) -> impl Iterator { + table + .into_iter() + .flat_map(|table| table.iter()) + .filter_map(|(key, item)| Some((key, item.as_table_like()?))) +} + +fn text<'a>(table: &'a dyn TableLike, key: &str) -> Option<&'a str> { + table.get(key)?.as_str() +} + +fn visit_strings(item: &Item, visit: &mut dyn FnMut(&str)) { + match item { + Item::Value(value) => visit_value(value, visit), + Item::Table(table) => table + .iter() + .for_each(|(_, item)| visit_strings(item, visit)), + Item::ArrayOfTables(tables) => tables + .iter() + .flat_map(Table::iter) + .for_each(|(_, item)| visit_strings(item, visit)), + Item::None => {} + } +} + +fn visit_value(value: &Value, visit: &mut dyn FnMut(&str)) { + match value { + Value::String(text) => visit(text.value()), + Value::Array(array) => array.iter().for_each(|value| visit_value(value, visit)), + Value::InlineTable(table) => table + .iter() + .for_each(|(_, value)| visit_value(value, visit)), + _ => {} + } +} + +/// This function returns the hosts that get the callers' own logins: the hosts of the +/// `forward_auth` clients that `choices` name, sorted, each once. +fn login_hosts<'c>(clients: &'c [Client], choices: &[Choice]) -> Vec<&'c str> { + let mut hosts: Vec<&str> = choices + .iter() + .filter_map(|choice| clients.iter().find(|client| client.name == choice.client)) + .filter(|client| client.forward_auth) + .map(Client::host) + .collect(); + hosts.sort_unstable(); + hosts.dedup(); + hosts +} + +// Generated-block markers can occur on nested route tables and still apply to retained targets. +fn retain_block_markers(doc: &mut DocumentMut, removed: &Item) { + let mut markers = Vec::new(); + let mut tables: Vec<_> = removed.as_table().into_iter().collect(); + while let Some(table) = tables.pop() { + if let Some(position) = table.position() + && let Some(prefix) = table.decor().prefix().and_then(|value| value.as_str()) + && prefix + .lines() + .any(|line| line.trim().starts_with("# >>>") || line.trim().starts_with("# <<<")) + { + markers.push((position, prefix.to_string())); + } + tables.extend(table.iter().filter_map(|(_, item)| item.as_table())); + } + markers.sort_by_key(|(position, _)| *position); + let mut positions = Vec::new(); + let mut tables = vec![doc.as_table()]; + while let Some(table) = tables.pop() { + positions.extend(table.position()); + tables.extend(table.iter().filter_map(|(_, item)| item.as_table())); + } + let mut prefixes: HashMap = HashMap::new(); + let mut trailing = String::new(); + for (removed_position, prefix) in markers { + if let Some(next) = positions + .iter() + .copied() + .filter(|position| *position > removed_position) + .min() + { + prefixes.entry(next).or_default().push_str(&prefix); + } else { + trailing.push_str(&prefix); + } + } + let mut tables = vec![doc.as_table_mut()]; + while let Some(table) = tables.pop() { + if let Some(position) = table.position() + && let Some(mut prefix) = prefixes.remove(&position) + { + prefix.push_str( + table + .decor() + .prefix() + .and_then(|value| value.as_str()) + .unwrap_or_default(), + ); + table.decor_mut().set_prefix(prefix); + } + tables.extend(table.iter_mut().filter_map(|(_, item)| item.as_table_mut())); + } + trailing.push_str(doc.trailing().as_str().unwrap_or_default()); + doc.set_trailing(trailing); +} + +/// This function finds the route and target tables that sit inside blocks another tool +/// writes. A block starts at a line that begins with `# >>>` and ends at the +/// next line that begins with `# <<<`, the markers that the installer scripts +/// use. Each table maps to the text of its block's first marker, with the `#` +/// and the `>>>` removed. +fn generated_tables(text: &str) -> HashMap { + let mut tables = HashMap::new(); + let mut block: Option = None; + for line in text.lines().map(str::trim) { + if line.starts_with("# >>>") { + block = Some( + line.trim_matches(|c: char| c == '#' || c == '>' || c.is_whitespace()) + .to_string(), + ); + } else if line.starts_with("# <<<") { + block = None; + } else if let Some(block) = &block + && let Some(header) = line.strip_prefix('[') + && let Some((table, rest)) = header.split_once('.') + && matches!(table, "routes" | "targets") + && let Some(key) = table_key(rest) + { + tables + .entry(format!("{table}.{key}")) + .or_insert_with(|| block.clone()); + } + } + tables +} + +/// This function returns the first key of a table header's dotted path, bare or in double +/// quotes: `name` for `name.child]`, and `a.b` for `"a.b"]`. +fn table_key(path: &str) -> Option<&str> { + if let Some(quoted) = path.strip_prefix('"') { + return quoted.split_once('"').map(|(key, _)| key); + } + path.split(['.', ']']).next().filter(|key| !key.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// This fixture uses a GPT judge and Claude on one gateway with different API formats. + const GATEWAY: &str = r#"schema_version = 1 + +[llm_clients.gateway] +format = "openai_responses" +base_url = "https://gateway.example.com/v1" +forward_auth = true + +# Claude caches prompts on the chat endpoint. +[llm_clients.gateway_chat] +format = "openai_chat" +base_url = "https://gateway.example.com/v1" +forward_auth = true + +# The gateway rejects any thinking setting for these models. +[targets.gateway_capable] +id = "claude-opus-5-5" +llm_client = "gateway_chat" # chat, for caching +omit_body_fields = ["reasoning_effort"] + +[targets.gateway_efficient] +id = "claude-sonnet-5" +llm_client = "gateway_chat" +omit_body_fields = ["reasoning_effort"] + +[targets.gateway_judge] +id = "gpt-5.6-terra" +llm_client = "gateway" + +[routes.gateway] +id = "switchyard-gateway" +type = "composite" +context_window = 200000 + +[routes.gateway.classifier] +target = "gateway_judge" +base_threshold = 0.5 +classify_trigger = "user_turn" +message_hash_fallback = true + +[routes.gateway.stage] +capable_target = "gateway_capable" +efficient_target = "gateway_efficient" +confidence_threshold = 0.5 +"#; + + fn choice(client: &str, model: &str) -> Choice { + Choice { + client: client.to_string(), + model: model.to_string(), + } + } + + fn algorithm(kind: &str) -> &'static Algorithm { + ALGORITHMS + .iter() + .find(|algorithm| algorithm.kind == kind) + .expect("known algorithm") + } + + /// This function edits the config and checks the result with the server's parser. + fn edit(text: &str, route: &str, kind: &str, choices: &[Choice]) -> Edited { + let edited = ServerConfig::parse(text) + .expect("parse") + .edit(route, algorithm(kind), choices) + .expect("edit"); + if let Err(error) = switchyard_runner::Runner::from_toml(&edited.text) { + panic!( + "the server rejects the edited config: {error}\n{}", + edited.text + ); + } + edited + } + + fn capable_target(config: &ServerConfig) -> Option<&str> { + config.target_at("gateway", &Slot::Key(&["stage", "capable_target"])) + } + + #[test] + fn removing_a_route_keeps_generated_block_markers_for_retained_targets() { + for (suffix, target, owned) in [ + ( + "# >>> generated >>>\n\ + [routes.generated]\n\ + id='generated'\n\ + type='passthrough'\n\ + target='owned'\n\ + [targets.owned]\n\ + id='m'\n\ + llm_client='gateway'\n\ + # <<< generated <<<\n", + "owned", + true, + ), + ( + "# >>> generated >>>\n\ + [targets.owned]\n\ + id='m'\n\ + llm_client='gateway'\n\ + # <<< generated <<<\n\ + [routes.generated]\n\ + id='generated'\n\ + type='passthrough'\n\ + target='owned'\n\ + [targets.unowned]\n\ + id='other'\n\ + llm_client='gateway'\n", + "unowned", + false, + ), + ( + "[routes.generated]\n\ + id='generated'\n\ + type='composite'\n\ + # >>> generated >>>\n\ + [routes.generated.classifier]\n\ + target='owned'\n\ + [targets.owned]\n\ + id='m'\n\ + llm_client='gateway'\n\ + # <<< generated <<<\n", + "owned", + true, + ), + ] { + let config = ServerConfig::parse(&format!("{GATEWAY}\n{suffix}")).expect("config"); + assert_eq!(config.generated_by("targets", target).is_some(), owned); + let edited = config.remove("generated").expect("remove"); + let remaining = ServerConfig::parse(&edited.text).expect("config"); + assert_eq!( + remaining.generated_by("targets", target).is_some(), + owned, + "{}", + edited.text + ); + } + } + + #[test] + fn every_algorithm_writes_a_config_the_server_accepts() { + let gateway = ServerConfig::parse(GATEWAY).expect("parse"); + let models = [ + choice("gateway", "gpt-5.6-terra"), + choice("gateway", "gpt-5.6-sol"), + choice("gateway_chat", "claude-sonnet-5"), + ]; + for algorithm in ALGORITHMS { + let roles = gateway.roles("gateway", algorithm).len(); + + let edited = edit(GATEWAY, "gateway", algorithm.kind, &models[3 - roles..]); + + let config = ServerConfig::parse(&edited.text).expect("parse"); + assert_eq!( + config.algorithm("gateway").map(|a| a.kind), + Some(algorithm.kind) + ); + assert_eq!(config.choices("gateway"), models[3 - roles..]); + } + } + + #[test] + fn a_target_another_route_uses_is_copied_not_changed() { + // The shared target has a sub-table, which the copy must keep under + // its own header. + let shared = GATEWAY.replace( + "omit_body_fields = [\"reasoning_effort\"]\n\n[targets.gateway_efficient]", + "omit_body_fields = [\"reasoning_effort\"]\n\n[targets.gateway_capable.extra_body]\n\ + top_k = 5\n\n[targets.gateway_efficient]", + ) + "\n[routes.direct]\n\ + id = \"direct\"\n\ + type = \"passthrough\"\n\ + target = \"gateway_capable\"\n"; + + let edited = edit( + &shared, + "gateway", + "composite", + &[ + choice("gateway", "gpt-5.6-terra"), + choice("gateway", "gpt-5.6-sol"), + choice("gateway_chat", "claude-sonnet-5"), + ], + ); + + let config = ServerConfig::parse(&edited.text).expect("parse"); + assert_eq!( + config.choices("direct"), + [choice("gateway_chat", "claude-opus-5-5")] + ); + assert_eq!( + config.choices("gateway")[1], + choice("gateway", "gpt-5.6-sol") + ); + let copy = capable_target(&config).expect("capable target"); + assert_ne!(copy, "gateway_capable"); + let targets = config.table("targets").expect("targets"); + let (source, copied) = ( + targets.get("gateway_capable").and_then(Item::as_table_like), + targets.get(copy).and_then(Item::as_table_like), + ); + for key in TARGET_SETTINGS { + assert_eq!( + source.and_then(|target| setting(target, key)), + copied.and_then(|target| setting(target, key)), + "the copy keeps {key}" + ); + } + let header = |name: &str| edited.text.find(&format!("[{name}]")).expect(name); + assert!( + header(&format!("targets.{copy}")) < header(&format!("targets.{copy}.extra_body")), + "the copied sub-table follows the copy's header:\n{}", + edited.text + ); + } +} diff --git a/crates/switchyard-desktop/src/sessions.rs b/crates/switchyard-desktop/src/sessions.rs new file mode 100644 index 0000000000..9ab43cc8ec --- /dev/null +++ b/crates/switchyard-desktop/src/sessions.rs @@ -0,0 +1,1110 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module starts CLI sessions with separate worktrees and model settings. + +use crate::harness::{self, Harness}; +use std::path::Path; + +// PendingSession attempts cleanup until open accepts the launch request. +// A successful launch keeps the worktree and settings for the coding session. +struct PendingSession { + project: std::path::PathBuf, + worktree: std::path::PathBuf, + private: std::path::PathBuf, + branch: String, + revision: String, + opened: bool, +} + +impl Drop for PendingSession { + fn drop(&mut self) { + if self.opened { + return; + } + let _ = std::process::Command::new("git") + .arg("-C") + .arg(&self.project) + .args(["worktree", "remove"]) + .arg(&self.worktree) + .output(); + delete_session_branch(&self.project, &self.branch, &self.revision); + let _ = std::fs::remove_dir_all(&self.private); + } +} + +// This deletes the session branch only while it still points at the session's starting commit. +// The commit check makes `branch -D` safe. `branch -d` also requires a merge into the original +// checkout's HEAD, so it fails after the user switches that checkout to an unrelated branch. +fn delete_session_branch(repository: &Path, branch: &str, revision: &str) -> bool { + let tip = std::process::Command::new("git") + .arg("-C") + .arg(repository) + .args(["rev-parse", "--verify", "--quiet"]) + .arg(format!("refs/heads/{branch}")) + .output(); + tip.is_ok_and(|tip| { + tip.status.success() && String::from_utf8_lossy(&tip.stdout).trim() == revision + }) && std::process::Command::new("git") + .arg("-C") + .arg(repository) + .args(["branch", "-D", branch]) + .output() + .is_ok_and(|output| output.status.success()) +} + +pub(crate) fn quote(text: &str) -> String { + format!("'{}'", text.replace('\'', "'\\''")) +} + +pub fn preview(project: &Path) -> Result { + if project.as_os_str().is_empty() { + return Err("Choose a Git checkout directory.".into()); + } + let project = std::fs::canonicalize(crate::config::expand_home(project)?) + .map_err(|error| format!("Open project: {error}"))?; + let git = |args: &[&str]| -> Result { + let output = std::process::Command::new("git") + .arg("-C") + .arg(&project) + .args(args) + .output() + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err("Choose a Git checkout with a committed HEAD.".into()); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + }; + let repository = git(&["rev-parse", "--show-toplevel"])?; + let repository_path = std::fs::canonicalize(&repository).map_err(|error| error.to_string())?; + let relative = project + .strip_prefix(&repository_path) + .map_err(|error| error.to_string())?; + Ok(serde_json::json!({ + "repository": repository, + "project": project, + "relative_directory": relative, + "revision": git(&["rev-parse", "HEAD"])?, + "branch": git(&["branch", "--show-current"])?, + "dirty": !git(&["status", "--porcelain"])?.is_empty(), + "notice": "The session starts from committed HEAD. \ + Uncommitted changes stay in the original checkout." + })) +} + +fn session_root() -> Result { + crate::config::expand_home(Path::new("~/.switchyard/worktrees")) +} + +pub fn inventory() -> Result, String> { + let root = session_root()?; + let entries = match std::fs::read_dir(&root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(format!("Read session inventory: {error}")), + }; + let mut sessions = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| error.to_string())?; + if entry + .file_type() + .map_err(|error| error.to_string())? + .is_dir() + && entry.file_name().to_string_lossy().ends_with("-settings") + { + let receipt = entry.path().join("session.json"); + if receipt.exists() { + let mut value: serde_json::Value = serde_json::from_slice( + &std::fs::read(&receipt).map_err(|error| error.to_string())?, + ) + .map_err(|error| format!("Read {}: {error}", receipt.display()))?; + value + .as_object_mut() + .ok_or("The session receipt does not match this worktree.")? + .insert("process_status".into(), serde_json::json!("unknown")); + sessions.push(value); + } + } + } + sessions.sort_by(|left, right| { + right["created_at"] + .as_str() + .cmp(&left["created_at"].as_str()) + }); + Ok(sessions) +} + +pub fn managed_session(id: &str) -> Result { + let suffix = id.strip_prefix("sy-").ok_or("Choose a managed session.")?; + uuid::Uuid::parse_str(suffix).map_err(|_| "Choose a managed session.")?; + let root = session_root()?; + let private = root.join(format!("{id}-settings")); + let worktree = root.join(id); + for path in [&root, &private, &worktree] { + if std::fs::symlink_metadata(path) + .map_err(|error| error.to_string())? + .file_type() + .is_symlink() + { + return Err("Session directories must not be symlinks.".into()); + } + } + let receipt: serde_json::Value = serde_json::from_slice( + &std::fs::read(private.join("session.json")).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + if receipt["id"] != id || receipt["worktree"] != worktree.display().to_string() { + return Err("The session receipt does not match this worktree.".into()); + } + Ok(receipt) +} + +pub fn cleanup_preview(id: &str) -> Result { + let mut receipt = managed_session(id)?; + let worktree = receipt["worktree"] + .as_str() + .ok_or("Missing session worktree.")? + .to_string(); + let run = |args: &[&str]| -> Result { + let output = std::process::Command::new("git") + .args(["-C", &worktree]) + .args(args) + .output() + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).into_owned()); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + }; + // Plain `git worktree remove` deletes ignored files, so the review lists them. + let status = run(&["status", "--porcelain", "--ignored=matching"])?; + receipt["dirty"] = serde_json::json!(status.lines().any(|line| !line.starts_with("!! "))); + receipt["ignored"] = serde_json::json!( + status + .lines() + .filter_map(|line| line.strip_prefix("!! ")) + .collect::>() + ); + receipt["current_revision"] = serde_json::json!(run(&["rev-parse", "HEAD"])?); + receipt["current_branch"] = serde_json::json!(run(&["branch", "--show-current"])?); + receipt["cleanup_notice"] = serde_json::json!( + "Cleanup removes a clean worktree and its unchanged session branch. \ + It also deletes the Git-ignored files in the worktree, which the ignored list names. \ + Recorded usage stays in the routing log. Unknown private files are preserved." + ); + Ok(receipt) +} + +pub fn cleanup(id: &str, reviewed: &serde_json::Value) -> Result { + let current = cleanup_preview(id)?; + if ¤t != reviewed { + return Err("The session changed since review. Review cleanup again.".into()); + } + if current["dirty"] != false + || current["current_revision"] != current["revision"] + || current["current_branch"] != id + { + return Err("Save or move the session’s work before cleanup. \ + Cleanup requires a clean worktree with its original commit and branch." + .into()); + } + let repository = current["repository"] + .as_str() + .ok_or("Missing session repository.")?; + let worktree = current["worktree"] + .as_str() + .ok_or("Missing session worktree.")?; + let revision = current["revision"] + .as_str() + .ok_or("Missing session commit.")?; + // Receipt path resolution must succeed before cleanup removes the worktree. + let private = session_root()?.join(format!("{id}-settings")); + let output = std::process::Command::new("git") + .args(["-C", repository, "worktree", "remove", worktree]) + .output() + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).into_owned()); + } + + // The worktree is gone, so the receipt goes too; otherwise the inventory keeps the session. + // Cleanup removes only the receipt because settings can contain later user edits. + std::fs::remove_file(private.join("session.json")).map_err(|error| error.to_string())?; + let _ = std::fs::remove_dir(&private); + if !delete_session_branch(Path::new(repository), id, revision) { + return Ok(format!( + "Removed the clean session worktree. Git kept the branch {id}; \ + inspect it before deleting it. Retained private settings and recorded usage." + )); + } + Ok("Removed the clean session worktree and branch. \ + Retained private settings and recorded usage." + .into()) +} + +/// A harness opened in a directory that already exists. +/// +/// `launch` creates a worktree and then opens the harness in it. A hand-off later opens +/// another harness in the same directory, so the two steps stay separate. +pub struct OpenRequest<'a> { + pub tool: Harness, + pub binary: std::path::PathBuf, + /// The directory the harness starts in. + pub directory: std::path::PathBuf, + /// The public route ID the harness uses as its model. + pub model: &'a str, + /// The validated server URL. + pub root: &'a str, + pub login: bool, + /// The login directory the script exports, when the harness must select one. + pub account: Option<&'a Path>, + /// The value of the `x-switchyard-session-id` header. + pub session_id: &'a str, + /// The private settings files written for Claude and Pi. + pub files: &'a [std::path::PathBuf], + /// Pi's private agent directory. + pub private: &'a Path, + /// Claude's session UUID. Choosing it here makes the transcript path known before the + /// session starts. + pub native_session_id: Option<&'a str>, + /// Instructions the harness reads before the first turn: Codex `developer_instructions`, + /// Claude `--append-system-prompt`. A hand-off points at its file here. + pub instructions: Option<&'a str>, + /// Directories outside the session directory that Claude may read without asking. + pub read_dirs: &'a [std::path::PathBuf], + /// A first prompt that starts the session's first turn at once. + pub first_prompt: Option<&'a str>, +} + +/// Claude Code accepts a UUID for `--session-id`. Its own session IDs are version 4, so the +/// bytes of a time-ordered UUID are relabeled as version 4 rather than enabling another +/// `uuid` feature. +pub fn claude_session_id() -> String { + uuid::Builder::from_random_bytes(*uuid::Uuid::now_v7().as_bytes()) + .into_uuid() + .to_string() +} + +fn create_worktree( + project: &Path, + id: &str, + revision: &str, +) -> Result<(std::path::PathBuf, std::path::PathBuf, PendingSession), String> { + let worktree = session_root()?.join(id); + let parent = worktree + .parent() + .ok_or("Worktree needs a parent directory")? + .to_path_buf(); + std::fs::create_dir_all(&parent).map_err(|e| e.to_string())?; + let output = std::process::Command::new("git") + .arg("-C") + .arg(project) + .args(["worktree", "add", "-b"]) + .arg(id) + .arg(&worktree) + .arg(revision) + .output() + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(format!( + "Could not create worktree: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + let private = parent.join(format!("{id}-settings")); + let pending = PendingSession { + project: project.to_path_buf(), + worktree: worktree.clone(), + private: private.clone(), + branch: id.to_string(), + revision: revision.into(), + opened: false, + }; + Ok((worktree, private, pending)) +} + +pub fn launch( + tool: Harness, + project: &Path, + model: &str, + root: &str, + login: bool, + account: Option<&Path>, + revision: &str, +) -> Result { + let root = harness::validate(tool, root, model, login)?; + if let Some(account) = account { + crate::accounts::validate(tool, account)?; + } + if tool == Harness::CodexApp { + return Err("Choose Codex CLI to launch a worktree session. \ + Codex app uses its own workspace controls." + .into()); + } + let binary = harness::binary(tool).ok_or("Install the selected harness first.")?; + if project.as_os_str().is_empty() { + return Err("Choose a Git checkout directory.".into()); + } + let project = crate::config::expand_home(project)?; + let project = std::fs::canonicalize(&project).map_err(|e| format!("Open project: {e}"))?; + let output = std::process::Command::new("git") + .arg("-C") + .arg(&project) + .args(["rev-parse", "--show-toplevel"]) + .output() + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err("Choose a Git checkout directory.".into()); + } + let repository = Path::new( + std::str::from_utf8(&output.stdout) + .map_err(|error| error.to_string())? + .trim(), + ) + .to_path_buf(); + let repository = std::fs::canonicalize(repository).map_err(|error| error.to_string())?; + let relative = project + .strip_prefix(&repository) + .map_err(|error| error.to_string())? + .to_path_buf(); + let id = format!("sy-{}", uuid::Uuid::now_v7()); + // Path resolution must succeed before launch creates directories or a worktree. + harness::paths(tool)?; + let (worktree, private, mut pending) = create_worktree(&project, &id, revision)?; + let files = match tool { + Harness::Pi => vec![private.join("models.json"), private.join("settings.json")], + _ => vec![private.join("settings.json")], + }; + std::fs::create_dir_all(&private).map_err(|error| error.to_string())?; + if matches!(tool, Harness::Claude | Harness::Pi) { + harness::install(tool, &files, &root, model, None, login)?; + } + let native_session_id = (tool == Harness::Claude).then(claude_session_id); + let created_at = chrono::Utc::now().to_rfc3339(); + // `launches` records every harness opened in this worktree. The top-level `tool`, + // `route`, `account_directory`, and `settings_directory` mirror the latest launch. + let launched = serde_json::json!({ + "tool": tool, + "route": model, + "account_directory": account, + "settings_directory": private, + "started_at": created_at, + "native_session_id": native_session_id, + }); + let receipt = serde_json::json!({ + "id": id, + "tool": tool, + "route": model, + "repository": repository, + "revision": preview(&worktree)?["revision"], + "worktree": worktree, + "branch": id, + "relative_directory": relative, + "created_at": created_at, + "settings_directory": private, + "account_directory": account, + "launches": [launched], + }); + std::fs::write( + private.join("session.json"), + serde_json::to_vec_pretty(&receipt).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + let account = script_account(tool, account)?; + open_harness(&OpenRequest { + tool, + binary, + directory: worktree.join(&relative), + model, + root: &root, + login, + account: account.as_deref(), + session_id: &id, + files: &files, + private: &private, + native_session_id: native_session_id.as_deref(), + instructions: None, + read_dirs: &[], + first_prompt: None, + })?; + pending.opened = true; + Ok(format!( + "Opened Terminal to start the session.\n\ + Worktree: {}\n\ + Branch: {id}\n\ + Model route: {model}\n\ + Remove the worktree with git worktree remove after saving or discarding its changes.", + worktree.display() + )) +} + +/// This records another harness opened in a managed session's worktree. The top-level `tool`, +/// `route`, `account_directory`, and `settings_directory` follow the latest launch. +pub fn append_launch(id: &str, launched: &serde_json::Value) -> Result<(), String> { + let mut receipt = managed_session(id)?; + let path = session_root()?.join(format!("{id}-settings/session.json")); + let launches = receipt + .as_object_mut() + .ok_or("The session receipt does not match this worktree.")? + .entry("launches") + .or_insert_with(|| serde_json::json!([])); + launches + .as_array_mut() + .ok_or("The session receipt lists launches in an unexpected form.")? + .push(launched.clone()); + for key in ["tool", "route", "account_directory", "settings_directory"] { + receipt[key] = launched[key].clone(); + } + std::fs::write( + &path, + serde_json::to_vec_pretty(&receipt).map_err(|error| error.to_string())?, + ) + .map_err(|error| format!("Write {}: {error}", path.display())) +} + +/// The arguments and environment that start a harness. `open_harness` runs them in a terminal; +/// a review window shows them first. +pub struct Invocation { + pub args: Vec, + pub environment: Vec<(String, String)>, + pub remove_environment: Vec, +} + +/// The login directory the launch exports: the named account, or the harness's current +/// directory. Claude keeps an unset `CLAUDE_CONFIG_DIR` unset, so its default login stays +/// the user's default. +pub fn script_account( + tool: Harness, + account: Option<&Path>, +) -> Result, String> { + if let Some(account) = account { + return Ok(Some(account.to_path_buf())); + } + let default_files = harness::paths(tool)?; + Ok(match tool { + Harness::Claude if std::env::var_os("CLAUDE_CONFIG_DIR").is_none() => None, + _ => default_files[0].parent().map(Path::to_path_buf), + }) +} + +/// This builds the harness command for `request` without opening anything. +pub fn invocation(request: &OpenRequest) -> Result { + let OpenRequest { + tool, + binary: _, + directory: _, + model, + root, + login, + account, + session_id, + files, + private, + native_session_id, + instructions, + read_dirs, + first_prompt, + } = request; + let (tool, model, login, session_id) = (*tool, *model, *login, *session_id); + let mut codex_args = vec![ + "-c".to_string(), + format!("model={}", toml_edit::Value::from(model)), + "-c".into(), + "model_provider=\"sy\"".into(), + "-c".into(), + format!( + "model_providers.sy={{name=\"Switchyard\",base_url={},wire_api=\"responses\",\ + requires_openai_auth={login},http_headers={{\"x-switchyard-session-id\"={},\ + \"x-switchyard-origin\"=\"codex\"}}}}", + toml_edit::Value::from(format!("{}/v1", root.trim_end_matches('/'))), + toml_edit::Value::from(session_id) + ), + ]; + if let Some(instructions) = instructions { + codex_args.extend([ + "-c".to_string(), + format!( + "developer_instructions={}", + toml_edit::Value::from(*instructions) + ), + ]); + } + if let Some(prompt) = first_prompt { + codex_args.push((*prompt).to_string()); + } + // `--add-dir` takes every following word as a directory, so it goes first and a flag that + // takes one value closes it. The prompt is the last word. + let mut claude_args = Vec::new(); + for dir in read_dirs.iter() { + claude_args.extend(["--add-dir".to_string(), dir.display().to_string()]); + } + claude_args.extend([ + "--settings".to_string(), + files[0].display().to_string(), + "--model".into(), + model.into(), + ]); + if let Some(native) = native_session_id { + claude_args.extend(["--session-id".to_string(), (*native).to_string()]); + } + if let Some(instructions) = instructions { + claude_args.extend([ + "--append-system-prompt".to_string(), + (*instructions).to_string(), + ]); + } + if let Some(prompt) = first_prompt { + claude_args.push((*prompt).to_string()); + } + let mut environment = Vec::new(); + let mut remove_environment = Vec::new(); + let args = match tool { + Harness::CodexCli => codex_args, + Harness::Claude => { + environment.push(( + "ANTHROPIC_CUSTOM_HEADERS".to_string(), + format!("x-switchyard-session-id: {session_id}\nx-switchyard-origin: claude"), + )); + if login { + remove_environment.extend( + [ + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN", + ] + .map(String::from), + ); + } + claude_args + } + Harness::Pi => { + environment.push(( + "PI_CODING_AGENT_DIR".to_string(), + private.display().to_string(), + )); + vec![ + "--provider".into(), + "switchyard".into(), + "--model".into(), + model.into(), + ] + } + Harness::CodexApp => return Err("Choose a CLI harness.".into()), + }; + if let Some(path) = account { + match tool { + Harness::CodexCli => { + environment.push(("CODEX_HOME".into(), path.display().to_string())) + } + Harness::Claude => { + environment.push(("CLAUDE_CONFIG_DIR".into(), path.display().to_string())) + } + _ => {} + } + } + Ok(Invocation { + args, + environment, + remove_environment, + }) +} + +/// The shell lines that `open_harness` runs, for a review window. Windows runs the same +/// program, arguments, and environment through its own console. +pub fn display_command(request: &OpenRequest) -> Result { + let invocation = invocation(request)?; + let mut text = format!("cd {}", quote(&request.directory.display().to_string())); + for (name, value) in &invocation.environment { + text.push_str(&format!("\nexport {name}={}", quote(value))); + } + if !invocation.remove_environment.is_empty() { + text.push_str(&format!( + "\nunset {}", + invocation.remove_environment.join(" ") + )); + } + text.push('\n'); + text.push_str("e(&request.binary.display().to_string())); + for arg in &invocation.args { + text.push(' '); + text.push_str("e(arg)); + } + Ok(text) +} + +/// This opens the harness in `request.directory` through the user's terminal. +pub fn open_harness(request: &OpenRequest) -> Result<(), String> { + let invocation = invocation(request)?; + #[cfg(windows)] + { + crate::windows::open_terminal(crate::windows::TerminalJob { + binary: request.binary.clone(), + args: invocation.args, + directory: Some(request.directory.clone()), + environment: invocation.environment, + remove_environment: invocation.remove_environment, + })?; + } + #[cfg(not(windows))] + { + let mut environment = String::new(); + for (name, value) in &invocation.environment { + environment.push_str(&format!("export {name}={}\n", quote(value))); + } + if !invocation.remove_environment.is_empty() { + environment.push_str(&format!( + "unset {}\n", + invocation.remove_environment.join(" ") + )); + } + let command = std::iter::once(quote(&request.binary.display().to_string())) + .chain(invocation.args.iter().map(|arg| quote(arg))) + .collect::>() + .join(" "); + let script = format!( + "#!/bin/bash\n\ + export PATH=\"$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH\"\n\ + rm -- \"$0\"\n\ + cd {} || exit 1\n\ + {environment}{command}", + quote(&request.directory.display().to_string()) + ); + crate::server::open_terminal_script("switchyard-session-", &script)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Claude Code's `--add-dir` swallows every following word as a directory, which once made + // the first prompt a "path not found". Codex takes one value per `-c`. + #[test] + fn the_first_prompt_is_the_last_word_and_no_list_option_precedes_it() { + let dir = std::path::PathBuf::from("/tmp/hd-1"); + let files = vec![dir.join("settings.json")]; + let request = |tool: Harness| OpenRequest { + tool, + binary: std::path::PathBuf::from("/usr/bin/harness"), + directory: std::path::PathBuf::from("/work"), + model: "claude", + root: "http://127.0.0.1:4123", + login: true, + account: None, + session_id: "sy-1", + files: &files, + private: &dir, + native_session_id: Some("00000000-0000-4000-8000-000000000001"), + instructions: Some("Read the file first."), + read_dirs: std::slice::from_ref(&dir), + first_prompt: Some("Read the hand-off file and continue."), + }; + let claude = invocation(&request(Harness::Claude)).expect("claude").args; + assert_eq!(&claude[..2], &["--add-dir", "/tmp/hd-1"]); + assert_eq!(claude[2], "--settings"); + assert_eq!( + claude.last().map(String::as_str), + Some("Read the hand-off file and continue.") + ); + assert_eq!(claude[claude.len() - 3], "--append-system-prompt"); + let codex = invocation(&request(Harness::CodexCli)).expect("codex").args; + assert_eq!( + codex.last().map(String::as_str), + Some("Read the hand-off file and continue.") + ); + assert!( + codex[codex.len() - 2].starts_with("developer_instructions="), + "{codex:?}" + ); + } + + #[test] + fn failed_session_setup_removes_its_worktree_branch_and_private_settings() { + let dir = tempfile::tempdir().expect("directory"); + let project = dir.path().join("project"); + std::fs::create_dir(&project).expect("project"); + let git = |args: &[&str]| { + let output = std::process::Command::new("git") + .arg("-C") + .arg(&project) + .args(args) + .output() + .expect("git"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + output + }; + git(&["init"]); + git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "fixture", + ]); + let worktree = dir.path().join("worktree"); + let private = dir.path().join("settings"); + git(&[ + "worktree", + "add", + "-b", + "session-fixture", + worktree.to_str().expect("path"), + ]); + std::fs::create_dir(&private).expect("private"); + std::fs::write(private.join("settings.json"), "{}").expect("settings"); + let revision = String::from_utf8_lossy(&git(&["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + // An unrelated HEAD in the original checkout makes `branch -d` refuse the session branch. + git(&["checkout", "--orphan", "unrelated"]); + git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "unrelated", + ]); + drop(PendingSession { + project: project.clone(), + worktree: worktree.clone(), + private: private.clone(), + branch: "session-fixture".into(), + revision, + opened: false, + }); + assert!(!worktree.exists()); + assert!(!private.exists()); + assert!( + !String::from_utf8_lossy(&git(&["branch", "--list", "session-fixture"]).stdout) + .contains("session-fixture") + ); + } + // The subprocess isolates HOME and PATH. + // The Terminal stub injects a conflicting directory when the script must select one. + // Claude's default case requires an unset CLAUDE_CONFIG_DIR, not an explicit ~/.claude. + #[cfg(unix)] + #[test] + fn generated_sessions_use_explicit_accounts_without_changing_user_defaults() { + use std::os::unix::fs::PermissionsExt; + if let Ok(fixture) = std::env::var("SWITCHYARD_SESSION_FIXTURE") { + let fixture = std::path::PathBuf::from(fixture); + let tool = if std::env::var("SWITCHYARD_SESSION_TOOL").expect("tool") == "codex" { + Harness::CodexCli + } else { + Harness::Claude + }; + let account = + std::env::var_os("SWITCHYARD_SESSION_ACCOUNT").map(std::path::PathBuf::from); + let revision = preview(&fixture.join("project")).expect("preview")["revision"] + .as_str() + .expect("revision") + .to_string(); + // Advancing the original checkout makes a launch from HEAD differ from the review. + if tool == Harness::CodexCli && account.is_none() { + let committed = std::process::Command::new("git") + .arg("-C") + .arg(fixture.join("project")) + .args([ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "advanced", + ]) + .output() + .expect("advance HEAD"); + assert!(committed.status.success()); + } + launch( + tool, + &fixture.join("project"), + "route'$(literal)", + "http://localhost:4123", + tool == Harness::Claude, + account.as_deref(), + &revision, + ) + .expect("launch"); + if tool == Harness::CodexCli && account.is_none() { + let records = inventory().expect("inventory"); + assert_eq!(records.len(), 1); + assert_eq!(records[0]["revision"], revision); + assert_eq!(records[0]["relative_directory"], ""); + let launches = records[0]["launches"].as_array().expect("launches"); + assert_eq!(launches.len(), 1); + assert_eq!(launches[0]["tool"], "codex_cli"); + assert_eq!(launches[0]["route"], records[0]["route"]); + assert_eq!(launches[0]["started_at"], records[0]["created_at"]); + assert!(launches[0]["native_session_id"].is_null()); + assert_ne!( + preview(&fixture.join("project")).expect("advanced project")["revision"], + revision + ); + let id = records[0]["id"].as_str().expect("session id"); + let receipt = session_root() + .expect("session root") + .join(format!("{id}-settings/session.json")); + let original = std::fs::read(&receipt).expect("receipt"); + // Valid JSON roots must still be objects before inventory adds process_status. + for malformed in ["null", "[]", "false", "42"] { + std::fs::write(&receipt, malformed).expect("malformed receipt"); + assert!(inventory().is_err()); + assert!(cleanup_preview(id).is_err()); + } + std::fs::write(&receipt, original).expect("restore receipt"); + let clean = cleanup_preview(id).expect("preview cleanup"); + let worktree = Path::new(records[0]["worktree"].as_str().expect("worktree")); + let unsaved = worktree.join("unsaved-user-work.txt"); + std::fs::write(&unsaved, "keep this work").expect("user work"); + assert!(cleanup(id, &clean).is_err()); + assert!(unsaved.exists()); + let dirty = cleanup_preview(id).expect("dirty preview"); + assert!(cleanup(id, &dirty).is_err()); + assert!(worktree.exists()); + std::fs::remove_file(&unsaved).expect("save fixture work"); + let clean = cleanup_preview(id).expect("clean preview"); + // Cleanup deletes ignored files, so a new ignored file requires a new review. + let info = fixture.join("project/.git/info"); + std::fs::create_dir_all(&info).expect("git info"); + std::fs::write(info.join("exclude"), "ignored-notes.txt\n").expect("exclude"); + std::fs::write(worktree.join("ignored-notes.txt"), "notes").expect("notes"); + assert!(cleanup(id, &clean).is_err()); + assert!(worktree.join("ignored-notes.txt").exists()); + let reviewed = cleanup_preview(id).expect("ignored preview"); + assert_eq!( + reviewed["ignored"], + serde_json::json!(["ignored-notes.txt"]) + ); + // An unrelated HEAD in the original checkout makes `branch -d` refuse the branch. + let project_git = |args: &[&str]| { + let output = std::process::Command::new("git") + .arg("-C") + .arg(fixture.join("project")) + .args(args) + .output() + .expect("git"); + assert!(output.status.success()); + output + }; + project_git(&["checkout", "--orphan", "unrelated"]); + project_git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "unrelated", + ]); + cleanup(id, &reviewed).expect("safe cleanup"); + assert!(!worktree.exists()); + assert!(inventory().expect("inventory after cleanup").is_empty()); + assert!(project_git(&["branch", "--list", id]).stdout.is_empty()); + } + return; + } + for (name, named, inherited_dir) in [ + ("codex", false, true), + ("codex", true, true), + ("claude", false, true), + ("claude", true, true), + ("claude", false, false), + ("claude", true, false), + ] { + let fixture = tempfile::tempdir().expect("fixture"); + let home = fixture.path().join("home"); + let bin = fixture.path().join("bin"); + let project = fixture.path().join("project"); + for path in [&home, &bin, &project] { + std::fs::create_dir(path).expect("directory"); + } + for args in [ + vec!["init"], + vec![ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "fixture", + ], + ] { + assert!( + std::process::Command::new("git") + .arg("-C") + .arg(&project) + .args(args) + .output() + .expect("git") + .status + .success() + ); + } + let default = home.join("default-login"); + std::fs::create_dir(&default).expect("default"); + std::fs::write(default.join("settings.json"), "{\"user\":true}").expect("settings"); + let account = home.join(".switchyard/accounts").join(name).join("named"); + if named { + std::fs::create_dir_all(&account).expect("account"); + } + let output = fixture.path().join("arguments"); + let captured = fixture.path().join("session.command"); + // macOS runs the script through `open`; Linux runs it through the program in + // TERMINAL, which receives `-e` before the script path. + let open = bin.join(if cfg!(target_os = "linux") { + "terminal" + } else { + "open" + }); + let terminal_claude = if inherited_dir || named { + "export CLAUDE_CONFIG_DIR=wrong-terminal-login" + } else { + "unset CLAUDE_CONFIG_DIR" + }; + std::fs::write( + &open, + format!( + "#!/bin/bash\n\ + [ \"$1\" = -e ] && shift\n\ + cp -- \"$1\" {}\n\ + export CODEX_HOME=wrong-terminal-login ANTHROPIC_AUTH_TOKEN=wrong-token\n\ + {terminal_claude}\n\ + bash \"$1\"\n", + quote(&captured.display().to_string()) + ), + ) + .expect("open"); + let tool = bin.join(name); + std::fs::write( + &tool, + format!( + "#!/bin/bash\n\ + printf '%s\\n' \"${{CODEX_HOME:-}}\" \"${{CLAUDE_CONFIG_DIR:-}}\" \ + \"${{ANTHROPIC_AUTH_TOKEN:-}}\" \"$@\" > {0}.partial && mv {0}.partial {0}\n", + quote(&output.display().to_string()) + ), + ) + .expect("tool"); + for path in [&open, &tool] { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .expect("executable"); + } + let mut child = std::process::Command::new(std::env::current_exe().expect("runner")); + child + .args([ + "--exact", + "sessions::tests::\ + generated_sessions_use_explicit_accounts_without_changing_user_defaults", + ]) + .env("SWITCHYARD_SESSION_FIXTURE", fixture.path()) + .env("SWITCHYARD_SESSION_TOOL", name) + .env("HOME", &home) + .env("CODEX_HOME", &default) + .env("CLAUDE_CONFIG_DIR", &default) + .env("PATH", format!("{}:/usr/bin:/bin", bin.display())) + .env("TERMINAL", &open); + if named { + child.env("SWITCHYARD_SESSION_ACCOUNT", &account); + } + if !inherited_dir { + child.env_remove("CLAUDE_CONFIG_DIR"); + } + let result = child.output().expect("child"); + assert!( + result.status.success(), + "{}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + // A Linux terminal starts apart from the launch, so the arguments can arrive + // after the child exits. The tool stub renames the file into place when done. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !output.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + let arguments = std::fs::read_to_string(output).expect("arguments"); + let lines: Vec<_> = arguments.lines().collect(); + let expected = if named { + account.to_str().expect("path") + } else if inherited_dir { + default.to_str().expect("path") + } else { + "" + }; + assert_eq!(lines[usize::from(name == "claude")], expected); + if name == "claude" { + assert_eq!(lines[2], ""); + } + assert!(arguments.contains("route'$(literal)")); + let script = std::fs::read_to_string(captured).expect("script"); + assert!(script.contains("x-switchyard-session-id")); + // The receipt names the Claude session UUID that the launch passed on the command + // line, so the transcript path is known without reading any transcript. + let receipts: Vec<_> = std::fs::read_dir(home.join(".switchyard/worktrees")) + .expect("worktrees") + .filter_map(|entry| { + let path = entry.expect("entry").path().join("session.json"); + std::fs::read(&path).ok().map(|bytes| { + serde_json::from_slice::(&bytes).expect("json") + }) + }) + .collect(); + if name == "claude" { + assert_eq!(receipts.len(), 1); + let native = receipts[0]["launches"][0]["native_session_id"] + .as_str() + .expect("claude session id"); + assert_eq!( + uuid::Uuid::parse_str(native) + .expect("uuid") + .get_version_num(), + 4 + ); + assert!(arguments.contains(&format!("--session-id\n{native}\n"))); + } else { + // The Codex case without an account cleans its session up in the child. + assert_eq!(receipts.len(), usize::from(named)); + assert!( + receipts + .iter() + .all(|receipt| { receipt["launches"][0]["native_session_id"].is_null() }) + ); + assert!(!arguments.contains("--session-id")); + } + if name == "claude" && !named && !inherited_dir { + assert!(!script.contains("export CLAUDE_CONFIG_DIR=")); + } + assert_eq!( + std::fs::read_to_string(default.join("settings.json")).expect("settings"), + "{\"user\":true}" + ); + } + } +} diff --git a/crates/switchyard-desktop/src/summary.rs b/crates/switchyard-desktop/src/summary.rs index 2e143bdcf5..5250c8f464 100644 --- a/crates/switchyard-desktop/src/summary.rs +++ b/crates/switchyard-desktop/src/summary.rs @@ -1,8 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! This module turns totals and recorded costs into usage summary rows. +//! This module turns totals and recorded costs into the usage summary that the tray and +//! `--print` show. The terminal app uses its count, money, and missing-savings wording too. +use serde::Serialize; use switchyard_costs::Pricing; use crate::pricing::{Estimate, Savings}; @@ -12,110 +14,186 @@ use switchyard_desktop::health::ServerStatus; const MAX_MODEL_ROWS: usize = 5; -/// This function builds the menu body for the current day and trailing week. -pub fn build(status: ServerStatus, usage: &Usage, config: &Config, rates: &Pricing) -> Vec { - let state = match status { - ServerStatus::Running => "running", - ServerStatus::Stopped => "not responding", - }; - let mut rows = vec![ - format!("Server: {state} · {}", config.server_url), - String::new(), - ]; +/// Overview holds the server state, today's and this week's traffic, and the share of +/// this week's tokens that each model answered. +#[derive(Debug, Serialize)] +pub struct Overview { + pub running: bool, + pub server_url: String, + /// This field explains why the overview has no periods. + pub empty: Option, + pub periods: Vec, + pub models: Vec, + /// This field explains why savings are missing. + pub note: Option, +} - if usage.week.is_empty() { - rows.push(if usage.all.is_empty() { - "No requests recorded yet".into() - } else { - "No requests recorded in the last 7 days".into() - }); - return rows; - } +/// Period holds one period's traffic, spending, and savings as display text. +#[derive(Debug, Serialize)] +pub struct Period { + pub name: &'static str, + pub traffic: String, + pub spent: String, + pub saved: Option, +} - rows.extend(period("Today", &usage.today, config, rates)); - rows.push(String::new()); - rows.extend(period("This week", &usage.week, config, rates)); +/// Share holds one model's percentage of this week's answer tokens. +#[derive(Debug, Serialize)] +pub struct Share { + pub model: String, + pub percent: f64, +} - let models = model_shares(&usage.week); - if !models.is_empty() { - rows.push(String::new()); - rows.push("This week by model".into()); - rows.extend(models); +impl Overview { + pub fn new(status: ServerStatus, usage: &Usage, config: &Config, rates: &Pricing) -> Self { + let mut overview = Self { + running: status == ServerStatus::Running, + server_url: config.server_url.clone(), + empty: None, + periods: Vec::new(), + models: Vec::new(), + note: None, + }; + if usage.week.is_empty() { + overview.empty = Some(if usage.all.is_empty() { + "No requests recorded yet".into() + } else { + "No requests recorded in the last 7 days".into() + }); + return overview; + } + let estimate = |totals: &Totals| Estimate::new(totals, rates, &config.baseline_model); + let week = estimate(&usage.week); + overview.periods = vec![ + period("Today", &usage.today, &estimate(&usage.today)), + period("This week", &usage.week, &week), + ]; + overview.models = model_shares(&usage.week); + overview.note = savings_note(&week, config, rates); + overview } - let unknown = usage.week.cost().unknown_calls; - let uncounted: u64 = usage - .week - .routed - .values() - .map(|tokens| tokens.uncounted) - .sum(); - if !rates.contains(&config.baseline_model) { - rows.push(String::new()); - rows.push(match config.prices_file.as_ref() { - Some(path) => format!("Add {} to {} to see savings", config.baseline_model, path.display()), - None => format!("Bundled prices have no rates for {}. Set pricing_file to a custom table to see savings.", config.baseline_model), - }); - } else if unknown > 0 { - rows.push(String::new()); - rows.push(format!( - "Savings hidden for this week: {unknown} {} no recorded cost", - if unknown == 1 { - "call has" - } else { - "calls have" - } - )); - } else if uncounted > 0 { - rows.push(String::new()); - let answers = if uncounted == 1 { - "answer has" + /// This function formats the overview as text lines; an empty line separates groups. + pub fn rows(&self) -> Vec { + let state = if self.running { + "running" } else { - "answers have" + "not responding" }; - rows.push(format!( - "Savings hidden for this week: {uncounted} {answers} no token counts" - )); + let mut rows = vec![ + format!("Server: {state} · {}", self.server_url), + String::new(), + ]; + if let Some(empty) = &self.empty { + rows.push(empty.clone()); + return rows; + } + for (index, period) in self.periods.iter().enumerate() { + if index > 0 { + rows.push(String::new()); + } + rows.push(format!("{} — {}", period.name, period.traffic)); + rows.push(format!(" {}", period.spent)); + if let Some(saved) = &period.saved { + rows.push(format!(" {saved}")); + } + } + if !self.models.is_empty() { + rows.push(String::new()); + rows.push("This week by model".into()); + rows.extend( + self.models + .iter() + .map(|share| format!(" {} — {:.0}%", share.model, share.percent)), + ); + } + if let Some(note) = &self.note { + rows.push(String::new()); + rows.push(note.clone()); + } + rows } - rows +} + +/// This function builds the summary rows for the current day and trailing week. +pub fn build(status: ServerStatus, usage: &Usage, config: &Config, rates: &Pricing) -> Vec { + Overview::new(status, usage, config, rates).rows() } /// Each period shows its complete cost or labels its known subtotal as incomplete. -fn period(name: &str, totals: &Totals, config: &Config, rates: &Pricing) -> Vec { - let requests = totals.requests(); - let mut rows = vec![format!( - "{name} — {requests} request{} · {} tokens", - if requests == 1 { "" } else { "s" }, - tokens(totals.tokens()) - )]; - rows.extend(cost_rows(&Estimate::new( - totals, - rates, - &config.baseline_model, - ))); - rows +fn period(name: &'static str, totals: &Totals, estimate: &Estimate) -> Period { + let (spent, saved) = cost_rows(estimate); + Period { + name, + traffic: format!( + "{} · {} tokens", + plural(totals.requests(), "request", "requests"), + tokens(totals.tokens()) + ), + spent, + saved, + } } -/// This function formats a period's recorded spending and, when it is known, the savings. -pub fn cost_rows(estimate: &Estimate) -> Vec { - let mut rows = vec![match estimate.actual { - Some(actual) => format!(" Spent {}", money(actual)), +/// This function formats a period's recorded spending and, when it is known, the savings, +/// for the tray and `--print`. +fn cost_rows(estimate: &Estimate) -> (String, Option) { + let spent = match estimate.actual { + Some(actual) => format!("Spent {}", money(actual)), None if estimate.has_known_cost => format!( - " Known spending {} · total unknown ({} {} no recorded cost)", + "Known spending {} · total unknown ({} no recorded cost)", money(estimate.known), - estimate.unknown_calls, - if estimate.unknown_calls == 1 { - "call has" - } else { - "calls have" - }, + plural(estimate.unknown_calls, "call has", "calls have"), ), - None => " Spending unknown".into(), - }]; - if let Some(savings) = estimate.savings() { - rows.push(format!(" Saved {}", saved(&savings))); + None => "Spending unknown".into(), + }; + let saved = estimate + .savings() + .map(|savings| format!("Saved {}", saved(&savings))); + (spent, saved) +} + +/// This function explains why this week's savings are missing. A price table without the +/// baseline model gets advice about the price file; `hidden_savings` words the other reasons. +fn savings_note(week: &Estimate, config: &Config, rates: &Pricing) -> Option { + if !rates.contains(&config.baseline_model) { + return Some(match config.prices_file.as_ref() { + Some(path) => format!( + "Add {} to {} to see savings", + config.baseline_model, + path.display() + ), + None => format!( + "Bundled prices have no rates for {}. Set pricing_file to a custom table to see savings.", + config.baseline_model + ), + }); + } + week.savings().is_none().then(|| { + format!( + "Savings hidden for this week: {}", + hidden_savings(week, &config.baseline_model) + ) + }) +} + +/// This function explains why `Estimate::savings` has no figure. The tray, `--print`, and the +/// terminal app all call it, so they give the same reason. Call it only when the savings are +/// hidden. +pub(crate) fn hidden_savings(estimate: &Estimate, baseline_model: &str) -> String { + if estimate.baseline.is_none() { + format!("the price table has no rates for {baseline_model}") + } else if estimate.unknown_calls > 0 { + format!( + "{} no recorded cost", + plural(estimate.unknown_calls, "call has", "calls have") + ) + } else { + format!( + "{} no token counts", + plural(estimate.uncounted_answers, "answer has", "answers have") + ) } - rows } fn saved(savings: &Savings) -> String { @@ -126,8 +204,7 @@ fn saved(savings: &Savings) -> String { } } -/// The summary ranks up to five answer models by token share and excludes classifier calls. -fn model_shares(week: &Totals) -> Vec { +fn model_shares(week: &Totals) -> Vec { let total: u64 = week.routed.values().map(|tokens| tokens.total()).sum(); if total == 0 { return Vec::new(); @@ -142,14 +219,35 @@ fn model_shares(week: &Totals) -> Vec { models .into_iter() .take(MAX_MODEL_ROWS) - .map(|(model, count)| format!(" {model} — {:.0}%", count as f64 / total as f64 * 100.0)) + .map(|(model, count)| Share { + model: model.clone(), + percent: count as f64 / total as f64 * 100.0, + }) .collect() } +/// This function separates thousands with commas, so a count such as 2,062 reads at a glance. +pub(crate) fn grouped(value: u64) -> String { + let digits = value.to_string(); + let mut text = String::with_capacity(digits.len() + digits.len() / 3); + for (index, digit) in digits.chars().enumerate() { + if index > 0 && (digits.len() - index).is_multiple_of(3) { + text.push(','); + } + text.push(digit); + } + text +} + +/// This function writes a grouped count with the singular or plural words that follow it. +pub(crate) fn plural(value: u64, one: &str, many: &str) -> String { + format!("{} {}", grouped(value), if value == 1 { one } else { many }) +} + /// This function formats a token count compactly, since exact totals are not useful here. pub(crate) fn tokens(value: u64) -> String { match value { - 0..=9_999 => value.to_string(), + 0..=9_999 => grouped(value), 10_000..=999_999 => format!("{:.0}K", value as f64 / 1_000.0), 1_000_000..=999_999_999 => format!("{:.1}M", value as f64 / 1_000_000.0), _ => format!("{:.1}B", value as f64 / 1_000_000_000.0), @@ -157,11 +255,9 @@ pub(crate) fn tokens(value: u64) -> String { } pub(crate) fn money(value: f64) -> String { - if value < 0.0 { - format!("-${:.2}", -value) - } else { - format!("${value:.2}") - } + let cents = (value.abs() * 100.0).round() as u64; + let sign = if value < 0.0 { "-" } else { "" }; + format!("{sign}${}.{:02}", grouped(cents / 100), cents % 100) } #[cfg(test)] diff --git a/crates/switchyard-desktop/src/transcripts.rs b/crates/switchyard-desktop/src/transcripts.rs new file mode 100644 index 0000000000..b46256d42f --- /dev/null +++ b/crates/switchyard-desktop/src/transcripts.rs @@ -0,0 +1,1524 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module reads the transcripts that Claude Code and Codex keep for their sessions and +//! renders a bounded hand-off that another harness can read when it starts. +//! +//! Switchyard never replays a transcript into another harness: the tools, system prompts, and +//! signed reasoning differ between harnesses. It extracts text only: the goal, recent turns, +//! files touched, commands, the last error, and any compaction summary. Reads are bounded to a +//! head window and a tail window, so describing a live multi-megabyte transcript stays cheap. +//! Tool outputs other than the last error never appear in a hand-off. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +/// The harnesses whose transcripts this module reads. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Kind { + Claude, + Codex, +} + +impl Kind { + pub fn label(self) -> &'static str { + match self { + Kind::Claude => "Claude Code", + Kind::Codex => "Codex", + } + } + + /// The directory under the harness config directory that holds transcripts. + pub fn transcript_dir(self, config_dir: &Path) -> PathBuf { + match self { + Kind::Claude => config_dir.join("projects"), + Kind::Codex => config_dir.join("sessions"), + } + } +} + +/// Byte and item limits for one read. Each limit keeps one part of the hand-off bounded. +#[derive(Clone, Debug)] +pub struct Limits { + pub head_bytes: u64, + pub tail_bytes: u64, + pub turns: usize, + pub turn_bytes: usize, + pub goal_bytes: usize, + pub summary_bytes: usize, + pub files: usize, + pub commands: usize, + pub error_lines: usize, + pub max_bytes: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { + head_bytes: 256 * 1024, + tail_bytes: 2 * 1024 * 1024, + turns: 8, + turn_bytes: 2048, + goal_bytes: 2048, + summary_bytes: 8192, + files: 50, + commands: 20, + error_lines: 40, + max_bytes: 32 * 1024, + } + } +} + +/// Listing reads only enough of each transcript to describe its session. +const LIST_HEAD_BYTES: u64 = 64 * 1024; +/// `detect` reads this much to find the first record of an unnamed transcript. +const DETECT_HEAD_BYTES: u64 = 64 * 1024; +const MAX_WALK_DEPTH: usize = 4; +const TITLE_BYTES: usize = 120; + +/// The size and modification time of a transcript when it was read. A later read with a +/// different fingerprint means the session continued. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Fingerprint { + pub len: u64, + pub modified_ms: i64, +} + +pub fn fingerprint(path: &Path) -> Result { + let metadata = + std::fs::metadata(path).map_err(|error| format!("Read {}: {error}", path.display()))?; + Ok(Fingerprint { + len: metadata.len(), + modified_ms: modified_ms(&metadata), + }) +} + +fn modified_ms(metadata: &std::fs::Metadata) -> i64 { + metadata + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map_or(0, |duration| duration.as_millis() as i64) +} + +fn modified_rfc3339(metadata: &std::fs::Metadata) -> Option { + metadata.modified().ok().map(|time| { + chrono::DateTime::::from(time) + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + }) +} + +/// What a transcript says about its session, read from its head window only. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Session { + pub kind: Kind, + pub native_id: String, + pub transcript: PathBuf, + pub cwd: Option, + pub branch: Option, + pub commit: Option, + pub started: Option, + /// The transcript's modification time, as RFC 3339. + pub last_activity: Option, + pub title: Option, + pub model: Option, + pub bytes: u64, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct Turn { + pub prompt: String, + pub answer: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Excerpt { + pub session: Session, + pub goal: Option, + pub summary: Option, + pub turns: Vec, + pub files: Vec, + pub commands: Vec, + pub last_error: Option, + pub notes: Vec, + /// True when a limit dropped text or when the windows did not cover the whole file. + pub truncated: bool, + pub fingerprint: Fingerprint, + pub extracted_at: String, +} + +/// Sessions found under one harness config directory. +#[derive(Debug, Default)] +pub struct Listing { + pub sessions: Vec, + /// Files that were not readable transcripts, such as compressed rollouts. + pub skipped: usize, +} + +/// This lists the sessions whose transcripts live under `config_dir`, newest first. +pub fn list(kind: Kind, config_dir: &Path) -> Result { + let root = kind.transcript_dir(config_dir); + let mut files = Vec::new(); + match kind { + Kind::Claude => { + for project in directories(&root)? { + for entry in std::fs::read_dir(&project).map_err(|error| error.to_string())? { + let path = entry.map_err(|error| error.to_string())?.path(); + if is_regular_file(&path) && path.extension().is_some_and(|e| e == "jsonl") { + files.push(path); + } + } + } + } + Kind::Codex => walk_rollouts(&root, MAX_WALK_DEPTH, &mut files)?, + } + let mut listing = Listing::default(); + for path in files { + match describe_with(kind, &path, LIST_HEAD_BYTES) { + Ok(session) => listing.sessions.push(session), + Err(_) => listing.skipped += 1, + } + } + listing + .sessions + .sort_by(|left, right| right.last_activity.cmp(&left.last_activity)); + Ok(listing) +} + +/// This tells which harness wrote a transcript. Codex rollouts are named `rollout-…` and +/// Claude Code transcripts are named by their session UUID; otherwise the first record decides. +pub fn detect(path: &Path) -> Result { + let stem = path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + if stem.starts_with("rollout-") { + return Ok(Kind::Codex); + } + if looks_like_uuid(&stem) { + return Ok(Kind::Claude); + } + let (head, _) = read_head(path, DETECT_HEAD_BYTES)?; + let first = records(&head, false) + .next() + .ok_or_else(|| format!("{} is not a JSONL transcript.", path.display()))?; + match first["type"].as_str() { + Some("session_meta" | "turn_context" | "event_msg" | "response_item" | "compacted") => { + Ok(Kind::Codex) + } + Some(_) => Ok(Kind::Claude), + None => Err(format!("{} is not a harness transcript.", path.display())), + } +} + +/// This finds the transcript of one session by its harness-native ID. +pub fn locate(kind: Kind, config_dir: &Path, native_id: &str) -> Result { + if native_id.is_empty() + || !native_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return Err("Choose a session.".into()); + } + let root = kind.transcript_dir(config_dir); + match kind { + Kind::Claude => { + for project in directories(&root)? { + let candidate = project.join(format!("{native_id}.jsonl")); + if is_regular_file(&candidate) { + return Ok(candidate); + } + } + } + Kind::Codex => { + let mut files = Vec::new(); + walk_rollouts(&root, MAX_WALK_DEPTH, &mut files)?; + let suffix = format!("-{native_id}.jsonl"); + if let Some(path) = files + .into_iter() + .find(|path| path.to_string_lossy().ends_with(&suffix)) + { + return Ok(path); + } + } + } + Err(format!( + "No {} transcript for session {native_id} under {}.", + kind.label(), + root.display() + )) +} + +fn directories(root: &Path) -> Result, String> { + let entries = match std::fs::read_dir(root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(format!("Read {}: {error}", root.display())), + }; + let mut directories = Vec::new(); + for entry in entries { + let path = entry.map_err(|error| error.to_string())?.path(); + if std::fs::symlink_metadata(&path).is_ok_and(|m| m.is_dir() && !m.file_type().is_symlink()) + { + directories.push(path); + } + } + directories.sort(); + Ok(directories) +} + +fn is_regular_file(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|m| m.is_file() && !m.file_type().is_symlink()) +} + +fn walk_rollouts(dir: &Path, depth: usize, files: &mut Vec) -> Result<(), String> { + if depth == 0 { + return Ok(()); + } + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(format!("Read {}: {error}", dir.display())), + }; + for entry in entries { + let path = entry.map_err(|error| error.to_string())?.path(); + let Ok(metadata) = std::fs::symlink_metadata(&path) else { + continue; + }; + if metadata.file_type().is_symlink() { + continue; + } + if metadata.is_dir() { + walk_rollouts(&path, depth - 1, files)?; + } else if metadata.is_file() { + let name = path + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + if name.starts_with("rollout-") && name.ends_with(".jsonl") { + files.push(path); + } + } + } + files.sort(); + Ok(()) +} + +/// This describes a session from the head of its transcript. +pub fn describe(kind: Kind, path: &Path) -> Result { + describe_with(kind, path, Limits::default().head_bytes) +} + +fn describe_with(kind: Kind, path: &Path, head_bytes: u64) -> Result { + let (head, metadata) = read_head(path, head_bytes)?; + let records: Vec = records(&head, metadata.len() > head_bytes).collect(); + let (session, _) = describe_records(kind, path, &metadata, &records)?; + Ok(session) +} + +/// Returns the session plus the goal found in the head records. +fn describe_records( + kind: Kind, + path: &Path, + metadata: &std::fs::Metadata, + records: &[Value], +) -> Result<(Session, Option), String> { + let mut session = Session { + kind, + native_id: String::new(), + transcript: path.to_path_buf(), + cwd: None, + branch: None, + commit: None, + started: None, + last_activity: modified_rfc3339(metadata), + title: None, + model: None, + bytes: metadata.len(), + }; + let stem = path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let mut goal = None; + match kind { + Kind::Claude => { + session.native_id = stem; + let mut title_rank = 0; + for record in records { + match record["type"].as_str() { + Some("user" | "assistant") => { + if session.cwd.is_none() { + session.cwd = string(record, "cwd").map(PathBuf::from); + } + if session.branch.is_none() { + session.branch = string(record, "gitBranch"); + } + if session.started.is_none() { + session.started = string(record, "timestamp"); + } + if session.model.is_none() { + session.model = record["message"]["model"].as_str().map(str::to_string); + } + if goal.is_none() + && let Some(prompt) = claude_prompt(record) + { + if title_rank < 1 { + session.title = Some(title_of(&prompt)); + title_rank = 1; + } + goal = Some(prompt); + } + } + Some("summary") if title_rank < 2 => { + if let Some(text) = string(record, "summary") { + session.title = Some(title_of(&text)); + title_rank = 2; + } + } + Some("ai-title") if title_rank < 3 => { + if let Some(text) = string(record, "aiTitle") { + session.title = Some(title_of(&text)); + title_rank = 3; + } + } + Some("custom-title") => { + if let Some(text) = string(record, "customTitle") { + session.title = Some(title_of(&text)); + title_rank = 4; + } + } + _ => {} + } + } + } + Kind::Codex => { + // Rollout names end in the thread ID, which stands in until `session_meta` is read. + session.native_id = stem + .rsplit('-') + .next() + .filter(|tail| looks_like_uuid(tail)) + .unwrap_or(&stem) + .to_string(); + for record in records { + let payload = &record["payload"]; + match record["type"].as_str() { + Some("session_meta") => { + if let Some(id) = string(payload, "id") { + session.native_id = id; + } + session.cwd = string(payload, "cwd").map(PathBuf::from); + session.started = + string(payload, "timestamp").or_else(|| string(record, "timestamp")); + session.branch = string(&payload["git"], "branch"); + session.commit = string(&payload["git"], "commit_hash"); + } + Some("turn_context") => { + if session.model.is_none() { + session.model = string(payload, "model"); + } + if session.cwd.is_none() { + session.cwd = string(payload, "cwd").map(PathBuf::from); + } + } + Some("event_msg") if goal.is_none() => { + if payload["type"] == "user_message" + && let Some(message) = string(payload, "message") + { + session.title = Some(title_of(&message)); + goal = Some(message); + } + } + _ => {} + } + } + } + } + if records.is_empty() { + return Err(format!("{} is not a JSONL transcript.", path.display())); + } + Ok((session, goal)) +} + +/// This extracts a bounded excerpt: the goal from the head window and the recent turns, files, +/// commands, last error, and compaction summary from the tail window. +pub fn excerpt(kind: Kind, path: &Path, limits: &Limits) -> Result { + let (head, metadata) = read_head(path, limits.head_bytes)?; + let head_records: Vec = records(&head, metadata.len() > limits.head_bytes).collect(); + let (session, goal) = describe_records(kind, path, &metadata, &head_records)?; + let (tail, tail_start) = read_tail(path, limits.tail_bytes)?; + let tail_records: Vec = records(&tail, false).collect(); + let mut collector = Collector::new(limits); + match kind { + Kind::Claude => collector.claude(&tail_records), + Kind::Codex => collector.codex(&tail_records), + } + if kind == Kind::Codex + && head_records + .iter() + .any(|r| r["type"] == "session_meta" && !r["payload"]["history_base"].is_null()) + { + collector.notes.push( + "Earlier history of this Codex session is stored in another file and was not read." + .into(), + ); + collector.truncated = true; + } + // The head window ends at its last complete line. The windows cover the whole file only + // when the tail window starts at or before that line, so a record that straddles the gap + // between them counts as cut. + let head_end = if metadata.len() > limits.head_bytes { + head.iter() + .rposition(|byte| *byte == b'\n') + .map_or(0, |position| position as u64 + 1) + } else { + head.len() as u64 + }; + let windows_cover_file = tail_start <= head_end; + let (goal, goal_cut) = goal + .map(|text| truncate(&text, limits.goal_bytes)) + .map_or((None, false), |(text, cut)| (Some(text), cut)); + let truncated = collector.truncated || goal_cut || !windows_cover_file; + Ok(Excerpt { + session, + goal, + summary: collector.summary, + turns: collector.turns, + files: collector.files, + commands: collector.commands, + last_error: collector.last_error, + notes: collector.notes, + truncated, + fingerprint: Fingerprint { + len: metadata.len(), + modified_ms: modified_ms(&metadata), + }, + extracted_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + }) +} + +/// This renders the hand-off as Markdown of at most `limits.max_bytes` bytes. +pub fn render(excerpt: &Excerpt, limits: &Limits) -> String { + let session = &excerpt.session; + let mut text = format!( + "# Hand-off from {}\n\n\ + Continue this task in your own tools. First summarize the state in at most five lines, \ + confirm the next step, then continue. Do not redo completed work.\n\n\ + ## Origin\n\n\ + - Harness: {}\n\ + - Session: {}\n", + session.kind.label(), + session.kind.label(), + session.native_id + ); + if let Some(cwd) = &session.cwd { + text.push_str(&format!("- Directory: {}\n", cwd.display())); + } + if let Some(branch) = &session.branch { + text.push_str(&format!("- Branch: {branch}")); + if let Some(commit) = &session.commit { + text.push_str(&format!( + " (commit {})", + commit.chars().take(12).collect::() + )); + } + text.push('\n'); + } + if let Some(started) = &session.started { + text.push_str(&format!("- Started: {started}\n")); + } + if let Some(last) = &session.last_activity { + text.push_str(&format!("- Last activity: {last}\n")); + } + if let Some(model) = &session.model { + text.push_str(&format!("- Model: {model}\n")); + } + text.push_str(&format!( + "- Transcript: {} ({} bytes{})\n", + session.transcript.display(), + session.bytes, + if excerpt.truncated { + "; this hand-off is an excerpt" + } else { + "" + } + )); + if let Some(goal) = &excerpt.goal { + text.push_str(&format!("\n## Goal\n\n{goal}\n")); + } + if let Some(summary) = &excerpt.summary { + text.push_str(&format!("\n## Earlier summary\n\n{summary}\n")); + } + if !excerpt.turns.is_empty() { + text.push_str("\n## Recent turns\n"); + for (index, turn) in excerpt.turns.iter().enumerate() { + let prompt = if turn.prompt.is_empty() { + "(prompt outside the read window)" + } else { + turn.prompt.as_str() + }; + text.push_str(&format!("\n### Turn {} — user\n\n{prompt}\n", index + 1)); + if !turn.answer.is_empty() { + text.push_str(&format!( + "\n### Turn {} — assistant\n\n{}\n", + index + 1, + turn.answer + )); + } + } + } + if !excerpt.files.is_empty() { + text.push_str("\n## Files touched\n\n"); + for file in &excerpt.files { + text.push_str(&format!("- {file}\n")); + } + } + if !excerpt.commands.is_empty() { + text.push_str("\n## Commands\n\n"); + for command in &excerpt.commands { + text.push_str(&format!("- `{}`\n", command.replace('`', "'"))); + } + } + if let Some(error) = &excerpt.last_error { + text.push_str(&format!("\n## Last error\n\n```\n{error}\n```\n")); + } + if !excerpt.notes.is_empty() || excerpt.truncated { + text.push_str("\n## Notes\n\n"); + for note in &excerpt.notes { + text.push_str(&format!("- {note}\n")); + } + if excerpt.truncated { + text.push_str("- Parts of the transcript were cut to keep this hand-off short.\n"); + } + } + cap(text, limits.max_bytes) +} + +fn cap(text: String, max_bytes: usize) -> String { + const MARKER: &str = "\n\n_(hand-off cut at the size limit)_\n"; + if text.len() <= max_bytes { + return text; + } + let mut end = max_bytes.saturating_sub(MARKER.len()); + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + let mut cut = text[..end].to_string(); + cut.push_str(MARKER); + cut +} + +struct Collector<'a> { + limits: &'a Limits, + turns: Vec, + files: Vec, + commands: Vec, + last_error: Option, + summary: Option, + notes: Vec, + truncated: bool, + /// Tool call IDs mapped to the command or tool name that made them. + calls: HashMap, +} + +impl<'a> Collector<'a> { + fn new(limits: &'a Limits) -> Self { + Self { + limits, + turns: Vec::new(), + files: Vec::new(), + commands: Vec::new(), + last_error: None, + summary: None, + notes: Vec::new(), + truncated: false, + calls: HashMap::new(), + } + } + + fn prompt(&mut self, prompt: &str) { + let (prompt, cut) = truncate(prompt, self.limits.turn_bytes); + self.truncated |= cut; + self.turns.push(Turn { + prompt, + answer: String::new(), + }); + if self.turns.len() > self.limits.turns { + self.turns.remove(0); + self.truncated = true; + } + } + + fn answer(&mut self, text: &str) { + if self.turns.is_empty() { + self.turns.push(Turn::default()); + } + let turn = self.turns.last_mut().expect("a turn exists"); + if !turn.answer.is_empty() { + turn.answer.push('\n'); + } + turn.answer.push_str(text.trim()); + if turn.answer.len() > self.limits.turn_bytes { + let (cut, _) = truncate(&turn.answer, self.limits.turn_bytes); + turn.answer = cut; + self.truncated = true; + } + } + + /// Codex records an answer twice: as an `agent_message` event and as a `response_item` + /// message. Whichever comes second is skipped when its text repeats the answer so far. + fn answer_once(&mut self, text: &str) { + let text = text.trim(); + if text.is_empty() + || self + .turns + .last() + .is_some_and(|turn| turn.answer.ends_with(text)) + { + return; + } + self.answer(text); + } + + fn file(&mut self, path: String) { + if path.is_empty() || self.files.contains(&path) { + return; + } + if self.files.len() >= self.limits.files { + self.truncated = true; + return; + } + self.files.push(path); + } + + fn command(&mut self, command: String) { + if command.is_empty() { + return; + } + if self.commands.len() >= self.limits.commands { + self.commands.remove(0); + self.truncated = true; + } + self.commands.push(command); + } + + fn error(&mut self, context: Option<&str>, output: &str) { + let (lines, cut) = first_lines(output, self.limits.error_lines); + self.truncated |= cut; + self.last_error = Some(match context { + Some(context) if !context.is_empty() => format!("$ {context}\n{lines}"), + _ => lines, + }); + } + + fn compaction(&mut self, summary: &str) { + let (summary, cut) = truncate(summary, self.limits.summary_bytes); + self.truncated |= cut; + self.summary = Some(summary); + // The summary stands in for the turns before it. + self.truncated |= !self.turns.is_empty(); + self.turns.clear(); + } + + fn claude(&mut self, records: &[Value]) { + for record in records { + if record["isSidechain"] == true || record["isMeta"] == true { + continue; + } + match record["type"].as_str() { + Some("system") => { + if record["subtype"] == "compact_boundary" { + self.truncated |= !self.turns.is_empty(); + self.turns.clear(); + } + } + Some("user") => { + if record["isCompactSummary"] == true { + if let Some(text) = record["message"]["content"].as_str() { + self.compaction(text); + } else if let Some(text) = text_blocks(&record["message"]["content"]) { + self.compaction(&text); + } + continue; + } + if let Some(prompt) = claude_prompt(record) { + self.prompt(&prompt); + continue; + } + if let Some(blocks) = record["message"]["content"].as_array() { + for block in blocks { + if block["type"] == "tool_result" && block["is_error"] == true { + let context = block["tool_use_id"] + .as_str() + .and_then(|id| self.calls.get(id).cloned()); + let output = block["content"] + .as_str() + .map(str::to_string) + .or_else(|| text_blocks(&block["content"])) + .unwrap_or_default(); + self.error(context.as_deref(), &output); + } + } + } + } + Some("assistant") => { + let Some(blocks) = record["message"]["content"].as_array() else { + continue; + }; + for block in blocks { + match block["type"].as_str() { + Some("text") => { + if let Some(text) = block["text"].as_str() { + self.answer(text); + } + } + Some("tool_use") => { + let name = block["name"].as_str().unwrap_or_default(); + let input = &block["input"]; + let context = match name { + "Edit" | "MultiEdit" | "Write" | "NotebookEdit" => { + let path = string(input, "file_path") + .or_else(|| string(input, "notebook_path")) + .unwrap_or_default(); + self.file(path.clone()); + format!("{name} {path}") + } + "Bash" => { + let command = string(input, "command").unwrap_or_default(); + self.command(command.clone()); + command + } + _ => name.to_string(), + }; + if let Some(id) = block["id"].as_str() { + self.calls.insert(id.to_string(), context); + } + } + _ => {} + } + } + } + _ => {} + } + } + } + + fn codex(&mut self, records: &[Value]) { + for record in records { + let payload = &record["payload"]; + match record["type"].as_str() { + Some("event_msg") => match payload["type"].as_str() { + Some("user_message") => { + if let Some(message) = payload["message"].as_str() { + self.prompt(message); + } + } + Some("agent_message") => { + if let Some(message) = payload["message"].as_str() { + self.answer_once(message); + } + } + Some("exec_command_end") => { + let failed = payload["exit_code"].as_i64().is_some_and(|code| code != 0); + if failed { + let command = command_text(&payload["command"]); + let output = payload["aggregated_output"].as_str().unwrap_or_default(); + self.error(Some(&command), output); + } + } + Some("patch_apply_end") => { + if let Some(changes) = payload["changes"].as_object() { + for path in changes.keys() { + self.file(path.clone()); + } + } + } + _ => {} + }, + Some("response_item") => match payload["type"].as_str() { + Some("function_call") if payload["name"] == "exec_command" => { + let arguments = payload["arguments"] + .as_str() + .and_then(|text| serde_json::from_str::(text).ok()) + .unwrap_or(Value::Null); + let command = command_text(&arguments["cmd"]); + let command = if command.is_empty() { + command_text(&arguments["command"]) + } else { + command + }; + self.command(command); + } + Some("custom_tool_call") if payload["name"] == "apply_patch" => { + for path in patch_files(payload["input"].as_str().unwrap_or_default()) { + self.file(path); + } + } + Some("message") + if payload["role"] == "assistant" && payload["phase"] != "commentary" => + { + if let Some(text) = text_blocks(&payload["content"]) { + self.answer_once(&text); + } + } + _ => {} + }, + Some("compacted") => { + if let Some(message) = payload["message"].as_str() { + self.compaction(message); + } + } + _ => {} + } + } + } +} + +/// A user prompt typed by the user: string content, or text blocks only. +fn claude_prompt(record: &Value) -> Option { + if record["type"] != "user" + || record["isMeta"] == true + || record["isSidechain"] == true + || record["isCompactSummary"] == true + { + return None; + } + let content = &record["message"]["content"]; + if let Some(text) = content.as_str() { + return (!text.trim().is_empty()).then(|| text.trim().to_string()); + } + let blocks = content.as_array()?; + if blocks.is_empty() || blocks.iter().any(|block| block["type"] != "text") { + return None; + } + text_blocks(content).filter(|text| !text.trim().is_empty()) +} + +/// Joins the text of `text`, `input_text`, and `output_text` blocks; other block kinds are +/// dropped. +fn text_blocks(content: &Value) -> Option { + let blocks = content.as_array()?; + let text = blocks + .iter() + .filter(|block| { + matches!( + block["type"].as_str(), + Some("text" | "input_text" | "output_text") + ) + }) + .filter_map(|block| block["text"].as_str()) + .map(str::trim) + .filter(|text| !text.is_empty()) + .collect::>() + .join("\n"); + (!text.is_empty()).then_some(text) +} + +fn command_text(value: &Value) -> String { + match value { + Value::String(text) => text.trim().to_string(), + Value::Array(parts) => parts + .iter() + .filter_map(|part| part.as_str()) + .collect::>() + .join(" "), + _ => String::new(), + } +} + +fn patch_files(input: &str) -> Vec { + input + .lines() + .filter_map(|line| { + [ + "*** Add File: ", + "*** Update File: ", + "*** Delete File: ", + "*** Move to: ", + ] + .iter() + .find_map(|prefix| line.strip_prefix(prefix)) + .map(|path| path.trim().to_string()) + }) + .filter(|path| !path.is_empty()) + .collect() +} + +fn looks_like_uuid(text: &str) -> bool { + text.len() == 36 + && text.bytes().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }) +} + +fn string(value: &Value, key: &str) -> Option { + value[key] + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_string) +} + +fn title_of(text: &str) -> String { + let first = text + .lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or(""); + truncate(first.trim(), TITLE_BYTES).0 +} + +/// Cuts `text` at a character boundary so that it fits in `max_bytes`. +fn truncate(text: &str, max_bytes: usize) -> (String, bool) { + if text.len() <= max_bytes { + return (text.to_string(), false); + } + // The ellipsis takes three bytes, so the kept text stops three bytes early. + let mut end = max_bytes.saturating_sub('…'.len_utf8()); + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + if end == 0 { + return (String::new(), true); + } + (format!("{}…", &text[..end]), true) +} + +fn first_lines(text: &str, lines: usize) -> (String, bool) { + let mut taken: Vec<&str> = text.lines().take(lines).collect(); + let cut = text.lines().nth(lines).is_some(); + if cut { + taken.push("…"); + } + (taken.join("\n"), cut) +} + +/// Reads the first `limit` bytes. The caller drops a trailing partial line when the file is +/// longer than the window. +fn read_head(path: &Path, limit: u64) -> Result<(Vec, std::fs::Metadata), String> { + let mut file = + std::fs::File::open(path).map_err(|error| format!("Read {}: {error}", path.display()))?; + let metadata = file.metadata().map_err(|error| error.to_string())?; + if !metadata.is_file() { + return Err(format!("{} is not a file.", path.display())); + } + let mut bytes = Vec::new(); + file.by_ref() + .take(limit) + .read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + if let Some(first) = bytes.iter().find(|byte| !byte.is_ascii_whitespace()) + && *first != b'{' + { + return Err(format!( + "{} is not a JSONL transcript. Compressed rollouts are not supported.", + path.display() + )); + } + Ok((bytes, metadata)) +} + +/// Reads the last `limit` bytes, starting at a line boundary. Returns the bytes and the file +/// offset of their first byte. +fn read_tail(path: &Path, limit: u64) -> Result<(Vec, u64), String> { + let mut file = + std::fs::File::open(path).map_err(|error| format!("Read {}: {error}", path.display()))?; + let size = file.metadata().map_err(|error| error.to_string())?.len(); + let offset = size.saturating_sub(limit); + // The preceding byte distinguishes a partial record from a complete first record when the + // window starts immediately after a newline. + let mut previous = *b"\n"; + if offset > 0 { + file.seek(SeekFrom::Start(offset - 1)) + .map_err(|error| error.to_string())?; + file.read_exact(&mut previous) + .map_err(|error| error.to_string())?; + } + file.seek(SeekFrom::Start(offset)) + .map_err(|error| error.to_string())?; + let mut bytes = Vec::new(); + file.take(limit) + .read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + let mut start = offset; + if offset > 0 && previous[0] != b'\n' { + if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') { + bytes.drain(..=newline); + start += newline as u64 + 1; + } else { + start += bytes.len() as u64; + bytes.clear(); + } + } + Ok((bytes, start)) +} + +/// Parses complete JSON object lines. A trailing line without a newline counts only when the +/// window reached the end of the file. +fn records(bytes: &[u8], complete_only: bool) -> impl Iterator + '_ { + bytes + .split_inclusive(|byte| *byte == b'\n') + .take_while(move |line| !complete_only || line.ends_with(b"\n")) + .filter_map(|line| serde_json::from_slice::(line).ok()) + .filter(Value::is_object) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn write_lines(path: &Path, lines: &[Value]) { + std::fs::create_dir_all(path.parent().expect("parent")).expect("directories"); + let text = lines + .iter() + .map(|line| serde_json::to_string(line).expect("json")) + .collect::>() + .join("\n") + + "\n"; + std::fs::write(path, text).expect("transcript"); + } + + fn claude_user(uuid: &str, content: Value) -> Value { + json!({"type":"user","uuid":uuid,"parentUuid":null,"isSidechain":false,"userType":"external", + "cwd":"/work/repo","gitBranch":"feature","sessionId":"ignored-by-reader", + "version":"2.1.300","timestamp":"2026-10-20T10:00:00.000Z", + "message":{"role":"user","content":content}}) + } + + fn claude_assistant(id: &str, block: Value) -> Value { + json!({"type":"assistant","uuid":format!("a-{id}"),"cwd":"/work/repo","gitBranch":"feature", + "timestamp":"2026-10-20T10:01:00.000Z", + "message":{"id":id,"role":"assistant","model":"claude-opus-5-5","content":[block]}}) + } + + fn claude_fixture(dir: &Path) -> PathBuf { + let session = "0f6a1c2e-1111-4222-8333-444455556666"; + let path = dir + .join("projects/-work-repo") + .join(format!("{session}.jsonl")); + let summary = format!("Summary so far: {}", "s".repeat(20 * 1024)); + write_lines( + &path, + &[ + json!({"type":"queue-operation","operation":"enqueue"}), + json!({"type":"custom-title","customTitle":"Fix the parser"}), + claude_user("u1", json!("Make the parser accept trailing commas")), + claude_assistant( + "msg_1", + json!({"type":"text","text":"I will edit the parser."}), + ), + claude_assistant( + "msg_1", + json!({"type":"thinking","thinking":"THINK-SENTINEL","signature":"x"}), + ), + claude_assistant( + "msg_1", + json!({"type":"tool_use","id":"toolu_1","name":"Edit", + "input":{"file_path":"/work/repo/src/parser.rs","old_string":"x","new_string":"y"}}), + ), + claude_assistant( + "msg_1", + json!({"type":"tool_use","id":"toolu_2","name":"Bash", + "input":{"command":"cargo test -p parser"}}), + ), + claude_user( + "u2", + json!([{"type":"tool_result","tool_use_id":"toolu_1","content":"OKBODY-SENTINEL"}]), + ), + claude_user( + "u3", + json!([{"type":"tool_result","tool_use_id":"toolu_2","is_error":true, + "content":"error[E0308]: mismatched types\n --> src/parser.rs:10\nmore"}]), + ), + json!({"type":"attachment","attachment":{"type":"skill_listing","content":"ATTACH-SENTINEL"}}), + json!({"type":"user","isSidechain":true,"message":{"role":"user","content":"SIDECHAIN-SENTINEL"}}), + json!({"type":"user","isMeta":true,"message":{"role":"user","content":"META-SENTINEL"}}), + json!({"type":"file-history-snapshot","snapshot":{"trackedFiles":"FHS-SENTINEL"}}), + json!({"type":"system","subtype":"compact_boundary","compactMetadata":{"trigger":"auto"}}), + json!({"type":"user","isCompactSummary":true,"message":{"role":"user","content":summary}}), + claude_user("u4", json!("Now add a test for trailing commas")), + claude_assistant( + "msg_2", + json!({"type":"text","text":"Added the test; it passes."}), + ), + ], + ); + path + } + + #[test] + fn claude_transcripts_describe_the_session_and_excerpt_only_text() { + let dir = tempfile::tempdir().expect("dir"); + let path = claude_fixture(dir.path()); + let session = describe(Kind::Claude, &path).expect("describe"); + assert_eq!(session.native_id, "0f6a1c2e-1111-4222-8333-444455556666"); + assert_eq!(session.cwd.as_deref(), Some(Path::new("/work/repo"))); + assert_eq!(session.branch.as_deref(), Some("feature")); + assert_eq!(session.started.as_deref(), Some("2026-10-20T10:00:00.000Z")); + assert_eq!(session.title.as_deref(), Some("Fix the parser")); + assert_eq!(session.model.as_deref(), Some("claude-opus-5-5")); + assert!(session.last_activity.is_some()); + + let limits = Limits::default(); + let excerpt = excerpt(Kind::Claude, &path, &limits).expect("excerpt"); + assert_eq!( + excerpt.goal.as_deref(), + Some("Make the parser accept trailing commas") + ); + let summary = excerpt.summary.as_deref().expect("summary"); + assert!(summary.starts_with("Summary so far")); + assert!(summary.len() <= limits.summary_bytes); + assert!(excerpt.truncated); + assert_eq!(excerpt.turns.len(), 1); + assert_eq!( + excerpt.turns[0].prompt, + "Now add a test for trailing commas" + ); + assert_eq!(excerpt.turns[0].answer, "Added the test; it passes."); + assert_eq!(excerpt.files, vec!["/work/repo/src/parser.rs"]); + assert_eq!(excerpt.commands, vec!["cargo test -p parser"]); + let error = excerpt.last_error.as_deref().expect("error"); + assert!(error.starts_with("$ cargo test -p parser\nerror[E0308]")); + assert_eq!( + excerpt.fingerprint, + fingerprint(&path).expect("fingerprint") + ); + + let text = render(&excerpt, &limits); + assert!(text.len() <= limits.max_bytes); + for sentinel in [ + "OKBODY-SENTINEL", + "ATTACH-SENTINEL", + "SIDECHAIN-SENTINEL", + "META-SENTINEL", + "FHS-SENTINEL", + "THINK-SENTINEL", + "ignored-by-reader", + ] { + assert!( + !text.contains(sentinel), + "{sentinel} leaked into the hand-off" + ); + } + for expected in [ + "# Hand-off from Claude Code", + "- Directory: /work/repo", + "- Branch: feature", + "## Goal", + "## Earlier summary", + "### Turn 1 — user", + "- /work/repo/src/parser.rs", + "- `cargo test -p parser`", + "error[E0308]", + ] { + assert!(text.contains(expected), "missing {expected}"); + } + } + + #[test] + fn claude_listing_reads_cwd_from_records_and_ignores_subagent_files() { + let dir = tempfile::tempdir().expect("dir"); + let first = claude_fixture(dir.path()); + let colliding = dir + .path() + .join("projects/-work-repo-/2f6a1c2e-1111-4222-8333-444455557777.jsonl"); + write_lines( + &colliding, + &[ + json!({"type":"ai-title","aiTitle":"Second session"}), + claude_user("u1", json!("Second goal")), + ], + ); + let subagent = first.with_extension("").join("subagents/agent-abc.jsonl"); + write_lines(&subagent, &[claude_user("u9", json!("SUBAGENT-SENTINEL"))]); + std::fs::create_dir_all(dir.path().join("projects/-work-repo/memory")).expect("memory"); + std::fs::write( + dir.path().join("projects/-work-repo/memory/notes.md"), + "notes", + ) + .expect("notes"); + std::fs::write(dir.path().join("projects/-work-repo/scratch.txt"), "{}").expect("txt"); + + let listing = list(Kind::Claude, dir.path()).expect("list"); + assert_eq!(listing.skipped, 0); + let mut ids: Vec<_> = listing + .sessions + .iter() + .map(|s| s.native_id.as_str()) + .collect(); + ids.sort(); + assert_eq!( + ids, + vec![ + "0f6a1c2e-1111-4222-8333-444455556666", + "2f6a1c2e-1111-4222-8333-444455557777" + ] + ); + assert!( + listing + .sessions + .iter() + .all(|s| s.cwd.as_deref() == Some(Path::new("/work/repo"))) + ); + let second = listing + .sessions + .iter() + .find(|s| s.native_id.ends_with("7777")) + .expect("second"); + assert_eq!(second.title.as_deref(), Some("Second session")); + assert_eq!( + locate( + Kind::Claude, + dir.path(), + "2f6a1c2e-1111-4222-8333-444455557777" + ) + .expect("locate"), + colliding + ); + assert!(locate(Kind::Claude, dir.path(), "../etc/passwd").is_err()); + assert!(locate(Kind::Claude, dir.path(), "missing").is_err()); + assert!( + list(Kind::Claude, &dir.path().join("absent")) + .expect("absent") + .sessions + .is_empty() + ); + } + + fn codex_fixture(dir: &Path, id: &str) -> PathBuf { + let path = dir.join(format!( + "sessions/2026/10/20/rollout-2026-10-20T10-00-00-{id}.jsonl" + )); + write_lines( + &path, + &[ + json!({"timestamp":"2026-10-20T10:00:00.000Z","type":"turn_context", + "payload":{"turn_id":"t1","cwd":"/work/repo","model":"gpt-6-sol"}}), + json!({"timestamp":"2026-10-20T10:00:00.000Z","type":"session_meta", + "payload":{"id":id,"timestamp":"2026-10-20T10:00:00.000Z","cwd":"/work/repo", + "originator":"codex_cli_rs","cli_version":"0.160.0","source":"cli", + "git":{"commit_hash":"abc123def456789","branch":"feature"}}}), + json!({"type":"compacted","payload":{"message":"Earlier: set up the project."}}), + json!({"type":"event_msg","payload":{"type":"user_message","message":"Make the parser accept trailing commas"}}), + json!({"type":"response_item","payload":{"type":"message","role":"user", + "content":[{"type":"input_text","text":"# AGENTS.md instructions INJECT-SENTINEL"}]}}), + json!({"type":"response_item","payload":{"type":"reasoning","encrypted_content":"REASONING-SENTINEL"}}), + json!({"type":"response_item","payload":{"type":"function_call","name":"exec_command", + "arguments":"{\"cmd\":\"cargo test -p parser\"}","call_id":"c1"}}), + json!({"type":"response_item","payload":{"type":"function_call_output","call_id":"c1","output":"OUTPUT-SENTINEL"}}), + json!({"type":"event_msg","payload":{"type":"exec_command_end","command":["bash","-lc","cargo test -p parser"], + "exit_code":101,"aggregated_output":"error[E0308]: mismatched types\nline two"}}), + json!({"type":"response_item","payload":{"type":"custom_tool_call","name":"apply_patch", + "input":"*** Begin Patch\n*** Update File: src/parser.rs\n@@\n-x\n+y\n*** End Patch","call_id":"c2"}}), + json!({"type":"event_msg","payload":{"type":"agent_message","message":"I edited the parser and ran the tests."}}), + json!({"type":"response_item","payload":{"type":"message","role":"assistant","phase":"final_answer", + "content":[{"type":"output_text","text":"I edited the parser and ran the tests."}]}}), + ], + ); + let mut text = std::fs::read_to_string(&path).expect("read"); + text.push_str("not json at all\n"); + std::fs::write(&path, text).expect("malformed line"); + path + } + + #[test] + fn codex_rollouts_describe_the_thread_and_excerpt_only_text() { + let dir = tempfile::tempdir().expect("dir"); + let id = "019a6c2e-1111-7222-8333-444455556666"; + let path = codex_fixture(dir.path(), id); + let session = describe(Kind::Codex, &path).expect("describe"); + assert_eq!(session.native_id, id); + assert_eq!(session.cwd.as_deref(), Some(Path::new("/work/repo"))); + assert_eq!(session.branch.as_deref(), Some("feature")); + assert_eq!(session.commit.as_deref(), Some("abc123def456789")); + assert_eq!(session.model.as_deref(), Some("gpt-6-sol")); + assert_eq!( + session.title.as_deref(), + Some("Make the parser accept trailing commas") + ); + + let limits = Limits::default(); + let excerpt = excerpt(Kind::Codex, &path, &limits).expect("excerpt"); + assert_eq!( + excerpt.goal.as_deref(), + Some("Make the parser accept trailing commas") + ); + assert_eq!(excerpt.turns.len(), 1); + assert_eq!( + excerpt.turns[0].answer, + "I edited the parser and ran the tests." + ); + assert_eq!(excerpt.files, vec!["src/parser.rs"]); + assert_eq!(excerpt.commands, vec!["cargo test -p parser"]); + assert!( + excerpt + .last_error + .as_deref() + .expect("error") + .contains("error[E0308]") + ); + assert!( + excerpt + .last_error + .as_deref() + .expect("error") + .starts_with("$ bash -lc cargo test -p parser") + ); + // The compaction summary stands in for the history before it; the turn after it stays. + assert_eq!( + excerpt.summary.as_deref(), + Some("Earlier: set up the project.") + ); + assert!(!excerpt.truncated); + + let text = render(&excerpt, &limits); + for sentinel in ["INJECT-SENTINEL", "REASONING-SENTINEL", "OUTPUT-SENTINEL"] { + assert!( + !text.contains(sentinel), + "{sentinel} leaked into the hand-off" + ); + } + assert!(text.contains("# Hand-off from Codex")); + assert!(text.contains("(commit abc123def456)")); + + let listing = list(Kind::Codex, dir.path()).expect("list"); + assert_eq!(listing.sessions.len(), 1); + assert_eq!(listing.skipped, 0); + assert_eq!(locate(Kind::Codex, dir.path(), id).expect("locate"), path); + } + + #[test] + fn unreadable_rollouts_are_counted_not_fatal() { + let dir = tempfile::tempdir().expect("dir"); + codex_fixture(dir.path(), "019a6c2e-1111-7222-8333-444455556666"); + let compressed = dir + .path() + .join("sessions/2026/10/21/rollout-2026-10-21T10-00-00-019a6c2e-1111-7222-8333-444455559999.jsonl"); + std::fs::create_dir_all(compressed.parent().expect("parent")).expect("dir"); + std::fs::write(&compressed, [0x1f, 0x8b, 0x08, 0x00]).expect("gzip bytes"); + std::fs::write( + dir.path() + .join("sessions/2026/10/21/rollout-decoy.jsonl.zst"), + [0x28, 0xb5, 0x2f, 0xfd], + ) + .expect("zstd bytes"); + let listing = list(Kind::Codex, dir.path()).expect("list"); + assert_eq!(listing.sessions.len(), 1); + assert_eq!(listing.skipped, 1); + assert!( + describe(Kind::Codex, &compressed) + .expect_err("compressed") + .contains("Compressed rollouts are not supported") + ); + } + + #[test] + fn long_transcripts_read_only_the_head_and_tail_windows() { + let dir = tempfile::tempdir().expect("dir"); + let path = dir + .path() + .join("projects/-work-repo/aaaaaaaa-0000-4000-8000-000000000000.jsonl"); + let mut lines = vec![ + json!({"type":"queue-operation","operation":"enqueue"}), + claude_user("u0", json!("GOAL-FIRST-PROMPT")), + ]; + for index in 0..40_000 { + lines.push(claude_user( + &format!("u{index}"), + json!(format!("FILLER-SENTINEL-{index:05} {}", "x".repeat(80))), + )); + lines.push(claude_assistant( + &format!("m{index}"), + json!({"type":"text","text":format!("FILLER-ANSWER-{index:05}")}), + )); + } + lines.push(claude_user("last", json!("LAST-PROMPT"))); + lines.push(claude_assistant( + "last", + json!({"type":"text","text":"LAST-ANSWER"}), + )); + write_lines(&path, &lines); + let limits = Limits::default(); + assert!( + fingerprint(&path).expect("fingerprint").len > limits.head_bytes + limits.tail_bytes + ); + + let excerpt = excerpt(Kind::Claude, &path, &limits).expect("excerpt"); + assert_eq!(excerpt.goal.as_deref(), Some("GOAL-FIRST-PROMPT")); + assert!(excerpt.truncated); + assert_eq!(excerpt.turns.len(), limits.turns); + let last = excerpt.turns.last().expect("last turn"); + assert_eq!(last.prompt, "LAST-PROMPT"); + assert_eq!(last.answer, "LAST-ANSWER"); + let text = render(&excerpt, &limits); + assert!(text.len() <= limits.max_bytes); + assert!(!text.contains("FILLER-SENTINEL-00001")); + assert!(text.contains("LAST-ANSWER")); + + let before = fingerprint(&path).expect("before"); + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("open"); + std::io::Write::write_all(&mut file, b"{\"type\":\"user\"}\n").expect("append"); + assert_ne!(before, fingerprint(&path).expect("after")); + } + + #[test] + fn rendering_caps_the_size_at_a_character_boundary() { + let limits = Limits { + max_bytes: 400, + ..Limits::default() + }; + let session = Session { + kind: Kind::Codex, + native_id: "id".into(), + transcript: PathBuf::from("/tmp/t.jsonl"), + cwd: None, + branch: None, + commit: None, + started: None, + last_activity: None, + title: None, + model: None, + bytes: 0, + }; + let excerpt = Excerpt { + session, + goal: Some("é".repeat(600)), + summary: None, + turns: Vec::new(), + files: Vec::new(), + commands: vec!["echo `x`".into()], + last_error: None, + notes: vec!["note".into()], + truncated: false, + fingerprint: Fingerprint { + len: 0, + modified_ms: 0, + }, + extracted_at: String::new(), + }; + let text = render(&excerpt, &limits); + assert!(text.len() <= limits.max_bytes); + assert!(text.ends_with("_(hand-off cut at the size limit)_\n")); + assert_eq!( + patch_files("*** Add File: a.rs\n*** Move to: b.rs\n*** Delete File: \n"), + vec!["a.rs", "b.rs"] + ); + assert_eq!(first_lines("a\nb\nc", 2), ("a\nb\n…".to_string(), true)); + assert_eq!(truncate("héllo", 4), ("h…".to_string(), true)); + assert!(truncate(&"é".repeat(10), 7).0.len() <= 7); + assert_eq!(truncate("abc", 2), (String::new(), true)); + assert!(looks_like_uuid("019a6c2e-1111-7222-8333-444455556666")); + assert!(!looks_like_uuid("rollout-2026")); + } +} diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index b3db4bf69c..dad21ee911 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -1,18 +1,22 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! The terminal app shows recorded usage, recent calls, and server status. +//! The terminal app shows usage, recent calls, routes, harness settings, and worktree sessions +//! from controller snapshots, and it runs server, route, harness, account, and session actions. //! //! One worker thread owns the controller and refreshes its snapshots while the UI thread //! draws and reads input. use crate::controller::{ - Action, ActionKind, AppSettings, Availability, Controller, Reply, Snapshot, + AccountInfo, Action, ActionKind, AppSettings, Availability, Controller, Dependent, + HandoffSource, Reply, RouteInfo, Snapshot, ToolInfo, }; +use crate::harness::Harness; use crate::history::Entry; use crate::pricing::Estimate; use crate::rollup::{CLASSIFIER_TIER, ModelTokens, Totals, Usage}; -use crate::summary::{money, tokens}; +use crate::server_config::Choice; +use crate::summary::{grouped, hidden_savings, money, plural, tokens}; use chrono::{DateTime, Utc}; use ratatui::{ DefaultTerminal, Frame, @@ -28,6 +32,7 @@ use ratatui::{ }; use serde_json::Value; use std::{ + borrow::Cow, collections::BTreeMap, path::{Path, PathBuf}, sync::mpsc, @@ -48,7 +53,7 @@ const SPINNER: [char; 8] = ['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', ' type Key = (&'static str, &'static str, &'static str); const GLOBAL_KEYS: &[Key] = &[ ( - "1-4", + "1-7", "page", "Switch pages. Tab, Shift-Tab, ← and → also switch pages.", ), @@ -89,6 +94,65 @@ const MODELS_KEYS: &[Key] = &[( "period", "Switch the period: today, the past 7 days, or the whole log. The [ and ] keys also work.", )]; +const ROUTES_KEYS: &[Key] = &[ + ( + "↑↓", + "select", + "Select a route. j, PgUp, PgDn, g, and G also move it.", + ), + ( + "/", + "filter", + "Filter as you type. Every word must appear in the route's public model ID, config \ + name, algorithm, endpoints, or models. A word with a capital letter must match case \ + exactly.", + ), + ("esc", "clear", "Clear the filter."), +]; +const HARNESSES_KEYS: &[Key] = &[ + ( + "↑↓", + "route", + "Choose the route to install. j, k, PgUp, PgDn, g, and G also move it. The Routes and \ + Sessions pages select the same route, and the Routes filter limits the choices.", + ), + ("t", "harness", "Select the next harness."), + ( + "a", + "account", + "Select the next saved account, then the harness's own settings again. Install, \ + restore, and the account keys use the selection.", + ), + ( + "x", + "custom file", + "Install to or restore a custom settings file instead. Leave it empty to use the \ + detected settings again.", + ), +]; +const SESSIONS_KEYS: &[Key] = &[ + ( + "↑↓", + "route", + "Choose the route for a new session. j, k, PgUp, PgDn, g, and G also move it. The \ + Routes and Harnesses pages select the same route.", + ), + ( + "t", + "harness", + "Select the next harness for a new session. The Harnesses page selects the same one.", + ), + ( + "a", + "account", + "Select the next saved account for a new session, then the harness's own login again.", + ), + ( + "s", + "session", + "Select the next recorded session. The open and remove keys use the selection.", + ), +]; const SETTINGS_KEYS: &[Key] = &[ ("↑↓", "select", "Select an action. j, g, and G also move."), ( @@ -107,98 +171,312 @@ const FORM_KEYS: &[Key] = &[ ("tab", "next field", ""), ("esc", "cancel", ""), ]; -const UPDATE_KEYS: &[Key] = &[ - ("⏎", "start", ""), +/// The status bar shows these keys while the selected form field has a list of choices. +const CHOICE_KEYS: &[Key] = &[ + ("⏎", "submit", ""), + ("↑↓", "choose", ""), + ("tab", "next field", ""), + ("esc", "cancel", ""), +]; +const REVIEW_KEYS: &[Key] = &[ + ("⏎", "confirm", ""), ("↑↓", "scroll", ""), ("esc", "cancel", ""), ]; const POPUP_KEYS: &[Key] = &[("↑↓", "scroll", ""), ("esc", "close", "")]; const CONFIRM_KEYS: &[Key] = &[("y", "confirm", ""), ("n", "cancel", "")]; -/// The Settings page uses these entries for action labels, help text, and shortcut keys. -const ACTIONS: &[(char, &str, &str, ActionKind)] = &[ +/// Each row is a page, a key, a short label, a help sentence, and the actions that the key's +/// flow can run. `App::start_action` receives the first action. Settings lists its rows and +/// runs the selected one with Enter; other pages run a row's flow on the selected item when its +/// key is pressed. A new controller action breaks the build until `App::start_action` runs it, +/// and `every_controller_action_has_a_key` fails until a row's flow includes it. +type ActionRow = (Tab, char, &'static str, &'static str, &'static [ActionKind]); +const ACTIONS: &[ActionRow] = &[ + ( + Tab::Routes, + 'e', + "edit", + "Changes the selected route's algorithm and models. Enter reviews the change, and \ + typing SAVE saves it and restarts the server.", + &[ + ActionKind::Editor, + ActionKind::PreviewRoute, + ActionKind::Apply, + ], + ), + ( + Tab::Routes, + 'a', + "create", + "Creates a route from a public model ID, an algorithm, and its models. Type SAVE after \ + the review.", + &[ActionKind::PreviewRouteChange, ActionKind::RouteChange], + ), + ( + Tab::Routes, + 'n', + "rename", + "Changes the selected route's public model ID. Harnesses that send the old ID must \ + change too. Type SAVE after the review.", + &[ActionKind::PreviewRouteChange, ActionKind::RouteChange], + ), + ( + Tab::Routes, + 'c', + "duplicate", + "Copies the selected route under a new public model ID. The copy shares its targets. \ + Type SAVE after the review.", + &[ActionKind::PreviewRouteChange, ActionKind::RouteChange], + ), + ( + Tab::Routes, + 'd', + "delete", + "Deletes the selected route after you type its public model ID. Its targets and \ + endpoints stay, and the server config is backed up.", + &[ActionKind::Remove], + ), + ( + Tab::Routes, + 'm', + "models", + "Loads the model IDs that an endpoint offers.", + &[ActionKind::Models], + ), + ( + Tab::Routes, + 'k', + "model key", + "Saves an API key that lets the app load an endpoint's model IDs, then loads them.", + &[ActionKind::Models], + ), + ( + Tab::Harnesses, + 'p', + "preview", + "Shows the settings diff that installing the selected route would make. Nothing is \ + saved.", + &[ActionKind::PreviewInstall], + ), + ( + Tab::Harnesses, + 'i', + "install", + "Shows the settings diff, then saves it after you type INSTALL. A backup keeps the \ + original settings.", + &[ActionKind::PreviewInstall, ActionKind::Install], + ), + ( + Tab::Harnesses, + 'u', + "restore", + "Shows which files return to their original versions, then restores them after you \ + type RESTORE.", + &[ActionKind::PreviewRestore, ActionKind::Restore], + ), + ( + Tab::Harnesses, + 'n', + "add account", + "Creates a saved account and opens the harness's login in a new terminal window.", + &[ActionKind::AddAccount], + ), + ( + Tab::Harnesses, + 'l', + "login", + "Opens the selected account's login again in a new terminal window.", + &[ActionKind::LoginAccount], + ), + ( + Tab::Harnesses, + 'o', + "open account", + "Opens the selected account folder in $VISUAL or $EDITOR.", + &[ActionKind::OpenAccount], + ), + ( + Tab::Harnesses, + 'd', + "archive", + "Moves the selected account out of the list after you type its name. Its login files \ + are kept.", + &[ + ActionKind::PreviewAccountArchive, + ActionKind::ArchiveAccount, + ], + ), + ( + Tab::Sessions, + 'l', + "launch", + "Asks for a Git project folder and shows the session review. After you type LAUNCH, \ + it creates a worktree from committed HEAD and opens the harness there in a new \ + terminal window.", + &[ActionKind::PreviewLaunch, ActionKind::Launch], + ), + ( + Tab::Sessions, + 'n', + "add account", + "Creates a saved account and opens the harness's login in a new terminal window.", + &[ActionKind::AddAccount], + ), + ( + Tab::Sessions, + 'o', + "open session", + "Opens the selected session folder in $VISUAL or $EDITOR.", + &[ActionKind::OpenSession], + ), + ( + Tab::Sessions, + 'd', + "remove", + "Shows what cleanup deletes, including Git-ignored files, then removes the clean \ + worktree and its session branch after you type REMOVE.", + &[ActionKind::PreviewSessionCleanup, ActionKind::RemoveSession], + ), + ( + Tab::Sessions, + 'c', + "continue", + "Hands the selected recorded session to the selected harness, account, and route. The \ + review shows the hand-off text that the new session reads; after you type HANDOFF, \ + it writes that text to a private file and opens the harness in the same worktree.", + &[ActionKind::PreviewHandoff, ActionKind::Handoff], + ), + ( + Tab::Sessions, + 'x', + "transcript", + "Asks for a Claude Code or Codex transcript file, then shows the same hand-off review \ + for the selected harness, account, and route. The new session starts in the \ + directory that the transcript records.", + &[ActionKind::PreviewHandoff, ActionKind::Handoff], + ), + ( + Tab::Sessions, + 'h', + "harness sessions", + "Lists the sessions that the selected harness and account keep transcripts for, with \ + the transcript paths that the transcript key accepts.", + &[ActionKind::RecentSessions], + ), ( + Tab::Settings, 'o', "Open server config", "Opens the server config in $VISUAL or $EDITOR. Restart the server after you save it.", - ActionKind::OpenConfig, + &[ActionKind::OpenConfig], ), ( + Tab::Settings, 'c', "Check server config", "Runs the installed server's config check. The running server keeps running.", - ActionKind::CheckConfig, + &[ActionKind::CheckConfig], ), ( + Tab::Settings, 'b', "Restart server", "Checks the server config, then restarts the Switchyard service after you confirm. \ Requests in progress fail.", - ActionKind::Restart, + &[ActionKind::Restart], ), ( + Tab::Settings, 'a', "Open app settings", "Opens this app's settings file in $VISUAL or $EDITOR.", - ActionKind::OpenSettings, + &[ActionKind::OpenSettings], ), ( + Tab::Settings, 'e', "Change preferences", "Changes the refresh interval and the baseline model used to estimate savings.", - ActionKind::SavePreferences, + &[ActionKind::SavePreferences], + ), + ( + Tab::Settings, + 'f', + "Change configuration", + "Selects the server config, address, log, and price file. Existing files are preserved.", + &[ActionKind::SaveConfiguration], + ), + ( + Tab::Settings, + 'd', + "Remove app preferences", + "Reviews removal of app preferences and keeps a recovery copy. Server configs and history are preserved.", + &[ActionKind::PreviewResetSettings, ActionKind::ResetSettings], ), ( + Tab::Settings, 'p', "Open model prices", "Opens the custom price file in $VISUAL or $EDITOR. With bundled prices, this explains \ how to set a custom file.", - ActionKind::OpenPrices, + &[ActionKind::OpenPrices], ), ( + Tab::Settings, 'k', "Check server keys", "Lists the API key variables that the server config names and whether each is saved.", - ActionKind::ServerKeys, + &[ActionKind::ServerKeys], ), ( + Tab::Settings, 's', "Save a server key", "Saves an API key for a variable that the server config names. Restart the server to \ use it.", - ActionKind::SaveServerKey, + &[ActionKind::SaveServerKey], ), ( + Tab::Settings, 'i', "Import a server key", "Saves an API key from your login shell's environment. Restart the server to use it.", - ActionKind::ImportServerKey, + &[ActionKind::ImportServerKey], ), ( + Tab::Settings, 'x', "Remove a server key", "Removes a saved API key. The server does not start until the key is saved again.", - ActionKind::RemoveServerKey, + &[ActionKind::RemoveServerKey], ), ( + Tab::Settings, 'u', "Update from source", "Shows what the saved source checkout would install. Type UPDATE to start it.", - ActionKind::PreviewUpdate, + &[ActionKind::PreviewUpdate, ActionKind::Update], ), ( + Tab::Settings, 'l', "Open update log", "Opens the log of the last source update in $VISUAL or $EDITOR.", - ActionKind::OpenUpdateLog, + &[ActionKind::OpenUpdateLog], ), ( + Tab::Settings, 'v', "Open server logs", "Opens the Switchyard server logs folder in $VISUAL or $EDITOR.", - ActionKind::OpenLogs, + &[ActionKind::OpenLogs], ), ]; +/// This function lists one page's rows in `ACTIONS` order. +fn rows(tab: Tab) -> impl Iterator + Clone { + ACTIONS.iter().filter(move |row| row.0 == tab) +} /// Column is a table heading, its width, and whether its values are right-aligned. type Column = (&'static str, Constraint, bool); @@ -213,6 +491,30 @@ const CALL_COLUMNS: [(Column, bool); 8] = [ (("OUT", Constraint::Length(7), true), false), (("COST", Constraint::Length(9), true), false), ]; +/// The routes table drops the columns marked `true` below 80 columns; the route details still +/// show every model. +const ROUTE_COLUMNS: [(Column, bool); 3] = [ + (("ROUTE", Constraint::Fill(1), false), false), + (("ALGORITHM", Constraint::Length(24), false), false), + (("MODELS", Constraint::Fill(2), false), true), +]; +/// The harnesses table drops the columns marked `true` below 80 columns; the details still +/// show the current settings. +const HARNESS_COLUMNS: [(Column, bool); 4] = [ + (("HARNESS", Constraint::Fill(2), false), false), + (("STATE", Constraint::Length(13), false), false), + (("CURRENT SETTINGS", Constraint::Fill(3), false), true), + (("ACCOUNTS", Constraint::Length(8), true), false), +]; +/// The sessions table drops the columns marked `true` below 100 columns; the details still +/// show them. +const SESSION_COLUMNS: [(Column, bool); 5] = [ + (("SESSION", Constraint::Length(39), false), false), + (("REPOSITORY", Constraint::Fill(2), false), true), + (("ROUTE", Constraint::Fill(1), false), false), + (("CREATED", Constraint::Length(8), true), true), + (("LAST CALL", Constraint::Length(9), true), false), +]; /// Theme maps each kind of text to one style. NO_COLOR keeps the text modifiers. #[derive(Clone, Copy)] @@ -265,15 +567,29 @@ enum Tab { Overview, Calls, Models, + Routes, + Harnesses, + Sessions, Settings, } -const TABS: [Tab; 4] = [Tab::Overview, Tab::Calls, Tab::Models, Tab::Settings]; +const TABS: [Tab; 7] = [ + Tab::Overview, + Tab::Calls, + Tab::Models, + Tab::Routes, + Tab::Harnesses, + Tab::Sessions, + Tab::Settings, +]; impl Tab { fn name(self) -> &'static str { match self { Tab::Overview => "Overview", Tab::Calls => "Calls", Tab::Models => "Models", + Tab::Routes => "Routes", + Tab::Harnesses => "Harnesses", + Tab::Sessions => "Sessions", Tab::Settings => "Settings", } } @@ -324,19 +640,91 @@ impl Period { } } -#[derive(Clone, Copy, Debug, PartialEq)] enum FormKind { + Configuration, Preferences, SaveKey, ImportKey, RemoveKey, - Update, + /// The token comes from the update preview that the user read. + Update(Option), + /// The route is the snapshot's copy from when the user opened the editor. The models form + /// keeps its revision and choices, so a server config that changed since then fails the + /// review instead of mixing old choices into the new file. + Algorithm { + generation: u64, + route: Box, + }, + RouteModels { + generation: u64, + route: String, + algorithm: String, + revision: Option, + }, + /// The fields hold a new route's public model ID, config name, and algorithm. + NewRoute { + generation: u64, + }, + NewRouteModels { + generation: u64, + name: String, + id: String, + algorithm: String, + }, + /// The operation is `rename` or `duplicate`, and `route` is the selected route's config name. + RouteIdentity { + generation: u64, + operation: &'static str, + route: String, + }, + /// The fields hold the word that the user must type and the reviewed action, which the + /// form submits unchanged. + Confirm(Cow<'static, str>, Box), + Remove { + generation: u64, + route: String, + id: String, + revision: String, + }, + Models, + ModelKey, + /// The harness is the one selected when the form opened. + Account(Harness), + SettingsFile, + /// The harness, account, and route are the ones selected when the form opened. + Project { + tool: Harness, + account: Option, + route: String, + id: String, + }, + /// The field holds a transcript file path; the destination is the selection when the form + /// opened. + Transcript { + tool: Harness, + account: Option, + route: String, + id: String, + }, +} +/// Choices names the list that ↑ and ↓ cycle through in a form field. +enum Choices { + Algorithms, + Endpoints, +} +/// Submit is what a form sends: a controller action, the next form of a flow that needs more +/// input before the controller can review it, or a custom settings file, which changes only +/// where the Harnesses page installs. +enum Submit { + Run(Action), + Next(Form), + SettingsFile(String), } /// Form holds the text fields for an action that needs input. struct Form { kind: FormKind, - title: &'static str, - fields: Vec<(&'static str, String)>, + title: Cow<'static, str>, + fields: Vec<(Cow<'static, str>, String)>, index: usize, /// This field counts characters, not bytes, from the start of the selected field. cursor: usize, @@ -344,11 +732,19 @@ struct Form { error: String, } impl Form { - fn new(kind: FormKind, title: &'static str, fields: Vec<(&'static str, String)>) -> Self { + fn new>>( + kind: FormKind, + title: impl Into>, + fields: Vec<(L, String)>, + ) -> Self { + let fields: Vec<_> = fields + .into_iter() + .map(|(label, value)| (label.into(), value)) + .collect(); let cursor = fields.first().map_or(0, |field| field.1.chars().count()); Self { kind, - title, + title: title.into(), fields, index: 0, cursor, @@ -409,10 +805,84 @@ impl Form { fn value(&self, index: usize) -> String { self.fields[index].1.trim().to_string() } - /// This function builds the controller action that the form submits. An update needs the - /// token from the preview that the user read. - fn action(&self, preview: Option<&Value>) -> Result { - Ok(match self.kind { + /// This function reads the choices from fields that alternate endpoint and model. + fn choices(&self) -> Vec { + self.fields + .as_chunks::<2>() + .0 + .iter() + .map(|pair| Choice { + client: pair[0].1.trim().into(), + model: pair[1].1.trim().into(), + }) + .collect() + } + /// This function names the list that the selected field chooses from, if any. + fn options(&self) -> Option { + match self.kind { + FormKind::Algorithm { .. } => Some(Choices::Algorithms), + FormKind::NewRoute { .. } if self.index == 2 => Some(Choices::Algorithms), + FormKind::RouteModels { .. } | FormKind::NewRouteModels { .. } + if self.index.is_multiple_of(2) => + { + Some(Choices::Endpoints) + } + FormKind::Models | FormKind::ModelKey if self.index == 0 => Some(Choices::Endpoints), + _ => None, + } + } + /// This function replaces the selected field with the next or previous name in `names`. + fn choose<'a>(&mut self, mut names: impl Iterator + Clone, forward: bool) { + let count = names.clone().count(); + if count == 0 { + return; + } + let field = &mut self.fields[self.index].1; + let next = match names.clone().position(|name| name == field.trim()) { + Some(index) if forward => (index + 1) % count, + Some(index) => (index + count - 1) % count, + None => 0, + }; + *field = names.nth(next).unwrap_or_default().to_string(); + self.cursor = field.chars().count(); + } + /// This function builds what the form submits. The snapshot supplies the roles and + /// endpoints that a new route's models form starts with. + fn action(&self, data: &Snapshot) -> Result { + let confirm = |word: &str, error: &str| { + if self.value(0) == word { + Ok(()) + } else { + Err(error.to_string()) + } + }; + Ok(Submit::Run(match &self.kind { + FormKind::Configuration => { + let saved = data + .settings + .as_ref() + .ok_or("App settings are unavailable.")?; + let create_server = match self.value(6).as_str() { + "yes" => true, + "no" => false, + _ => return Err("Enter yes or no for creating a server config.".into()), + }; + Action::SaveConfiguration { + config: crate::config::Config { + server_url: self.value(0), + config_file: self.value(1).into(), + routing_log: self.value(2).into(), + prices_file: (!self.value(3).is_empty()).then(|| self.value(3).into()), + launchd_label: saved.launchd_label.clone(), + baseline_model: self.value(4), + refresh_seconds: self + .value(5) + .parse() + .map_err(|_| "Enter a whole refresh interval in seconds.")?, + }, + create_server, + } + } FormKind::Preferences => Action::SavePreferences { baseline_model: self.value(0), refresh_seconds: self.value(1).parse().map_err(|_| { @@ -434,29 +904,351 @@ impl Form { FormKind::RemoveKey => Action::RemoveServerKey { name: self.value(0), }, - FormKind::Update => { - if self.value(0) != "UPDATE" { - return Err("Type UPDATE to start the reviewed source update.".into()); - } + FormKind::Update(preview_token) => { + confirm("UPDATE", "Type UPDATE to start the reviewed source update.")?; Action::Update { - preview_token: preview - .and_then(|preview| preview["preview_token"].as_str()) - .map(str::to_owned), + preview_token: preview_token.clone(), } } - }) + FormKind::Algorithm { generation, route } => Action::Editor { + generation: *generation, + route: route.key.clone(), + algorithm: self.value(0), + }, + FormKind::RouteModels { + generation, + route, + algorithm, + revision, + } => Action::PreviewRoute { + generation: *generation, + route: route.clone(), + algorithm: algorithm.clone(), + choices: self.choices(), + revision: revision.clone(), + }, + FormKind::NewRoute { generation } => { + let id = self.value(0); + let algorithm = self.value(2); + let roles = &data + .algorithms + .iter() + .find(|known| known.kind == algorithm) + .ok_or("Unknown routing algorithm. Press ↑ or ↓ to choose one.")? + .roles; + let choice = Choice { + client: data + .clients + .first() + .map(|client| client.name.clone()) + .unwrap_or_default(), + model: String::new(), + }; + let fields = role_fields(roles.iter().map(|role| (&*role.label, choice.clone()))); + let title = format!("Models for the new route {id}"); + let kind = FormKind::NewRouteModels { + generation: *generation, + name: config_name(&id, &self.value(1)), + id, + algorithm, + }; + return Ok(Submit::Next(Form::new(kind, title, fields))); + } + FormKind::NewRouteModels { + generation, + name, + id, + algorithm, + } => Action::PreviewRouteChange { + generation: *generation, + operation: "create".into(), + route: String::new(), + name: name.clone(), + id: id.clone(), + algorithm: Some(algorithm.clone()), + choices: self.choices(), + }, + FormKind::RouteIdentity { + generation, + operation, + route, + } => { + let id = self.value(0); + // A rename keeps the config name, so its form has no config name field. + let name = match self.fields.get(1) { + Some((_, typed)) => config_name(&id, typed.trim()), + None => String::new(), + }; + Action::PreviewRouteChange { + generation: *generation, + operation: operation.to_string(), + route: route.clone(), + name, + id, + algorithm: None, + choices: Vec::new(), + } + } + FormKind::Confirm(word, action) => { + confirm(word, &format!("Type {word} to apply the reviewed changes."))?; + (**action).clone() + } + FormKind::Remove { + generation, + route, + id, + revision, + } => { + confirm(id, "Route name did not match. Nothing was deleted.")?; + Action::Remove { + generation: *generation, + route: route.clone(), + id: id.clone(), + revision: Some(revision.clone()), + } + } + FormKind::Models => Action::Models { + client: self.value(0), + refresh: true, + key: None, + }, + FormKind::ModelKey => Action::Models { + client: self.value(0), + refresh: true, + key: Some(self.value(1)), + }, + FormKind::Account(tool) => Action::AddAccount { + tool: *tool, + name: self.value(0), + }, + FormKind::SettingsFile => return Ok(Submit::SettingsFile(self.value(0))), + FormKind::Project { + tool, + account, + route, + id, + } => Action::PreviewLaunch { + tool: *tool, + account: account.clone(), + route: route.clone(), + id: id.clone(), + project: self.value(0), + }, + FormKind::Transcript { + tool, + account, + route, + id, + } => Action::PreviewHandoff { + source: HandoffSource::Transcript { + path: self.value(0), + }, + tool: *tool, + account: account.clone(), + route: route.clone(), + id: id.clone(), + }, + })) } fn label(&self) -> &'static str { - match self.kind { + match &self.kind { + FormKind::Configuration => "Saving the configuration", FormKind::Preferences => "Saving the preferences", FormKind::SaveKey => "Saving the server key", FormKind::ImportKey => "Importing the server key", FormKind::RemoveKey => "Removing the server key", - FormKind::Update => "Starting the update", + FormKind::Update(_) => "Starting the update", + FormKind::Algorithm { .. } => "Opening the route editor", + FormKind::RouteModels { .. } => "Reviewing the route", + FormKind::NewRoute { .. } + | FormKind::NewRouteModels { .. } + | FormKind::RouteIdentity { .. } => "Reviewing the route change", + // The reviewed action names what typing the confirmation word saves. + FormKind::Confirm(_, action) => match **action { + Action::Install { .. } => "Installing the harness settings", + Action::Restore { .. } => "Restoring the harness settings", + Action::ArchiveAccount { .. } => "Archiving the account", + Action::Launch { .. } => "Launching the session", + Action::RemoveSession { .. } => "Removing the session", + Action::Handoff { .. } => "Writing the hand-off and opening the harness", + _ => "Saving the route", + }, + FormKind::Remove { .. } => "Deleting the route", + FormKind::Models => "Loading models", + FormKind::ModelKey => "Saving the key and loading models", + FormKind::Account(_) => "Adding the account", + FormKind::SettingsFile => "Choosing the settings file", + FormKind::Project { .. } => "Reviewing the session", + FormKind::Transcript { .. } => "Reviewing the hand-off", } } } +/// This function returns an endpoint field and a model field for each role and its choice. +fn role_fields<'a>(roles: impl Iterator) -> Vec<(String, String)> { + roles + .flat_map(|(label, choice)| { + let model = if label == "Model" { + label.to_string() + } else { + format!("{label} model") + }; + [ + (format!("{label} endpoint"), choice.client), + (model, choice.model), + ] + }) + .collect() +} + +/// This function returns the typed config name, or one made from the public model ID the same +/// way the desktop app fills a blank config name. +fn config_name(id: &str, typed: &str) -> String { + if !typed.is_empty() { + return typed.to_string(); + } + id.split(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_')) + .filter(|part| !part.is_empty()) + .collect::>() + .join("-") + .trim_matches('-') + .chars() + .take(64) + .collect() +} + +/// This function opens the models form after the controller returns the roles of `algorithm`. +fn route_models_form( + generation: u64, + route: &RouteInfo, + algorithm: String, + editor: &Value, +) -> Result { + let roles = editor["roles"].as_array().ok_or("No roles returned.")?; + let fields = role_fields(roles.iter().enumerate().map(|(index, role)| { + // A role keeps its model by tier when the new algorithm changes the role order. + let matching = route.tiers.as_ref().and_then(|tiers| { + let tier = role["tier"].as_str()?; + tiers.iter().position(|t| t.as_deref() == Some(tier)) + }); + let choice = matching + .and_then(|index| route.choices.get(index)) + .or_else(|| (algorithm == route.kind).then(|| route.choices.get(index))?) + .or_else(|| route.choices.first()) + .cloned() + .unwrap_or_default(); + (role["label"].as_str().unwrap_or_default(), choice) + })); + let kind = FormKind::RouteModels { + generation, + route: route.key.clone(), + algorithm, + revision: Some(route.revision.clone()), + }; + Ok(Form::new(kind, format!("Models for {}", route.id), fields)) +} + +/// This function returns the action that a preview's review window submits after the user +/// types the confirmation word: the previewed inputs, plus the preview's token for every flow +/// except a route edit, which sends its revision instead. +fn reviewed(previewed: Action, preview: &Value) -> Option { + let preview_token = preview["preview_token"].as_str().map(str::to_owned); + Some(match previewed { + Action::PreviewResetSettings {} => Action::ResetSettings { preview_token }, + Action::PreviewRoute { + generation, + route, + algorithm, + choices, + revision, + } => Action::Apply { + generation, + route, + algorithm, + choices, + revision, + }, + Action::PreviewRouteChange { + generation, + operation, + route, + name, + id, + algorithm, + choices, + } => Action::RouteChange { + generation, + operation, + route, + name, + id, + algorithm, + choices, + preview_token, + }, + Action::PreviewInstall { + tool, + account, + route, + id, + settings_file, + } => Action::Install { + tool, + account, + route, + id, + settings_file, + preview_token, + }, + Action::PreviewRestore { + tool, + account, + settings_file, + } => Action::Restore { + tool, + account, + settings_file, + preview_token, + }, + Action::PreviewAccountArchive { tool, name } => Action::ArchiveAccount { + tool, + name, + preview_token, + }, + Action::PreviewLaunch { + tool, + account, + route, + id, + project, + } => Action::Launch { + preview_token, + tool, + account, + route, + id, + project, + }, + Action::PreviewSessionCleanup { id } => Action::RemoveSession { id, preview_token }, + Action::PreviewHandoff { + source, + tool, + account, + route, + id, + } => Action::Handoff { + source, + tool, + account, + route, + id, + preview_token, + attach: false, + }, + _ => return None, + }) +} + enum Popup { Help { scroll: u16, @@ -468,15 +1260,18 @@ enum Popup { }, Restart, Form(Form), - Update { - preview: Value, + /// The user reads the preview lines, then types the confirmation word into `confirm`. + Review { + lines: Vec>, + /// This field names what Enter does, such as Save or Delete. + verb: &'static str, scroll: u16, confirm: Form, }, Message { title: &'static str, text: String, - error: bool, + tone: Tone, scroll: u16, }, } @@ -485,17 +1280,32 @@ impl Popup { match self { Popup::Help { scroll } | Popup::Detail { scroll, .. } - | Popup::Update { scroll, .. } + | Popup::Review { scroll, .. } | Popup::Message { scroll, .. } => Some(scroll), Popup::Restart | Popup::Form(_) => None, } } fn form_mut(&mut self) -> Option<&mut Form> { match self { - Popup::Form(form) | Popup::Update { confirm: form, .. } => Some(form), + Popup::Form(form) | Popup::Review { confirm: form, .. } => Some(form), _ => None, } } + /// This function opens a review window whose one field takes the confirmation word. + fn review( + lines: Vec>, + verb: &'static str, + kind: FormKind, + title: impl Into>, + field: &'static str, + ) -> Self { + Popup::Review { + lines, + verb, + scroll: 0, + confirm: Form::new(kind, title, vec![(field, String::new())]), + } + } } enum Job { @@ -515,6 +1325,9 @@ struct Request { job: Job, label: &'static str, automatic: bool, + /// This field holds the `ACTIONS` key that started the job. Keys that share a first action + /// continue differently when the reply arrives, such as `p` and `i` on the Harnesses page. + key: Option, } impl Request { fn run(action: Action, label: &'static str) -> Self { @@ -522,6 +1335,7 @@ impl Request { job: Job::Run(action), label, automatic: false, + key: None, } } fn refresh(automatic: bool) -> Self { @@ -529,15 +1343,18 @@ impl Request { job: Job::Refresh, label: "Refreshing", automatic, + key: None, } } } struct Busy { label: &'static str, - /// This field names the running action, so its reply can update the right view. - kind: Option, + /// This field holds the running action, so its reply can update the right view and a + /// preview's review window can submit exactly what the controller reviewed. + action: Option, /// This field records the popup generation when the action starts. popup_generation: u64, + key: Option, started: Instant, /// Automatic refreshes report errors in the status bar, and automatic key checks update /// Settings without opening a popup. @@ -545,9 +1362,41 @@ struct Busy { } struct Toast { text: String, - error: bool, + tone: Tone, shown: Instant, } +/// Tone tells the user how an action went. A warning means the action did only part of its +/// work, such as saving a route when the server could not restart. Warnings and errors stay in +/// the status bar until Esc, because the user must act on them. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Tone { + Done, + Warning, + Error, +} +impl Tone { + /// The controller marks a reply as a warning when the action did only part of its work. + fn of(reply: &Reply) -> Self { + if reply.data["warning"] == true { + Tone::Warning + } else { + Tone::Done + } + } +} + +/// Destination is where the Harnesses page installs and restores settings. The Sessions page +/// launches with its account, and with the harness's own login otherwise. +enum Destination { + /// The harness's own settings files. + Harness, + /// A saved account, by name. The name stays after a refresh stops listing the account, so + /// its actions still send it and the controller rejects them instead of changing other + /// settings. + Account(String), + /// A custom settings file path. + File(String), +} struct App { theme: Theme, @@ -562,6 +1411,21 @@ struct App { selected: usize, offset: usize, action: usize, + route_filter: String, + /// These indexes point into `data.routes`, in config order, after the route filter. + route_visible: Vec, + route_selected: usize, + route_offset: usize, + /// This field indexes `data.tools`, which lists every harness in a fixed order. + harness: usize, + destination: Destination, + /// This field holds the last install preview and the action that produced it. The + /// Harnesses page shows it only while that action matches the selection. + install_preview: Option<(Action, Value)>, + /// This field indexes `data.session_inventory`, newest first. It starts at the first + /// session, and it is `None` after a refresh stops listing the selected session, so no + /// action reaches another session. + managed: Option, /// This field holds the last `ServerKeys` reply. It is `None` until the user checks the keys, /// and a failed check clears it so the page never shows outdated key states. server_keys: Option, @@ -591,6 +1455,14 @@ impl App { selected: 0, offset: 0, action: 0, + route_filter: String::new(), + route_visible: Vec::new(), + route_selected: 0, + route_offset: 0, + harness: 0, + destination: Destination::Harness, + install_preview: None, + managed: Some(0), server_keys: None, popup: None, popup_generation: 0, @@ -627,6 +1499,138 @@ impl App { .map(|&index| &self.data.entries[index]) } + /// A route keeps its selection by config name, so a refresh or a rename keeps it selected. + fn selected_route_key(&self) -> Option { + self.selected_route().ok().map(|route| route.key.clone()) + } + + /// This function filters the routes and selects `anchor` if it is still listed. Otherwise + /// the selection keeps its position, so deleting a route selects the next one; `refilter` + /// starts from the first match instead. + fn rebuild_routes(&mut self, anchor: Option) { + let algorithms = &self.data.algorithms; + let filter = self.route_filter.as_str(); + self.route_visible = self + .data + .routes + .iter() + .enumerate() + .filter(|(_, route)| { + let title = algorithms + .iter() + .find(|algorithm| algorithm.kind == route.kind) + .map_or("", |algorithm| algorithm.title); + matches_words(&[&route.label, &route.key, title], filter) + }) + .map(|(index, _)| index) + .collect(); + let routes = &self.data.routes; + self.route_selected = anchor + .and_then(|key| { + self.route_visible + .iter() + .position(|&index| routes[index].key == key) + }) + .unwrap_or(self.route_selected) + .min(self.route_visible.len().saturating_sub(1)); + } + + fn selected_route(&self) -> Result<&RouteInfo, String> { + match self.route_visible.get(self.route_selected) { + Some(&index) => Ok(&self.data.routes[index]), + None if !self.data.routes.is_empty() => { + Err("No route matches the filter. Clear or change the filter first.".into()) + } + None => Err("Choose a route first.".into()), + } + } + + fn harness(&self) -> Result<&ToolInfo, String> { + self.data + .tools + .get(self.harness) + .ok_or_else(|| "No harness is listed. Fix the app settings, then press r.".into()) + } + + /// This function returns the selected account's name, or `None` when the harness's own + /// settings or a custom settings file is the destination. + fn account(&self) -> Option { + match &self.destination { + Destination::Account(name) => Some(name.clone()), + Destination::Harness | Destination::File(_) => None, + } + } + + fn custom_file(&self) -> Option { + match &self.destination { + Destination::File(path) => Some(path.clone()), + Destination::Harness | Destination::Account(_) => None, + } + } + + /// Account actions change one saved account, so this function rejects other destinations. + fn selected_account(&self) -> Result<(Harness, String), String> { + let name = self + .account() + .ok_or("Press a to select a saved account first.")?; + Ok((self.harness()?.tool, name)) + } + + /// This function builds the install preview of the selected route for the selected + /// harness and destination. + fn install_action(&self) -> Result { + let tool = self.harness()?.tool; + let route = self.selected_route()?; + Ok(Action::PreviewInstall { + tool, + account: self.account(), + route: route.key.clone(), + id: route.id.clone(), + settings_file: self.custom_file(), + }) + } + + /// This function returns the last install preview while it still describes the selection. + fn install_preview(&self) -> Option<&Value> { + let ( + Action::PreviewInstall { + tool, + account, + route, + id, + settings_file, + }, + preview, + ) = self.install_preview.as_ref()? + else { + return None; + }; + let selected = self.selected_route().ok()?; + let destination = match (&self.destination, account, settings_file) { + (Destination::Harness, None, None) => true, + (Destination::Account(name), Some(previewed), None) => name == previewed, + (Destination::File(path), None, Some(previewed)) => path == previewed, + _ => false, + }; + let current = destination + && self.harness().is_ok_and(|harness| harness.tool == *tool) + && selected.key == *route + && selected.id == *id; + current.then_some(preview) + } + + fn selected_session(&self) -> Option<&Value> { + self.data.session_inventory.get(self.managed?) + } + + /// This function returns the ID of the session that the open and remove keys use. + fn session_id(&self) -> Result { + self.selected_session() + .and_then(|receipt| receipt["id"].as_str()) + .map(str::to_owned) + .ok_or_else(|| "Press s to select a recorded session first.".into()) + } + // Every replacement or dismissal changes the generation, even when the same form reopens. fn set_popup(&mut self, popup: Option) { self.popup_generation += 1; @@ -634,34 +1638,38 @@ impl App { } /// A reply from a dismissed or replaced popup uses the status bar to preserve the current draft. - fn report(&mut self, text: String, error: bool, popup_matches: bool) { + fn report(&mut self, text: String, tone: Tone, popup_matches: bool) { if popup_matches { - self.notify(text, error); + self.notify(text, tone); } else if !text.is_empty() { self.toast = Some(Toast { text, - error, + tone, shown: Instant::now(), }); } } /// Short single-line messages appear in the status bar; others open a scrollable window. - fn notify(&mut self, text: String, error: bool) { + fn notify(&mut self, text: String, tone: Tone) { if text.is_empty() { return; } if text.contains('\n') || text.chars().count() + 8 > usize::from(self.width) * 2 / 3 { self.set_popup(Some(Popup::Message { - title: if error { "Error" } else { "Done" }, + title: match tone { + Tone::Done => "Done", + Tone::Warning => "Attention", + Tone::Error => "Error", + }, text, - error, + tone, scroll: 0, })); } else { self.toast = Some(Toast { text, - error, + tone, shown: Instant::now(), }); } @@ -674,7 +1682,9 @@ impl App { ) -> Option { let busy = self.busy.take(); let automatic = busy.as_ref().is_some_and(|busy| busy.automatic); - let kind = busy.as_ref().and_then(|busy| busy.kind); + let kind = busy + .as_ref() + .and_then(|busy| busy.action.as_ref().map(ActionKind::from)); // The reply's popup must be identified before snapshot errors can change the popup. let popup_matches = busy .as_ref() @@ -686,9 +1696,26 @@ impl App { .selected_entry() .filter(|_| self.selected > 0) .map(|entry| (entry.ts.clone(), entry.model.clone(), entry.turn_id.clone())); + let initial = self.refreshed.is_none(); + let route = self.selected_route_key(); + let managed = self.session_id().ok(); self.data = *data; + if let Some(id) = managed { + self.managed = self + .data + .session_inventory + .iter() + .position(|receipt| receipt["id"] == id); + } self.refreshed = Some(Instant::now()); self.rebuild_calls(); + self.rebuild_routes(route); + if initial && (self.data.needs_setup || self.data.configuration_error.is_some()) { + self.tab = Tab::Settings; + if self.data.needs_setup { + let _ = self.start_action(ActionKind::SaveConfiguration, 'f'); + } + } if let Some((ts, model, turn)) = anchor && let Some(position) = self.visible.iter().position(|&index| { let entry = &self.data.entries[index]; @@ -708,26 +1735,37 @@ impl App { if automatic || !popup_matches || self.popup.is_some() { self.toast = Some(Toast { text: error.lines().next().unwrap_or_default().into(), - error: true, + tone: Tone::Error, shown: Instant::now(), }); } else { - self.notify(error, true); + self.notify(error, Tone::Error); } } } + let (action, key) = busy + .map(|busy| (busy.action, busy.key)) + .unwrap_or((None, None)); let reply = reply?; + match (&action, reply.is_ok()) { + // Saved settings no longer match an earlier diff, and a failed preview has none. + (Some(Action::Install { .. } | Action::Restore { .. }), true) + | (Some(Action::PreviewInstall { .. }), false) => self.install_preview = None, + // The archived account leaves the list, so the harness's own settings are next. + (Some(Action::ArchiveAccount { .. }), true) => self.destination = Destination::Harness, + _ => {} + } match (kind, reply) { (Some(ActionKind::ServerKeys), Err(error)) => { self.server_keys = None; - self.report(error, true, popup_matches); + self.report(error, Tone::Error, popup_matches); } // A form stays open with the error, so the user can fix the input and submit again. (_, Err(error)) => { if popup_matches && let Some(form) = self.popup.as_mut().and_then(Popup::form_mut) { form.error = error; } else { - self.report(error, true, popup_matches); + self.report(error, Tone::Error, popup_matches); } } (Some(ActionKind::PreviewUpdate), Ok(reply)) => { @@ -735,38 +1773,215 @@ impl App { self.report( "The source update preview finished. Press u in Settings to review it." .into(), - false, + Tone::Done, false, ); return None; } - self.set_popup(Some(Popup::Update { - preview: reply.data, - scroll: 0, - confirm: Form::new( - FormKind::Update, - "Update from source?", - vec![("Type UPDATE to start", String::new())], - ), - })); + let token = reply.data["preview_token"].as_str().map(str::to_owned); + self.set_popup(Some(Popup::review( + update_lines(self.theme, &reply.data), + "Start", + FormKind::Update(token), + "Update from source?", + "Type UPDATE to start", + ))); } - (Some(ActionKind::ServerKeys), Ok(reply)) => { - let text = key_lines(self.theme, Some(&reply.data)) - .iter() - .map(ToString::to_string) - .collect::>() - .join("\n"); - self.server_keys = Some(reply.data); - if !popup_matches { - if !automatic { - self.report("Checked the saved server keys.".into(), false, false); + // The p key shows the diff on the page. The i key also opens the review, unless the + // settings already match the route. + (Some(ActionKind::PreviewInstall), Ok(reply)) => { + let previewed = action?; + let changed = reply.data["changes"] + .as_array() + .is_some_and(|changes| !changes.is_empty()); + if !changed { + self.report( + "The settings already match this route.".into(), + Tone::Done, + popup_matches, + ); + } else if popup_matches + && key == Some('i') + && let Some(install @ Action::Install { tool, .. }) = + reviewed(previewed.clone(), &reply.data) + { + let known = self.data.tools.iter().find(|known| known.tool == tool); + let label = known.map_or("the harness", |known| known.label); + let title = format!("Apply routing to {label}?"); + self.set_popup(Some(Popup::review( + install_lines(self.theme, &reply.data), + "Install", + FormKind::Confirm("INSTALL".into(), Box::new(install)), + title, + "Type INSTALL to save the settings", + ))); + } else { + let text = "Press i to apply this diff."; + self.report(text.into(), Tone::Done, popup_matches); + } + self.install_preview = Some((previewed, reply.data)); + } + (Some(ActionKind::PreviewRestore), Ok(reply)) if popup_matches => { + if let Some(restore) = action.and_then(|action| reviewed(action, &reply.data)) { + self.set_popup(Some(Popup::review( + restore_lines(self.theme, &reply.data), + "Restore", + FormKind::Confirm("RESTORE".into(), Box::new(restore)), + "Restore harness settings?", + "Type RESTORE to replace the files", + ))); + } + } + (Some(ActionKind::PreviewAccountArchive), Ok(reply)) if popup_matches => { + if let Some(archive) = action.and_then(|action| reviewed(action, &reply.data)) + && let Action::ArchiveAccount { name, .. } = &archive + { + let name = name.clone(); + self.set_popup(Some(Popup::review( + archive_lines(self.theme, &reply.data), + "Archive", + FormKind::Confirm(name.clone().into(), Box::new(archive)), + format!("Archive the account {name}?"), + "Type the account name to archive", + ))); + } + } + // The reply lists the roles of the chosen algorithm. The models form replaces the + // algorithm form only while that form is still open for the same route. + (Some(ActionKind::Editor), Ok(reply)) if popup_matches => { + if let Some(Action::Editor { + route: key, + algorithm, + .. + }) = action + && let Some(Popup::Form(form)) = &self.popup + && let FormKind::Algorithm { generation, route } = &form.kind + && route.key == key + { + match route_models_form(*generation, route, algorithm, &reply.data) { + Ok(next) => self.set_popup(Some(Popup::Form(next))), + Err(error) => self.notify(error, Tone::Error), + } + } + } + // The review window replaces the form that asked for the preview, so a form that + // the user closed meanwhile stays closed. + (Some(ActionKind::PreviewRoute | ActionKind::PreviewRouteChange), Ok(reply)) + if popup_matches => + { + if matches!(self.popup, Some(Popup::Form(_))) + && let Some(reviewed) = action.and_then(|action| reviewed(action, &reply.data)) + { + self.set_popup(Some(Popup::review( + change_lines(self.theme, &reply.data), + "Save", + FormKind::Confirm("SAVE".into(), Box::new(reviewed)), + "Review route changes", + "Type SAVE to apply", + ))); + } + } + // Like a route review, the launch review replaces the project form that asked for + // it, so a form that the user closed meanwhile stays closed. + (Some(ActionKind::PreviewLaunch), Ok(reply)) if popup_matches => { + if matches!(self.popup, Some(Popup::Form(_))) + && let Some(launch) = action.and_then(|action| reviewed(action, &reply.data)) + { + self.set_popup(Some(Popup::review( + launch_lines(self.theme, &reply.data), + "Launch", + FormKind::Confirm("LAUNCH".into(), Box::new(launch)), + "Start this session?", + "Type LAUNCH to start it", + ))); + } + } + (Some(ActionKind::PreviewResetSettings), Ok(reply)) if popup_matches => { + if let Some(remove) = action.and_then(|action| reviewed(action, &reply.data)) { + self.set_popup(Some(Popup::review( + vec![ + Line::from( + reply.data["message"] + .as_str() + .unwrap_or_default() + .to_string(), + ), + Line::from(reply.data["file"].as_str().unwrap_or_default().to_string()), + ], + "Remove", + FormKind::Confirm("REMOVE".into(), Box::new(remove)), + "Remove app preferences?", + "Type REMOVE to keep a recovery copy and remove preferences", + ))); + } + } + (Some(ActionKind::PreviewSessionCleanup), Ok(reply)) if popup_matches => { + if let Some(remove) = action.and_then(|action| reviewed(action, &reply.data)) { + self.set_popup(Some(Popup::review( + cleanup_lines(self.theme, &reply.data), + "Remove", + FormKind::Confirm("REMOVE".into(), Box::new(remove)), + "Remove the session worktree?", + "Type REMOVE to delete it", + ))); + } + } + // The review shows the whole hand-off text, so the user sees what the file holds + // before it is written. It replaces the transcript form when that asked for it. + (Some(ActionKind::PreviewHandoff), Ok(reply)) if popup_matches => { + if let Some(handoff) = action.and_then(|action| reviewed(action, &reply.data)) { + self.set_popup(Some(Popup::review( + handoff_lines(self.theme, &reply.data), + "Continue", + FormKind::Confirm("HANDOFF".into(), Box::new(handoff)), + "Continue this session in another harness?", + "Type HANDOFF to write the file and open the harness", + ))); + } + } + (Some(ActionKind::RecentSessions), Ok(reply)) if popup_matches => { + self.set_popup(Some(Popup::Message { + title: "Harness sessions", + text: recent_sessions_text(&reply.data), + tone: Tone::Done, + scroll: 0, + })); + } + (Some(ActionKind::Models), Ok(reply)) if popup_matches => { + let models: Vec<&str> = reply.data["models"] + .as_array() + .map(|items| items.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + let list = if models.is_empty() { + "No model IDs found.".to_string() + } else { + models.join("\n") + }; + let client = reply.data["client"].as_str().unwrap_or_default(); + self.set_popup(Some(Popup::Message { + title: "Models", + text: format!("{}\n\nModel IDs on {client}:\n{list}", reply.message), + tone: Tone::of(&reply), + scroll: 0, + })); + } + (Some(ActionKind::ServerKeys), Ok(reply)) => { + let text = key_lines(self.theme, Some(&reply.data)) + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + self.server_keys = Some(reply.data); + if !popup_matches { + if !automatic { + self.report("Checked the saved server keys.".into(), Tone::Done, false); } return None; } self.set_popup(Some(Popup::Message { title: "Server keys", text, - error: false, + tone: Tone::Done, scroll: 0, })); } @@ -781,34 +1996,39 @@ impl App { if popup_matches { self.set_popup(None); } - self.report(reply.message, false, popup_matches); + self.report(reply.message, Tone::Done, popup_matches); let mut request = Request::run(Action::ServerKeys {}, "Checking the server keys"); request.automatic = true; return Some(request); } (_, Ok(reply)) => { if popup_matches - && matches!(self.popup, Some(Popup::Form(_) | Popup::Update { .. })) + && matches!(self.popup, Some(Popup::Form(_) | Popup::Review { .. })) { self.set_popup(None); } + let tone = Tone::of(&reply); let details: Vec<&str> = reply.data["details"] .as_array() .map(|items| items.iter().filter_map(Value::as_str).collect()) .unwrap_or_default(); if details.is_empty() { - self.report(reply.message, false, popup_matches); + self.report(reply.message, tone, popup_matches); } else if !popup_matches { self.report( format!("{}\n\n{}", reply.message, details.join("\n")), - false, + tone, false, ); } else { self.set_popup(Some(Popup::Message { - title: "Details", + title: if tone == Tone::Warning { + "Attention" + } else { + "Details" + }, text: format!("{}\n\n{}", reply.message, details.join("\n")), - error: false, + tone, scroll: 0, })); } @@ -855,20 +2075,21 @@ impl App { fn start(&mut self, jobs: &mpsc::Sender, request: Request) -> Result<(), String> { if let Some(busy) = &self.busy { let text = format!("Wait for \"{}\" to finish.", busy.label); - self.notify(text, true); + self.notify(text, Tone::Error); return Ok(()); } - let kind = match &request.job { + let action = match &request.job { Job::Refresh => { self.last_refresh = Instant::now(); None } - Job::Run(action) => Some(ActionKind::from(action)), + Job::Run(action) => Some(action.clone()), }; jobs.send(request.job).map_err(|error| error.to_string())?; self.busy = Some(Busy { label: request.label, - kind, + action, + key: request.key, started: Instant::now(), automatic: request.automatic, popup_generation: self.popup_generation, @@ -879,24 +2100,48 @@ impl App { fn keys(&self) -> &'static [Key] { match (&self.popup, self.filtering, self.tab) { (Some(Popup::Restart), _, _) => CONFIRM_KEYS, - (Some(Popup::Update { .. }), _, _) => UPDATE_KEYS, + (Some(Popup::Review { .. }), _, _) => REVIEW_KEYS, + (Some(Popup::Form(form)), _, _) if form.options().is_some() => CHOICE_KEYS, (Some(Popup::Form(_)), _, _) => FORM_KEYS, (Some(_), _, _) => POPUP_KEYS, (None, true, _) => FILTER_KEYS, (None, false, Tab::Overview) => &[], (None, false, Tab::Calls) => CALLS_KEYS, (None, false, Tab::Models) => MODELS_KEYS, + (None, false, Tab::Routes) => ROUTES_KEYS, + (None, false, Tab::Harnesses) => HARNESSES_KEYS, + (None, false, Tab::Sessions) => SESSIONS_KEYS, (None, false, Tab::Settings) => SETTINGS_KEYS, } } + /// The Calls and Routes pages each keep their own filter; `/` edits the current page's. + fn filter_mut(&mut self) -> &mut String { + if self.tab == Tab::Routes { + &mut self.route_filter + } else { + &mut self.filter + } + } + + fn refilter(&mut self) { + if self.tab == Tab::Routes { + let anchor = self.selected_route_key(); + self.route_selected = 0; + self.rebuild_routes(anchor); + } else { + self.selected = 0; + self.rebuild_calls(); + } + } + fn paste(&mut self, text: &str) { if let Some(form) = self.popup.as_mut().and_then(Popup::form_mut) { form.insert(text); } else if self.filtering && self.popup.is_none() { - self.filter.extend(text.chars().filter(|c| !c.is_control())); - self.selected = 0; - self.rebuild_calls(); + self.filter_mut() + .extend(text.chars().filter(|c| !c.is_control())); + self.refilter(); } } @@ -915,7 +2160,7 @@ impl App { match key.code { KeyCode::Char('q') => self.quit = true, KeyCode::Char('?') => self.set_popup(Some(Popup::Help { scroll: 0 })), - KeyCode::Char(digit @ '1'..='4') => { + KeyCode::Char(digit @ '1'..='7') => { self.tab = TABS[digit as usize - '1' as usize]; } KeyCode::Tab | KeyCode::Right => self.tab = TABS[(self.tab.index() + 1) % TABS.len()], @@ -925,22 +2170,49 @@ impl App { KeyCode::Char('r') | KeyCode::F(5) => return Some(Request::refresh(false)), KeyCode::Esc => { self.toast = None; - if self.tab == Tab::Calls { - self.filter.clear(); - self.session = None; - self.rebuild_calls(); + match self.tab { + Tab::Calls => { + self.filter.clear(); + self.session = None; + self.rebuild_calls(); + } + Tab::Routes => { + self.route_filter.clear(); + self.refilter(); + } + Tab::Overview + | Tab::Models + | Tab::Harnesses + | Tab::Sessions + | Tab::Settings => {} + } + } + code => { + // Action letters come before the page's own keys, so `k` runs an action on + // Routes and Settings instead of moving up. + if let KeyCode::Char(letter) = code + && let Some((index, &(_, key, _, _, flow))) = + rows(self.tab).enumerate().find(|(_, row)| row.1 == letter) + { + if self.tab == Tab::Settings { + self.action = index; + } + return self.run_row(flow[0], key); + } + match self.tab { + Tab::Overview => {} + Tab::Calls => self.calls_key(code), + Tab::Models => match code { + KeyCode::Char('p' | ']') => self.period = self.period.next(), + KeyCode::Char('[') => self.period = self.period.previous(), + _ => {} + }, + Tab::Routes => self.routes_key(code), + Tab::Harnesses => self.harnesses_key(code), + Tab::Sessions => self.sessions_key(code), + Tab::Settings => return self.settings_key(code), } } - code => match self.tab { - Tab::Overview => {} - Tab::Calls => self.calls_key(code), - Tab::Models => match code { - KeyCode::Char('p' | ']') => self.period = self.period.next(), - KeyCode::Char('[') => self.period = self.period.previous(), - _ => {} - }, - Tab::Settings => return self.settings_key(code), - }, } None } @@ -977,70 +2249,411 @@ impl App { } } + fn routes_key(&mut self, code: KeyCode) { + let last = self.route_visible.len().saturating_sub(1); + if let Some(selected) = step(code, self.route_selected, last) { + self.route_selected = selected; + } else if code == KeyCode::Char('/') { + self.filtering = true; + } + } + + /// The arrows choose the route, which is the longer list; `t` cycles through the few + /// harnesses. A new harness starts at its own settings, because accounts belong to one + /// harness. The Harnesses and Sessions pages share these keys and the selection. + fn selection_key(&mut self, code: KeyCode) { + let last = self.route_visible.len().saturating_sub(1); + if let Some(selected) = step(code, self.route_selected, last) { + self.route_selected = selected; + return; + } + match code { + KeyCode::Char('t') => { + self.harness = (self.harness + 1) % self.data.tools.len().max(1); + self.destination = Destination::Harness; + } + // The cycle runs through the harness's own settings and then each saved account. + KeyCode::Char('a') => { + let names = self.harness().map_or(&[][..], |tool| &tool.accounts); + let current = match &self.destination { + Destination::Account(name) => names.iter().position(|known| known == name), + Destination::Harness | Destination::File(_) => None, + }; + let next = names.get(current.map_or(0, |index| index + 1)).cloned(); + self.destination = next.map_or(Destination::Harness, Destination::Account); + } + _ => {} + } + } + + fn harnesses_key(&mut self, code: KeyCode) { + if code != KeyCode::Char('x') { + return self.selection_key(code); + } + self.set_popup(Some(Popup::Form(Form::new( + FormKind::SettingsFile, + "Custom settings file · leave empty to use the detected settings", + vec![( + "Absolute file path (Pi: models.json)", + self.custom_file().unwrap_or_default(), + )], + )))); + } + + /// The `s` key selects the next recorded session. Without a selection, it selects the + /// newest one. + fn sessions_key(&mut self, code: KeyCode) { + if code != KeyCode::Char('s') { + return self.selection_key(code); + } + let count = self.data.session_inventory.len().max(1); + self.managed = Some(self.managed.map_or(0, |index| (index + 1) % count)); + } + fn filter_key(&mut self, key: KeyEvent) { let control = key.modifiers.contains(KeyModifiers::CONTROL); match key.code { KeyCode::Enter => self.filtering = false, KeyCode::Esc => { self.filtering = false; - self.filter.clear(); + self.filter_mut().clear(); } KeyCode::Backspace => { - self.filter.pop(); + self.filter_mut().pop(); } - KeyCode::Char('u') if control => self.filter.clear(), + KeyCode::Char('u') if control => self.filter_mut().clear(), KeyCode::Char('w') if control => { - let kept = self - .filter - .trim_end() - .rfind(' ') - .map_or(0, |index| index + 1); - self.filter.truncate(kept); + let filter = self.filter_mut(); + let kept = filter.trim_end().rfind(' ').map_or(0, |index| index + 1); + filter.truncate(kept); } KeyCode::Char(c) if !control && !key.modifiers.contains(KeyModifiers::ALT) => { - self.filter.push(c); + self.filter_mut().push(c); } _ => return, } - self.selected = 0; - self.rebuild_calls(); + self.refilter(); } - /// Action letters come first, so `k` checks the server keys here instead of moving up. fn settings_key(&mut self, code: KeyCode) -> Option { - if let KeyCode::Char(letter) = code - && let Some(index) = ACTIONS.iter().position(|action| action.0 == letter) - { - self.action = index; - return self.start_action(ACTIONS[index].3); - } - if let Some(action) = step(code, self.action, ACTIONS.len() - 1) { + if let Some(action) = step(code, self.action, rows(Tab::Settings).count() - 1) { self.action = action; return None; } - if code == KeyCode::Enter { - return self.start_action(ACTIONS[self.action].3); + if code == KeyCode::Enter + && let Some(&(_, key, _, _, flow)) = rows(Tab::Settings).nth(self.action) + { + return self.run_row(flow[0], key); } None } - /// This function starts a Settings action. The match has no wildcard arm, so adding a - /// controller action breaks the build until the terminal app can run it. - fn start_action(&mut self, kind: ActionKind) -> Option { - let form = |kind, title, fields| Some(Popup::Form(Form::new(kind, title, fields))); - let request = |action, label| Some(Request::run(action, label)); + /// This function runs a row's flow and records its key on the request, so the reply can + /// continue that key's flow. + fn run_row(&mut self, kind: ActionKind, key: char) -> Option { + match self.start_action(kind, key) { + Ok(request) => request.map(|request| Request { + key: Some(key), + ..request + }), + Err(error) => { + self.notify(error, Tone::Error); + None + } + } + } + + fn open(&mut self, form: Form) -> Result, String> { + self.set_popup(Some(Popup::Form(form))); + Ok(None) + } + + /// This function starts the flow of an `ACTIONS` row. The match has no wildcard arm, so + /// adding a controller action breaks the build until the terminal app can run it. Only the + /// arms of actions that several keys start read `key`, and they return an error for a key + /// that they do not know, so a new row must also be added to its arm. + fn start_action(&mut self, kind: ActionKind, key: char) -> Result, String> { + let request = |action, label| Ok(Some(Request::run(action, label))); + let generation = self.data.generation; match kind { + // The algorithm form submits Editor, the models form submits PreviewRoute, and the + // review window submits Apply. + ActionKind::Editor | ActionKind::PreviewRoute | ActionKind::Apply => { + let route = Box::new(self.selected_route()?.clone()); + let title = format!("Routing algorithm for {}", route.id); + let fields = vec![("Algorithm", route.kind.clone())]; + self.open(Form::new( + FormKind::Algorithm { generation, route }, + title, + fields, + )) + } + // The route form submits PreviewRouteChange, and the review window submits + // RouteChange. A new route also needs a models form before the review. + ActionKind::PreviewRouteChange | ActionKind::RouteChange => { + const CONFIG_NAME: &str = "Config name (blank uses the public model ID)"; + let form = match key { + 'a' => { + let algorithm = self.data.algorithms.first().map(|a| a.kind); + Form::new( + FormKind::NewRoute { generation }, + "Create a route", + vec![ + ("Public model ID", String::new()), + (CONFIG_NAME, String::new()), + ("Algorithm", algorithm.unwrap_or_default().to_string()), + ], + ) + } + 'n' => { + let route = self.selected_route()?; + Form::new( + FormKind::RouteIdentity { + generation, + operation: "rename", + route: route.key.clone(), + }, + format!("Rename {}", route.id), + vec![("Public model ID", route.id.clone())], + ) + } + 'c' => { + let route = self.selected_route()?; + Form::new( + FormKind::RouteIdentity { + generation, + operation: "duplicate", + route: route.key.clone(), + }, + format!("Duplicate {}", route.id), + vec![ + ("Public model ID", String::new()), + (CONFIG_NAME, String::new()), + ], + ) + } + _ => return Err(format!("The {key} key does not start a route change.")), + }; + self.open(form) + } + ActionKind::Remove => { + let route = self.selected_route()?; + let lines = remove_lines(self.theme, route, self.data.settings.as_ref()); + let title = format!("Delete route {}?", route.id); + let kind = FormKind::Remove { + generation, + route: route.key.clone(), + id: route.id.clone(), + revision: route.revision.clone(), + }; + self.set_popup(Some(Popup::review( + lines, + "Delete", + kind, + title, + "Type the route name to delete", + ))); + Ok(None) + } + ActionKind::Models => { + let client = self.data.clients.first(); + let client = client.map(|c| c.name.clone()).unwrap_or_default(); + let form = match key { + 'm' => Form::new( + FormKind::Models, + "Load an endpoint's models", + vec![("Endpoint name", client)], + ), + 'k' => Form::new( + FormKind::ModelKey, + "Save a model-list key", + vec![("Endpoint name", client), ("API key", String::new())], + ), + _ => return Err(format!("The {key} key does not load models.")), + }; + self.open(form) + } + // Both keys preview the install. App::finish shows the diff for p and opens the + // review, which submits Install, for i. + ActionKind::PreviewInstall | ActionKind::Install => { + let label = match key { + 'p' => "Previewing the settings", + 'i' => "Reviewing the settings", + _ => return Err(format!("The {key} key does not preview harness settings.")), + }; + request(self.install_action()?, label) + } + // The review window lists the files that return to their backups and submits + // Restore. + ActionKind::PreviewRestore | ActionKind::Restore => { + let tool = self.harness()?.tool; + let restore = Action::PreviewRestore { + tool, + account: self.account(), + settings_file: self.custom_file(), + }; + request(restore, "Reading the original settings") + } + ActionKind::AddAccount => { + let harness = self.harness()?; + let title = format!("Sign in to another {} account", harness.label); + let kind = FormKind::Account(harness.tool); + self.open(Form::new( + kind, + title, + vec![("Account name", String::new())], + )) + } + ActionKind::LoginAccount => { + let (tool, name) = self.selected_account()?; + request(Action::LoginAccount { tool, name }, "Opening the login") + } + // Controller::editor_target returns the account folder, so it opens in the editor. + ActionKind::OpenAccount => { + let (tool, name) = self.selected_account()?; + request( + Action::OpenAccount { tool, name }, + "Opening the account folder", + ) + } + // The review window names the account folder and submits ArchiveAccount. + ActionKind::PreviewAccountArchive | ActionKind::ArchiveAccount => { + let (tool, name) = self.selected_account()?; + let archive = Action::PreviewAccountArchive { tool, name }; + request(archive, "Reviewing the account archive") + } + // The project form submits PreviewLaunch, and the review window submits Launch. + ActionKind::PreviewLaunch | ActionKind::Launch => { + let harness = self.harness()?; + let route = self.selected_route()?; + let title = format!( + "Launch a worktree session · {} · {}", + harness.label, route.label + ); + let kind = FormKind::Project { + tool: harness.tool, + account: self.account(), + route: route.key.clone(), + id: route.id.clone(), + }; + self.open(Form::new( + kind, + title, + vec![("Git project folder", String::new())], + )) + } + // Controller::editor_target returns the session worktree, so it opens in the editor. + ActionKind::OpenSession => { + let id = self.session_id()?; + request(Action::OpenSession { id }, "Opening the session folder") + } + // The review window lists the Git-ignored files that cleanup deletes and submits + // RemoveSession. The controller rejects the removal when the worktree changed after + // the review. + ActionKind::PreviewSessionCleanup | ActionKind::RemoveSession => { + let id = self.session_id()?; + let cleanup = Action::PreviewSessionCleanup { id }; + request(cleanup, "Reviewing the session cleanup") + } + // c hands the selected recorded session off, and x asks for a transcript file + // first. Both show the hand-off review, which submits Handoff. + ActionKind::PreviewHandoff | ActionKind::Handoff => { + let harness = self.harness()?; + let route = self.selected_route()?; + let (tool, account) = (harness.tool, self.account()); + let (route_key, route_id) = (route.key.clone(), route.id.clone()); + match key { + 'c' => { + let session = self.session_id()?; + request( + Action::PreviewHandoff { + source: HandoffSource::Managed { id: session }, + tool, + account, + route: route_key, + id: route_id, + }, + "Reviewing the hand-off", + ) + } + 'x' => { + let title = format!( + "Continue a transcript · {} · {}", + harness.label, route.label + ); + self.open(Form::new( + FormKind::Transcript { + tool, + account, + route: route_key, + id: route_id, + }, + title, + vec![("Transcript file", String::new())], + )) + } + _ => Err(format!("No hand-off flow starts with {key}.")), + } + } + ActionKind::RecentSessions => { + let harness = self.harness()?; + let sessions = Action::RecentSessions { + tool: Some(harness.tool), + account: self.account(), + }; + request(sessions, "Listing harness sessions") + } ActionKind::OpenConfig => request(Action::OpenConfig {}, "Opening the server config"), ActionKind::CheckConfig => { request(Action::CheckConfig {}, "Checking the server config") } ActionKind::Restart => { self.set_popup(Some(Popup::Restart)); - None + Ok(None) } ActionKind::OpenSettings => { request(Action::OpenSettings {}, "Opening the app settings") } + ActionKind::SaveConfiguration => { + let saved = self + .data + .settings + .as_ref() + .ok_or("App settings are unavailable.")?; + self.open(Form::new( + FormKind::Configuration, + "Switchyard configuration", + vec![ + ("Server address", saved.server_url.clone()), + ( + "Server config file", + saved.config_file.display().to_string(), + ), + ("Routing log file", saved.routing_log.display().to_string()), + ( + "Price override (optional)", + saved + .prices_file + .as_ref() + .map(|file| file.display().to_string()) + .unwrap_or_default(), + ), + ("Baseline model ID", saved.baseline_model.clone()), + ( + "Refresh interval in seconds", + saved.refresh_seconds.to_string(), + ), + ( + "Create server config? yes/no", + if self.data.needs_setup { "yes" } else { "no" }.into(), + ), + ], + )) + } + ActionKind::PreviewResetSettings | ActionKind::ResetSettings => request( + Action::PreviewResetSettings {}, + "Reviewing app preference removal", + ), ActionKind::SavePreferences => { let (baseline, refresh) = self .data @@ -1048,43 +2661,33 @@ impl App { .as_ref() .map(|s| (s.baseline_model.clone(), s.refresh_seconds.to_string())) .unwrap_or_default(); - self.set_popup(form( + self.open(Form::new( FormKind::Preferences, "Change preferences", vec![ ("Baseline model ID", baseline), ("Refresh interval in seconds", refresh), ], - )); - None + )) } ActionKind::OpenPrices => request(Action::OpenPrices {}, "Opening the model prices"), ActionKind::ServerKeys => request(Action::ServerKeys {}, "Checking the server keys"), - ActionKind::SaveServerKey => { - self.set_popup(form( - FormKind::SaveKey, - "Save a server key", - vec![("Key name", String::new()), ("Key", String::new())], - )); - None - } - ActionKind::ImportServerKey => { - self.set_popup(form( - FormKind::ImportKey, - "Import a server key from your login shell", - vec![("Key name", String::new())], - )); - None - } - ActionKind::RemoveServerKey => { - self.set_popup(form( - FormKind::RemoveKey, - "Remove a saved server key", - vec![("Key name", String::new())], - )); - None - } - // The update window submits Update after the user reads this preview. + ActionKind::SaveServerKey => self.open(Form::new( + FormKind::SaveKey, + "Save a server key", + vec![("Key name", String::new()), ("Key", String::new())], + )), + ActionKind::ImportServerKey => self.open(Form::new( + FormKind::ImportKey, + "Import a server key from your login shell", + vec![("Key name", String::new())], + )), + ActionKind::RemoveServerKey => self.open(Form::new( + FormKind::RemoveKey, + "Remove a saved server key", + vec![("Key name", String::new())], + )), + // The review window submits Update after the user reads this preview. ActionKind::PreviewUpdate | ActionKind::Update => { request(Action::PreviewUpdate {}, "Reading the source checkout") } @@ -1111,34 +2714,44 @@ impl App { } return None; } - Popup::Form(form) | Popup::Update { confirm: form, .. } => { + Popup::Form(form) | Popup::Review { confirm: form, .. } => { match key.code { KeyCode::Esc => self.set_popup(None), - KeyCode::Enter => { - let preview = match &self.popup { - Some(Popup::Update { preview, .. }) => Some(preview), - _ => None, - }; - let form = match &self.popup { - Some(Popup::Form(form) | Popup::Update { confirm: form, .. }) => form, - _ => return None, - }; - match form.action(preview) { - Ok(action) => return Some(Request::run(action, form.label())), - Err(error) => { - if let Some(form) = self.popup.as_mut().and_then(Popup::form_mut) { - form.error = error; - } - } + KeyCode::Enter => match form.action(&self.data) { + Ok(Submit::Run(action)) => return Some(Request::run(action, form.label())), + Ok(Submit::Next(next)) => self.set_popup(Some(Popup::Form(next))), + Ok(Submit::SettingsFile(path)) => { + self.set_popup(None); + self.destination = if path.is_empty() { + Destination::Harness + } else { + Destination::File(path) + }; + let text = "Press p to preview or i to install."; + self.notify(text.into(), Tone::Done); } - } - // Only the update window scrolls; its text field ignores the arrow keys. + Err(error) => form.error = error, + }, + // A field with choices cycles through them. Otherwise only the review + // window scrolls; text fields ignore these keys. KeyCode::Up | KeyCode::Down | KeyCode::PageUp | KeyCode::PageDown => { - if let Some(scroll) = popup.scroll_mut() - && let Some(next) = - step(key.code, usize::from(*scroll), usize::from(u16::MAX)) - { - *scroll = next as u16; + let forward = key.code == KeyCode::Down; + match (form.options(), key.code) { + (Some(Choices::Algorithms), KeyCode::Up | KeyCode::Down) => { + form.choose(self.data.algorithms.iter().map(|a| a.kind), forward); + } + (Some(Choices::Endpoints), KeyCode::Up | KeyCode::Down) => { + let names = self.data.clients.iter().map(|c| c.name.as_str()); + form.choose(names, forward); + } + _ => { + if let Some(scroll) = popup.scroll_mut() + && let Some(next) = + step(key.code, usize::from(*scroll), usize::from(u16::MAX)) + { + *scroll = next as u16; + } + } } } _ => form.edit(key), @@ -1176,7 +2789,6 @@ fn step(code: KeyCode, current: usize, last: usize) -> Option { } /// Every filter word must appear in the call's model, route, session, or turn ID. -/// Words with uppercase letters match case exactly; other words ignore ASCII case. fn matches_filter(entry: &Entry, filter: &str) -> bool { let fields = [ entry.model.as_str(), @@ -1184,6 +2796,12 @@ fn matches_filter(entry: &Entry, filter: &str) -> bool { entry.session_id.as_deref().unwrap_or_default(), entry.turn_id.as_deref().unwrap_or_default(), ]; + matches_words(&fields, filter) +} + +/// Every word must appear in one of the fields. A word with a capital letter matches case +/// exactly; other words ignore ASCII case, which covers model, route, and session IDs. +fn matches_words(fields: &[&str], filter: &str) -> bool { filter.split_whitespace().all(|word| { let exact = word.chars().any(char::is_uppercase); fields.iter().any(|field| { @@ -1199,22 +2817,6 @@ fn matches_filter(entry: &Entry, filter: &str) -> bool { }) } -fn count(value: u64) -> String { - let digits = value.to_string(); - let mut text = String::with_capacity(digits.len() + digits.len() / 3); - for (index, digit) in digits.chars().enumerate() { - if index > 0 && (digits.len() - index).is_multiple_of(3) { - text.push(','); - } - text.push(digit); - } - text -} - -fn plural(value: u64, one: &str, many: &str) -> String { - format!("{} {}", count(value), if value == 1 { one } else { many }) -} - /// Single calls often cost fractions of a cent, so `call_money` shows more digits than `money`. fn call_money(usd: f64) -> String { if usd == 0.0 { @@ -1261,24 +2863,6 @@ fn saved(theme: Theme, estimate: &Estimate, baseline: bool) -> Vec spans } -/// This function explains why `Estimate::savings` has no figure, in the order that -/// `summary::build` checks the reasons. Call it only when the savings are hidden. -fn hidden_savings(estimate: &Estimate, baseline: &str) -> String { - if estimate.baseline.is_none() { - format!("the price table has no rates for {baseline}") - } else if estimate.unknown_calls > 0 { - format!( - "{} no recorded cost", - plural(estimate.unknown_calls, "call has", "calls have") - ) - } else { - format!( - "{} no token counts", - plural(estimate.uncounted_answers, "answer has", "answers have") - ) - } -} - fn age(ts: &str, now: DateTime) -> String { let Ok(time) = DateTime::parse_from_rfc3339(ts) else { return "—".into(); @@ -1292,6 +2876,22 @@ fn age(ts: &str, now: DateTime) -> String { } } +/// This function shows a recorded time in local time with its age, or the recorded text when it +/// is not an RFC 3339 time. +fn local_time(ts: &str) -> String { + DateTime::parse_from_rfc3339(ts).map_or_else( + |_| ts.to_string(), + |time| { + format!( + "{} ({})", + time.with_timezone(&chrono::Local) + .format("%Y-%m-%d %H:%M:%S"), + age(ts, Utc::now()) + ) + }, + ) +} + fn server_state(theme: Theme, running: bool) -> [Span<'static>; 2] { [ Span::styled("● ", if running { theme.ok } else { theme.error }), @@ -1349,21 +2949,33 @@ fn block(theme: Theme, title: &str) -> Block<'static> { fn draw(frame: &mut Frame, app: &mut App) { // This exhaustive destructuring requires an update when Snapshot gains a field. let Snapshot { - update_status: _, // draw_settings shows the last update status. - settings: _, // draw_settings shows the app settings. - data_state: _, // draw_overview shows health; draw_models checks usage - metrics: _, // draw_header and the Overview cards - today: _, // the Overview "Today" card title - analytics: _, // draw_top_models and draw_models - estimates: _, // the Overview cards, draw_top_models, and draw_models - baseline_model: _, // draw_top_models and draw_models - activity: _, // draw_activity - sessions: _, // The Calls title and the `s` key use these IDs. - entries: _, // draw_call_table and the call details show these entries. - skipped: _, // calls_block shows the number of unreadable records. - limited: _, // calls_block and draw_activity show this limit. - errors: _, // draw_errors - refresh_seconds: _, // App::refresh_due + needs_setup: _, // The Settings page and initial form show setup. + configuration_error: _, // The Settings page shows configuration errors. + tools: _, // draw_harnesses and harness_lines show these tools. + generation: _, // Each route form returns this value with its action. + clients: _, // draw_endpoints and the endpoint forms show these clients. + algorithms: _, // draw_routes and the algorithm fields show these algorithms. + routes: _, // draw_routes and the route details show these routes. + update_status: _, // draw_settings shows the last update status. + settings: _, // draw_settings shows the app settings. + data_state: _, // draw_overview shows health; draw_models checks usage + metrics: _, // draw_header and the Overview cards + today: _, // the Overview "Today" card title + analytics: _, // draw_top_models and draw_models + estimates: _, // the Overview cards, draw_top_models, and draw_models + baseline_model: _, // draw_top_models and draw_models + activity: _, // draw_activity + sessions: _, // The Calls title and the `s` key use these IDs. + entries: _, // draw_call_table and the call details show these entries. + skipped: _, // calls_block shows the number of unreadable records. + limited: _, // calls_block and draw_activity show this limit. + errors: _, // draw_errors + refresh_seconds: _, // App::refresh_due + // draw_session_table lists the inventory or shows the error that stopped reading it, + // and session_lines shows the selected session. + session_inventory: _, + session_inventory_error: _, + handoffs: _, // new_session_lines counts them; session_lines lists a session's launches } = &app.data; let area = frame.area(); app.width = area.width; @@ -1382,6 +2994,9 @@ fn draw(frame: &mut Frame, app: &mut App) { Tab::Overview => draw_overview(frame, app, body), Tab::Calls => draw_calls(frame, app, body), Tab::Models => draw_models(frame, app, body), + Tab::Routes => draw_routes(frame, app, body), + Tab::Harnesses => draw_harnesses(frame, app, body), + Tab::Sessions => draw_sessions(frame, app, body), Tab::Settings => draw_settings(frame, app, body), } draw_status(frame, app, status); @@ -1415,14 +3030,27 @@ fn draw_too_small(frame: &mut Frame, theme: Theme, area: Rect) { fn draw_header(frame: &mut Frame, app: &App, area: Rect) { let theme = app.theme; - let titles = TABS.map(|tab| { - Line::from(vec![ - Span::styled(format!("{} ", tab.index() + 1), theme.key), - Span::raw(tab.name()), - ]) - }); + // Each title is the tab's digit and at most `letters` letters of its name. + let titles = |letters: usize| { + TABS.map(|tab| { + let name = tab.name().get(..letters).unwrap_or(tab.name()); + Line::from(vec![ + Span::styled((tab.index() + 1).to_string(), theme.key), + Span::raw(if name.is_empty() { "" } else { " " }), + Span::raw(name), + ]) + }) + }; // Tabs pads each title by one space on both sides and puts a divider between titles. - let tabs_width: usize = titles.iter().map(|title| title.width() + 3).sum(); + let width = |titles: &[Line]| titles.iter().map(|title| title.width() + 3).sum::(); + // Narrow terminals shorten the names to three letters, then show the digits alone, so no + // tab is cut off. + let titles = [usize::MAX, 3] + .into_iter() + .map(titles) + .find(|titles| width(titles) <= usize::from(area.width)) + .unwrap_or_else(|| titles(0)); + let tabs_width = width(&titles); let running = app.data.metrics.running; let mut status = server_state(theme, running).to_vec(); let updated = match app.refreshed { @@ -1430,11 +3058,16 @@ fn draw_header(frame: &mut Frame, app: &App, area: Rect) { None => " · no data yet ".into(), }; let url = format!(" · {}", app.data.metrics.server_url); - // Narrow terminals drop the URL first, then the update time, so the tabs stay readable. + // Narrow terminals drop the URL first, then the update time, then shorten the server state. + // The narrowest ones drop the server state too, which the Overview page still shows, so the + // tabs stay readable. let room = usize::from(area.width).saturating_sub(tabs_width); let base = status.iter().map(Span::width).sum::(); if base > room { status[1] = Span::raw(if running { "up" } else { "down" }); + if status.iter().map(Span::width).sum::() > room { + status.clear(); + } } if !app.data.metrics.server_url.is_empty() && base + url.chars().count() + updated.chars().count() <= room @@ -1554,7 +3187,7 @@ fn period_lines(app: &App, period: Period, baseline: bool) -> Vec> _ => &app.data.metrics.week, }; let mut lines = vec![Line::from(vec![ - Span::styled(count(counts.requests), Modifier::BOLD), + Span::styled(grouped(counts.requests), Modifier::BOLD), Span::styled(" calls · ", theme.muted), Span::styled(tokens(counts.tokens), Modifier::BOLD), Span::styled(" tokens", theme.muted), @@ -1576,7 +3209,7 @@ fn draw_activity(frame: &mut Frame, app: &App, area: Rect) { let data = &app.data.activity; let peak = data.iter().copied().max().unwrap_or_default(); let mut outer = block(theme, "Calls per hour · past 24h") - .title_top(Line::styled(format!(" peak {} ", count(peak)), theme.muted).right_aligned()) + .title_top(Line::styled(format!(" peak {} ", grouped(peak)), theme.muted).right_aligned()) .title_bottom(Line::styled(" 24h ago ", theme.muted)) .title_bottom(Line::styled(" now ", theme.muted).right_aligned()); if app.data.limited { @@ -1640,7 +3273,7 @@ fn model_row( ))); } cells.extend([ - right(count(usage.requests)), + right(grouped(usage.requests)), right(tokens(usage.total())), right(cost(theme, usage.cost.total_usd, usage.cost.known_usd)), Cell::from(Line::from(vec![ @@ -1762,7 +3395,7 @@ fn calls_block(app: &App) -> Block<'static> { let mut title = vec![ Span::styled(" Calls ", Modifier::BOLD), Span::styled( - format!("[{}] ", count(app.visible.len() as u64)), + format!("[{}] ", grouped(app.visible.len() as u64)), theme.muted, ), ]; @@ -1791,7 +3424,7 @@ fn calls_block(app: &App) -> Block<'static> { format!( " {} of {} ", app.selected + 1, - count(app.visible.len() as u64) + grouped(app.visible.len() as u64) ), theme.muted, ) @@ -1821,15 +3454,46 @@ fn calls_block(app: &App) -> Block<'static> { outer } +/// This function returns the first shown row of a list, moved only as far as needed to keep +/// the selection inside the `height` rows that fit. +fn keep_visible(selected: usize, offset: usize, len: usize, height: usize) -> usize { + let offset = if selected < offset { + selected + } else if selected >= offset + height { + selected + 1 - height + } else { + offset + }; + offset.min(len.saturating_sub(height)) +} + +/// This function draws a scrollbar over a bordered table's data rows, below the top border +/// and the header row, when the rows do not fit. +fn draw_scrollbar(frame: &mut Frame, theme: Theme, area: Rect, len: usize, position: usize) { + if len <= usize::from(area.height.saturating_sub(3)) { + return; + } + let mut scroll = ScrollbarState::new(len).position(position); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None) + .thumb_style(theme.key) + .track_symbol(Some("│")) + .track_style(theme.border), + Rect { + y: area.y + 2, + height: area.height.saturating_sub(3), + ..area + }, + &mut scroll, + ); +} + fn draw_call_table(frame: &mut Frame, app: &mut App, area: Rect) { // The table shows this many calls; the borders and the header row take three rows. let height = usize::from(area.height.saturating_sub(3)).max(1); - if app.selected < app.offset { - app.offset = app.selected; - } else if app.selected >= app.offset + height { - app.offset = app.selected + 1 - height; - } - app.offset = app.offset.min(app.visible.len().saturating_sub(height)); + app.offset = keep_visible(app.selected, app.offset, app.visible.len(), height); let app = &*app; let theme = app.theme; let outer = calls_block(app); @@ -1897,47 +3561,19 @@ fn draw_call_table(frame: &mut Frame, app: &mut App, area: Rect) { area, &mut state, ); - if app.visible.len() > height { - let mut scroll = ScrollbarState::new(app.visible.len()).position(app.selected); - frame.render_stateful_widget( - Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(None) - .end_symbol(None) - .thumb_style(theme.key) - .track_symbol(Some("│")) - .track_style(theme.border), - // The scrollbar covers the data rows, below the top border and the header row. - Rect { - y: area.y + 2, - height: area.height.saturating_sub(3), - ..area - }, - &mut scroll, - ); - } + draw_scrollbar(frame, theme, area, app.visible.len(), app.selected); } fn detail_lines(theme: Theme, entry: &Entry) -> Vec> { let usage = ModelTokens::from(entry); let field = |name: &str, value: String| labeled(theme, name, 9, value); - let time = DateTime::parse_from_rfc3339(&entry.ts).map_or_else( - |_| entry.ts.clone(), - |time| { - format!( - "{} ({})", - time.with_timezone(&chrono::Local) - .format("%Y-%m-%d %H:%M:%S"), - age(&entry.ts, Utc::now()) - ) - }, - ); let kind = if entry.tier == CLASSIFIER_TIER { "Routing call (classifier or judge)" } else { "Answer call" }; let mut lines = vec![ - field("Time", time), + field("Time", local_time(&entry.ts)), field("Model", entry.model.clone()), field("Kind", kind.into()), field("Route", entry.route_id.clone()), @@ -1948,8 +3584,8 @@ fn detail_lines(theme: Theme, entry: &Entry) -> Vec> { "Input", format!( "{} uncached · {} cached", - count(usage.input), - count(usage.cached_input) + grouped(usage.input), + grouped(usage.cached_input) ), ), ]; @@ -1958,12 +3594,12 @@ fn detail_lines(theme: Theme, entry: &Entry) -> Vec> { "Cache", format!( "{} written ({} at the 1-hour rate)", - count(usage.cache_write), - count(usage.cache_write_1h) + grouped(usage.cache_write), + grouped(usage.cache_write_1h) ), )); } - lines.push(field("Output", count(usage.output))); + lines.push(field("Output", grouped(usage.output))); lines.push(match entry.cost { Some(RecordedCost { usd, source }) => field( "Cost", @@ -1992,7 +3628,7 @@ fn draw_models(frame: &mut Frame, app: &App, area: Rect) { } let totals = app.period.totals(&app.data.analytics); let mut summary = vec![ - Span::styled(count(totals.requests()), Modifier::BOLD), + Span::styled(grouped(totals.requests()), Modifier::BOLD), Span::styled(" answer calls · ", theme.muted), Span::styled(tokens(totals.tokens()), Modifier::BOLD), Span::styled(" tokens", theme.muted), @@ -2079,7 +3715,7 @@ fn draw_models(frame: &mut Frame, app: &App, area: Rect) { } .to_string(), ), - right(count(usage.requests)), + right(grouped(usage.requests)), right(tokens(usage.total())), right(cost(theme, usage.cost.total_usd, usage.cost.known_usd)), ]) @@ -2098,31 +3734,769 @@ fn draw_models(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(table(theme, &columns, routes), routes_area); } -/// This function lists the server key variables from the last `ServerKeys` reply. -fn key_lines(theme: Theme, keys: Option<&Value>) -> Vec> { - let Some(data) = keys else { - return vec![Line::styled( - "Press k to check which keys are saved.", - theme.muted, - )]; +/// This function returns the algorithm's name, or the route type for a type the apps cannot +/// write. +fn algorithm_title<'a>(data: &'a Snapshot, kind: &'a str) -> &'a str { + data.algorithms + .iter() + .find(|algorithm| algorithm.kind == kind) + .map_or(kind, |algorithm| algorithm.title) +} + +fn draw_routes(frame: &mut Frame, app: &mut App, area: Rect) { + let theme = app.theme; + // A server config that cannot be read lists no routes, so its error explains the empty list. + let errors = if app.data.routes.is_empty() && !app.data.errors.is_empty() { + (app.data.errors.len() as u16 + 2).min(area.height / 3) + } else { + 0 }; - let keys = data["keys"] - .as_array() - .map(Vec::as_slice) - .unwrap_or_default(); - if keys.is_empty() { - return vec![Line::from( - "The server config names no keys with api_key_env.", - )]; + let notes = endpoint_notes(theme, &app.data); + let endpoints = (app.data.clients.len().max(1) + notes.len() + 3) as u16; + let [errors_area, top, endpoints_area] = Layout::vertical([ + Constraint::Length(errors), + Constraint::Fill(1), + Constraint::Length(endpoints.min(area.height / 3)), + ]) + .areas(area); + if errors > 0 { + draw_errors(frame, app, errors_area); } - let mut lines = vec![Line::styled( - format!( - "Stored in {}", - data["store"].as_str().unwrap_or("Not recorded") + draw_endpoints(frame, app, notes, endpoints_area); + let (title, lines) = match app.selected_route() { + Ok(route) => (route.id.clone(), route_lines(theme, &app.data, route)), + Err(_) => ( + "Route".to_string(), + vec![Line::styled("No route selected.", theme.muted)], ), - theme.muted, - )]; - lines.extend(keys.iter().map(|key| { + }; + let detail = Paragraph::new(lines).wrap(Wrap { trim: false }); + // Wide terminals put the details beside the list; others put them below it. + let (list, detail_area) = if top.width >= SIDE_DETAIL_WIDTH { + let [list, side] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(52)]).areas(top); + (list, side) + } else { + // The list keeps its borders, header, and the selected row; the details get the rest. + let rows = detail.line_count(top.width.saturating_sub(4)) as u16 + 2; + let [list, below] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(rows.min(top.height.saturating_sub(4))), + ]) + .areas(top); + (list, below) + }; + draw_route_table(frame, app, list); + frame.render_widget( + detail.block(block(theme, &title).padding(Padding::horizontal(1))), + detail_area, + ); +} + +fn draw_route_table(frame: &mut Frame, app: &mut App, area: Rect) { + // The table shows this many routes; the borders and the header row take three rows. + let height = usize::from(area.height.saturating_sub(3)).max(1); + let visible = app.route_visible.len(); + app.route_offset = keep_visible(app.route_selected, app.route_offset, visible, height); + let app = &*app; + let theme = app.theme; + let mut title = vec![ + Span::styled(" Routes ", Modifier::BOLD), + Span::styled(format!("[{}] ", grouped(visible as u64)), theme.muted), + ]; + if !app.route_filter.is_empty() || app.filtering { + title.push(Span::styled(format!("/{} ", app.route_filter), theme.key)); + } + let mut outer = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(if app.filtering { + theme.key + } else { + theme.border + }) + .title(Line::from(title)); + if visible == 0 { + let text = if app.data.routes.is_empty() { + "No routes in the server config. Press a to create one." + } else { + "No routes match the filter. Press Esc to clear it." + }; + frame.render_widget( + Paragraph::new(Line::styled(text, theme.muted)) + .centered() + .block(outer.padding(Padding::top(1))), + area, + ); + return; + } + outer = outer.title_bottom( + Line::styled( + format!( + " {} of {} ", + app.route_selected + 1, + grouped(visible as u64) + ), + theme.muted, + ) + .right_aligned(), + ); + let wide = area.width >= 80; + let shown = |column: &(Column, bool)| wide || !column.1; + let rows: Vec = app.route_visible[app.route_offset..] + .iter() + .take(height) + .map(|&index| { + let route = &app.data.routes[index]; + let models = if route.editable { + let models: Vec = route + .choices + .iter() + .map(|choice| format!("{} / {}", choice.client, choice.model)) + .collect(); + Span::raw(models.join(", ")) + } else { + Span::styled("set in the server config", theme.muted) + }; + let cells = [ + Cell::from(route.id.clone()), + Cell::from(algorithm_title(&app.data, &route.kind).to_string()), + Cell::from(models), + ]; + Row::new( + cells + .into_iter() + .zip(&ROUTE_COLUMNS) + .filter(|(_, column)| shown(column)) + .map(|(cell, _)| cell), + ) + }) + .collect(); + let columns: Vec = ROUTE_COLUMNS + .iter() + .filter(|column| shown(column)) + .map(|column| column.0) + .collect(); + let mut state = + TableState::default().with_selected(Some(app.route_selected - app.route_offset)); + frame.render_stateful_widget( + table(theme, &columns, rows) + .row_highlight_style(theme.selected) + .highlight_symbol("› ") + .block(outer), + area, + &mut state, + ); + draw_scrollbar(frame, theme, area, visible, app.route_selected); +} + +/// This function shows the selected route's algorithm and the model of each role first, so a +/// short terminal still shows them, then the notes that limit editing it here. +fn route_lines(theme: Theme, data: &Snapshot, route: &RouteInfo) -> Vec> { + const WIDTH: usize = 11; + // This destructuring has no `..`, so adding a route field breaks the build until the + // terminal app shows it. Each comment names the place that shows the field. + let RouteInfo { + key, + id: _, // draw_routes titles these details with it + kind, + revision: _, // the edit and delete flows send it back + label: _, // harness_lines names the route with it, and the filter reads it + choices, + tiers: _, // route_models_form keeps each role's model by tier + editable, + generated, + compatibility: _, // harness_lines explains it for the selected harness + dependents: _, // remove_lines lists them in the delete review + } = route; + let field = |name: &str, value: String| labeled(theme, name, WIDTH, value); + let algorithm = data.algorithms.iter().find(|known| known.kind == *kind); + let mut lines = vec![ + field("Config", format!("[routes.{key}]")), + field( + "Algorithm", + algorithm.map_or_else( + || kind.clone(), + |known| format!("{} ({})", known.title, known.kind), + ), + ), + ]; + // A random route can list more models than a new one starts with, so later roles are + // numbered the same way. + for (index, choice) in choices.iter().enumerate() { + let role = algorithm + .and_then(|known| known.roles.get(index)) + .map_or_else(|| format!("Model {}", index + 1), |role| role.label.clone()); + lines.push(if choice.model.is_empty() { + Line::from(vec![ + Span::styled(format!("{role: Vec> { + let unlisted = "The app cannot list this endpoint's models. Type model IDs by hand."; + data.clients + .iter() + .flat_map(|client| { + let unlisted = client.unlisted.then(|| unlisted.to_string()); + unlisted.into_iter().chain(client.note.clone()).map(|note| { + Line::from(vec![ + Span::styled(format!("{}: ", client.name), theme.muted), + Span::styled(note, theme.warn), + ]) + }) + }) + .collect() +} + +fn draw_endpoints(frame: &mut Frame, app: &App, notes: Vec>, area: Rect) { + let theme = app.theme; + let clients = &app.data.clients; + let outer = block(theme, &format!("Endpoints [{}]", clients.len())); + let inner = outer.inner(area); + frame.render_widget(outer, area); + if clients.is_empty() { + frame.render_widget( + Paragraph::new(Line::styled( + "The server config names no endpoints.", + theme.muted, + )), + inner, + ); + return; + } + let [table_area, notes_area] = Layout::vertical([ + Constraint::Length(clients.len() as u16 + 1), + Constraint::Fill(1), + ]) + .areas(inner); + let rows: Vec = clients + .iter() + .map(|client| { + Row::new([ + client.name.clone(), + client.host.clone(), + client.models.join(", "), + ]) + }) + .collect(); + let columns = [ + ("NAME", Constraint::Fill(1), false), + ("HOST", Constraint::Fill(1), false), + ("MODELS IN THE SERVER CONFIG", Constraint::Fill(3), false), + ]; + frame.render_widget(table(theme, &columns, rows), table_area); + frame.render_widget(Paragraph::new(notes).wrap(Wrap { trim: false }), notes_area); +} + +fn draw_harnesses(frame: &mut Frame, app: &App, area: Rect) { + let theme = app.theme; + // The borders and the header row take three rows. + let rows = app.data.tools.len().max(1) as u16 + 3; + let [list, rest] = Layout::vertical([ + Constraint::Length(rows.min(area.height / 2)), + Constraint::Fill(1), + ]) + .areas(area); + draw_harness_table(frame, app, list); + let Ok(harness) = app.harness() else { + return; + }; + let detail = Paragraph::new(harness_lines(app, harness)) + .wrap(Wrap { trim: false }) + .block(block(theme, harness.label).padding(Padding::horizontal(1))); + let Some(preview) = app.install_preview() else { + frame.render_widget(detail, rest); + return; + }; + let diff = Paragraph::new(install_lines(theme, preview)) + .wrap(Wrap { trim: false }) + .block(block(theme, "Settings diff").padding(Padding::horizontal(1))); + // Wide terminals put the diff beside the details; others put it below them. + let [details_area, diff_area] = if rest.width >= SIDE_DETAIL_WIDTH { + Layout::horizontal([Constraint::Fill(1); 2]).areas(rest) + } else { + Layout::vertical([Constraint::Fill(1); 2]).areas(rest) + }; + frame.render_widget(detail, details_area); + frame.render_widget(diff, diff_area); +} + +fn draw_harness_table(frame: &mut Frame, app: &App, area: Rect) { + let theme = app.theme; + let tools = &app.data.tools; + let outer = block(theme, &format!("Harnesses [{}]", tools.len())); + if tools.is_empty() { + let text = "No harness is listed. Fix the app settings, then press r."; + frame.render_widget( + Paragraph::new(Line::styled(text, theme.muted)) + .centered() + .block(outer), + area, + ); + return; + } + let wide = area.width >= 80; + let shown = |column: &(Column, bool)| wide || !column.1; + let rows: Vec = tools + .iter() + .map(|tool| { + let state = if tool.available { + Span::styled("Detected", theme.ok) + } else { + Span::styled("Not detected", theme.warn) + }; + // The first two lines name the model and the endpoint, or hold the read error. + let settings: Vec<&str> = tool.status.lines().take(2).collect(); + let cells = [ + Cell::from(tool.label), + Cell::from(state), + Cell::from(settings.join(" · ")), + right(grouped(tool.accounts.len() as u64)), + ]; + Row::new( + cells + .into_iter() + .zip(&HARNESS_COLUMNS) + .filter(|(_, column)| shown(column)) + .map(|(cell, _)| cell), + ) + }) + .collect(); + let columns: Vec = HARNESS_COLUMNS + .iter() + .filter(|column| shown(column)) + .map(|column| column.0) + .collect(); + let mut state = TableState::default().with_selected(Some(app.harness)); + frame.render_stateful_widget( + table(theme, &columns, rows) + .row_highlight_style(theme.selected) + .highlight_symbol("› ") + .block(outer), + area, + &mut state, + ); +} + +/// This function shows the route to install and whether the harness can use it, then where +/// the settings go and what they hold now, then the saved accounts. +fn harness_lines(app: &App, harness: &ToolInfo) -> Vec> { + const WIDTH: usize = 12; + let theme = app.theme; + let field = |name: &str, value: String| labeled(theme, name, WIDTH, value); + let heading = |text: &'static str| Line::styled(text, theme.muted.bold()); + // This destructuring has no `..`, so adding a harness field breaks the build until this + // page shows it. + let ToolInfo { + tool, + label: _, // draw_harnesses titles these details with it + available, + files, + status, + accounts: _, // the a key cycles through them, and account_details has the same names + account_details, + discovery_error, + } = harness; + let mut lines = Vec::new(); + match app.selected_route() { + Ok(route) => { + lines.push(field("Route", route.label.clone())); + match route.compatibility.get(tool) { + Some(Some(reason)) => lines.push(Line::styled(reason.clone(), theme.warn)), + Some(None) => lines.push(Line::styled("This harness can use the route.", theme.ok)), + None => {} + } + } + Err(error) => lines.push(Line::from(vec![ + Span::styled(format!("{: { + let files: Vec = files.iter().map(|f| f.display().to_string()).collect(); + lines.push(field("Install to", "The harness's own settings".into())); + lines.push(field("Files", files.join(", "))); + (status.clone(), Style::new()) + } + Destination::Account(name) => { + match account_details.iter().find(|account| account.name == *name) { + Some(account) => { + lines.push(field("Install to", format!("Account {name}"))); + lines.push(field("Folder", account.path.display().to_string())); + (account.status.clone(), Style::new()) + } + None => ( + format!( + "Account {name} is no longer listed. Press a to choose another \ + destination." + ), + theme.warn, + ), + } + } + Destination::File(path) => { + lines.push(field("Install to", "Custom file".into())); + lines.push(field("File", path.clone())); + ("Press p to read it.".into(), theme.muted) + } + }; + lines.extend([Line::default(), heading("CURRENT SETTINGS")]); + // A summary spans several lines, and Paragraph does not start a new line at '\n'. + lines.extend( + current + .lines() + .map(|line| Line::styled(line.to_string(), style)), + ); + lines.extend([ + Line::default(), + heading("SAVED ACCOUNTS · a folder does not confirm a completed login"), + ]); + if let Some(error) = discovery_error { + lines.push(Line::styled(error.clone(), theme.error)); + } + for account in account_details { + // This destructuring has no `..`, so adding an account field breaks the build until + // this page shows it. + let AccountInfo { + name, + path, + login_status, + status: _, // CURRENT SETTINGS shows it for the selected account + } = account; + let selected = matches!(&app.destination, Destination::Account(chosen) if chosen == name); + lines.push(Line::from(vec![ + Span::styled(if selected { "› " } else { " " }, theme.key), + Span::raw(name.clone()), + Span::styled( + format!(" · login status {login_status} · {}", path.display()), + theme.muted, + ), + ])); + } + if account_details.is_empty() { + lines.push(Line::styled( + "None. Press n to sign in to another account.", + theme.muted, + )); + } + lines.extend([ + Line::default(), + Line::styled( + "Login opens in a new terminal window, and account folders open in $VISUAL or \ + $EDITOR. Native account limits still apply. Switchyard does not rotate login \ + tokens.", + theme.muted, + ), + ]); + lines +} + +fn draw_sessions(frame: &mut Frame, app: &App, area: Rect) { + let theme = app.theme; + let launch = Paragraph::new(new_session_lines(app)).wrap(Wrap { trim: false }); + // The borders take two rows. + let rows = launch.line_count(area.width.saturating_sub(4)) as u16 + 2; + let [launch_area, inventory] = Layout::vertical([ + Constraint::Length(rows.min(area.height / 2)), + Constraint::Fill(1), + ]) + .areas(area); + frame.render_widget( + launch.block(block(theme, "New session").padding(Padding::horizontal(1))), + launch_area, + ); + let Some(receipt) = app.selected_session() else { + draw_session_table(frame, app, inventory); + return; + }; + let detail = Paragraph::new(session_lines(app, receipt)).wrap(Wrap { trim: false }); + // The details go below the list at full width, because worktree paths are long. The list + // keeps its borders, header, and the selected row; the details get the rest. + let rows = detail.line_count(inventory.width.saturating_sub(4)) as u16 + 2; + let [list, detail_area] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(rows.min(inventory.height.saturating_sub(4))), + ]) + .areas(inventory); + draw_session_table(frame, app, list); + let title = text(receipt, "id"); + frame.render_widget( + detail.block(block(theme, &title).padding(Padding::horizontal(1))), + detail_area, + ); +} + +fn draw_session_table(frame: &mut Frame, app: &App, area: Rect) { + let theme = app.theme; + let sessions = &app.data.session_inventory; + let outer = block(theme, &format!("Recorded sessions [{}]", sessions.len())); + // A failed read lists no sessions, so its error replaces the empty-list text. + let empty = match &app.data.session_inventory_error { + Some(error) => Some(Line::styled(error.clone(), theme.error)), + None if sessions.is_empty() => Some(Line::styled( + "Switchyard has not recorded any managed sessions.", + theme.muted, + )), + None => None, + }; + if let Some(line) = empty { + let paragraph = Paragraph::new(line).centered().wrap(Wrap { trim: false }); + frame.render_widget(paragraph.block(outer), area); + return; + } + let wide = area.width >= 100; + let shown = |column: &(Column, bool)| wide || !column.1; + let now = Utc::now(); + let rows: Vec = sessions + .iter() + .map(|receipt| { + let id = text(receipt, "id"); + let last = last_call(&app.data, &id).map_or_else(|| "—".into(), |e| age(&e.ts, now)); + let created = age(&text(receipt, "created_at"), now); + let cells = [ + Cell::from(id), + Cell::from(Span::styled(text(receipt, "repository"), theme.muted)), + Cell::from(text(receipt, "route")), + right(Span::styled(created, theme.muted)), + right(last), + ]; + Row::new( + cells + .into_iter() + .zip(&SESSION_COLUMNS) + .filter(|(_, column)| shown(column)) + .map(|(cell, _)| cell), + ) + }) + .collect(); + let columns: Vec = SESSION_COLUMNS + .iter() + .filter(|column| shown(column)) + .map(|column| column.0) + .collect(); + let selected = app.managed.filter(|&index| index < sessions.len()); + let mut state = TableState::default().with_selected(selected); + frame.render_stateful_widget( + table(theme, &columns, rows) + .row_highlight_style(theme.selected) + .highlight_symbol("› ") + .block(outer), + area, + &mut state, + ); +} + +/// This function shows what the l key launches: the harness, the account, the route, and +/// whether the harness can use the route. +fn new_session_lines(app: &App) -> Vec> { + const WIDTH: usize = 9; + let theme = app.theme; + let field = |name: &str, value: Span<'static>| { + Line::from(vec![ + Span::styled(format!("{name: { + let (state, style) = if harness.available { + ("Detected", theme.ok) + } else { + ("Not detected", theme.warn) + }; + lines.push(Line::from(vec![ + Span::styled(format!("{: { + match harness.account_details.iter().find(|a| a.name == *name) { + Some(account) => Span::raw(format!( + "{name} · login status {} · {}", + account.login_status, + account.path.display() + )), + None => Span::styled( + format!("{name} is no longer listed. Press a to choose another."), + theme.warn, + ), + } + } + Destination::Harness | Destination::File(_) => Span::raw("Current login"), + }; + lines.push(field("Account", account)); + match app.selected_route() { + Ok(route) => { + lines.push(field("Route", Span::raw(route.label.clone()))); + match route.compatibility.get(&harness.tool) { + Some(Some(reason)) => lines.push(Line::styled(reason.clone(), theme.warn)), + Some(None) => { + lines.push(Line::styled("This harness can use the route.", theme.ok)); + } + None => {} + } + } + Err(error) => lines.push(field("Route", Span::styled(error, theme.warn))), + } + } + Err(error) => lines.push(Line::styled(error, theme.warn)), + } + lines.extend([ + Line::default(), + Line::styled( + "Each session is a new Git worktree from the project's committed HEAD, on its own \ + branch. Uncommitted changes stay in the original checkout.", + theme.muted, + ), + ]); + if let Some(newest) = app.data.handoffs.first() { + lines.push(Line::styled( + format!( + "Hand-offs: {} recorded · newest {} → {} {} · c continues the selected session \ + in the harness above, x continues a transcript file", + app.data.handoffs.len(), + text_or(&newest["source"], "label", "?"), + text_or(&newest["destination"], "label", "?"), + age(&text(newest, "created_at"), Utc::now()) + ), + theme.muted, + )); + } + lines +} + +/// This function shows where the selected session lives and its newest recorded call. +fn session_lines(app: &App, receipt: &Value) -> Vec> { + let theme = app.theme; + let field = |name: &str, value: String| labeled(theme, name, 11, value); + let last = last_call(&app.data, receipt["id"].as_str().unwrap_or_default()); + vec![ + field("Repository", text(receipt, "repository")), + field("Branch", text(receipt, "branch")), + field("Route", text(receipt, "route")), + field("Worktree", text(receipt, "worktree")), + field("Created", local_time(&text(receipt, "created_at"))), + field( + "Last call", + last.map_or_else( + || "No recent recorded call".into(), + |entry| local_time(&entry.ts), + ), + ), + field("Launches", launches_text(receipt)), + Line::default(), + Line::styled( + "Recorded calls do not establish that a process is running.", + theme.muted, + ), + ] +} + +/// This function lists the harnesses opened in a session's worktree, oldest first, and marks +/// the ones that a hand-off opened. +fn launches_text(receipt: &Value) -> String { + let launches: Vec = receipt["launches"] + .as_array() + .into_iter() + .flatten() + .map(|launch| { + let tool = text(launch, "tool"); + let tool = match tool.as_str() { + "codex_cli" => "Codex CLI".to_string(), + "claude" => "Claude Code".to_string(), + _ => tool, + }; + let when = local_time(&text(launch, "started_at")); + if launch["handoff"].is_object() { + format!("{tool} {when} (hand-off)") + } else { + format!("{tool} {when}") + } + }) + .collect(); + if launches.is_empty() { + text(receipt, "tool") + } else { + launches.join(" → ") + } +} + +/// This function returns the session's newest call among the calls that the app loaded. +fn last_call<'a>(data: &'a Snapshot, id: &str) -> Option<&'a Entry> { + let session = Some(id); + data.entries + .iter() + .rev() + .find(|entry| entry.session_id.as_deref() == session) +} + +/// This function lists the server key variables from the last `ServerKeys` reply. +fn key_lines(theme: Theme, keys: Option<&Value>) -> Vec> { + let Some(data) = keys else { + return vec![Line::styled( + "Press k to check which keys are saved.", + theme.muted, + )]; + }; + let keys = data["keys"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default(); + if keys.is_empty() { + return vec![Line::from( + "The server config names no keys with api_key_env.", + )]; + } + let mut lines = vec![Line::styled( + format!( + "Stored in {}", + data["store"].as_str().unwrap_or("Not recorded") + ), + theme.muted, + )]; + lines.extend(keys.iter().map(|key| { let saved = key["saved"] == true; Line::from(vec![ Span::raw(format!("{} ", key["name"].as_str().unwrap_or_default())), @@ -2140,9 +4514,8 @@ fn draw_settings(frame: &mut Frame, app: &App, area: Rect) { let theme = app.theme; let [list_area, info_area] = Layout::horizontal([Constraint::Length(30), Constraint::Fill(1)]).areas(area); - let items: Vec = ACTIONS - .iter() - .map(|(letter, name, ..)| { + let items: Vec = rows(Tab::Settings) + .map(|(_, letter, name, ..)| { ListItem::new(Line::from(vec![ Span::styled(format!("{letter} "), theme.key), Span::raw(*name), @@ -2160,7 +4533,12 @@ fn draw_settings(frame: &mut Frame, app: &App, area: Rect) { ); let row = |name: &str, value: String| labeled(theme, name, 15, value); let heading = |text: &'static str| Line::styled(text, theme.muted.bold()); - let mut lines = vec![Line::from(ACTIONS[app.action].2), Line::default()]; + let (_, _, name, about, _) = rows(Tab::Settings).nth(app.action).unwrap_or(&ACTIONS[0]); + let mut lines = vec![Line::from(*about), Line::default()]; + if app.data.needs_setup { + lines.push(Line::from("Choose Change configuration to save setup. Existing config files skip setup on later installs.")); + } + if !app.data.errors.is_empty() { lines.extend( app.data @@ -2224,7 +4602,7 @@ fn draw_settings(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget( Paragraph::new(lines) .wrap(Wrap { trim: false }) - .block(block(theme, ACTIONS[app.action].1).padding(Padding::horizontal(1))), + .block(block(theme, name).padding(Padding::horizontal(1))), info_area, ); } @@ -2232,15 +4610,16 @@ fn draw_settings(frame: &mut Frame, app: &App, area: Rect) { fn draw_status(frame: &mut Frame, app: &App, area: Rect) { let theme = app.theme; let left = if app.filtering { + let (filter, matches) = match app.tab { + Tab::Routes => (&app.route_filter, app.route_visible.len()), + _ => (&app.filter, app.visible.len()), + }; Line::from(vec![ Span::styled(" / ", theme.key), - Span::raw(app.filter.clone()), + Span::raw(filter.clone()), Span::styled("▏", theme.key), Span::styled( - format!( - " {} ", - plural(app.visible.len() as u64, "match", "matches") - ), + format!(" {} ", plural(matches as u64, "match", "matches")), theme.muted, ), ]) @@ -2255,17 +4634,24 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { } Line::from(spans) } else if let Some(toast) = &app.toast { - let style = if toast.error { theme.error } else { theme.ok }; + let (mark, style) = match toast.tone { + Tone::Done => (" ✓ ", theme.ok), + Tone::Warning => (" ! ", theme.warn), + Tone::Error => (" ✗ ", theme.error), + }; Line::from(vec![ - Span::styled(if toast.error { " ✗ " } else { " ✓ " }, style), + Span::styled(mark, style), Span::styled(toast.text.clone(), style), ]) } else { Line::default() }; let room = usize::from(area.width).saturating_sub(left.width() + 2); + // Page keys do nothing while a window is open or the filter takes the keyboard. The + // Settings page lists its action letters beside its rows, so only other pages put them here. let global = app.popup.is_none() && !app.filtering; - let hints = fit_keys(theme, app.keys(), global, room); + let actions = rows(app.tab).filter(|_| global && app.tab != Tab::Settings); + let hints = fit_keys(theme, actions, app.keys(), global, room); let [left_area, right_area] = Layout::horizontal([Constraint::Length(left.width() as u16), Constraint::Fill(1)]) .areas(area); @@ -2273,10 +4659,17 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(Paragraph::new(hints.right_aligned()), right_area); } -/// This function adds page and global key hints while they fit in the status bar. -/// It reserves room for the help key when global hints are shown. -fn fit_keys(theme: Theme, keys: &[Key], global: bool, room: usize) -> Line<'static> { - let hint = |key: &'static str, label: &str| { +/// This function lists the page's action keys, its other keys, then the global keys, and stops +/// before a hint would be cut. It keeps room for the help key, so the status bar always says how +/// to find every key. +fn fit_keys( + theme: Theme, + actions: impl Iterator, + keys: &[Key], + global: bool, + room: usize, +) -> Line<'static> { + let hint = |key: Cow<'static, str>, label: &str| { [ Span::styled(key, theme.key), Span::styled(format!(" {label} "), theme.muted), @@ -2286,8 +4679,15 @@ fn fit_keys(theme: Theme, keys: &[Key], global: bool, room: usize) -> Line<'stat let mut spans = Vec::new(); let mut used = if global { HELP_WIDTH } else { 0 }; let globals = if global { GLOBAL_KEYS } else { &[] }; - for (key, label, _) in keys.iter().chain(globals) { - if *key == "?" { + let hints = actions + .map(|&(_, key, label, ..)| (Cow::Owned(key.to_string()), label)) + .chain( + keys.iter() + .chain(globals) + .map(|&(key, label, _)| (Cow::Borrowed(key), label)), + ); + for (key, label) in hints { + if key == "?" { continue; } let width = key.chars().count() + label.chars().count() + 3; @@ -2298,7 +4698,7 @@ fn fit_keys(theme: Theme, keys: &[Key], global: bool, room: usize) -> Line<'stat spans.extend(hint(key, label)); } if global && room >= HELP_WIDTH { - spans.extend(hint("?", "help")); + spans.extend(hint(Cow::Borrowed("?"), "help")); } Line::from(spans) } @@ -2311,7 +4711,8 @@ fn form_lines(theme: Theme, form: &Form) -> Vec> { .enumerate() .map(|(index, (label, value))| { // The key field shows one bullet per character, so the key never appears on screen. - let mut value = if form.kind == FormKind::SaveKey && index == 1 { + let masked = matches!(form.kind, FormKind::SaveKey | FormKind::ModelKey); + let mut value = if masked && index == 1 { "•".repeat(value.chars().count()) } else { value.clone() @@ -2386,48 +4787,44 @@ fn draw_popup(frame: &mut Frame, app: &mut App, area: Rect) { 64, footer(&[("y", "Restart"), ("n", "Cancel")]), ), - Popup::Form(form) => { - let mut lines = Vec::new(); - if matches!( - form.kind, - FormKind::SaveKey | FormKind::ImportKey | FormKind::RemoveKey - ) { - lines.push(Line::styled("Server keys", theme.muted)); - lines.extend(key_lines(theme, app.server_keys.as_ref())); - lines.push(Line::default()); - } - ( - form.title, - lines, - form_lines(theme, form), - theme.key, - 72, - footer(&[("⏎", "Submit"), ("esc", "Cancel")]), - ) - } - Popup::Update { - preview, confirm, .. + Popup::Form(form) => ( + &*form.title, + form_notes(app, form), + form_lines(theme, form), + theme.key, + 72, + footer(&[("⏎", "Submit"), ("esc", "Cancel")]), + ), + Popup::Review { + lines, + verb, + confirm, + .. } => { let mut pinned = vec![Line::default()]; pinned.extend(form_lines(theme, confirm)); ( - confirm.title, - update_lines(theme, preview), + &*confirm.title, + lines.clone(), pinned, theme.warn, 84, - footer(&[("⏎", "Start"), ("esc", "Cancel")]), + footer(&[("⏎", *verb), ("esc", "Cancel")]), ) } Popup::Message { - title, text, error, .. + title, text, tone, .. } => ( *title, text.lines() .map(|line| Line::from(line.to_string())) .collect(), Vec::new(), - if *error { theme.error } else { theme.key }, + match tone { + Tone::Done => theme.key, + Tone::Warning => theme.warn, + Tone::Error => theme.error, + }, 76, close, ), @@ -2467,6 +4864,485 @@ fn draw_popup(frame: &mut Frame, app: &mut App, area: Rect) { frame.render_widget(pinned, pinned_area); } +/// This function returns the text above a form's fields: what the fields accept and the lists +/// that ↑ and ↓ cycle through. +fn form_notes(app: &App, form: &Form) -> Vec> { + let theme = app.theme; + let data = &app.data; + let algorithms = |selected: &str| { + let names: Vec = data + .algorithms + .iter() + .map(|known| format!("{} ({})", known.title, known.kind)) + .collect(); + let mut lines = vec![Line::styled( + format!("Algorithms: {}", names.join(" · ")), + theme.muted, + )]; + if let Some(known) = data.algorithms.iter().find(|a| a.kind == selected.trim()) { + lines.push(Line::from(known.summary)); + } + lines.push(Line::default()); + lines + }; + let identity = |config_name: bool| { + let mut lines = vec![Line::from( + "Harnesses send the public model ID as the model. Each route needs its own ID.", + )]; + if config_name { + lines.push(Line::from( + "The config name is the route's table name in the server config, as in \ + [routes.team-router]. It uses 1–64 letters, digits, hyphens, or underscores.", + )); + } + lines.push(Line::default()); + lines + }; + match &form.kind { + FormKind::SaveKey | FormKind::ImportKey | FormKind::RemoveKey => { + let mut lines = vec![Line::styled("Server keys", theme.muted)]; + lines.extend(key_lines(theme, app.server_keys.as_ref())); + lines.push(Line::default()); + lines + } + FormKind::Algorithm { .. } => algorithms(&form.fields[0].1), + FormKind::NewRoute { .. } => { + let mut lines = identity(true); + lines.extend(algorithms(&form.fields[2].1)); + lines + } + FormKind::RouteIdentity { operation, .. } => identity(*operation != "rename"), + FormKind::RouteModels { .. } | FormKind::NewRouteModels { .. } => { + let names: Vec<&str> = data.clients.iter().map(|c| c.name.as_str()).collect(); + vec![ + Line::styled(format!("Endpoints: {}", names.join(", ")), theme.muted), + Line::from("Use the endpoint name and a model ID. Unlisted model IDs are allowed."), + Line::default(), + ] + } + FormKind::Models | FormKind::ModelKey => { + let key = matches!(form.kind, FormKind::ModelKey); + let mut lines: Vec = data + .clients + .iter() + .map(|client| { + let mut spans = vec![ + Span::raw(client.name.clone()), + Span::styled(format!(" · {}", client.host), theme.muted), + ]; + if key && client.accepts_key { + spans.push(Span::styled(" · accepts a model-list key", theme.ok)); + } else if key { + spans.push(Span::styled(" · no model-list key", theme.warn)); + } + Line::from(spans) + }) + .collect(); + lines.push(Line::default()); + lines + } + FormKind::SettingsFile => vec![ + Line::from( + "The harness must load a custom file itself. Pi reads settings.json from the \ + folder of models.json.", + ), + Line::default(), + ], + FormKind::Account(_) => vec![ + Line::from( + "Use 1–64 ASCII letters, digits, hyphens, or underscores. Login opens in a new \ + terminal window; finish it there. API routes use credentials configured on \ + the server. The Codex app manages its own account.", + ), + Line::default(), + ], + FormKind::Project { .. } => vec![ + Line::from( + "Switchyard creates a Git worktree from the folder's committed HEAD and opens \ + the harness there in a new terminal window. Uncommitted changes stay in the \ + original checkout. The next step shows the review.", + ), + Line::default(), + ], + FormKind::Transcript { .. } => vec![ + Line::from( + "Enter the absolute path of a Claude Code transcript (/projects/\ + /.jsonl) or a Codex rollout (/sessions/…/\ + rollout-….jsonl). The h key lists them. The next step shows the hand-off \ + text before anything is written.", + ), + Line::default(), + ], + FormKind::Configuration + | FormKind::Preferences + | FormKind::Update(_) + | FormKind::Confirm(..) + | FormKind::Remove { .. } => Vec::new(), + } +} + +/// This function shows the file and notes of a route preview before the user types SAVE. +fn change_lines(theme: Theme, preview: &Value) -> Vec> { + let file = preview["file"].as_str().unwrap_or_default().to_string(); + let mut lines = vec![labeled(theme, "Changed file", 13, file)]; + let notes = preview["notes"].as_array().map(Vec::as_slice); + for note in notes.unwrap_or_default().iter().filter_map(Value::as_str) { + lines.push(Line::default()); + lines.push(Line::from(note.to_string())); + } + lines.push(Line::default()); + lines.push(Line::from( + "Switchyard validates the config, saves a backup, and restarts the server.", + )); + lines +} + +/// This function explains what deleting a route keeps and who must change. +fn remove_lines( + theme: Theme, + route: &RouteInfo, + settings: Option<&AppSettings>, +) -> Vec> { + let mut lines = vec![Line::from(format!( + "This removes the public model ID from the server. Targets and harness settings are \ + kept. Any harness using {} must choose another route.", + route.id + ))]; + if !route.dependents.is_empty() { + lines.push(Line::default()); + lines.push(Line::styled( + format!( + "These harness settings use {} and must choose another route:", + route.id + ), + theme.warn, + )); + for dependent in &route.dependents { + // This destructuring has no `..`, so adding a field breaks the build until this + // review shows it. + let Dependent { + tool: _, // the label names the harness + label, + account, + files, + } = dependent; + let account = account.as_ref().map(|name| format!(" ({name})")); + let files: Vec = files.iter().map(|f| f.display().to_string()).collect(); + lines.push(Line::from(format!( + "{label}{}: {}", + account.unwrap_or_default(), + files.join(", ") + ))); + } + } + if let Some(generated) = &route.generated { + lines.push(Line::default()); + lines.push(Line::styled( + format!("Generated by {generated}. That tool can add the route back."), + theme.warn, + )); + } + if let Some(settings) = settings { + lines.push(Line::default()); + lines.push(labeled( + theme, + "Changed file", + 13, + settings.config_file.display().to_string(), + )); + } + lines.push(Line::default()); + lines.push(Line::from( + "Switchyard validates the config, saves a backup, and restarts the server.", + )); + lines +} + +/// This function returns a text field of a controller reply, or nothing when it is missing. +fn text(value: &Value, key: &str) -> String { + value[key].as_str().unwrap_or_default().to_string() +} + +/// This function lists each setting that a harness preview changes: the file and setting, the +/// current value, and the value afterward. +fn diff_lines(theme: Theme, preview: &Value, after: &str) -> Vec> { + let mut lines = vec![Line::styled( + format!("SETTINGS DIFF · − current / + {after}"), + theme.muted.bold(), + )]; + for change in preview["changes"].as_array().into_iter().flatten() { + lines.extend([ + Line::default(), + Line::styled( + format!("{} · {}", text(change, "file"), text(change, "key")), + theme.muted, + ), + Line::styled(format!("− {}", text(change, "before")), theme.error), + Line::styled(format!("+ {}", text(change, "after")), theme.ok), + ]); + } + lines +} + +/// This function shows an install preview: the settings afterward, each changed setting, what +/// happens to each file, and how the harness signs in. +fn install_lines(theme: Theme, preview: &Value) -> Vec> { + let mut lines = vec![Line::styled("AFTER APPLYING ROUTING", theme.muted.bold())]; + let proposed = text(preview, "proposed"); + lines.extend(proposed.lines().map(|line| Line::from(line.to_string()))); + lines.push(Line::default()); + lines.extend(diff_lines(theme, preview, "proposed")); + lines.push(Line::default()); + for file in preview["file_actions"].as_array().into_iter().flatten() { + let (action, file) = (text(file, "action"), text(file, "file")); + lines.push(Line::from(format!("Will {action}: {file}"))); + } + lines.push(Line::default()); + lines.push(Line::from(text(preview, "authentication"))); + lines +} + +/// This function shows a restore preview: what restoring does to each file, the backup it +/// uses, and the settings that change. +fn restore_lines(theme: Theme, preview: &Value) -> Vec> { + let mut lines = vec![Line::from(text(preview, "warning"))]; + for file in preview["files"].as_array().into_iter().flatten() { + lines.extend([ + Line::default(), + Line::from(format!("{}: {}", text(file, "action"), text(file, "file"))), + Line::styled( + format!("Original backup: {}", text(file, "backup")), + theme.muted, + ), + ]); + } + // The controller leaves the diff out when a file cannot be compared. + if preview["changes"].is_array() { + lines.push(Line::default()); + lines.extend(diff_lines(theme, preview, "restored")); + } + lines +} + +fn archive_lines(theme: Theme, preview: &Value) -> Vec> { + vec![ + Line::from(text(preview, "notice")), + Line::default(), + labeled(theme, "Account", 11, text(preview, "name")), + labeled(theme, "Directory", 11, text(preview, "path")), + ] +} + +/// This function returns a text field of a controller reply, or `fallback` when it is empty +/// or missing. +fn text_or(value: &Value, key: &str, fallback: &str) -> String { + match value[key].as_str() { + Some(text) if !text.is_empty() => text.into(), + _ => fallback.into(), + } +} + +/// This function shows a launch preview: where the session starts, what it uses, and what +/// stays in the original checkout. +fn launch_lines(theme: Theme, preview: &Value) -> Vec> { + let field = |name: &str, value: String| labeled(theme, name, 17, value); + let folder = text(preview, "relative_directory"); + let folder = if folder.is_empty() { + "New worktree root".into() + } else { + format!("{folder} within the new worktree") + }; + let mut lines = vec![ + field("Repository", text(preview, "repository")), + field("Starting folder", folder), + field("Branch", text_or(preview, "branch", "Detached HEAD")), + field("Revision", text(preview, "revision")), + field("Route", text(preview, "route")), + field( + "Account", + text_or(preview, "account_directory", "Current login"), + ), + Line::default(), + ]; + if preview["dirty"] == true { + lines.push(Line::styled( + "Your checkout has uncommitted changes.", + theme.warn, + )); + } + lines.push(Line::from(text(preview, "notice"))); + let settings = text(preview, "settings_notice"); + if !settings.is_empty() { + lines.extend([Line::default(), Line::from(settings)]); + } + lines +} + +/// This function shows a cleanup preview: the worktree, whether it has uncommitted changes, +/// and the Git-ignored files that cleanup deletes. Like the desktop app, it names at most 20 +/// files and counts the rest. +fn cleanup_lines(theme: Theme, preview: &Value) -> Vec> { + const NAMED: usize = 20; + let field = |name: &str, value: String| labeled(theme, name, 11, value); + let mut lines = vec![ + Line::from(text(preview, "cleanup_notice")), + Line::default(), + field("Directory", text(preview, "worktree")), + field( + "Branch", + text_or(preview, "current_branch", "Detached HEAD"), + ), + field("Revision", text(preview, "current_revision")), + Line::default(), + if preview["dirty"] == true { + Line::styled( + "This worktree has uncommitted changes. Cleanup will refuse to remove it.", + theme.warn, + ) + } else { + Line::styled("The worktree has no uncommitted changes.", theme.ok) + }, + ]; + let ignored: Vec<&str> = preview["ignored"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); + if !ignored.is_empty() { + lines.push(Line::default()); + lines.push(Line::from("Cleanup also deletes these Git-ignored files:")); + lines.extend( + ignored + .iter() + .take(NAMED) + .map(|file| Line::from(file.to_string())), + ); + if ignored.len() > NAMED { + let more = format!("…and {} more", ignored.len() - NAMED); + lines.push(Line::styled(more, theme.muted)); + } + } + lines +} + +/// This function shows a hand-off preview: where the session continues, what the new session +/// uses, the warnings, the command, and the whole hand-off text that the file will hold. +fn handoff_lines(theme: Theme, preview: &Value) -> Vec> { + let field = |name: &str, value: String| labeled(theme, name, 14, value); + let source = &preview["source"]; + let destination = &preview["destination"]; + let mut lines = vec![ + field( + "From", + format!( + "{} · {}", + text_or(source, "label", "?"), + text_or(source, "title", &text(source, "native_id")) + ), + ), + field("Transcript", text(source, "transcript")), + field("To", text_or(destination, "label", "?")), + field("Directory", text(destination, "directory")), + field("Route", text(destination, "route")), + field( + "Account", + text_or(destination, "account_directory", "Current login"), + ), + field("Session ID", text(destination, "session_id")), + field("Hand-off file", text(preview, "handoff_file")), + field( + "Excerpt", + format!( + "{} bytes · {} turns · {} files · {} commands", + preview["bytes"].as_u64().unwrap_or_default(), + preview["turns"].as_u64().unwrap_or_default(), + preview["files_touched"].as_u64().unwrap_or_default(), + preview["commands"].as_u64().unwrap_or_default() + ), + ), + Line::default(), + ]; + for warning in preview["warnings"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + { + lines.push(Line::styled(warning.to_string(), theme.warn)); + } + lines.extend([ + Line::from(text(preview, "notice")), + Line::default(), + Line::styled("COMMAND", theme.muted.bold()), + ]); + lines.extend( + text(preview, "command") + .lines() + .map(|line| Line::from(line.to_string())), + ); + lines.extend([ + Line::default(), + Line::styled( + "HAND-OFF TEXT · the new session reads this", + theme.muted.bold(), + ), + Line::default(), + ]); + lines.extend( + text(preview, "handoff") + .lines() + .map(|line| Line::from(line.to_string())), + ); + lines +} + +/// This function lists a harness's sessions from a `HarnessSessions` reply, newest first. +fn recent_sessions_text(reply: &Value) -> String { + let sessions = reply["sessions"].as_array().map(Vec::as_slice); + let sessions = sessions.unwrap_or_default(); + let roots: Vec = reply["roots"] + .as_array() + .into_iter() + .flatten() + .map(|root| text(root, "transcripts")) + .collect(); + let mut text_out = format!( + "{} sessions under {}", + reply["total"].as_u64().unwrap_or_default(), + roots.join(", ") + ); + let skipped = reply["skipped"].as_u64().unwrap_or_default(); + if skipped > 0 { + text_out.push_str(&format!(" · {skipped} unreadable files skipped")); + } + let elsewhere = reply["elsewhere"].as_u64().unwrap_or_default(); + if elsewhere > 0 { + text_out.push_str(&format!( + " · {elsewhere} sessions whose directory is not on this computer left out" + )); + } + for error in reply["errors"].as_array().into_iter().flatten() { + text_out.push_str(&format!("\n{}", error.as_str().unwrap_or_default())); + } + if sessions.is_empty() { + text_out.push_str("\n\nNo transcripts found."); + return text_out; + } + text_out.push_str("\n\nPress x and enter a transcript path to continue one.\n"); + for session in sessions { + text_out.push_str(&format!( + "\n{} · {} → {}\n {}\n {}\n", + local_time(&text(session, "last_activity")), + text_or(session, "title", &text(session, "native_id")), + text(&session["destination"], "label"), + text_or(session, "cwd", "(directory unknown)"), + text(session, "transcript") + )); + } + text_out +} + fn update_lines(theme: Theme, preview: &Value) -> Vec> { // `git status --short` starts some lines with a space, so only trailing space is removed. let field = |name: &str| { @@ -2507,36 +5383,45 @@ fn update_lines(theme: Theme, preview: &Value) -> Vec> { } /// The help window indents wrapped descriptions under their first line, so keys stay in one column. +/// Each page lists its other keys first, then its action keys from `ACTIONS`. fn help_lines(theme: Theme, width: usize) -> Vec> { const KEY_WIDTH: usize = 8; let room = width.saturating_sub(KEY_WIDTH).max(20); let mut lines = Vec::new(); - for (section, keys) in [ - ("Every page", GLOBAL_KEYS), - ("Calls", CALLS_KEYS), - ("While filtering calls", FILTER_KEYS), - ("Models", MODELS_KEYS), - ("Settings", SETTINGS_KEYS), + for (section, keys, page) in [ + ("Every page", GLOBAL_KEYS, None), + ("Calls", CALLS_KEYS, Some(Tab::Calls)), + ("While filtering calls or routes", FILTER_KEYS, None), + ("Models", MODELS_KEYS, Some(Tab::Models)), + ("Routes", ROUTES_KEYS, Some(Tab::Routes)), + ("Harnesses", HARNESSES_KEYS, Some(Tab::Harnesses)), + ("Sessions", SESSIONS_KEYS, Some(Tab::Sessions)), + ("Settings", SETTINGS_KEYS, Some(Tab::Settings)), ] { if !lines.is_empty() { lines.push(Line::default()); } lines.push(Line::styled(format!(" {section} "), theme.selected.bold())); - for (key, _, about) in keys { - let mut rows = Vec::new(); + let actions = page.into_iter().flat_map(rows); + let entries = keys + .iter() + .map(|&(key, _, about)| (Cow::Borrowed(key), about)) + .chain(actions.map(|&(_, key, _, about, _)| (Cow::Owned(key.to_string()), about))); + for (key, about) in entries { + let mut wrapped = Vec::new(); let mut row = String::new(); for word in about.split_whitespace() { if !row.is_empty() && row.chars().count() + 1 + word.chars().count() > room { - rows.push(std::mem::take(&mut row)); + wrapped.push(std::mem::take(&mut row)); } if !row.is_empty() { row.push(' '); } row.push_str(word); } - rows.push(row); - for (index, row) in rows.into_iter().enumerate() { - let key = if index == 0 { *key } else { "" }; + wrapped.push(row); + for (index, row) in wrapped.into_iter().enumerate() { + let key = if index == 0 { &*key } else { "" }; lines.push(Line::from(vec![ Span::styled(format!("{key: Result<(), String> { loop { if let Some(path) = app.poll_results(&results, &jobs)? { match edit_in_foreground(terminal, &path).map_err(|error| error.to_string())? { - Ok(()) => { - app.notify(format!("Closed the editor for {}.", path.display()), false) - } - Err(error) => app.notify(error, true), + Ok(()) => app.notify( + format!("Closed the editor for {}.", path.display()), + Tone::Done, + ), + Err(error) => app.notify(error, Tone::Error), } app.start(&jobs, Request::refresh(false))?; } @@ -2679,7 +5565,8 @@ pub fn run(mut controller: Controller) -> Result<(), String> { if app .toast .as_ref() - .is_some_and(|toast| !toast.error && toast.shown.elapsed() > TOAST_TIME) + .filter(|toast| toast.tone == Tone::Done) + .is_some_and(|toast| toast.shown.elapsed() > TOAST_TIME) { app.toast = None; } @@ -2716,19 +5603,37 @@ pub fn run(mut controller: Controller) -> Result<(), String> { #[cfg(test)] mod tests { - use super::{ - ACTIONS, Action, ActionKind, App, Availability, BTreeMap, Done, Estimate, Form, FormKind, - KeyCode, KeyEvent, KeyModifiers, ModelTokens, Popup, Request, Snapshot, Tab, Theme, Totals, - Usage, draw, mpsc, - }; + use super::*; + use crate::config::Config; use crate::history::Entry; use ratatui::{Terminal, backend::TestBackend}; use serde_json::json; use strum::IntoEnumIterator; + fn press(app: &mut App, code: KeyCode) -> Option { + app.key(KeyEvent::from(code)) + } + + /// This function runs one request the way the worker thread does. + fn work(app: &mut App, controller: &mut Controller, request: Option) { + let (jobs, inbox) = mpsc::channel(); + app.start(&jobs, request.expect("a request")) + .expect("start"); + let Ok(Job::Run(action)) = inbox.try_recv() else { + panic!("the request runs no action"); + }; + let reply = controller.dispatch(action); + let next = app.finish(Some(reply), controller.snapshot().map(Box::new)); + assert!(next.is_none()); + } + fn screen(app: &mut App, tab: Tab) -> String { + screen_sized(app, tab, 140, 40) + } + + fn screen_sized(app: &mut App, tab: Tab, width: u16, height: u16) -> String { app.tab = tab; - let mut terminal = Terminal::new(TestBackend::new(140, 40)).expect("terminal"); + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); terminal.draw(|frame| draw(frame, app)).expect("draw"); let buffer = terminal.backend().buffer(); buffer @@ -2938,13 +5843,56 @@ mod tests { if replacement.is_some() { let toast = app.toast.as_ref().expect("reported completion"); assert!(toast.text.contains(message)); - assert_eq!(toast.error, !success); + assert_eq!(toast.tone == Tone::Error, !success); } } } } } + // Model lists and preview replies must preserve edits in a form opened after the request. + #[test] + fn preview_and_model_replies_preserve_replacement_forms() { + let actions = [ + Action::Models { + client: "endpoint".into(), + refresh: true, + key: None, + }, + Action::PreviewRouteChange { + generation: 0, + operation: "create".into(), + route: String::new(), + name: "route".into(), + id: "public".into(), + algorithm: None, + choices: vec![], + }, + Action::PreviewSessionCleanup { + id: "session".into(), + }, + ]; + for action in actions { + let mut app = App::new(Theme::new(false)); + app.harnesses_key(KeyCode::Char('x')); + let (jobs, _inbox) = mpsc::channel(); + app.start(&jobs, Request::run(action, "Refreshing")) + .expect("start"); + app.harnesses_key(KeyCode::Char('x')); + app.paste("replacement"); + app.finish( + Some(Ok(super::Reply { + message: "Done".into(), + data: json!({}), + })), + Ok(Box::new(Snapshot::default())), + ); + let form = app.popup.as_mut().and_then(Popup::form_mut).expect("form"); + assert_eq!(form.value(0), "replacement"); + assert!(form.error.is_empty()); + } + } + // The automatic key check must preserve the form opened while the save was running. #[test] fn key_refresh_after_saving_does_not_replace_a_new_form() { @@ -3023,7 +5971,7 @@ mod tests { app.poll_results(&results, &jobs).expect("result"); match replacement { None => { - assert!(matches!(app.popup, Some(Popup::Update { .. }))); + assert!(matches!(app.popup, Some(Popup::Review { .. }))); assert!(app.key(key(KeyCode::Enter)).is_none()); app.paste("UPDATE"); let request = app.key(key(KeyCode::Enter)).expect("confirmed update"); @@ -3085,7 +6033,7 @@ mod tests { app.data.errors, ["Could not read settings.\nCheck the TOML file."] ); - assert!(app.toast.as_ref().expect("failure").error); + assert!(app.toast.as_ref().expect("failure").tone == Tone::Error); } } @@ -3323,33 +6271,231 @@ mod tests { } #[test] - // Every controller action needs a Settings key, or the desktop app can do something - // that the terminal app cannot. - fn every_controller_action_has_a_settings_key() { - let mut keys: Vec = ACTIONS.iter().map(|&(key, ..)| key).collect(); - // The Settings page reads its letters before the global keys and the j, g, and G moves. - let reserved = ['q', 'r', '?', '1', '2', '3', '4', 'j', 'g', 'G']; - keys.extend(reserved); - keys.sort_unstable(); - keys.dedup(); - assert_eq!( - keys.len(), - ACTIONS.len() + reserved.len(), - "Settings keys must be unique and must not use a global or movement key" + // c hands the selected recorded session off and x asks for a transcript file; both reach + // the review that shows the whole hand-off text and submits Handoff with the preview token. + fn continue_keys_hand_off_the_selected_session_and_a_transcript_file() { + let snapshot = Snapshot { + routes: vec![RouteInfo { + key: "r".into(), + id: "public".into(), + label: "public · passthrough".into(), + ..Default::default() + }], + tools: vec![ToolInfo { + tool: Harness::Claude, + label: "Claude Code", + available: true, + files: Vec::new(), + status: String::new(), + accounts: Vec::new(), + account_details: Vec::new(), + discovery_error: None, + }], + session_inventory: vec![json!({ + "id": "sy-0192f0a0-0000-7000-8000-000000000001", + "tool": "codex_cli", + "route": "public", + "repository": "/repo", + "worktree": "/worktrees/sy-0192f0a0-0000-7000-8000-000000000001", + "branch": "sy-0192f0a0-0000-7000-8000-000000000001", + "created_at": "2026-10-26T10:00:00Z", + })], + ..Snapshot::default() + }; + let mut app = App::new(Theme::new(false)); + app.finish(None, Ok(Box::new(snapshot))); + app.tab = Tab::Sessions; + let request = press(&mut app, KeyCode::Char('c')).expect("preview request"); + match &request.job { + Job::Run(Action::PreviewHandoff { + source: HandoffSource::Managed { id }, + tool: Harness::Claude, + account: None, + route, + id: route_id, + }) => { + assert_eq!(id, "sy-0192f0a0-0000-7000-8000-000000000001"); + assert_eq!((route.as_str(), route_id.as_str()), ("r", "public")); + } + _ => panic!("c did not request a hand-off preview"), + } + // The reply opens the review over the page with the hand-off text and the token. + let (jobs, _inbox) = mpsc::channel(); + app.start(&jobs, request).expect("start"); + let reply = Reply { + message: String::new(), + data: json!({ + "preview_token": "token-1", + "handoff": "# Hand-off from Codex\n\n## Goal\n\nFix the flaky login test\n", + "bytes": 52, "turns": 1, "files_touched": 0, "commands": 0, + "source": {"label": "Codex", "native_id": "thread-1", "title": "Fix the flaky login test", + "transcript": "/codex/sessions/rollout-1.jsonl"}, + "destination": {"label": "Claude Code", "directory": "/worktrees/sy-1", "route": "public", + "session_id": "sy-0192f0a0-0000-7000-8000-000000000001"}, + "handoff_file": "/handoffs/hd-1/HANDOFF.md", + "warnings": ["The transcript changed in the last two minutes."], + "command": "cd '/worktrees/sy-1'\n'claude' '--settings'", + "notice": "Nothing is uploaded.", + }), + }; + let data = app.data.clone(); + assert!(app.finish(Some(Ok(reply)), Ok(Box::new(data))).is_none()); + match &app.popup { + Some(Popup::Review { lines, confirm, .. }) => { + let text: String = lines + .iter() + .flat_map(|line| line.spans.iter().map(|span| span.content.clone())) + .collect::>() + .join("\n"); + for expected in [ + "Fix the flaky login test", + "changed in the last two minutes", + "'--settings'", + "/handoffs/hd-1/HANDOFF.md", + ] { + assert!(text.contains(expected), "{text}"); + } + match &confirm.kind { + FormKind::Confirm(word, action) => { + assert_eq!(word.as_ref(), "HANDOFF"); + assert!(matches!( + **action, + Action::Handoff { + source: HandoffSource::Managed { .. }, + tool: Harness::Claude, + preview_token: Some(ref token), + .. + } if token == "token-1" + )); + } + _ => panic!("the review does not ask for the HANDOFF word"), + } + } + _ => panic!("the preview reply did not open the review"), + } + app.popup = None; + // x asks for a transcript path first; the form submits the preview for that file. + assert!(press(&mut app, KeyCode::Char('x')).is_none()); + assert!(matches!(app.popup, Some(Popup::Form(_)))); + for c in "/codex/sessions/rollout-2.jsonl".chars() { + assert!(press(&mut app, KeyCode::Char(c)).is_none()); + } + let request = press(&mut app, KeyCode::Enter).expect("transcript preview"); + match &request.job { + Job::Run(Action::PreviewHandoff { + source: HandoffSource::Transcript { path }, + tool: Harness::Claude, + .. + }) => assert_eq!(path, "/codex/sessions/rollout-2.jsonl"), + _ => panic!("x did not request a transcript hand-off preview"), + } + // Without a selected session, c explains what to do instead of sending an action. + app.popup = None; + app.busy = None; + app.managed = None; + assert!(press(&mut app, KeyCode::Char('c')).is_none()); + assert!( + app.toast + .as_ref() + .is_some_and(|toast| toast.text.contains("Press s")) + ); + } + + #[test] + // Every controller action needs a key, or the desktop app can do something that the + // terminal app cannot. + fn every_controller_action_has_a_key() { + let mut pairs: Vec<(usize, char)> = ACTIONS + .iter() + .map(|&(tab, key, ..)| (tab.index(), key)) + .collect(); + pairs.sort_unstable(); + pairs.dedup(); + assert_eq!(pairs.len(), ACTIONS.len(), "A page uses one key twice"); + + // App::key handles the global keys and the page digits before action letters, and + // j, g, and G must keep moving the selection. Each page's own keys come after its + // action letters, so a row with one of them would hide it. + let global = ['q', 'r', '?', 'j', 'g', 'G']; + let digits: Vec = (1..=TABS.len() as u32) + .filter_map(|digit| char::from_digit(digit, 10)) + .collect(); + let page_keys = |tab| -> &[char] { + match tab { + Tab::Calls => &['/', 's'], + Tab::Models => &['p', '[', ']'], + Tab::Routes => &['/'], + Tab::Harnesses => &['t', 'a', 'x'], + // The arrows choose the route here, so k must keep moving it. + Tab::Sessions => &['t', 'a', 's', 'k'], + Tab::Overview | Tab::Settings => &[], + } + }; + let reserved: Vec<(Tab, char)> = ACTIONS + .iter() + .filter(|&&(tab, key, ..)| { + global.contains(&key) || digits.contains(&key) || page_keys(tab).contains(&key) + }) + .map(|&(tab, key, ..)| (tab, key)) + .collect(); + assert!( + reserved.is_empty(), + "These rows use a reserved key: {reserved:?}" ); - // Update is the only action without its own key: the u key reviews the update - // first, and the review window submits Update. let missing: Vec = ActionKind::iter() - .filter(|&kind| kind != ActionKind::Update && !ACTIONS.iter().any(|&(.., k)| k == kind)) + .filter(|kind| !ACTIONS.iter().any(|&(.., flow)| flow.contains(kind))) .collect(); - assert!(missing.is_empty(), "No Settings key runs {missing:?}"); - let mut review = Form::new(FormKind::Update, "", vec![("", "UPDATE".into())]); - assert!(matches!( - review.action(Some(&json!({"preview_token": "t"}))), - Ok(Action::Update { preview_token: Some(token) }) if token == "t" - )); - review.fields[0].1 = "update".into(); - assert!(review.action(None).is_err()); + assert!(missing.is_empty(), "No key runs {missing:?}"); + } + + #[test] + // The models form must keep the revision from when the editor opened, so a server config + // that changed since then fails the review and keeps the draft instead of saving old + // choices into the new file. + fn externally_changed_routes_cannot_bless_snapshot_choices() { + let dir = tempfile::tempdir().expect("fixture"); + let settings = dir.path().join("desktop.toml"); + let routes = dir.path().join("server.toml"); + let original = "[llm_clients.c]\n\ + base_url='http://localhost:1'\n\ + format='openai_responses'\n\ + [targets.m]\n\ + id='original'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n"; + std::fs::write(&routes, original).expect("routes"); + std::fs::write( + &settings, + format!( + "config_file={routes:?}\nrouting_log={:?}\nserver_url='http://127.0.0.1:0'", + dir.path().join("log") + ), + ) + .expect("settings"); + let mut controller = Controller::new(Config::load(&settings).expect("config"), settings); + let mut app = App::new(Theme::new(false)); + app.finish(None, Ok(Box::new(controller.snapshot().expect("snapshot")))); + app.tab = Tab::Routes; + assert!(press(&mut app, KeyCode::Char('e')).is_none()); + let external = original.replace("id='original'", "id='external'"); + std::fs::write(&routes, &external).expect("external edit"); + let editor = press(&mut app, KeyCode::Enter); + work(&mut app, &mut controller, editor); + let preview = press(&mut app, KeyCode::Enter); + work(&mut app, &mut controller, preview); + let Some(Popup::Form(draft)) = &app.popup else { + panic!("the draft closed"); + }; + assert!(matches!(draft.kind, FormKind::RouteModels { .. })); + assert!(draft.error.contains("Review"), "{}", draft.error); + assert_eq!( + std::fs::read_to_string(&routes).expect("preserved"), + external + ); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 2); } } diff --git a/crates/switchyard-desktop/src/windows.rs b/crates/switchyard-desktop/src/windows.rs index f939f3a168..fe206a1f93 100644 --- a/crates/switchyard-desktop/src/windows.rs +++ b/crates/switchyard-desktop/src/windows.rs @@ -1,6 +1,65 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +//! Harnesses run in their own Windows console with explicit arguments and account directories. + +use serde::{Deserialize, Serialize}; +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +/// This function reads current-user MSIX registrations without launching or changing the Codex app. +pub fn codex_app() -> Option { + use windows::{Management::Deployment::PackageManager, core::HSTRING}; + // An empty SID restricts discovery to packages registered for the current user. + let packages = PackageManager::new() + .ok()? + .FindPackagesByUserSecurityId(&HSTRING::new()) + .ok()?; + for package in packages { + let Some(name) = package.Id().ok().and_then(|id| id.Name().ok()) else { + continue; + }; + if name == "OpenAI.Codex" { + return Some(PathBuf::from(package.InstalledPath().ok()?.to_os_string())); + } + } + None +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TerminalJob { + pub binary: PathBuf, + pub args: Vec, + pub directory: Option, + pub environment: Vec<(String, String)>, + pub remove_environment: Vec, +} +/// The child reads structured launch settings so arguments and account directories remain separate. +pub fn open_terminal(job: TerminalJob) -> Result<(), String> { + use std::io::Write; + let mut file = tempfile::Builder::new() + .prefix("switchyard-terminal-") + .suffix(".json") + .tempfile() + .map_err(|e| e.to_string())?; + file.write_all(&serde_json::to_vec(&job).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + let mut child = Command::new(std::env::current_exe().map_err(|e| e.to_string())?); + // The child deletes the file, so the file must be kept before the child starts. + let path = file + .into_temp_path() + .keep() + .map_err(|error| error.to_string())?; + child.arg("--terminal-job").arg(&path); + if let Err(error) = child.spawn() { + let _ = std::fs::remove_file(&path); + return Err(error.to_string()); + } + Ok(()) +} pub fn attach_console() { unsafe { windows_console(false); @@ -28,3 +87,42 @@ unsafe fn windows_console(allocate: bool) { } } } +pub fn terminal_job() -> Option> { + use std::io::IsTerminal; + let args: Vec<_> = std::env::args_os().skip(1).collect(); + if args.first().is_none_or(|v| v != "--terminal-job") { + return None; + } + Some((|| { + if args.len() != 2 { + return Err("The terminal job requires one saved launch file.".into()); + } + let path = Path::new(&args[1]); + let job: TerminalJob = + serde_json::from_slice(&std::fs::read(path).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + std::fs::remove_file(path).map_err(|e| e.to_string())?; + allocate_console(); + let mut child = Command::new(&job.binary); + child.args(&job.args).envs(job.environment); + for name in job.remove_environment { + child.env_remove(name); + } + if let Some(directory) = job.directory { + child.current_dir(directory); + } + let status = child + .status() + .map_err(|e| format!("Could not start the harness: {e}"))?; + println!("Harness exited with {status}. Press Enter to close this window."); + if std::io::stdin().is_terminal() { + let mut line = String::new(); + let _ = std::io::stdin().read_line(&mut line); + } + if status.success() { + Ok(()) + } else { + Err(format!("Harness exited with {status}.")) + } + })()) +} diff --git a/crates/switchyard-desktop/tauri.conf.json b/crates/switchyard-desktop/tauri.conf.json index 3ca080f16f..b6ce1dc040 100644 --- a/crates/switchyard-desktop/tauri.conf.json +++ b/crates/switchyard-desktop/tauri.conf.json @@ -11,15 +11,19 @@ { "label": "main", "title": "Switchyard", - "width": 680, - "height": 500, - "minWidth": 440, - "minHeight": 400 + "width": 1080, + "height": 760, + "minWidth": 760, + "minHeight": 540, + "transparent": true, + "titleBarStyle": "Overlay", + "hiddenTitle": true } ], "security": { "csp": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src ipc: http://ipc.localhost; img-src 'self' data:; object-src 'none'; frame-src 'none'; base-uri 'none'" - } + }, + "macOSPrivateApi": true }, "bundle": { "active": false, diff --git a/crates/switchyard-desktop/tests/desktop.rs b/crates/switchyard-desktop/tests/desktop.rs index 71e560b5b3..f54c36b20c 100644 --- a/crates/switchyard-desktop/tests/desktop.rs +++ b/crates/switchyard-desktop/tests/desktop.rs @@ -169,6 +169,94 @@ esac port } + #[test] + fn fresh_install_defers_setup_and_reinstalls_reuse_saved_configuration() { + let f = Fixture::new(); + let server = f.s.sy_home.join("composite.toml"); + fs::remove_file(&server).expect("fresh state"); + write(&f.profile(), "invalid ["); + write(&f.s.prices_file(), "invalid prices"); + f.ok("install"); + assert!(!server.exists()); + assert!(!f.s.desktop_settings().exists()); + assert_eq!(read(&f.profile()), "invalid ["); + assert_eq!(read(&f.s.prices_file()), "invalid prices"); + fs::remove_file(f.profile()).expect("unrelated profile"); + assert!( + switchyard_desktop::config::needs_setup(&f.s.desktop_settings()).expect("fresh setup") + ); + let config = + switchyard_desktop::config::Config::load(&f.s.desktop_settings()).expect("defaults"); + switchyard_desktop::config::save_configuration(&f.s.desktop_settings(), &config, true) + .expect("save setup"); + let mut selected = config.clone(); + selected.config_file = f.s.sy_home.join("selected-server.toml"); + selected.routing_log = f.s.sy_home.join("selected-log.jsonl"); + selected.prices_file = Some(f.s.sy_home.join("selected-prices.toml")); + fs::copy(&server, &selected.config_file).expect("selected config"); + write( + selected.prices_file.as_ref().expect("price path"), + "[model]\ninput=1\noutput=2\ncache_read=0\ncache_write=0\n", + ); + switchyard_desktop::config::save_configuration(&f.s.desktop_settings(), &selected, false) + .expect("change selected files"); + let command = f.s.server_command().expect("selected server command"); + let args: Vec<_> = command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + assert!( + args.windows(2) + .any(|pair| pair == ["--config", selected.config_file.to_str().expect("path")]) + ); + assert!(args.windows(2).any(|pair| pair + == [ + "--routing-log-file", + selected.routing_log.to_str().expect("path") + ])); + assert!(args.windows(2).any(|pair| { + pair == [ + "--pricing-file", + selected + .prices_file + .as_ref() + .expect("price") + .to_str() + .expect("path"), + ] + })); + for host in ["localhost", "0.0.0.0"] { + selected.server_url = format!("http://{host}:{}", f.s.port); + switchyard_desktop::config::save_configuration( + &f.s.desktop_settings(), + &selected, + false, + ) + .expect("selected server address"); + let command = f.s.server_command().expect("loopback command"); + let args: Vec<_> = command + .get_args() + .map(|arg| arg.to_string_lossy()) + .collect(); + assert!(args.windows(2).any(|pair| pair == ["--host", "127.0.0.1"])); + } + selected.server_url = format!("http://localhost:{}", health_server()); + switchyard_desktop::config::save_configuration(&f.s.desktop_settings(), &selected, false) + .expect("restore server address"); + let saved = fs::read(f.s.desktop_settings()).expect("saved settings"); + f.ok("install"); + f.ok("install"); + assert!( + !switchyard_desktop::config::needs_setup(&f.s.desktop_settings()).expect("reinstall") + ); + assert_eq!(fs::read(f.s.desktop_settings()).expect("preserved"), saved); + assert_eq!(read(&server), install::BUNDLED_CONFIG); + assert_eq!( + toml(&f.profile())["model_providers"]["sy"]["base_url"].as_str(), + Some(format!("{}/v1", selected.server_url).as_str()) + ); + } + struct Fixture { dir: tempfile::TempDir, s: Settings, @@ -202,6 +290,8 @@ esac }; let file = dir.path().join("install.toml"); let f = Self { dir, s, file }; + // Most cases exercise an installation with an existing server configuration. + write(&f.s.sy_home.join("composite.toml"), install::BUNDLED_CONFIG); f.save(); f } @@ -423,6 +513,7 @@ esac // The helper rejects malformed commands and settings before it writes anything. let f = Fixture::new(); + let unchanged = f.files(); let bad = "key;key set;key status --config;install --dryrun;install --dry-run extra;\ uninstall extra;validate --server relative --config /config;\ validate --server /server --config /config\n"; @@ -449,7 +540,7 @@ esac let names_profile = String::from_utf8_lossy(&output.stdout).contains("team-dev"); assert_eq!(names_profile, args.contains("FILE"), "{args}"); } - assert!(f.files().is_empty()); + assert_eq!(f.files(), unchanged); write(&f.file, &format!("{}\nunknown = true\n", read(&f.file))); assert!(Settings::load(&f.file).is_err()); } diff --git a/crates/switchyard-desktop/tests/frontend.test.cjs b/crates/switchyard-desktop/tests/frontend.test.cjs new file mode 100644 index 0000000000..aec8edd723 --- /dev/null +++ b/crates/switchyard-desktop/tests/frontend.test.cjs @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +'use strict'; +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +function editor(invoke = () => new Promise(() => {})) { + const elements = new Map(); + const element = selector => { + if(!elements.has(selector))elements.set(selector,{dataset:{},append(){},querySelectorAll(){return [];},setAttribute(){},focus(){this.focused=true;}}); + return elements.get(selector); + }; + const context = vm.createContext({ + window: {__TAURI__: {core: {invoke}}}, + document: {createElement:()=>({append(){}}),querySelector: element, querySelectorAll: () => []}, + setTimeout: () => {},structuredClone, + }); + const source=fs.readFileSync(path.join(__dirname, '../ui/app.js'), 'utf8'); + // The fixture loads the real functions without starting the app's initial refresh. + vm.runInContext(source.slice(0,source.indexOf("\ncontent.append(node('p','Loading Switchyard…'")), context); + return context; +} +// The classifier occupies position zero, so copying by position would replace the capable model. +test('algorithm switches preserve model roles instead of positions', () => { + const context = editor(); + const result = vm.runInContext(` + const route = {kind:'composite',choices:[{model:'judge'},{model:'capable'},{model:'efficient'}]}; + const draft = {algorithm:'stage_router',choices:route.choices,memory:new Map([['Judge',route.choices[0]],['Capable',route.choices[1]],['Efficient',route.choices[2]]])}; + [roleChoice(draft,route,{tier:'Capable'},0).model,roleChoice(draft,route,{tier:'Efficient'},1).model]; + `, context); + assert.deepEqual(Array.from(result), ['capable', 'efficient']); +}); +// A successful editor redraw must preserve the distinction between a saved route and a failed restart. +test('editor redraw retains the saved route and restart result', async () => { + const context = editor((command, args) => command === 'snapshot' + ? new Promise(() => {}) + : Promise.resolve({message: args.action.kind === 'editor' ? 'Single model.' : 'Saved the route. Restart failed.',data:{warning:args.action.kind!=='editor'}})); + const result = await vm.runInContext(`(async () => { + busy = false; + await run({kind:'apply'}, false); + await run({kind:'editor'}, false); + return status.textContent; + })()`, context); + assert.equal(result, 'Attention: Saved the route. Restart failed.'); +}); + +// Navigation during a pending Usage refresh must not restore Usage's scroll position on Settings. +test('a pending refresh preserves the destination page scroll position', async () => { + let release; + const context=editor(()=>new Promise(resolve=>{release=resolve;})); + vm.runInContext(` + snapshot={};page='usage';scroller.scrollTop=200; + scroller.scrollTo=(x,y)=>{scroller.scrollTop=y;}; + capturePageState=()=>({scroll:scroller.scrollTop}); + restorePageState=saved=>scroller.scrollTo(0,saved.scroll); + render=()=>{}; + const pendingRefresh=refresh(); + `,context); + await new Promise(setImmediate); + vm.runInContext("navigate('settings')",context); + release({errors:[]}); + await vm.runInContext('pendingRefresh',context); + assert.equal(vm.runInContext('page',context),'settings'); + assert.equal(vm.runInContext('scroller.scrollTop',context),0); +}); + +test('route creation keeps pending model lists with their endpoint', async () => { + const context=editor(); + vm.runInContext(` + const controls=new Map();let load,resolveModels; + node=()=>({dataset:{},isConnected:true,append(){},replaceChildren(){}}); + combobox=(options,value)=>({value,setOptions(options){this.options=options;}}); + field=(parent,label,control)=>{controls.set(label,control);return control;}; + modelField=(parent,label,client,value)=>{const control=combobox([],value);controls.set(label,control);return control;}; + actions=()=>({});button=(parent,label,callback)=>{load=callback;return {};}; + snapshot={settings:{config_file:'fixture'},clients:[{name:'A',host:'a'},{name:'B',host:'b'}],algorithms:[{kind:'passthrough',title:'Single',summary:'Single',roles:[{label:'Model'}]}]}; + readAction=()=>new Promise(resolve=>{resolveModels=resolve;}); + routeModels({append(){}}); + const pending=load(); + controls.get('Endpoint').value='B';controls.get('Endpoint').onchange(); + resolveModels({data:{models:['A/model'],source:'endpoint'}}); + `,context); + await vm.runInContext('pending',context); + const result=vm.runInContext(`[lists.get(modelListKey('A'))?.models,lists.has(modelListKey('B')),controls.get('Model').options]`,context); + assert.deepEqual(JSON.parse(JSON.stringify(result)),[['A/model'],false,[]]); +}); + +// Hand-off previews and transcript listings only read, so a running mutation must not block +// them, while the hand-off itself waits like every other mutation. +test('hand-off previews and session listings read without taking the busy lock', async () => { + const context = editor((command, args) => Promise.resolve({message: '', data: {kind: args.action.kind}})); + const result = await vm.runInContext(`(async () => { + busy = true; + const preview = await run({kind:'preview_handoff'}, false); + const sessions = await run({kind:'recent_sessions'}, false); + const blocked = await run({kind:'handoff'}, false); + return [preview.data.kind, sessions.data.kind, blocked === undefined, busy]; + })()`, context); + assert.deepEqual(Array.from(result), ['preview_handoff', 'recent_sessions', true, true]); +}); + +// A wrong route used to surface only as a toast after the click, so the button looked dead. +test('a hand-off destination defaults to the first route its harness can use', () => { + const context = editor(); + const result = vm.runInContext(` + snapshot={tools:[{tool:'claude',label:'Claude Code',available:true,accounts:[]},{tool:'codex_cli',label:'Codex CLI profile',available:false,accounts:[]}], + routes:[{key:'sy',id:'switchyard',compatibility:{claude:'This route forwards the login to chatgpt.com.',codex_cli:null}},{key:'cl',id:'claude',compatibility:{claude:null,codex_cli:'This route forwards the login to api.anthropic.com.'}}]}; + const chosen=handoffDestination('claude'); + handoffView.options.claude.route='sy'; + let rejected='';try{handoffDestination('claude');}catch(error){rejected=error.message;} + let missing='';try{handoffDestination('codex_cli');}catch(error){missing=error.message;} + [chosen.route, chosen.id, chosen.label, rejected.includes('cannot use route switchyard'), missing.includes('not detected')]; + `, context); + assert.deepEqual(Array.from(result), ['cl', 'claude', 'Claude Code', true, true]); +}); diff --git a/crates/switchyard-desktop/tests/setup.test.cjs b/crates/switchyard-desktop/tests/setup.test.cjs new file mode 100644 index 0000000000..56f97b2453 --- /dev/null +++ b/crates/switchyard-desktop/tests/setup.test.cjs @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +'use strict'; +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +function editor(invoke = () => new Promise(() => {})) { + const elements = new Map(); + const element = selector => { + if(!elements.has(selector))elements.set(selector,{dataset:{},append(){},querySelectorAll(){return [];},setAttribute(){},focus(){this.focused=true;}}); + return elements.get(selector); + }; + const context = vm.createContext({ + window: {__TAURI__: {core: {invoke}}}, + document: {createElement:()=>({append(){}}),querySelector: element, querySelectorAll: () => []}, + setTimeout: () => {},structuredClone, + }); + const source=fs.readFileSync(path.join(__dirname, '../ui/app.js'), 'utf8'); + // The fixture loads the real functions without starting the app's initial refresh. + vm.runInContext(source.slice(0,source.indexOf("\ncontent.append(node('p','Loading Switchyard…'")), context); + return context; +} +// Only the first snapshot selects setup; a later refresh must preserve the user's navigation. +test('first launch opens setup and retained files skip it after reinstall', async () => { + for(const needsSetup of [true,false]){ + const context=editor(()=>Promise.resolve({needs_setup:needsSetup,errors:[]})); + vm.runInContext(`render=()=>{};showPageErrors=()=>{};showFreshness=()=>{};capturePageState=()=>({});restorePageState=()=>{};`,context); + await vm.runInContext('refresh()',context); + assert.equal(vm.runInContext('page',context),needsSetup?'setup':'overview'); + vm.runInContext("page='settings'",context); + await vm.runInContext('refresh()',context); + assert.equal(vm.runInContext('page',context),'settings'); + } +}); + +// The real form handlers submit the selected files through the shared controller action. +test('setup submits selected files and Settings reuses the same form', async () => { + const context=editor(); + const result=await vm.runInContext(`(async()=>{ + const controls=new Map(),handlers=new Map(),saved=[]; + node=()=>({append(){}});card=()=>({append(){}});actions=()=>({}); + input=value=>({value});combobox=(options,value)=>({value}); + field=(parent,label,control)=>{controls.set(label,control);return control;}; + button=()=>({});mutation=(parent,label,callback)=>{handlers.set(label,callback);return {};}; + fieldError=()=>true;confirmAction=(title,message,label,callback)=>callback();navigate=()=>{}; + run=async action=>{saved.push(action);return {};}; + snapshot={needs_setup:true,settings:{path:'/tmp/desktop.toml',server_url:'http://127.0.0.1:4123',config_file:'/tmp/default.toml',routing_log:'/tmp/routing.jsonl',launchd_label:'com.nvidia.switchyard.server',baseline_model:'baseline',refresh_seconds:30}}; + renderConfiguration(true); + controls.get('Server config file').value='/tmp/chosen.toml';controls.get('Server config file').oninput(); + controls.get('Server configuration').value='existing';controls.get('Server configuration').onchange(); + await handlers.get('Save setup')();await Promise.resolve(); + renderConfiguration(false); + return {saved,settingsHasSave:handlers.has('Save configuration')}; + })()`,context); + const plain=JSON.parse(JSON.stringify(result)); + assert.equal(plain.saved.length,1); + assert.equal(plain.saved[0].kind,'save_configuration'); + assert.equal(plain.saved[0].config.config_file,'/tmp/chosen.toml'); + assert.equal(plain.saved[0].create_server,false); + assert.equal(plain.settingsHasSave,true); +}); diff --git a/crates/switchyard-desktop/tests/windows_terminal.rs b/crates/switchyard-desktop/tests/windows_terminal.rs new file mode 100644 index 0000000000..2242174fdf --- /dev/null +++ b/crates/switchyard-desktop/tests/windows_terminal.rs @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(windows)] + +#[test] +fn native_and_npm_launchers_preserve_arguments_accounts_and_working_directory() { + use std::{ + fs, + process::{Command, Stdio}, + }; + let root = tempfile::Builder::new() + .prefix("Terminal équipe space ") + .tempdir() + .expect("fixture"); + let source = root.path().join("tool.rs"); + fs::write(&source,r#"fn main() { + let output=std::env::var_os("FIXTURE_OUTPUT").unwrap(); + let text=format!("{:?}\n{:?}\n{:?}\n{:?}",std::env::args().skip(1).collect::>(),std::env::current_dir().unwrap(),std::env::var("CODEX_HOME"),std::env::var("ANTHROPIC_AUTH_TOKEN")); + std::fs::write(output,text).unwrap(); + }"#).expect("tool source"); + let tool = root.path().join("tool.exe"); + assert!( + Command::new("rustc") + .arg(&source) + .arg("-o") + .arg(&tool) + .status() + .expect("compile fixture") + .success() + ); + let shim = root.path().join("tool.cmd"); + fs::write(&shim, "@echo off\r\n@\"%~dp0tool.exe\" %*\r\n").expect("npm shim"); + let args = vec![ + "space équipe".to_string(), + "a\"b".into(), + "%PATH%".into(), + "& echo injected > forbidden-file".into(), + "C:\\space équipe\\".into(), + "|output^".into(), + ]; + let output = root.path().join("arguments.txt"); + let account = root.path().join("account équipe space"); + for binary in [tool, shim] { + let launch = root.path().join("launch.json"); + fs::write(&launch,serde_json::to_vec(&serde_json::json!({"binary":binary,"args":args,"directory":root.path(),"environment":[["CODEX_HOME",account.display().to_string()],["FIXTURE_OUTPUT",output.display().to_string()]],"remove_environment":["ANTHROPIC_AUTH_TOKEN"]})).expect("launch JSON")).expect("launch record"); + let result = Command::new(env!("CARGO_BIN_EXE_switchyard-desktop")) + .arg("--terminal-job") + .arg(&launch) + .env("ANTHROPIC_AUTH_TOKEN", "must-be-removed") + .stdin(Stdio::null()) + .output() + .expect("terminal job"); + assert!( + result.status.success(), + "terminal launch failed for {binary:?}: status={:?}; stdout={}; stderr={}", + result.status, + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + let captured = fs::read_to_string(&output).expect("captured tool launch"); + assert!(captured.starts_with(&format!("{args:?}\n")), "{captured}"); + assert!(captured.contains(&format!("Ok({:?})", account.display().to_string()))); + assert!(captured.contains("NotPresent")); + assert!(captured.contains(&format!("{:?}", root.path()))); + assert!(!launch.exists()); + assert!(!root.path().join("forbidden-file").exists()); + fs::remove_file(&output).expect("reset output"); + } +} diff --git a/crates/switchyard-desktop/ui/analytics.js b/crates/switchyard-desktop/ui/analytics.js new file mode 100644 index 0000000000..3608d51b5c --- /dev/null +++ b/crates/switchyard-desktop/ui/analytics.js @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +'use strict'; +const analyticsView = {period:'today',group:'model',metric:'tokens',search:'',model:null}; +const chartInstances = []; +const tokenFields = ['requests','input','cached_input','cache_write','output']; +const chartColor = index => getComputedStyle(document.documentElement).getPropertyValue(`--model-${index+1}`).trim(); +function chartTheme() { + const style=getComputedStyle(document.documentElement); + return {ink:style.getPropertyValue('--muted').trim(),panel:style.getPropertyValue('--control').trim(),line:style.getPropertyValue('--line').trim()}; +} +const emptyTokens = () => ({requests:0,input:0,cached_input:0,cache_write:0,output:0}); +const tokenTotal = row => row.input + row.cached_input + row.cache_write + row.output; +const compactNumber = value => new Intl.NumberFormat(undefined,{notation:'compact',maximumFractionDigits:1}).format(value); +const dollars = value => new Intl.NumberFormat(undefined,{style:'currency',currency:'USD'}).format(value); +function addTokens(target, source) { for(const key of tokenFields)target[key] += source[key]; return target; } +function modelRows(totals) { + const rows = new Map(); + for(const group of [totals.routed,totals.classifier])for(const [id,tokens] of Object.entries(group)) { + if(!rows.has(id))rows.set(id,{id,...emptyTokens()}); + addTokens(rows.get(id),tokens); + } + return [...rows.values()]; +} +function disposeCharts() { + for(const {chart,observer} of chartInstances) { observer.disconnect(); chart.dispose(); } + chartInstances.length = 0; +} +// Canvas tooltips display recorded names without interpreting them as HTML. +function mountChart(parent, options, height, description, onClick) { + const container=node('div',undefined,'usage-chart');container.style.height=`${height}px`; + container.setAttribute('role','img');container.setAttribute('aria-label',description);parent.append(container); + const colors=chartTheme(); + const chart=window.echarts.init(container,null,{renderer:'canvas'}); + // Recorded model names may match color literals, so theme colors apply only to presentation fields. + chart.setOption({animation:false,backgroundColor:'transparent',textStyle:{fontFamily:'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif',color:colors.ink}, + aria:{enabled:true},tooltip:{trigger:'axis',renderMode:'richText',confine:true,backgroundColor:colors.panel,textStyle:{color:colors.ink},borderColor:colors.line},...options}); + if(onClick)chart.on('click',onClick); + const observer=new ResizeObserver(()=>chart.resize());observer.observe(container);chartInstances.push({chart,observer}); +} +// Local midnight boundaries match the Rust reader's calendar-day totals. +function inPeriod(timestamp, period, today) { + if(period==='all')return true; + const date=new Date(timestamp),start=new Date(`${today}T00:00:00`),end=new Date(start); + end.setDate(end.getDate()+1); + if(period==='week')start.setDate(start.getDate()-6); + return date>=start&&date>>0;return chartColor(value%6);} +function openUsage(group, id) { + usageView.page=0;usageView.session='';usageView.search=''; + usageView.exact={group,id,period:analyticsView.period};usageView.period=analyticsView.period; + navigate('usage'); +} +function renderOverview() { + const colors=chartTheme(); + const metrics=snapshot.metrics,line=node('div',undefined,'connection-line'); + if(metrics.running)line.append(node('span','Connected','connection-state connected')); + line.append(node('span',metrics.server_url,'connection-address')); + button(line,`Manage ${snapshot.routes.length} routes`,()=>navigate('routes'));content.append(line);renderOnboarding(); + if(usageUnavailable()){const c=card('Usage unavailable');c.append(node('p','The routing log could not be read. Totals and costs are unavailable.'));button(actions(c),'Retry',()=>refresh());button(actions(c),'Open settings',()=>navigate('settings'));return;} + const controls=node('div',undefined,'analytics-controls');content.append(controls); + const period=field(controls,'Period',combobox([['all','All retained history'],['week','Seven local calendar days'],['today','Today']],analyticsView.period)); + const totals=snapshot.analytics[analyticsView.period],models=modelRows(totals),combined=models.reduce(addTokens,emptyTokens()); + const overhead=Object.values(totals.classifier).reduce(addTokens,emptyTokens()); + const estimate=snapshot.estimates[analyticsView.period],baseline=snapshot.baseline_model; + const saved=estimate.actual!==null&&estimate.baseline!==null&&!estimate.uncounted_answers?estimate.baseline-estimate.actual:null; + const unknown=estimate.unknown_calls?`${estimate.unknown_calls.toLocaleString()} ${estimate.unknown_calls===1?'call has':'calls have'} no recorded cost`:''; + const metricsGrid=node('div',undefined,'usage-metrics');content.append(metricsGrid); + for(const [label,value,detail] of [ + ['Recorded tokens',compactNumber(tokenTotal(combined)),`Model calls: ${combined.requests.toLocaleString()} · includes cached reads`], + ['Routing overhead',compactNumber(tokenTotal(overhead)),`Classifier and judge calls: ${overhead.requests.toLocaleString()}`], + ['Cost',estimate.actual!==null?dollars(estimate.actual):unknown?`${dollars(estimate.known)} or more`:'—',unknown||'Recorded by the server · includes routing overhead'], + [saved!==null&&saved<0?'Extra cost':'Savings',saved!==null?dollars(Math.abs(saved)):'—',saved!==null?`vs ${baseline}`:estimate.baseline===null?`Add ${baseline} to the model prices`:unknown?'Needs a cost for every call':'Needs token counts for every answer'], + ]) { + const panel=node('div',undefined,'usage-metric');panel.append(node('p',label),node('strong',value),node('small',detail));metricsGrid.append(panel); + } + const costNote=node('details',undefined,'estimate-note');costNote.append(node('summary','How costs work')); + costNote.append(node('p','The server records each call’s cost when the call finishes: the charge the provider reports, or an estimate from the model prices. Savings compare that cost with the same answer tokens at the baseline model’s rates. Classifier and judge calls add cost but have no baseline, so they reduce savings. These figures are not subscription charges.')); + if(saved!==null)costNote.append(node('p',`Baseline: ${baseline} · ${dollars(estimate.baseline)}. ${estimate.baseline>0?`${(Math.abs(saved)/estimate.baseline*100).toFixed(1)}% ${saved<0?'extra cost':'savings'}.`:''}`)); + else {const missing=node('p',estimate.baseline===null?`The model prices have no rates for ${baseline}.`:unknown?`${unknown}. Calls recorded before the server priced calls, or for models without rates, have none.`:`${estimate.uncounted_answers.toLocaleString()} ${estimate.uncounted_answers===1?'answer has':'answers have'} no token counts, so the baseline cannot price ${estimate.uncounted_answers===1?'it':'them'}.`,'warning-note');content.append(missing);button(actions(missing),snapshot.settings?.prices_file?'Open model price override':'About model prices',()=>run({kind:'open_prices'},false));} + content.append(costNote); + const comparison=card(analyticsView.model?`Usage for ${analyticsView.model}`:'Usage by model');comparison.append(node('p',`Period: ${analyticsView.period==='all'?'All retained history':analyticsView.period==='today'?snapshot.today:`${new Date(new Date(`${snapshot.today}T00:00:00`).setDate(new Date(`${snapshot.today}T00:00:00`).getDate()-6)).toLocaleDateString()} – ${snapshot.today}`} · ${Intl.DateTimeFormat().resolvedOptions().timeZone}.`,'muted')); + const filters=node('div',undefined,'analytics-controls');comparison.append(filters); + const group=field(filters,'Compare',combobox([['model','Models'],['route','Routes'],['session','Sessions']],analyticsView.group)); + const metric=field(filters,'Measure',combobox([['tokens','Tokens'],['requests','Model calls']],analyticsView.metric)); + const search=field(filters,'Filter names',input(analyticsView.search));search.placeholder='Find a model, route, or session'; + button(actions(comparison),'Clear filters',()=>{analyticsView.search='';analyticsView.model=null;analyticsView.group='model';render();}); + const chartParent=node('div'),tableParent=node('div',undefined,'analytics-table');comparison.append(chartParent,tableParent); + const labelFor=row=>row.id|| (group.value==='session'?'Session not recorded':'Route not recorded'); + const draw=()=>{ + analyticsView.group=group.value;analyticsView.metric=metric.value;analyticsView.search=search.value; + comparison.querySelector('h3').textContent=analyticsView.model&&group.value==='model'?`Usage for ${analyticsView.model}`:`Usage by ${group.value}`; + // A chart owns its resize observer only while its container is visible. + disposeCharts();chartParent.replaceChildren();tableParent.replaceChildren(); + let rows=group.value==='model'?models:group.value==='route'?Object.entries(totals.routes).map(([id,tokens])=>({id,...tokens})):sessionRows(snapshot.entries,period.value,snapshot.today); + const value=row=>metric.value==='tokens'?tokenTotal(row):row.requests; + rows=rows.filter(row=>(!analyticsView.model||group.value!=='model'||row.id===analyticsView.model)&&labelFor(row).toLowerCase().includes(search.value.toLowerCase())).sort((a,b)=>value(b)-value(a)||a.id.localeCompare(b.id)); + chartParent.append(node('p',group.value==='session'?`Session comparison uses ${snapshot.entries.length.toLocaleString()} recent calls${snapshot.limited?' · limited to 5,000 records / 8 MiB':''}.`:'Totals cover completed calls in the retained log, including routing overhead.','muted')); + if(rows.length) { + const shown=rows.slice(0,10),names=shown.map(labelFor); + if(rows.length>10)chartParent.append(node('p',`${rows.length-10} other groups: ${compactNumber(rows.slice(10).reduce((sum,row)=>sum+value(row),0))} ${metric.value==='tokens'?'tokens':'model calls'}. See all groups in the table.`,'muted')); + chartParent.append(node('small',`Showing ${shown.length} of ${rows.length} ${group.value==='model'?'models':group.value==='route'?'routes':'sessions'} · exact values below`)); + mountChart(chartParent,{ + grid:{left:12,right:24,top:36,bottom:24,containLabel:true}, + legend:{top:0,textStyle:{color:colors.ink},itemWidth:12,itemHeight:8}, + xAxis:{type:'value',axisLabel:{formatter:compactNumber,color:colors.ink},splitLine:{lineStyle:{color:colors.line}}}, + yAxis:{type:'category',inverse:true,data:names,axisLabel:{color:colors.ink,width:160,overflow:'truncate'},axisTick:{show:false},axisLine:{show:false}}, + series:metric.value==='tokens'?['input','cached_input','cache_write','output'].map((key,i)=>({name:['Uncached input','Cached input','Cache writes','Output'][i],type:'bar',stack:'tokens',barMaxWidth:18,itemStyle:{color:chartColor(i)},data:shown.map(row=>row[key])})):[{name:'Model calls',type:'bar',barMaxWidth:18,itemStyle:{color:chartColor(0)},data:shown.map(row=>row.requests)}], + },Math.max(200,shown.length*38+80),'Usage comparison. Exact counts and links are in the table below.',event=>openUsage(group.value,shown[event.dataIndex].id)); + } else chartParent.append(node('p',search.value?'No names match this filter.':'No model calls recorded for this period.','empty-state')); + const scroll=node('div',undefined,'usage-table-scroll'),table=node('table',undefined,'usage-totals-table'),head=node('tr'),thead=node('thead'),body=node('tbody'); + for(const name of [group.value==='model'?'Model':group.value==='route'?'Route':'Session','Calls','Uncached input','Cached input','Cache writes','Output','Total tokens',...(group.value==='model'?['Answer tokens','Overhead tokens']:[])]) { const th=node('th',name);th.scope='col';head.append(th); } + scroll.tabIndex=0;scroll.setAttribute('role','region');scroll.setAttribute('aria-label','Usage comparison table');thead.append(head);table.append(thead,body);scroll.append(table);tableParent.append(scroll); + for(const row of rows) { + const tr=node('tr'),name=node('td');button(name,labelFor(row),()=>openUsage(group.value,row.id));tr.append(name); + for(const count of [row.requests,row.input,row.cached_input,row.cache_write,row.output,tokenTotal(row),...(group.value==='model'?[tokenTotal(totals.routed[row.id]||emptyTokens()),tokenTotal(totals.classifier[row.id]||emptyTokens())]:[])])tr.append(node('td',count.toLocaleString()));body.append(tr); + } + renderDaily(comparison); + }; + period.onchange=()=>{analyticsView.period=period.value;analyticsView.model=null;render();};group.onchange=()=>{analyticsView.model=null;draw();};metric.onchange=draw;search.oninput=()=>{analyticsView.model=null;draw();};draw(); + content.append(node('p',`Aggregate totals exclude ${snapshot.analytics.skipped||0} unreadable records. Incomplete log data can undercount usage.`,'coverage-note')); + content.append(node('p','All retained history resets if the routing log is replaced or truncated. Session and turn details use recent history.','coverage-note')); +} +function renderDaily(parent) { + const colors=chartTheme(); + parent.querySelector('.daily-usage')?.remove(); + const panel=node('div',undefined,'daily-usage');parent.append(panel);panel.append(node('h3','Daily usage by model'),node('p',`This chart shows tokens for the seven local calendar days through ${snapshot.today}, including cached reads and routing overhead.`,'muted')); + const end=new Date(`${snapshot.today}T00:00:00`),days=[]; + for(let offset=6;offset>=0;offset--) {const date=new Date(end);date.setDate(date.getDate()-offset);days.push(`${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`);} + const models=modelRows(snapshot.analytics.week).sort((a,b)=>tokenTotal(b)-tokenTotal(a)||a.id.localeCompare(b.id)); + if(!models.length){panel.append(node('p','No model calls recorded in the past 7 days.','empty-state'));return;} + const shown=models.slice(0,5),ids=new Set(shown.map(model=>model.id)); + const daily=days.map(day=>new Map(modelRows(snapshot.analytics.days[day]||{routed:{},classifier:{}}).map(row=>[row.id,tokenTotal(row)]))); + const series=shown.map((model,i)=>({name:model.id,type:'bar',stack:'models',barMaxWidth:40,itemStyle:{color:stableColor(model.id)},data:daily.map(totals=>totals.get(model.id)||0)})); + if(models.length>5)series.push({name:'Other models',type:'bar',stack:'models',itemStyle:{color:chartColor(5)},data:daily.map(totals=>[...totals].reduce((sum,[id,count])=>sum+(ids.has(id)?0:count),0))}); + mountChart(panel,{grid:{left:12,right:20,top:20,bottom:28,containLabel:true}, + xAxis:{type:'category',data:days.map(day=>new Date(`${day}T12:00:00`).toLocaleDateString(undefined,{month:'short',day:'numeric'})),axisLabel:{color:colors.ink},axisTick:{show:false}}, + yAxis:{type:'value',axisLabel:{color:colors.ink,formatter:compactNumber},splitLine:{lineStyle:{color:colors.line}}},series},220,'Daily recorded tokens. Exact daily totals follow.'); + const key=node('div',undefined,'chart-key');series.forEach((item,i)=>{const label=node('span'),swatch=node('i');swatch.style.background=item.itemStyle.color;label.append(swatch,document.createTextNode(item.name));key.append(label);});panel.append(key); + const details=node('details');details.append(node('summary','Exact daily totals'));for(const [index,day] of days.entries())details.append(node('p',`${day}: ${series.map(item=>`${item.name} ${item.data[index].toLocaleString()}`).join(' · ')}`,'summary-row'));panel.append(details); +} + +function renderOnboarding() { + if(snapshot.entries.length || sessionStorage.getItem('switchyard-checklist-dismissed'))return; + const c=card('Get started');c.append(node('p','Complete these steps to see your first recorded call.')); + const steps=[['Server ready',snapshot.metrics.running,'settings'],['Choose a route',snapshot.routes.length>0,'routes'],['Connect a harness',connectedTools.size>0,'install'],['Open a session in Terminal',sessionOpened,'sessions'],['Find the first recorded call',false,'usage']]; + for(const [label,done,target] of steps)button(c,`${done?'✓ ':''}${label}`,()=>navigate(target));button(actions(c),'Dismiss checklist',()=>{sessionStorage.setItem('switchyard-checklist-dismissed','1');c.remove();}); +} diff --git a/crates/switchyard-desktop/ui/app.css b/crates/switchyard-desktop/ui/app.css new file mode 100644 index 0000000000..585d4e82d1 --- /dev/null +++ b/crates/switchyard-desktop/ui/app.css @@ -0,0 +1,344 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ +/* A 224px sidebar beside a rounded page panel, with a 48px header and 32px controls. */ +:root { + font: 14px/1.5 var(--font); + color: var(--text); + background: var(--canvas); + -webkit-font-smoothing: antialiased; +} +* { box-sizing: border-box; scrollbar-width: thin; scrollbar-color: var(--scrollbar) transparent; } +::selection { background: #CD744F80; color: var(--strong); } +[hidden] { display: none !important; } + +/* Shell */ +/* The page panel scrolls; the sidebar and the 8px strip above the panel stay put. */ +body { margin: 0; display: grid; grid-template-columns: 224px minmax(0, 1fr); height: calc(100dvh / var(--ui-scale, 1)); overflow: hidden; background: var(--chrome); } +.app-sidebar { display: flex; flex-direction: column; min-height: 0; overflow: auto; padding: 40px 8px 8px; color: var(--chrome-text); } +:root[data-native-material="true"]:not([data-reduce-transparency="true"]) { background: transparent; } +:root[data-native-material="true"]:not([data-reduce-transparency="true"]) body { background: color-mix(in srgb, var(--chrome) 88%, transparent); } +.brand { display: flex; align-items: center; gap: 8px; padding: 8px; } +.brand-mark { display: grid; place-items: center; flex: none; width: 32px; height: 32px; border-radius: var(--radius-lg); background: var(--accent); color: #FFFFFF; } +.brand-mark svg { width: 16px; height: 16px; } +h1 { margin: 0; font-size: 14px; font-weight: 600; line-height: 1.3; color: var(--strong); } +.sidebar-caption { margin: 0; font-size: 12px; color: var(--chrome-muted); } +nav { display: grid; gap: 4px; margin-top: 8px; padding: 8px 0; } +nav button { display: flex; align-items: center; gap: 8px; width: 100%; height: 32px; min-height: 0; padding: 0 8px; border: 0; border-radius: var(--radius); background: transparent; box-shadow: none; color: var(--chrome-text); font-size: 14px; font-weight: 400; } +nav button svg { flex: none; width: 16px; height: 16px; } +nav button:hover { background: var(--selection); color: var(--strong); } +nav button.active { background: var(--selection); color: var(--strong); font-weight: 500; } +.app-sidebar footer { margin-top: auto; padding: 8px; font-size: 12px; color: var(--chrome-muted); } +main { min-width: 0; margin: 8px 8px 8px 0; padding: 0 24px 32px; overflow: auto; border-radius: var(--radius-xl); background: var(--canvas); box-shadow: 0 1px 3px 0 #0000001A, 0 1px 2px -1px #0000001A; } +.window-drag-strip { position: fixed; top: 0; left: 0; right: 0; z-index: 50; height: 8px; } +.app-header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 16px; height: 48px; margin: 0 -24px 24px; padding: 0 24px; border-bottom: 1px solid var(--line); background: var(--canvas); } +h2 { margin: 0; font-size: 16px; font-weight: 500; color: var(--strong); } +h2:focus { outline: none; } +.refresh-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +#refresh svg { width: 14px; height: 14px; } +#refresh[aria-busy="true"] svg { animation: spin 1s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +#content, .server-state, #page-errors { background: var(--canvas); } +section { display: grid; gap: 16px; } + +/* Text */ +h3 { margin: 0 0 12px; font-size: 16px; font-weight: 600; line-height: 1.4; color: var(--strong); overflow-wrap: anywhere; } +h4 { color: var(--strong); } +p { margin: 0 0 12px; } +p:last-child { margin-bottom: 0; } +small, .muted { color: var(--muted); } +small { font-size: 12px; line-height: 1.5; } +pre { font: 12px/1.7 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; } +pre + p { margin-top: 12px; } + +/* Panels */ +.card { min-width: 0; padding: 24px; border: 1px solid var(--line); border-radius: var(--radius-xl); background: var(--panel); box-shadow: 0 1px 3px 0 #0000001A, 0 1px 2px -1px #0000001A; } +:where(.card) p { font-size: 13px; } +.card > p { max-width: 76ch; color: var(--muted); } +.empty-state { padding: 20px 12px; text-align: center; color: var(--muted); } +.coverage-note { font-size: 12px; color: var(--muted); } + +/* Controls */ +button, input { font: inherit; color: inherit; } +button { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 12px; border: 1px solid var(--control-border); border-radius: var(--radius); background: var(--control); color: var(--text); font-size: 14px; font-weight: 500; line-height: 1.35; text-align: left; cursor: pointer; transition: background-color .12s, border-color .12s, color .12s; } +button:hover { background: var(--hover); } +button:disabled, input:disabled { opacity: .5; } +button:disabled { cursor: default; } +button:disabled:hover:not(.primary) { background: var(--control); } +button.primary { background: var(--accent); border-color: transparent; color: #FFFFFF; } +button.primary:hover:not(:disabled) { background: color-mix(in srgb, var(--accent) 90%, transparent); } +button.danger { color: var(--danger); border-color: var(--danger-border); } +button.danger:hover:not(:disabled) { background: var(--danger-bg); } +button:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } +input { width: 100%; min-width: 0; height: 32px; padding: 0 12px; border: 1px solid var(--control-border); border-radius: var(--radius); background: var(--control); font-size: 14px; outline: none; } +input:focus, input:focus-visible { border-color: var(--focus); outline: none; } +input::placeholder { color: var(--muted); font-family: var(--font); } +input[type="text"], input[type="password"], .table-primary, .route-name, .connection-address { font-family: var(--mono); font-size: 13px; } +.field { display: grid; gap: 8px; margin: 16px 0; font-size: 14px; font-weight: 500; color: var(--text); } +/* Single fields in a panel keep a form width instead of spanning the window. */ +.card > .field { max-width: 480px; } +.field-help { display: block; max-width: 70ch; font-size: 13px; font-weight: 400; line-height: 1.5; color: var(--muted); } +.field-error { display: block; color: var(--danger); } +.field-grid, .role-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; align-items: start; } +.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 16px; } +.actions + .actions { margin-top: 8px; } +.actions + p, .actions + pre, .actions + div { margin-top: 16px; } +.badge { display: inline-flex; align-items: center; padding: 0 6px; border: 1px solid var(--line); border-radius: var(--radius); font-size: 11px; font-weight: 500; line-height: 18px; color: var(--soft); vertical-align: 1px; } +details { margin-top: 12px; color: var(--muted); } +summary { cursor: pointer; font-size: 12px; } +summary:hover { color: var(--text); } +details[open] { padding-bottom: 2px; } +details .field { color: var(--soft); } + +/* Feedback */ +.notification { position: relative; display: flex; align-items: flex-start; gap: 12px; width: 100%; margin: 0 0 16px; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--success) 40%, transparent); border-radius: var(--radius-lg); background: color-mix(in srgb, var(--success) 8%, var(--canvas)); color: var(--text); font-size: 13px; } +.notification[data-tone="progress"] { border-color: var(--line); background: var(--panel); } +.notification[data-tone="warning"] { border-color: color-mix(in srgb, var(--status) 40%, transparent); background: color-mix(in srgb, var(--status) 8%, var(--canvas)); } +.notification[data-tone="error"] { border-color: var(--danger-border); background: var(--danger-bg); } +.notification-body { min-width: 0; flex: 1; max-height: 240px; overflow: auto; } +#status { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; } +#dismiss-notification { flex: none; justify-content: center; width: 24px; min-height: 24px; padding: 0; border: 0; background: transparent; color: var(--muted); font-size: 18px; line-height: 1; } +#dismiss-notification:hover { color: var(--text); } +.notification details { color: inherit; } +.notification details pre { margin-top: 8px; } +#page-errors { color: var(--status); white-space: pre-wrap; overflow-wrap: anywhere; } +.warning-note, .server-state { padding: 12px 16px; border: 1px solid color-mix(in srgb, var(--status) 45%, transparent); border-radius: var(--radius-lg); background: var(--panel); color: var(--text); font-size: 13px; } +.server-state { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; } +.server-state span { flex: 1 1 200px; overflow-wrap: anywhere; } + +/* Tables */ +.scroll, .usage-table-scroll { overflow: auto; border: 1px solid var(--line); border-radius: var(--radius-lg); } +/* Tables inside a panel run edge to edge, so the panel border frames them. */ +.card .scroll, .card .usage-table-scroll { margin-inline: -24px; border-width: 1px 0; border-radius: 0; } +.card > .scroll:last-child { margin-bottom: -24px; border-bottom: 0; } +.card th:first-child, .card td:first-child { padding-left: 24px; } +.card th:last-child, .card td:last-child { padding-right: 24px; } +.scroll { margin: 16px 0 0; } +.usage-table-scroll { max-height: 320px; margin-top: 12px; } +.scroll:focus-visible, .usage-table-scroll:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } +table { width: 100%; min-width: 760px; table-layout: fixed; border-collapse: collapse; font-size: 13px; line-height: 1.5; } +th, td { padding: 8px 12px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; } +th { position: sticky; top: 0; z-index: 1; height: 40px; vertical-align: middle; background: var(--panel); color: var(--muted); font-size: 13px; font-weight: 500; white-space: nowrap; } +tbody tr { transition: background-color .12s; } +tbody tr:hover { background: var(--hover); } +tbody tr:last-child td { border-bottom: 0; } +td:nth-last-child(-n+3), th:nth-last-child(-n+3) { width: 112px; white-space: nowrap; text-align: right; font-variant-numeric: tabular-nums; } +td button { display: block; max-width: 100%; min-height: 0; margin: 0 0 4px; padding: 0; border: 0; background: none; color: var(--text); font: 12px/1.5 var(--mono); text-align: left; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +td button:hover { background: none; color: var(--accent); } +.table-primary { display: block; color: var(--text); } +.table-secondary { display: block; margin-top: 2px; font-size: 12px; color: var(--muted); } +.usage-totals-table { min-width: 640px; table-layout: auto; } +.usage-totals-table th:first-child { width: 38%; } +.usage-totals-table th:not(:first-child), .usage-totals-table td:not(:first-child) { width: auto; white-space: nowrap; text-align: right; font-variant-numeric: tabular-nums; } +.usage-totals-table td:first-child { min-width: 140px; max-width: 280px; } +.usage-totals-table button { max-width: 100%; min-height: 0; margin: 0; padding: 0; border: 0; background: none; color: var(--text); font-family: var(--mono); overflow-wrap: anywhere; } +.usage-totals-table button:hover { background: none; color: var(--accent); } + +/* Lists */ +.summary-list { display: grid; gap: 0; margin: 0; } +.summary-row { margin: 0; padding: 8px 0; border-bottom: 1px solid var(--line); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } +.summary-row:first-child { padding-top: 0; } +.summary-row:last-child { padding-bottom: 0; border-bottom: 0; } +.route-list { display: grid; gap: 0; } +.route-row { padding: 12px 0; border-bottom: 1px solid var(--line); } +.route-row:first-child { padding-top: 0; } +.route-row:last-child { padding-bottom: 0; border-bottom: 0; } +.route-row h4 { display: inline; margin: 0 8px 0 0; font-size: 13px; font-weight: 500; overflow-wrap: anywhere; } +.route-row p { margin: 4px 0 0; font-size: 12px; overflow-wrap: anywhere; } + +/* Connect tools */ +.install-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 16px; margin-bottom: 16px; } +.install-progress { padding: 0; color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; } +.install-progress:empty { display: none; } +[data-role] { padding: 16px 0; border-top: 1px solid var(--line); } +[data-role] .field:first-child { margin-top: 0; } +[data-role] .actions { margin-top: 10px; } +[data-role] > small { display: block; } +.install-comparison { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 16px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--raised); } +.install-comparison > div { min-width: 0; padding: 12px; } +.install-comparison > div + div { border-left: 1px solid var(--line); } +.install-comparison h4 { margin: 0 0 8px; font-size: 12px; font-weight: 500; color: var(--muted); } +.install-auth { margin-top: 12px; } +.install-diff { margin-top: 16px; overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--raised); } +.install-diff h4 { margin: 0; padding: 10px 12px; border-bottom: 1px solid var(--line); font-size: 12px; font-weight: 500; } +.install-diff pre { padding: 6px 12px; } +.install-diff p { padding: 12px; } +.install-diff details { padding: 10px 12px; } +.install-diff details pre { padding: 10px 0 0; } +.diff-heading { border-top: 1px solid var(--line); font-size: 11px; color: var(--muted); } +.diff-remove { background: var(--diff-remove-bg); color: var(--danger); } +.diff-add { background: var(--diff-add-bg); color: var(--success); } + +/* Dialogs */ +.modal-backdrop { position: fixed; inset: 0; right: auto; bottom: auto; z-index: 100; display: grid; place-items: center; width: calc(100vw / var(--ui-scale, 1)); height: calc(100dvh / var(--ui-scale, 1)); padding: 16px; background: #00000099; } +.confirm-dialog { display: flex; flex-direction: column; width: 100%; max-width: 440px; max-height: calc(100dvh / var(--ui-scale, 1) - 32px); padding: 20px; border: 1px solid var(--line); border-radius: var(--radius-xl); background: var(--panel); box-shadow: 0 10px 15px -3px #0000001A, 0 4px 6px -4px #0000001A; } +.confirmation-description { min-height: 0; overflow: auto; white-space: pre-wrap; color: var(--soft); } +.confirm-dialog .actions { flex: none; justify-content: flex-end; } +.route-dialog { overflow: auto; } +.route-dialog > h3 { margin-bottom: 6px; } +.route-dialog .field { margin: 14px 0; } +.dialog-intro { font-size: 12px; color: var(--muted); } +.create-route { max-width: 720px; } +.dialog-roles { grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 0 24px; } +.dialog-roles [data-role] { padding: 16px 0 4px; } +.dialog-roles [data-role] .field { margin: 10px 0; } +.dialog-roles .actions { justify-content: flex-start; margin-top: 8px; } +.dialog-roles > .actions { grid-column: 1 / -1; margin: 4px 0 0; } +.dialog-actions { position: sticky; bottom: -20px; z-index: 1; justify-content: flex-end; margin: 16px -20px -20px; padding: 12px 20px; border-top: 1px solid var(--line); background: var(--panel); } + +/* Overview */ +.connection-line { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; font-size: 12px; color: var(--muted); } +.connection-state { display: inline-flex; align-items: center; gap: 6px; font-weight: 500; } +.connection-state::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--muted); } +.connection-state.connected { color: var(--success); } +.connection-state.connected::before { background: var(--success); } +.connection-address { min-width: 0; overflow-wrap: anywhere; } +.connection-line button { margin-left: auto; } +/* Filters form a toolbar: each label sits beside its control. */ +.analytics-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; } +.analytics-controls .field { display: flex; align-items: center; gap: 8px; margin: 0; font-weight: 400; color: var(--muted); white-space: nowrap; } +.analytics-controls .combobox { width: 184px; } +.analytics-controls .field > input { width: 240px; } +.card .analytics-controls { margin: 12px 0; } +.analytics-controls + div > p { margin-top: 16px; font-size: 12px; } +.usage-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; } +.usage-metric { min-width: 0; padding: 20px 24px; border: 1px solid var(--line); border-radius: var(--radius-xl); background: var(--panel); box-shadow: 0 1px 3px 0 #0000001A, 0 1px 2px -1px #0000001A; overflow-wrap: anywhere; } +.usage-metric > p { margin-bottom: 4px; font-size: 13px; color: var(--muted); } +.usage-metric > strong { display: block; margin-bottom: 4px; font-size: 24px; font-weight: 600; line-height: 1.3; color: var(--strong); font-variant-numeric: tabular-nums; } +.estimate-note { margin: -4px 0 0; font-size: 12px; } +.estimate-note p { max-width: 90ch; margin-top: 8px; overflow-wrap: anywhere; } +.usage-chart { width: 100%; min-width: 0; margin-top: 12px; } +.daily-usage { min-width: 0; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--line); } +.daily-usage > p { font-size: 12px; } +.chart-key { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 12px; font-size: 12px; color: var(--soft); } +.chart-key span { display: flex; align-items: center; gap: 6px; min-width: 0; overflow-wrap: anywhere; } +.chart-key i { flex: none; width: 8px; height: 8px; border-radius: 2px; } +.usage-filter { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; overflow-wrap: anywhere; } + +/* Routes */ +.route-browser { padding: 0 !important; overflow: hidden; } +.route-workspace { display: grid; grid-template-columns: 240px minmax(0, 1fr); } +.route-library { min-width: 0; padding: 16px 12px; border-right: 1px solid var(--line); } +.route-library h3 { margin-bottom: 12px; } +.route-library .actions { margin-top: 0; } +.route-library .field { margin: 16px 0 8px; } +.route-library > p.muted { margin-bottom: 8px; font-size: 12px; } +.route-picker { display: grid; grid-auto-rows: max-content; align-content: start; gap: 2px; max-height: calc(100dvh - 270px); overflow: auto; } +.route-picker button { display: block; width: 100%; min-width: 0; height: auto; padding: 8px; border: 0; border-radius: var(--radius); background: transparent; white-space: normal; overflow-wrap: anywhere; } +.route-picker button:hover { background: var(--hover); } +.route-picker button.selected { background: var(--selection); color: var(--strong); } +.route-picker .route-name, .route-picker small { display: block; min-width: 0; overflow-wrap: anywhere; } +.route-picker .route-name { font-weight: 500; } +.route-picker button small { margin-top: 2px; font-size: 11px; font-weight: 400; line-height: 1.5; color: var(--muted); } +.draft-badge { font-weight: 600; color: var(--accent); } +.route-detail { min-width: 0; padding: 16px 20px; } +.route-detail .card { padding: 0; border: 0; background: none; box-shadow: none; } +.route-editor > h3 { margin-bottom: 4px; font: 500 16px/1.4 var(--mono); } +.route-editor > .field { max-width: 420px; margin-top: 14px; } +.route-editor .description { max-width: 80ch; font-size: 12px; } +.route-detail .role-fields { grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr)); gap: 0 24px; } +.role-fields [data-role] { position: relative; padding-top: 16px; border-top: 1px solid var(--line); } +.role-fields [data-role] .field { margin: 12px 0; } +.role-heading { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; font-size: 12px; } +.role-heading::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); } +.role-hint { margin: -6px 0 4px; font-size: 12px; color: var(--muted); } +.algorithm-summary { margin: -6px 0 6px; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--raised); font-size: 12px; color: var(--soft); } +.model-list-status { display: block; margin-top: 2px; font-size: 11px; color: var(--muted); overflow-wrap: anywhere; } +.editor-state { margin-top: 10px; font-size: 12px; font-weight: 500; color: var(--muted); } +.editor-footer { position: sticky; bottom: 0; gap: 8px; padding: 12px 0 4px; border-top: 1px solid var(--line); background: var(--panel); } +.editor-footer .restart-note { order: -2; flex-basis: 100%; margin-bottom: 2px; } +.editor-footer button.primary { order: -1; } +.route-diagram { margin: 16px 0; padding: 14px 0 16px; border-top: 1px solid var(--line); } +.diagram-caption { font-size: 12px; font-weight: 500; color: var(--muted); } +.diagram-flow { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-top: 12px; } +.diagram-request { font-size: 12px; color: var(--muted); } +.diagram-connector { font-size: 12px; color: var(--accent); } +.diagram-choices { display: flex; flex: 1; flex-wrap: wrap; align-items: stretch; gap: 8px; min-width: 0; padding-left: 12px; border-left: 1px solid var(--accent); } +.diagram-flow button { display: grid; gap: 2px; flex: 1 1 110px; min-width: 100px; max-width: 180px; padding: 8px 10px; border-color: var(--line); background: var(--raised); } +.diagram-flow button:hover { border-color: var(--accent); } +.diagram-flow button strong { font-size: 12px; font-weight: 500; } +.diagram-flow button small { font: 11px/1.5 var(--mono); overflow-wrap: anywhere; } +.diagram-note { margin-top: 12px; font-size: 12px; color: var(--muted); } +.route-pane-switch { display: none; } +.route-pane-switch button[aria-pressed="true"] { background: var(--selection); color: var(--strong); } + +/* Settings */ +.facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 24px; margin: 0; font-size: 13px; } +.facts dt { color: var(--muted); } +.facts dt::first-letter { text-transform: uppercase; } +.facts dd { margin: 0; font: 12px/1.6 var(--mono); color: var(--text); overflow-wrap: anywhere; } +.session-ids button { font-family: var(--mono); font-weight: 400; } +.server-key { margin-top: 16px; padding-top: 4px; border-top: 1px solid var(--line); } +.server-key h4 { margin: 12px 0 4px; font: 500 13px var(--mono); } +.server-key .field:has(input[type="checkbox"]) { display: flex; flex-direction: row-reverse; justify-content: flex-end; align-items: center; gap: 8px; margin: -8px 0 8px; } +.server-key input[type="checkbox"] { flex: none; width: auto; height: auto; min-height: 0; margin: 0; accent-color: var(--accent); } + +/* Searchable dropdowns replace native menus. The list floats over the content below. */ +.combobox { position: relative; min-width: 0; font-weight: 400; } +.combobox input { padding-right: 32px; text-overflow: ellipsis; } +.combobox:not(.free) input { cursor: pointer; } +.combobox:not(.free) input:focus { cursor: text; } +.combobox:not(.mono) input { font-family: inherit; font-size: 14px; } +.combobox-toggle { position: absolute; top: 1px; right: 1px; bottom: 1px; display: grid; place-items: center; width: 28px; min-height: 0; padding: 0; border: 0; border-radius: 0 5px 5px 0; background: transparent; color: var(--muted); } +.combobox-toggle:hover { background: transparent; color: var(--text); } +.combobox-toggle::before { content: ''; width: 6px; height: 6px; margin-top: -3px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(45deg); transition: transform .12s ease; } +.combobox[data-open] .combobox-toggle::before { margin-top: 3px; transform: rotate(-135deg); } +.combobox input:disabled ~ .combobox-toggle { opacity: .5; cursor: default; } +.combobox-list { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 60; display: grid; align-content: start; gap: 1px; max-height: 320px; overflow: auto; padding: 4px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--panel); box-shadow: 0 4px 6px -1px #0000001A, 0 2px 4px -2px #0000001A; overscroll-behavior: contain; } +.combobox[data-placement="top"] .combobox-list { top: auto; bottom: calc(100% + 4px); } +.combobox-option { position: relative; display: grid; gap: 2px; min-width: 0; padding: 6px 8px 6px 28px; border-radius: 4px; color: var(--text); line-height: 1.4; cursor: pointer; } +.combobox-option.active { background: var(--selection); } +.combobox-option[aria-selected="true"]::before { content: ''; position: absolute; top: 11px; left: 11px; width: 7px; height: 3px; border-left: 1.5px solid var(--accent); border-bottom: 1.5px solid var(--accent); transform: rotate(-45deg); } +.combobox-label { min-width: 0; font-size: 14px; overflow-wrap: anywhere; } +.combobox.mono .combobox-label { font-family: var(--mono); font-size: 13px; } +.combobox-label mark { background: none; color: var(--strong); font-weight: 600; text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: 3px; } +.combobox-option small { font-size: 11px; line-height: 1.45; color: var(--muted); overflow-wrap: anywhere; } +.combobox-note { margin: 0; padding: 6px 8px; font-size: 12px; color: var(--muted); } + +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-thumb { border-radius: 4px; background: var(--scrollbar); } + +/* Narrow windows */ +@media (max-width: 1000px) { + .usage-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .route-workspace { grid-template-columns: 220px minmax(0, 1fr); } + .route-detail .role-fields { grid-template-columns: 1fr; } +} +@media (max-width: 860px) { + .route-pane-switch { display: flex; gap: 6px; padding: 12px 12px 0; } + .route-workspace { display: block; } + .route-workspace[data-pane="editor"] .route-library, .route-workspace[data-pane="library"] .route-detail { display: none; } + .route-library { border-right: 0; } + .route-picker { max-height: none; } +} +@media (max-width: 850px) { + .install-comparison { grid-template-columns: 1fr; } + .install-comparison > div + div { border-left: 0; border-top: 1px solid var(--line); } +} +@media (max-width: 700px) { + body { grid-template-columns: 160px minmax(0, 1fr); } + .field-grid, .role-fields { grid-template-columns: 1fr; } +} +@media (max-width: 460px) { + body { display: flex; flex-direction: column; } + .app-sidebar { flex: none; overflow: visible; padding: 12px 8px; } + main { flex: 1; min-height: 0; margin: 0; border-radius: 0; } + .sidebar-caption, .app-sidebar footer { display: none; } + nav { display: flex; flex-wrap: wrap; } + nav button { width: auto; } + .usage-metrics { grid-template-columns: 1fr; } + .connection-line button { margin-left: 0; } +} +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; scroll-behavior: auto !important; } } + +/* Large text sizes stack the sidebar above the page panel. */ +body.enlarged { display: flex; flex-direction: column; } +body.enlarged .app-sidebar { flex: none; overflow: visible; padding: 12px 8px; } +body.enlarged main { flex: 1; min-height: 0; margin: 0; border-radius: 0; } +body.enlarged nav { display: flex; flex-wrap: wrap; } +body.enlarged nav button { width: auto; } +body.enlarged .sidebar-caption, body.enlarged .app-sidebar footer { display: none; } +body.enlarged .field-grid, body.enlarged .role-fields { grid-template-columns: 1fr; } +body.enlarged .route-workspace { display: block; } +body.enlarged .route-pane-switch { display: flex; gap: 6px; padding: 12px 12px 0; } +body.enlarged .route-workspace[data-pane="editor"] .route-library, body.enlarged .route-workspace[data-pane="library"] .route-detail { display: none; } +body.enlarged .route-library { border-right: 0; } diff --git a/crates/switchyard-desktop/ui/app.js b/crates/switchyard-desktop/ui/app.js new file mode 100644 index 0000000000..0bf54df5d5 --- /dev/null +++ b/crates/switchyard-desktop/ui/app.js @@ -0,0 +1,755 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +'use strict'; +const invoke = window.__TAURI__.core.invoke; +const content = document.querySelector('#content'); +// The page panel scrolls; the window does not. +const scroller = document.querySelector('main'); +const status = document.querySelector('#status'); +const notification = document.querySelector('#notification'); +const pageErrors = document.querySelector('#page-errors'); +// Each action replaces the previous notification so dismissed results stay dismissed. +let notificationReturn; +function notify(message, tone = 'success', details = []) { + if(notification.hidden&&document.activeElement!==document.body)notificationReturn=document.activeElement; + notification.dataset.tone = tone; + const label = {success:'✓',progress:'In progress:',warning:'Attention:',error:'Error:'}[tone]; + status.textContent = message ? `${label} ${message}` : ''; + const disclosure = document.querySelector('#notification-details'); + disclosure.open = false; + disclosure.hidden = !details.length; + document.querySelector('#notification-detail-text').textContent = details.join('\n'); + notification.hidden = !message; +} +// Background failures remain on the page without replacing the last action result. +function showPageErrors(message) { + pageErrors.textContent = message; + pageErrors.hidden = !message; +} +// Dismissal returns keyboard focus to an available element, even during an operation. +function dismissNotification(event) { + notification.hidden = true; + status.textContent = ''; + const refreshButton = document.querySelector('#refresh'); + if(!event || event.detail===0) (notificationReturn?.isConnected && !notificationReturn.disabled ? notificationReturn : refreshButton.disabled ? document.querySelector('#title') : refreshButton).focus(); +} + +let snapshot, page = 'overview', busy = false, reading = false, lastRefresh = null, stale = false; +const sessionView = {tool:'',account:'',route:'',project:''}; +// The transcript browser keeps its listing across refreshes because listing reads every transcript. +const handoffView = {options:{claude:{route:'',account:''},codex_cli:{route:'',account:''}},chosen:{codex_cli:'',claude:''},recent:null,loading:false,loadedAt:0,container:null}; +const accountView = {tool:'codex_cli',name:''}; +const activity = []; +const connectedTools=new Set(); +let sessionOpened=false; +let configurationDraft; +const pageNames = {overview:'Overview',routes:'Routes',install:'Connect tools',usage:'Usage',sessions:'Sessions',settings:'Settings',setup:'Set up Switchyard'}; +let operationQueue = Promise.resolve(),ipcQueue=Promise.resolve(); +// The native controller accepts one operation at a time; queued reads leave the page usable. +function request(command,args){const captured=args===undefined?undefined:structuredClone(args);const pending=ipcQueue.then(()=>invoke(command,captured));ipcQueue=pending.catch(()=>{});return pending;} +const drafts = new Map(), lists = new Map(); +const usagePageSize = 100; +const usageView = {page:0,session:'',search:'',exact:null,period:'all',role:'all',turn:'',sort:'newest'}; +const routeView = {selected:'',search:'',pane:'editor'}; +const installView = {tool:'',account:'',route:'',file:'',mode:'default'}; +function node(tag, text, cls) { const n = document.createElement(tag); if (text !== undefined) n.textContent = text; if (cls) n.className = cls; return n; } +function card(title) { const c = node('div', undefined, 'card'); c.append(node('h3', title)); content.append(c); return c; } +function button(parent, text, fn, primary = false) { const b = node('button', text, primary ? 'primary' : ''); b.onclick = fn; parent.append(b); return b; } +// Wry's macOS delegate has no JavaScript confirm handler, so this dialog handles confirmation. +function confirmAction(title, message, label, action, onCancel) { + const previous=document.activeElement,overlay=node('div',undefined,'modal-backdrop'),dialog=node('div',undefined,'confirm-dialog'); + dialog.setAttribute('role','dialog');dialog.setAttribute('aria-modal','true');dialog.setAttribute('aria-labelledby','confirmation-title');dialog.setAttribute('aria-describedby','confirmation-description'); + const heading=node('h3',title);heading.id='confirmation-title';heading.tabIndex=-1; + const description=node('p',message,'confirmation-description');description.id='confirmation-description'; + dialog.append(heading,description);overlay.append(dialog);document.body.append(overlay); + const background=[...document.body.children].filter(element=>element!==overlay).map(element=>[element,element.inert]); + background.forEach(([element])=>{element.inert=true;}); + const close=(cancelled=true)=>{overlay.remove();background.forEach(([element,inert])=>{element.inert=inert;});if(previous?.isConnected)previous.focus();if(cancelled)onCancel?.();}; + const controls=actions(dialog),cancel=button(controls,'Cancel',close),accept=button(controls,label,()=>{close(false);action();});accept.classList.add('danger'); + dialog.onkeydown=event=>{if(event.key==='Escape'){event.preventDefault();close();}else if(event.key==='Tab'){const items=[cancel,accept],index=items.indexOf(document.activeElement);event.preventDefault();items[index<0?(event.shiftKey?items.length-1:0):(index+(event.shiftKey?-1:1)+items.length)%items.length].focus();}}; + if(message.length>300)heading.focus();else cancel.focus(); +} +// A combobox lives in a div because a label would forward clicks on its list to the input. +let helpCount=0; +function field(parent, title, control, help) { + control.setAttribute('aria-label',title); + const box=node(control.host?'div':'label',title,'field');box.append(control.host||control); + if(help){const note=node('small',help,'field-help');note.id=`field-help-${++helpCount}`;box.append(note);control.dataset.help=note.id;control.setAttribute('aria-describedby',note.id);} + parent.append(box);return control; +} +function input(value = '', type = 'text') { const i = node('input'); i.type = type; i.value = value; return i; } +function text(parent, value) { parent.append(node('pre', value)); } +function actions(parent) { const a = node('div', undefined, 'actions'); parent.append(a); return a; } +let lockedFields=[]; +function setBusy(value) { + busy=value; + if(value){lockedFields=[...document.activeElement?.closest?.('.card')?.querySelectorAll('input,select')||[]].map(control=>[control,control.disabled]);lockedFields.forEach(([control])=>{control.disabled=true;});} + else{for(const [control,disabled] of lockedFields)control.disabled=disabled||control.dataset.blocked==='true';lockedFields=[];} + document.querySelectorAll('button[data-mutation]').forEach(n=>{n.disabled=value || n.dataset.blocked==='true';}); + document.querySelector('#content').setAttribute?.('aria-busy',String(value)); +} +// Reads and writes use the controller queue; only writes lock mutation controls. +function enqueue(operation) { + operationQueue=operationQueue.then(operation).catch(error=>notify(String(error),'error')); + return operationQueue; +} +async function readAction(action) { + return request('action',{action}); +} +async function run(action, refreshAfter = true) { + if(['editor','preview_install','preview_restore','preview_launch','preview_update','preview_route','preview_account_archive','preview_session_cleanup','preview_handoff','recent_sessions'].includes(action.kind)) { + try { return await readAction(action); } catch(error) { notify(String(error),'error'); return; } + } + if(busy)return; + const progress={apply:'Saving the route and restarting Switchyard…',remove:'Removing the route and restarting Switchyard…',restart:'Restarting Switchyard…',install:'Applying routing settings…',restore:'Restoring settings…',launch:'Opening Terminal…',handoff:'Writing the hand-off and opening Terminal…',models:'Loading models…',save_server_key:'Saving the key…',import_server_key:'Reading the variable from your login shell…',remove_server_key:'Removing the key…'}; + const previous=document.activeElement; + setBusy(true);notificationReturn=previous?.tagName==='BODY'?document.querySelector('#title'):previous;notify(progress[action.kind]||'Working…','progress'); + try { + const result=await request('action',{action}); + notify(result.message,result.data?.warning?'warning':'success',result.data?.details||[]); + if(action.kind==='install')connectedTools.add(action.tool);if(action.kind==='launch')sessionOpened=true; + activity.unshift({message:result.message,time:new Date().toLocaleTimeString()});activity.splice(8); + if(refreshAfter)await refresh(true); + return result; + } catch(error) {notify(String(error),'error');} + finally {setBusy(false);if(previous?.isConnected&&!previous.disabled)previous.focus({preventScroll:true});else document.querySelector('#title').focus();} +} +function capturePageState() { + const focused=document.activeElement; + return {label:focused?.getAttribute?.('aria-label'),id:focused?.id,start:focused?.selectionStart,end:focused?.selectionEnd, + open:[...content.querySelectorAll('details[open]')].map(n=>n.querySelector('summary')?.textContent),scroll:scroller.scrollTop||0, + regions:[...content.querySelectorAll('.scroll,.usage-table-scroll,.route-picker')].map(n=>[n.scrollTop,n.scrollLeft])}; +} +function restorePageState(saved) { + content.querySelectorAll('details').forEach(n=>{n.open=saved.open.includes(n.querySelector('summary')?.textContent);}); + const control=[...content.querySelectorAll('input,select,button')].find(n=>saved.id?n.id===saved.id:saved.label&&n.getAttribute('aria-label')===saved.label); + if(control && !control.disabled){control.focus({preventScroll:true});if(saved.start!==null && saved.start!==undefined && control.setSelectionRange && !['number','date'].includes(control.type))control.setSelectionRange(saved.start,saved.end);} + content.querySelectorAll('.scroll,.usage-table-scroll,.route-picker').forEach((n,i)=>{if(saved.regions[i])[n.scrollTop,n.scrollLeft]=saved.regions[i];}); + scroller.scrollTo?.(0,saved.scroll); +} +async function refresh(force = true) { + if(reading)return; + reading=true; + document.querySelector('#refresh').setAttribute?.('aria-busy','true'); + try { + const latest=await request('snapshot'); + // A menu, dialog, or action may start while IPC waits; the next poll can apply a fresh snapshot. + if(!force&&(busy||document.querySelector('[role="dialog"]')||choosing()))return; + // The user may navigate during the read, so capture the current page after the reply. + const saved=snapshot?capturePageState():null; + if(!snapshot && latest.needs_setup)page='setup'; + snapshot=latest;lastRefresh=new Date();stale=false; + if(force || ['overview','usage','settings','setup'].includes(page)) {render();if(saved)restorePageState(saved);} + showPageErrors((snapshot.errors||[]).join('\n'));showFreshness(); + } catch(error) {stale=true;showPageErrors(`Could not refresh the page: ${error}`);showFreshness();if(!snapshot)renderRecovery(String(error));} + finally {reading=false;document.querySelector('#refresh').setAttribute?.('aria-busy','false');} +} +function showFreshness() { + const health=document.querySelector('#server-state');if(health&&snapshot?.metrics){health.replaceChildren();const state=snapshot.data_state?.health?.state||(snapshot.metrics.running?'running':'unreachable');health.hidden=state==='running'&&!stale;if(!health.hidden){health.append(node('span',stale?'Server status is stale. Check again before relying on it.':`Server ${state}. ${snapshot.data_state?.health?.reason||snapshot.metrics.server_url}`));button(health,'Check again',()=>refresh(false));mutation(health,'Retry restart',()=>run({kind:'restart'}));button(health,'Open recovery',()=>navigate('settings'));}} + const label=document.querySelector('#freshness'); + if(label)label.textContent=lastRefresh?`${stale?'Stale · last successful check':'Checked'} ${lastRefresh.toLocaleTimeString()}`:'Loading…'; + const connection=document.querySelector('.connection-state');if(stale&&connection){connection.textContent='Unavailable · last check failed';connection.classList.remove('connected');} +} +function navigate(next) {page=next;if(next==='settings')serverKeys.loaded=false;render();document.querySelector('#title').focus();scroller.scrollTo?.(0,0);} +function renderRecovery(error) { + content.replaceChildren();const c=card('App settings need attention');c.append(node('p',error)); + button(actions(c),'Open app settings',()=>run({kind:'open_settings'},false));button(actions(c),'Retry',()=>refresh()); +} +function blocked(control, value) {control.dataset.blocked=String(value);control.disabled=(control.dataset.mutation==='true'&&busy)||value;} +function mutation(parent,label,fn,primary=false) {const b=button(parent,label,fn,primary);b.dataset.mutation='true';b.disabled=busy;return b;} +function fieldError(control,message) { + const id=`error-${control.getAttribute('aria-label').replace(/[^a-z0-9]/gi,'-')}`,box=control.closest('.field'); + let note=box.querySelector('.field-error');if(!note){note=node('small',undefined,'field-error');note.id=id;box.append(note);} + note.textContent=message;note.hidden=!message;control.setAttribute('aria-describedby',[control.dataset.help,id].filter(Boolean).join(' '));control.setAttribute('aria-invalid',String(Boolean(message))); + return !message; +} +// A refresh would redraw an open option list and discard the user's search text. +function choosing() {return document.activeElement?.getAttribute?.('aria-expanded')==='true';} +function routeMatches(route, query) { return [route.id,route.label,snapshot.algorithms.find(a=>a.kind===route.kind)?.title,...route.choices.flatMap(c=>[c.model,c.client,snapshot.clients.find(e=>e.name===c.client)?.host])].join(' ').toLowerCase().includes(query.toLowerCase()); } +function routeField(c,chosen='',tool='') { + const options=snapshot.routes.map(r=>[r.key,r.id,[snapshot.algorithms.find(a=>a.kind===r.kind)?.title||r.kind,routeSummary(r),tool&&r.compatibility?.[tool]?'Unavailable for this harness':''].filter(Boolean).join(' · '),r.choices.map(c=>snapshot.clients.find(e=>e.name===c.client)?.host).join(' ')]); + return field(c,'Route',combobox(options,chosen||undefined,{mono:true,placeholder:'Search by route, model, or endpoint'}),'The harness requests this route’s public model ID.'); +} +function routeArgs(s) { const r=snapshot.routes.find(r=>r.key===s.value); if (!r) throw new Error('Choose a route first.'); return {route:r.key,id:r.id}; } +let generation; +function render() { + disposeCharts(); + content.replaceChildren(); + document.body.dataset.page=page; + document.querySelector('#title').textContent=pageNames[page]; + document.querySelectorAll('[data-page]').forEach(b=>{b.classList.toggle('active',b.dataset.page===page);if(b.dataset.page===page)b.setAttribute('aria-current','page');else b.removeAttribute('aria-current');}); + if(!snapshot){content.append(node('p','Loading Switchyard…','empty-state'));return;} + generation=snapshot.generation; + if (page==='overview') { + renderOverview(); + } else if (page==='routes') { + renderRouteBrowser(); + + } else if (page==='install') { + const intro=card('Connect tools'); + intro.append(node('p','Choose a Switchyard route for each harness, review the settings below, then apply routing settings.')); + const help=node('details');help.append(node('summary','How accounts and settings work'));help.append(node('p','Subscription routes use the harness’s login. API routes use credentials configured on the Switchyard server. Keys entered in Routes only load model lists.'));help.append(node('p','Installation backs up the original settings. Shell variables and project settings may override these defaults.'));intro.append(help); + const tool=field(intro,'Harness',combobox(snapshot.tools.map(t=>[t.tool,t.label,t.available?'Detected':'Not detected']),installView.tool||snapshot.tools[0].tool),'Switchyard changes this harness’s settings so it sends requests through the selected route.'); + installView.tool=tool.value; + const panel=node('div');content.append(panel); + const show=()=>{installView.tool=tool.value;installView.account='';installView.file='';installView.mode='default';panel.replaceChildren();renderInstall(snapshot.tools.find(t=>t.tool===tool.value),panel);}; + tool.onchange=show;renderInstall(snapshot.tools.find(t=>t.tool===tool.value),panel); + renderAccounts(); + } else if(page==='usage')renderUsage(); + else if(page==='sessions'){renderSessions();renderAccounts();} + else if(page==='settings')renderSettings(); + else if(page==='setup')renderConfiguration(true); + showFreshness(); +} +function usageUnavailable() {return snapshot.data_state.usage.state==='unavailable'||snapshot.data_state.history.state==='unavailable';} +// The final tie-breaker keeps calls with later log positions first across refreshes. +function sortedCalls(entries, order) { + const compareText=(a,b)=>ab?1:0; + return entries.map((entry,index)=>({entry,index,time:Date.parse(entry.ts)})).sort((a,b)=>{ + const validA=Number.isFinite(a.time),validB=Number.isFinite(b.time); + let time=0; + if(validA!==validB)time=validA?-1:1; + else if(validA)time=order==='oldest'?a.time-b.time:b.time-a.time; + const model=compareText(a.entry.model||'',b.entry.model||''); + const tokens=(b.entry.prompt_tokens+b.entry.completion_tokens)-(a.entry.prompt_tokens+a.entry.completion_tokens); + let primary=0; + if(order==='model')primary=model; + else if(order==='tokens')primary=tokens; + return primary||time||model||b.index-a.index; + }).map(row=>row.entry); +} +function renderUsage() { + const c=card('Model calls');c.append(node('p','Each row shows a completed model call. A turn may include several calls. Choose the order below.')); + const entries=snapshot.entries,dates=entries.map(e=>new Date(e.ts)).filter(d=>!Number.isNaN(d.getTime())).sort((a,b)=>a-b); + c.append(node('p',`${entries.length} retained calls${snapshot.limited?' · limited to 8 MiB / 5,000 records':''} · ${snapshot.skipped} unreadable recent records. ${dates.length?`${dates[0].toLocaleString()} – ${dates.at(-1).toLocaleString()}`:'No retained timestamps.'}`,'muted')); + if(usageView.exact){const pill=node('div',undefined,'usage-filter');pill.append(node('span',`${usageView.exact.group}: ${usageView.exact.id||'Not recorded'}`));button(pill,'Clear comparison',()=>{usageView.exact=null;usageView.page=0;render();});c.append(pill);} + const filters=node('div',undefined,'analytics-controls');c.append(filters); + const period=field(filters,'Usage period',combobox([['all','All recent history'],['week','Seven local calendar days'],['today','Today']],usageView.period)); + const session=field(filters,'Session',combobox([['','All sessions'],['__missing','Session not recorded'],...snapshot.sessions.map(s=>[s,s])],usageView.session)); + const search=field(filters,'Search turn, model, route, or session',input(usageView.search)); + const role=field(filters,'Call role',combobox([['all','All calls'],['answer','Answer calls'],['overhead','Routing overhead']],usageView.role)); + const sort=field(filters,'Sort calls',combobox([['newest','Time: newest first'],['oldest','Time: oldest first'],['model','Model: A to Z'],['tokens','Total tokens: highest first']],usageView.sort)); + const tally=node('p',undefined,'muted');c.append(tally); + const a=actions(c);button(a,'Clear all filters',()=>{Object.assign(usageView,{page:0,session:'',search:'',exact:null,role:'all',turn:'',period:'all'});render();}); + button(a,'Copy filtered calls',()=>copyText(JSON.stringify(filteredCalls(),null,2))); + const paging=actions(c),previous=button(paging,'Previous calls',()=>{usageView.page--;draw(false);container.scrollTop=0;}),range=node('span'),next=button(paging,'Next calls',()=>{usageView.page++;draw(false);container.scrollTop=0;});paging.insertBefore(range,next);range.setAttribute('aria-live','polite'); + const container=node('div',undefined,'scroll');container.tabIndex=0;container.setAttribute('role','region');container.setAttribute('aria-label','Model calls table');c.append(container); + function filteredCalls(){return sortedCalls(snapshot.entries,usageView.sort).filter(e=>{ + const exact=usageView.exact;if(exact&&(e[{model:'model',route:'route_id',session:'session_id'}[exact.group]]||'')!==exact.id)return false; + if(!inPeriod(e.ts,usageView.period,snapshot.today))return false; + if(usageView.session==='__missing'?Boolean(e.session_id):usageView.session&&e.session_id!==usageView.session)return false; + if(usageView.turn&&e.turn_id!==usageView.turn)return false; + if(usageView.role!=='all'&&(usageView.role==='overhead')!==(e.tier==='classifier'))return false; + return [e.model,e.turn_id,e.route_id,e.session_id].join(' ').toLowerCase().includes(usageView.search.toLowerCase()); + });} + const draw=(reset=true)=>{ + if(reset)usageView.page=0; + usageView.session=session.value;usageView.search=search.value;usageView.period=period.value;usageView.role=role.value;usageView.sort=sort.value; + container.replaceChildren();const rows=filteredCalls();tally.textContent=`${rows.length} matching of ${entries.length} retained calls${usageView.turn?` · Turn: ${usageView.turn}`:''}`; + usageView.page=Math.max(0,Math.min(usageView.page,Math.ceil(rows.length/usagePageSize)-1)); + const start=usageView.page*usagePageSize,shown=rows.slice(start,start+usagePageSize); + range.textContent=rows.length?`Showing calls ${start+1}–${start+shown.length} of ${rows.length}`:'No matching calls';previous.disabled=usageView.page===0;next.disabled=start+shown.length>=rows.length; + if(usageUnavailable()){paging.hidden=true;container.append(node('p','Usage unavailable. Check the routing log in Settings and retry.','empty-state'));button(container,'Retry',()=>refresh());return;} + const table=node('table'),thead=node('thead'),head=node('tr'),tbody=node('tbody'); + for(const label of ['Time','Model / role','Session / turn','Route','Uncached input','Cached input','Cache writes','Output']){const th=node('th',label);th.scope='col';head.append(th);}thead.append(head);table.append(thead,tbody); + for(const e of shown){const row=node('tr'),time=node('td'),date=new Date(e.ts);time.title=e.ts;time.textContent=Number.isNaN(date.getTime())?e.ts:date.toLocaleString(); + const model=node('td');model.append(node('span',e.model,'table-primary'),node('small',e.tier==='classifier'?'Routing overhead':'Answer call','table-secondary')); + const ids=node('td');if(e.session_id)button(ids,e.session_id,()=>{usageView.session=e.session_id;usageView.page=0;render();}).title=e.session_id;else ids.append(node('span','Session not recorded'));if(e.turn_id){button(ids,e.turn_id,()=>{usageView.turn=e.turn_id;draw();}).title=e.turn_id;button(ids,'Copy turn ID',()=>copyText(e.turn_id));}else ids.append(node('small','Turn not recorded')); + row.append(time,model,ids,node('td',e.route_id||'Route not recorded'));for(const value of [Math.max(0,e.prompt_tokens-e.cached_tokens-(e.cache_creation_tokens||0)),e.cached_tokens,e.cache_creation_tokens||0,e.completion_tokens])row.append(node('td',value.toLocaleString()));tbody.append(row); + }container.append(table); + if(!rows.length)container.append(node('p',entries.length?'No calls match these filters. A selected session may be outside recent history.':'No calls recorded yet. Start a session, then return here.','empty-state')); + if(usageView.exact&&snapshot.limited)container.prepend(node('p','Comparison totals cover the full log; this table only includes the retained range shown above.','coverage-note')); + };for(const control of [period,session,role,sort])control.onchange=draw;search.oninput=draw;draw(false); +} +async function choosePath(control,directory,changed){try{const result=await request('choose_path',{directory});if(result&&control.isConnected){control.value=result;changed();}}catch(error){notify(`Could not choose a ${directory?'folder':'file'}: ${error}`,'error');}} +async function copyText(value) {try{await navigator.clipboard.writeText(value);notify('Copied.');}catch{notify('Copy failed. Select and copy the text manually.','error');}} +function ignoredText(paths=[]){if(!paths.length)return '';const shown=paths.slice(0,20);return `\nCleanup also deletes these Git-ignored files:\n${shown.join('\n')}${paths.length>shown.length?`\n…and ${paths.length-shown.length} more`:''}`;} +function handoffPreviewText(d){const s=d.source||{},t=d.destination||{};return [`From: ${s.label||'?'} · ${s.title||s.native_id||''}`,`Transcript: ${s.transcript||''}`,`To: ${t.label||'?'}`,`Directory: ${t.directory||''}`,`Route: ${t.route||''}`,`Account: ${t.account_directory||'Current login'}`,`Session ID: ${t.session_id||''}`,`Hand-off file: ${d.handoff_file||''}`,`Excerpt: ${d.bytes} bytes · ${d.turns} turns · ${d.files_touched} files · ${d.commands} commands`,...(d.warnings||[]).map(w=>`Warning: ${w}`),d.notice,`Command:\n${d.command||''}`,`Hand-off text (the new session reads this):\n\n${d.handoff||''}`].filter(Boolean).join('\n\n');} +// A hand-off continues a session in the other command line harness. The destination defaults to +// that harness's first usable route and its current login; Options can choose others. The route +// check runs before the review, so a wrong choice is a message and never a dead button. +const harnessNames={codex_cli:'Codex',codex_app:'Codex app',claude:'Claude Code',pi:'Pi'}; +function shortName(tool){return harnessNames[tool]||snapshot?.tools?.find(t=>t.tool===tool)?.label||tool;} +function otherHarness(tool){return tool==='claude'?'codex_cli':'claude';} +function compatibleRoute(tool){return snapshot.routes.find(r=>!r.compatibility?.[tool])?.key||'';} +function handoffDestination(tool){ + const info=snapshot.tools.find(t=>t.tool===tool); + if(!info)throw new Error(`${shortName(tool)} is not a harness this app can open.`); + if(!info.available)throw new Error(`${shortName(tool)} is not detected. Install it or add it to PATH, then refresh.`); + const chosen=handoffView.options[tool]||{},key=chosen.route||compatibleRoute(tool),route=snapshot.routes.find(r=>r.key===key); + if(!route)throw new Error(`No route in the server config can serve ${shortName(tool)}. Add one in Routes.`); + const incompatible=route.compatibility?.[tool]; + if(incompatible)throw new Error(`${shortName(tool)} cannot use route ${route.id}. ${incompatible}`); + return {tool,label:shortName(tool),account:chosen.account||null,route:route.key,id:route.id}; +} +async function continueSession(source,tool){ + let destination;try{destination=handoffDestination(tool);}catch(error){notify(error.message||String(error),'error');return;} + const {label,...args}=destination; + const result=await run({kind:'preview_handoff',source,...args},false);if(!result)return;const data=result.data; + confirmAction(`Continue this session in ${data.destination?.label||label}?`,handoffPreviewText(data),'Write hand-off and open Terminal',()=>run({kind:'handoff',source,...args,preview_token:data.preview_token})); +} +function sessionPreviewText(data){return [`Repository: ${data.repository}`,`Starting folder: ${data.relative_directory?`${data.relative_directory} within the new worktree`:'New worktree root'}`,`Branch: ${data.branch||'Detached HEAD'}`,`Revision: ${data.revision}`,`Route: ${data.route}`,`Account: ${data.account_directory||'Current login'}`,data.dirty?'Your checkout has uncommitted changes.':'',data.notice,data.settings_notice].filter(Boolean).join('\n\n');} +function renderSessions() { + const c=card('New session');c.append(node('p','Create a Git worktree from committed HEAD and open the harness in Terminal. Uncommitted changes stay in the original checkout.')); + const tools=snapshot.tools.filter(t=>['codex_cli','claude','pi'].includes(t.tool)); + const tool=field(c,'Session harness',combobox(tools.map(t=>[t.tool,t.label,t.available?'Detected':'Not detected']),sessionView.tool||tools[0]?.tool));sessionView.tool=tool.value; + const account=field(c,'Session account',combobox([['','Current login','The harness’s normal login'],...(tools.find(t=>t.tool===tool.value)?.accounts||[]).map(n=>[n,n,'Saved account'])],sessionView.account)); + const route=routeField(c,sessionView.route||compatibleRoute(tool.value),tool.value); + const project=field(c,'Project folder',input(sessionView.project));project.placeholder='Choose a Git checkout folder'; + const info=node('p',undefined,'muted');c.append(info);const preview=node('div');preview.setAttribute('role','status');preview.setAttribute('aria-live','polite');c.append(preview);let plan; + const a=actions(c),review=button(a,'Review session',async()=>{const reviewed=args();preview.setAttribute('aria-busy','true');preview.textContent='Checking the project and session settings…';try{const r=await readAction({kind:'preview_launch',...reviewed});if(JSON.stringify(reviewed)!==JSON.stringify(args())){preview.textContent='Session inputs changed. Review them again.';return;}plan={reply:r,args:structuredClone(reviewed)};preview.replaceChildren(node('pre',sessionPreviewText(r.data)));r.message=sessionPreviewText(r.data);blocked(launch,false);}catch(error){fieldError(project,String(error));preview.textContent='Session preview unavailable. Correct the project or selected settings and retry.';}finally{preview.setAttribute('aria-busy','false');}}),launch=mutation(a,'Open Terminal…',()=>{if(!plan)return;const reviewed=structuredClone(plan.args),reply=plan.reply;confirmAction('Start this session?',reply.message,'Open Terminal',()=>run({kind:'launch',...reviewed,preview_token:reply.data.preview_token}));},true); + const args=()=>({tool:tool.value,account:account.value||null,...routeArgs(route),project:project.value}); + const changed=()=>{Object.assign(sessionView,{tool:tool.value,account:account.value,route:route.value,project:project.value});plan=null;preview.replaceChildren();blocked(launch,true);const t=tools.find(t=>t.tool===tool.value);const incompatible=snapshot.routes.find(r=>r.key===route.value)?.compatibility?.[tool.value];fieldError(project,'');blocked(review,!t?.available||!route.value||!project.value||Boolean(incompatible));if(incompatible)preview.append(node('p',incompatible,'field-error'));info.textContent=account.value?`Saved account: ${account.value}. ${t?.account_details?.find(a=>a.name===account.value)?.path||''}. Login status is unknown until the harness confirms it.`:t?.status||'Select a harness.';if(tool.value==='pi')info.append(node('p','Pi uses isolated session settings. Personal settings and extensions are not copied.'));}; + button(a,'Choose project folder…',()=>choosePath(project,true,changed)); + tool.onchange=()=>{sessionView.account='';sessionView.tool=tool.value;if(snapshot.routes.find(r=>r.key===sessionView.route)?.compatibility?.[tool.value])sessionView.route=compatibleRoute(tool.value);render();};account.onchange=changed;route.onchange=changed;project.oninput=changed;changed(); + if(!snapshot.routes.length){c.append(node('p','Add a route before starting a session.'));button(a,'Choose a route',()=>navigate('routes'));} + c.append(node('p','Codex app workspaces are managed in the Codex app. Supported command line harnesses appear above.','muted')); + const inventory=card('Recorded sessions');inventory.append(node('p','Recorded calls do not establish that a process is running.')); + if(snapshot.session_inventory_error)inventory.append(node('p',snapshot.session_inventory_error,'field-error')); + for(const item of snapshot.session_inventory||[]){const row=node('div',undefined,'route-row'),last=snapshot.entries.filter(e=>e.session_id===item.id).at(-1);row.append(node('h4',item.id),node('p',[item.repository,item.branch,item.route,item.worktree,item.created_at,last?`Last recorded call: ${last.ts}`:'No recent recorded call'].filter(Boolean).join(' · ')));button(actions(row),'Copy path',()=>copyText(item.worktree));button(actions(row),'Open folder',()=>run({kind:'open_session',id:item.id},false));mutation(actions(row),`Switch to ${shortName(otherHarness(item.tool))}`,()=>continueSession({kind:'managed',id:item.id},otherHarness(item.tool)));mutation(actions(row),'Review cleanup…',async()=>{const result=await run({kind:'preview_session_cleanup',id:item.id},false);if(!result)return;const data=result.data;confirmAction('Remove session worktree?',`${data.cleanup_notice}\n\nDirectory: ${data.worktree}\nBranch: ${data.current_branch}\nRevision: ${data.current_revision}\n${data.dirty?'This worktree has uncommitted changes. Cleanup will refuse to remove it.':'The worktree has no uncommitted changes.'}${ignoredText(data.ignored)}`,'Remove session',()=>run({kind:'remove_session',id:item.id,preview_token:data.preview_token}));});inventory.append(row);} + if(!(snapshot.session_inventory||[]).length){inventory.append(node('p','Switchyard has not recorded any managed sessions.'));const ids=actions(inventory);ids.classList.add('session-ids');for(const id of snapshot.sessions){button(ids,id,()=>{usageView.session=id;navigate('usage');});}} + renderContinueCard();renderRecentHandoffs(); +} +// Switch harness: one dropdown per harness, newest session first, and one button that continues +// the chosen session in the other harness. The listing is read on demand because it opens every +// transcript; it reloads when the page opens and when the window regains focus. +const RECENT_AGE_MS=30000; +function renderContinueCard(){ + const c=card('Switch harness'); + c.append(node('p','Pick a session and switch it to the other harness. It continues in its own directory with the first route that harness can use and your current login. The review shows the hand-off text before anything is written; nothing is uploaded.')); + const body=node('div');c.append(body);handoffView.container=body; + renderSwitchRows(body); + const options=node('details');options.append(node('summary','Options: route and account for each destination'));c.append(options); + for(const tool of ['claude','codex_cli']){ + const chosen=handoffView.options[tool],name=shortName(tool); + const routes=snapshot.routes.filter(r=>!r.compatibility?.[tool]).map(r=>[r.key,r.id,routeSummary(r)]); + const route=field(options,`Route for ${name}`,combobox(routes,chosen.route||compatibleRoute(tool)||undefined,{mono:true,placeholder:routes.length?'Search by route or model':'No usable route in the server config'})); + route.onchange=()=>{chosen.route=route.value;renderSwitchRows(body);}; + const accounts=snapshot.tools.find(t=>t.tool===tool)?.accounts||[]; + const account=field(options,`Account for ${name}`,combobox([['','Current login','The harness’s normal login'],...accounts.map(n=>[n,n,'Saved account'])],chosen.account)); + account.onchange=()=>{chosen.account=account.value;}; + } + if(!handoffView.recent||Date.now()-handoffView.loadedAt>RECENT_AGE_MS)loadRecentSessions(body); +} +async function loadRecentSessions(body){ + if(handoffView.loading)return;handoffView.loading=true; + try{const r=await readAction({kind:'recent_sessions'});handoffView.recent={data:r.data,error:null};} + catch(error){handoffView.recent={data:null,error:String(error)};} + finally{handoffView.loading=false;handoffView.loadedAt=Date.now();if(body.isConnected)renderSwitchRows(body);} +} +function sessionOption(s){ + const folder=(s.cwd||'').replace(/\/+$/,'').split('/').pop()||'(directory unknown)'; + return [s.transcript,`${folder}${s.title?` · ${s.title}`:''}`,[s.account,s.branch,s.last_activity,s.managed_id?'Switchyard worktree':''].filter(Boolean).join(' · '),[s.cwd,s.native_id].filter(Boolean).join(' ')]; +} +function renderSwitchRows(body){ + body.replaceChildren(); + const recent=handoffView.recent; + if(!recent){body.append(node('p','Reading transcripts…','muted'));return;} + if(recent.error){body.append(node('p',recent.error,'field-error'));return;} + const sessions=recent.data?.sessions||[]; + for(const tool of ['codex_cli','claude']){ + const mine=sessions.filter(s=>s.tool===tool),target=otherHarness(tool),name=shortName(tool); + const remembered=handoffView.chosen[tool],chosen=mine.some(s=>s.transcript===remembered)?remembered:mine[0]?.transcript; + const picker=field(body,`${name} session`,combobox(mine.map(sessionOption),chosen,{placeholder:mine.length?'Search by folder, title, or branch':`No ${name} sessions found`})); + picker.host.closest('.field').style.maxWidth='480px'; + picker.onchange=()=>{handoffView.chosen[tool]=picker.value;}; + if(!mine.length)picker.disabled=true; + const a=actions(body),go=mutation(a,`Switch to ${shortName(target)}`,()=>{const s=mine.find(x=>x.transcript===picker.value)||mine[0];if(!s)return;continueSession(s.managed_id?{kind:'managed',id:s.managed_id}:{kind:'transcript',path:s.transcript},target);},true); + let reason=''; + if(!mine.length)reason=`No ${name} sessions found.`;else{try{handoffDestination(target);}catch(error){reason=error.message||String(error);}} + if(reason){blocked(go,true);a.append(node('small',reason,'field-error'));} + } + const d=recent.data||{}; + const count=(n,noun)=>`${n} ${noun}${n===1?'':'s'}`; + for(const note of [...(d.errors||[]),d.elsewhere?`${count(d.elsewhere,'session')} whose directory is not on this computer, such as remote sessions, left out.`:'',d.skipped?`${count(d.skipped,'unreadable transcript file')} skipped.`:''].filter(Boolean))body.append(node('p',note,'muted')); +} +function renderRecentHandoffs(){ + const recent=card('Recent hand-offs');recent.append(node('p','Hand-off files hold conversation excerpts. They are readable only by you and are removed seven days after they are written.','muted')); + for(const h of snapshot.handoffs||[]){const row=node('div',undefined,'route-row');row.append(node('h4',`${h.source?.label||'?'} → ${h.destination?.label||'?'}`),node('p',[h.created_at,h.destination?.directory,h.destination?.session_id,h.opened===false?'Not opened':''].filter(Boolean).join(' · ')));if(h.file)row.append(node('pre',h.file));recent.append(row);} + if(!(snapshot.handoffs||[]).length)recent.append(node('p','No hand-offs recorded.')); +} +function updatePreviewText(data){return [`Source checkout: ${data.source}`,`Branch: ${data.branch||'Detached HEAD'}`,`Revision: ${data.revision}`,`Local changes: ${data.changes||'None'}`,Array.isArray(data.plan)?data.plan.join('\n'):data.plan].filter(Boolean).join('\n\n');} +// Key status loads on demand because each check runs the helper and queries the credential store. +// Typed keys stay in drafts so a periodic re-render does not clear a half-typed key. +const serverKeys={loaded:false,loading:false,keys:[],store:'',error:'',changed:false,drafts:new Map(),shown:new Set()}; +async function loadServerKeys(){ + if(serverKeys.loading)return;serverKeys.loading=true; + try{const result=await readAction({kind:'server_keys'});serverKeys.keys=result.data.keys;serverKeys.store=result.data.store;serverKeys.error='';} + catch(error){serverKeys.error=String(error);} + finally{serverKeys.loading=false;serverKeys.loaded=true;} + if(page==='settings'){const saved=capturePageState();render();restorePageState(saved);} +} +function renderServerKeys() { + const c=card('Server keys'); + c.append(node('p',`The server that this app starts reads each api_key_env key from ${serverKeys.store||'the system credential store'}, not from your shell. A server you start from a terminal still reads your shell's variables. Import from shell reads the variable once from your login shell.`)); + if(!serverKeys.loaded){c.append(node('p','Loading server keys…'));loadServerKeys();return;} + if(serverKeys.error){c.append(node('p',`Could not check server keys: ${serverKeys.error}`,'field-error'));} + else if(!serverKeys.keys.length)c.append(node('p','The server config names no api_key_env variables.')); + for(const key of serverKeys.keys){ + const row=node('div',undefined,'server-key');row.append(node('h4',key.name),node('p',key.saved?'Saved.':'Missing. The server does not start until this key is saved.',key.saved?'':'warning-note')); + const value=field(row,`New key for ${key.name}`,input(serverKeys.drafts.get(key.name)||'',serverKeys.shown.has(key.name)?'text':'password')); + value.autocomplete='off';value.spellcheck=false;value.oninput=()=>{fieldError(value,'');serverKeys.drafts.set(key.name,value.value);}; + const show=field(row,`Show key for ${key.name}`,input('','checkbox'));show.checked=serverKeys.shown.has(key.name); + show.onchange=()=>{if(show.checked)serverKeys.shown.add(key.name);else serverKeys.shown.delete(key.name);value.type=show.checked?'text':'password';}; + const saved=async()=>{serverKeys.drafts.delete(key.name);serverKeys.shown.delete(key.name);serverKeys.changed=true;await loadServerKeys();}; + const save=async()=>{if(!value.value.trim()){fieldError(value,'Enter a key first.');return;}if(await run({kind:'save_server_key',name:key.name,key:value.value},false))await saved();}; + value.onkeydown=event=>{if(event.key==='Enter'){event.preventDefault();save();}}; + const a=actions(row);mutation(a,'Save key',save,true); + mutation(a,'Import from shell',async()=>{if(await run({kind:'import_server_key',name:key.name},false))await saved();}); + if(key.saved)mutation(a,'Remove…',()=>confirmAction(`Remove the saved key for ${key.name}?`,'The server does not start until this key is saved again.','Remove key',async()=>{if(await run({kind:'remove_server_key',name:key.name},false))await saved();})); + c.append(row); + } + const a=actions(c); + if(serverKeys.changed){c.insertBefore(node('p','Restart the server to use the changed keys.','warning-note'),a);mutation(a,'Restart server now',async()=>{if(await run({kind:'restart'},false)){serverKeys.changed=false;await refresh(true);}},true);} + button(a,'Check keys again',()=>{serverKeys.loaded=false;render();}); +} +function renderSettings() { + const appearanceCard=card('Appearance'); + const appearance=field(appearanceCard,'Theme',combobox(window.switchyardAppearance.options,window.switchyardAppearance.mode)); + appearance.id='appearance-select'; + appearance.onchange=async()=>{await window.switchyardAppearance.choose(appearance.value);appearance.value=window.switchyardAppearance.mode;}; + appearanceCard.append(node('p','System follows your device’s light or dark appearance. This setting applies to the desktop app and menu-bar popup.')); + const appearanceError=node('p',window.switchyardAppearance.error);appearanceError.id='appearance-error';appearanceError.setAttribute('role','status');appearanceCard.append(appearanceError); + + const c=card('Server and app');const paths=snapshot.settings||{}; + const facts=node('dl',undefined,'facts');c.append(facts); + for(const [label,value] of Object.entries(paths))if(typeof value==='string')facts.append(node('dt',label.replaceAll('_',' ')),node('dd',value)); + if(snapshot.version)facts.append(node('dt','installed version'),node('dd',snapshot.version)); + const a=actions(c);button(a,'Copy diagnostics',()=>copyText(JSON.stringify({app:'Switchyard desktop',version:snapshot.version||'Not reported',last_check:lastRefresh?.toISOString(),stale,server:snapshot.metrics,paths:Object.fromEntries(Object.entries(snapshot.settings||{}).filter(([key,value])=>typeof value==='string'&&['path','config_file','routing_log','prices_file'].includes(key))),routes:snapshot.routes.map(r=>r.id),tools:snapshot.tools.map(t=>({label:t.label,available:t.available})),update:snapshot.update_status?.state||'Unknown'},null,2)));for(const [label,kind] of [['Open server config','open_config'],[paths.prices_file?'Open model price override':'About model prices','open_prices'],['Open app settings','open_settings'],['Check config','check_config'],['Restart and check health','restart'],['Open server logs','open_logs']])mutation(a,label,()=>run({kind})); + c.append(node('p','Set the baseline model, server address, and refresh interval in app settings. The server and app use bundled model prices unless you configure a custom price file.')); + renderServerKeys(); + const update=card('Update from source');update.append(node('p','Review the source checkout and service restarts before building.')); + if(snapshot.update_status){const state=snapshot.update_status;update.append(node('p',`${state.state||'No update running'}${state.stage?` · ${state.stage}`:''}: ${state.message||''}`));} + button(actions(update),'Review update…',async()=>{const result=await run({kind:'preview_update'},false);if(!result)return;const unsaved=[...drafts.values()].some(d=>d.dirty);confirmAction('Build and reinstall?',`${updatePreviewText(result.data)}${unsaved?'\n\nYou have unsaved route edits. Cancel and save or discard them before updating.':''}`,'Build and reinstall',()=>{if(unsaved){notify('Save or discard route drafts before updating.','warning');return;}run({kind:'update',preview_token:result.data.preview_token});});}); + button(actions(update),'Open update log',()=>run({kind:'open_update_log'},false)); + renderConfiguration(false); + button(actions(c),'Run setup again',()=>{configurationDraft=undefined;navigate('setup');}); + mutation(actions(c),'Remove app preferences…',async()=>{ + const result=await run({kind:'preview_reset_settings'},false);if(!result)return; + confirmAction('Remove app preferences?',`${result.data.message}\n\nFile: ${result.data.file}`,'Remove preferences',async()=>{ + const removed=await run({kind:'reset_settings',preview_token:result.data.preview_token}); + if(removed){configurationDraft=undefined;navigate('settings');} + }); + }); + const display=card('Display');const zoom=field(display,'Text size',combobox([['100','100%'],['125','125%'],['150','150%'],['200','200%']],String(Math.round(Number(document.documentElement.style.zoom||1)*100))));zoom.onchange=()=>{document.documentElement.style.zoom=String(Number(zoom.value)/100);document.documentElement.style.setProperty('--ui-scale',String(Number(zoom.value)/100));document.body.classList.toggle('enlarged',Number(zoom.value)>=150);}; + const recent=card('Recent actions');for(const item of activity)recent.append(node('p',`${item.time} · ${item.message}`));if(!activity.length)recent.append(node('p','Actions from this app session appear here.')); + c.append(node('p','Closing the window keeps the app in the menu bar and the server running. Quit Switchyard exits the app; Restart controls the server.'));text(c,'Terminal: switchyard-desktop --tui'); +} + +// Setup and Settings submit the same configuration fields and preserve drafts during refreshes. +function renderConfiguration(setup) { + const c=card(setup?'Choose your Switchyard configuration':'Configuration'); + c.append(node('p',setup?'Use an existing server config or create one from the bundled defaults. Reinstalling keeps these files and skips this screen.':'Changes are saved in app settings. Restart the installed server to load a different config, log, or price file.')); + if(snapshot.configuration_error)c.append(node('p',`The saved settings need repair: ${snapshot.configuration_error}. Open app settings to fix syntax errors or unknown keys.`,'warning-note')); + if(!configurationDraft){ + const saved=snapshot.settings||{}; + configurationDraft={server_url:saved.server_url||'',config_file:saved.config_file||'',routing_log:saved.routing_log||'',pricing_file:saved.prices_file||'',launchd_label:saved.launchd_label||'',baseline_model:saved.baseline_model||snapshot.baseline_model||'',refresh_seconds:String(saved.refresh_seconds||snapshot.refresh_seconds||30),create_server:Boolean(snapshot.needs_setup)}; + } + const draft=configurationDraft; + const mode=field(c,'Server configuration',combobox([['existing','Use an existing file'],['create','Create a file from bundled defaults']],draft.create_server?'create':'existing')); + mode.onchange=()=>{draft.create_server=mode.value==='create';}; + for(const [key,label,file] of [['config_file','Server config file',true],['server_url','Server address',false],['routing_log','Routing log file',true],['pricing_file','Model price override (optional)',true],['baseline_model','Baseline model ID',false],['refresh_seconds','Refresh interval in seconds',false]]){ + const control=field(c,label,input(draft[key],key==='refresh_seconds'?'number':'text')); + control.oninput=()=>{draft[key]=control.value;fieldError(control,'');}; + if(file)button(actions(c),'Choose '+label.toLowerCase()+'…',()=>choosePath(control,false,()=>{draft[key]=control.value;})); + } + c.append(node('p','Leave the model price override empty to use bundled rates. Creating a server config never replaces an existing file.')); + c.append(node('p','Savings compare each call’s recorded cost with the same answer tokens at the baseline model’s rates. Rates are in dollars per million tokens. The app and installed server use the selected price file.')); + mutation(actions(c),setup?'Save setup':'Save configuration',async()=>{ + const config={server_url:draft.server_url,config_file:draft.config_file,routing_log:draft.routing_log,pricing_file:draft.pricing_file||undefined,launchd_label:draft.launchd_label,baseline_model:draft.baseline_model,refresh_seconds:Number(draft.refresh_seconds)}; + if(!Number.isSafeInteger(config.refresh_seconds)||config.refresh_seconds<1){notify('Enter a whole refresh interval of at least one second.','error');return;} + confirmAction('Save configuration?',`${draft.create_server?'Create':'Use'} server config: ${config.config_file}\nServer: ${config.server_url}\nRouting log: ${config.routing_log}\nModel prices: ${config.pricing_file||'Bundled rates'}\n\nSave app settings in ${snapshot.settings?.path}. Existing server files, credentials, and history are preserved.`,'Save',async()=>{ + const result=await run({kind:'save_configuration',config,create_server:draft.create_server}); + if(result){configurationDraft=undefined;navigate('settings');} + }); + },true); + button(actions(c),'Open app settings',()=>run({kind:'open_settings'},false)); +} + + +function draftKey(route) {return `${snapshot.settings?.config_file||generation}:${route.key}`;} +function routeSummary(route){return route.choices.map(c=>`${c.client} / ${c.model}`).join(' → ');} +function renderRouteBrowser() { + const c=card('Route library');c.classList.add('route-browser'); + const layout=node('div',undefined,'route-workspace'),library=node('div',undefined,'route-library'),list=node('div',undefined,'route-picker'),detail=node('div',undefined,'route-detail'); + const libraryTitle=c.querySelector('h3');library.append(libraryTitle);c.append(layout);layout.append(library,detail); + button(actions(library),'Create route…',()=>routeIdentity('create_route')); + if([...drafts.keys()].some(k=>!k.startsWith(`${snapshot.settings?.config_file||generation}:`)))library.append(node('p','Drafts from another config file are retained in this app session. Return to that config to finish or discard them.','warning-note')); + if(!snapshot.routes.length){library.append(node('p',(snapshot.errors||[]).length?'Routes could not be loaded. Open the server config to fix the error.':'No routes configured. Create a route or open the server config.'));button(actions(library),'Open server config',()=>run({kind:'open_config'},false));return;} + const search=field(library,'Search routes',input(routeView.search));search.placeholder='Name, method, model…'; + const count=node('p',undefined,'muted');library.append(count,list); + const switcher=node('div',undefined,'route-pane-switch');c.prepend(switcher); + const choosePane=pane=>{routeView.pane=pane;layout.dataset.pane=pane;libraryButton.setAttribute('aria-pressed',String(pane==='library'));editorButton.setAttribute('aria-pressed',String(pane==='editor'));}; + const libraryButton=button(switcher,'Route library',()=>choosePane('library')),editorButton=button(switcher,'Selected route',()=>choosePane('editor'));choosePane(routeView.pane); + const draw=()=>{ + routeView.search=search.value;const routes=snapshot.routes.filter(r=>routeMatches(r,search.value));count.textContent=`${routes.length} of ${snapshot.routes.length} routes`;list.replaceChildren();detail.replaceChildren(); + if(!snapshot.routes.some(r=>r.key===routeView.selected))routeView.selected=routes[0]?.key||''; + for(const route of routes){const b=button(list,'',()=>{routeView.selected=route.key;draw();choosePane('editor');detail.querySelector('h3')?.focus();});b.dataset.route=route.key;b.classList.toggle('selected',route.key===routeView.selected);b.setAttribute('aria-pressed',String(route.key===routeView.selected));b.append(node('span',route.id,'route-name'),node('small',snapshot.algorithms.find(a=>a.kind===route.kind)?.title||route.kind),node('small',routeSummary(route)));const badge=node('small','Unsaved edits','draft-badge');badge.hidden=!drafts.get(draftKey(route))?.dirty;b.append(badge);} + if(!routes.length){list.append(node('p','No routes match your search.','empty-state'));button(list,'Clear search',()=>{search.value='';draw();});return;} + const route=routes.find(r=>r.key===routeView.selected); + if(route)renderRoute(route,detail);else detail.append(node('p','Selected route is outside this filter. Clear the search to return to it.','empty-state')); + };search.oninput=draw;draw(); +} + +function routeIdentity(kind,route) { + const creating=kind==='create_route',title=creating?'Create route':kind==='rename_route'?'Rename route':'Duplicate route'; + const overlay=node('div',undefined,'modal-backdrop'),form=node('form',undefined,`confirm-dialog route-dialog${creating?' create-route':''}`);form.setAttribute('role','dialog');form.setAttribute('aria-modal','true');form.setAttribute('aria-label',title);overlay.append(form);document.body.append(overlay); + const siblings=[...document.body.children].filter(n=>n!==overlay).map(n=>[n,n.inert]);siblings.forEach(([n])=>{n.inert=true;});const previous=document.activeElement; + const close=()=>{overlay.remove();siblings.forEach(([n,inert])=>{n.inert=inert;});if(previous?.isConnected)previous.focus();else document.querySelector('#title').focus();}; + form.append(node('h3',title)); + if(creating)form.append(node('p','A route is a model name that harnesses can request. Switchyard sends each request for that name to the models you choose below.','dialog-intro')); + const id=field(form,'Public model ID',input(kind==='rename_route'?route.id:''),'Harnesses send this name as the model, for example in Codex or Claude Code settings. Each route needs its own name.');id.placeholder='team-router'; + const name=kind==='rename_route'?null:field(form,'Config name',input(),'The route’s table name in the server config file, as in [routes.team-router]. Switchyard fills it in from the public model ID. Use 1–64 letters, digits, hyphens, or underscores.'); + if(name){name.placeholder='team-router';let typed=false;name.addEventListener('input',()=>{typed=Boolean(name.value);});id.addEventListener('input',()=>{if(!typed)name.value=id.value.replace(/[^A-Za-z0-9_-]+/g,'-').replace(/^-+|-+$/g,'').slice(0,64);});} + const models=creating?routeModels(form):null; + const values=()=>({name:name?.value||'',id:id.value,...(models?models.value():{algorithm:null,choices:[]})}); + const failure=node('p',undefined,'field-error');failure.setAttribute('role','alert');failure.hidden=true;form.append(failure); + const a=actions(form);a.classList.add('dialog-actions');button(a,'Cancel',close).type='button';const save=button(a,'Review changes…',()=>{},true);save.type='submit'; + form.onsubmit=async event=>{ + event.preventDefault();failure.hidden=true;let valid=fieldError(id,!id.value.trim()||/[\n\r]/.test(id.value)?'Enter a public model ID without line breaks.':snapshot.routes.some(r=>r.id===id.value&&!(kind==='rename_route'&&r.key===route?.key))?'That public model ID already exists.':''); + if(name)valid=fieldError(name,!/^[A-Za-z0-9_-]{1,64}$/.test(name.value)?'Use 1–64 letters, digits, hyphens, or underscores.':snapshot.routes.some(r=>r.key===name.value)?'That config name already exists.':'')&&valid; + if(models)valid=models.validate()&&valid;if(!valid){form.querySelector('[aria-invalid="true"]')?.focus();return;} + const reviewed=values(),args={generation,operation:kind.replace('_route',''),route:route?.key||'',...reviewed}; + // The dialog covers the page notification, so a failed review shows its error inside the dialog. + save.disabled=true;let preview;try{preview=await readAction({kind:'preview_route_change',...args});}catch(error){failure.textContent=String(error);failure.hidden=false;}save.disabled=false;if(!preview)return; + if(JSON.stringify(values())!==JSON.stringify(reviewed)){notify('The form changed during review. Review the current values again.','warning');return;} + confirmAction('Save this route change?', [`Changed file: ${preview.data.file}`,...preview.data.notes,'Switchyard validates the config, saves a backup, and restarts the server.'].join('\n\n'),'Save and restart',async()=>{form.querySelectorAll('input,button').forEach(control=>{control.disabled=true;});const result=await run({kind:'route_change',...args,preview_token:preview.data.preview_token});if(result)close();else form.querySelectorAll('input,button').forEach(control=>{control.disabled=false;});}); + }; + + form.onkeydown=event=>{if(event.key==='Escape'){event.preventDefault();close();}if(event.key==='Tab'){const controls=[...form.querySelectorAll('input,button')].filter(control=>!control.disabled&&control.tabIndex>=0&&!control.closest('[hidden]')),i=controls.indexOf(document.activeElement);if(event.shiftKey&&i===0){event.preventDefault();controls.at(-1).focus();}else if(!event.shiftKey&&i===controls.length-1){event.preventDefault();controls[0].focus();}}};id.focus(); +} +const fieldHelp={ + endpoint:'The server connection, from [llm_clients] in the server config, that sends requests to this model.', + model:'Choose a suggested model ID or type any ID that the endpoint accepts.', +}; +function clientOptions(){return snapshot.clients.map(c=>[c.name,c.name,c.host]);} +// Model fields accept any ID; the list only suggests IDs that the endpoint is known to serve. +function modelOptions(client){return (lists.get(modelListKey(client))?.models||snapshot.clients.find(c=>c.name===client)?.models||[]).map(model=>[model]);} +function modelField(parent,label,client,value){const model=field(parent,label,combobox(modelOptions(client),value,{free:true,mono:true,placeholder:'Type or choose a model ID'}),fieldHelp.model);model.classList.add('model-id');return model;} +function rememberModels(client,data){lists.set(modelListKey(client),{models:data.models,time:new Date().toLocaleTimeString(),warning:Boolean(data.warning&&['cache','config'].includes(data.source)),source:data.source||'model-list result',fetchedAt:data.fetched_at});} +// The create dialog asks for every model the chosen algorithm needs. Switching algorithms keeps +// each model whose role (judge, capable, or efficient) the new algorithm also has. +function routeModels(form) { + const fallback=()=>({client:snapshot.clients[0]?.name||'',model:''}),spec=kind=>snapshot.algorithms.find(a=>a.kind===kind); + if(!snapshot.clients.length)form.append(node('p','The server config has no endpoints. Add an [llm_clients] entry to the server config, then create the route.','warning-note')); + const algorithm=field(form,'Routing algorithm',combobox(snapshot.algorithms.map(a=>[a.kind,a.title,a.summary]),snapshot.algorithms[0]?.kind),'The algorithm decides which of the models below answers each request.'); + const summary=node('p',undefined,'algorithm-summary'),rows=node('div',undefined,'role-fields dialog-roles');form.append(summary,rows); + const memory=new Map();let kind,choices=[],random=[]; + const roles=()=>kind==='random'?random.map((_,i)=>({label:`Model ${i+1}`,hint:'This model gets a share of the requests.',tier:spec(kind).roles[i]?.tier})):spec(kind).roles; + const render=()=>{ + rows.replaceChildren(); + roles().forEach((role,index)=>{ + const choice=choices[index],row=node('div');row.dataset.role=index;row.append(node('h4',role.label,'role-heading'),node('p',role.hint,'role-hint'));rows.append(row); + const single=role.label==='Model',client=field(row,single?'Endpoint':`${role.label} endpoint`,combobox(clientOptions(),choice.client,{mono:true}),fieldHelp.endpoint); + const model=modelField(row,single?'Model':`${role.label} model`,choice.client,choice.model),status=node('small',undefined,'model-list-status');row.append(status); + client.onchange=()=>{choice.client=client.value;model.setOptions(modelOptions(client.value));status.textContent='';};model.oninput=()=>{choice.model=model.value;fieldError(model,'');}; + const tools=actions(row); + button(tools,'Load models from endpoint',async()=>{ + const endpoint=client.value; + status.textContent='Loading models…'; + try{ + const result=await readAction({kind:'models',client:endpoint,refresh:true,key:null}); + rememberModels(endpoint,result.data); + if(!row.isConnected||client.value!==endpoint)return; + model.setOptions(modelOptions(endpoint)); + status.textContent=`${result.data.models.length} models loaded.`; + }catch(error){ + if(!row.isConnected||client.value!==endpoint)return; + status.textContent=`Could not load models: ${error}. Type the model ID instead, or save a model-list key in the route editor.`; + } + }).type='button'; + if(kind==='random'&&random.length>2)button(tools,'Remove model',()=>{random.splice(index,1);render();}).type='button'; + }); + if(kind==='random')button(actions(rows),'Add model',()=>{random.push(fallback());render();rows.querySelector(`[data-role="${random.length-1}"] [role="combobox"]`)?.focus();}).type='button'; + }; + const choose=()=>{ + if(kind)roles().forEach((role,i)=>{if(role.tier)memory.set(role.tier,choices[i]);}); + kind=algorithm.value;summary.textContent=spec(kind).summary; + const restored=spec(kind).roles.map(role=>({...(memory.get(role.tier)||fallback())})); + if(kind==='random'){if(!random.length)random=restored;choices=random;}else choices=restored; + render(); + }; + algorithm.onchange=choose;choose(); + return { + value:()=>({algorithm:kind,choices:choices.map(choice=>({...choice}))}), + validate:()=>{let valid=snapshot.clients.length>0;rows.querySelectorAll('.model-id').forEach(model=>{valid=fieldError(model,model.value.trim()?'':'Enter or choose a model ID.')&&valid;});return valid;}, + }; +} +// A preview belongs to its exact inputs; obsolete replies never enable Apply. +function renderInstall(tool,parent) { + const c=card(tool.label);parent.append(c);c.dataset.tool=tool.tool; + c.append(node('p',tool.available?'Harness detected.':'Harness not detected. You can prepare settings and install the harness later.','muted')); + c.append(node('p',{codex_cli:'Choose the Codex CLI profile file to change.',codex_app:'Detected user settings change defaults shared by the Codex app and CLI. Custom files must be loaded by the harness.',claude:'Start a new Claude Code session after changing its settings.',pi:'Pi uses models.json and settings.json together. Model capabilities and prices are presets; check them for your chosen route.'}[tool.tool])); + const fields=node('div',undefined,'field-grid');c.append(fields); + const mode=field(fields,'Destination',combobox([['default','Detected settings','The harness’s usual settings file'],['account','Saved account','Settings for a separate login'],['custom','Custom file','A settings file path that you enter']],installView.mode),'The settings file that Switchyard changes.'); + const account=field(fields,'Saved account',combobox(tool.accounts.map(name=>[name,name]),installView.account)); + const file=field(fields,tool.tool==='pi'?'Custom models.json path':'Custom settings file path',input(installView.file));file.placeholder=tool.files?.[0]||'/absolute/path/to/settings'; + const chooseFile=button(c,'Choose settings file…',()=>choosePath(file,false,update)); + const route=routeField(fields,installView.route,tool.tool); + const comparison=node('div',undefined,'install-diff'),authentication=node('p',undefined,'install-auth'),progress=node('p',undefined,'install-progress');progress.setAttribute('role','status');progress.setAttribute('aria-live','polite');c.append(comparison,authentication,progress); + const target=()=>({tool:tool.tool,account:mode.value==='account'?account.value||null:null,settings_file:mode.value==='custom'?file.value||null:null}); + let plan,revision=0;const a=actions(c); + const install=mutation(a,'Apply routing…',()=>{if(!plan)return;const selected=plan;confirmAction(`Apply routing to ${tool.label}?`,`${selected.data.proposed}\n\n${selected.data.authentication}\n\nFiles: ${[...new Set(selected.data.changes.map(change=>change.file))].join(', ')}. Switchyard backs up the originals and preserves unrelated settings.`, 'Apply routing',async()=>{if(!c.isConnected||selected!==plan)return;progress.textContent='Saving settings…';const result=await run(selected.action,false);progress.textContent='';if(result)update();});},true); + const restore=mutation(a,'Review restore…',async()=>{ + blocked(install,true);plan=null;progress.textContent='Checking backup…'; + // The preview and confirmation must use the same destination revision. + const selectedTarget=target(),reviewedRevision=revision; + const result=await run({kind:'preview_restore',...selectedTarget},false); + if(!c.isConnected||reviewedRevision!==revision||JSON.stringify(selectedTarget)!==JSON.stringify(target()))return; + progress.textContent='';if(!result){update();return;} + const data=result.data; + confirmAction('Restore harness settings?',[data.warning,...(data.files||[]).map(file=>`${file.action}: ${file.file}${file.backup?`\nOriginal backup: ${file.backup}`:''}`)].join('\n\n'),'Restore settings',async()=>{if(!c.isConnected||reviewedRevision!==revision||JSON.stringify(selectedTarget)!==JSON.stringify(target()))return;progress.textContent='Restoring settings…';await run({kind:'restore',...selectedTarget,preview_token:data.preview_token},false);progress.textContent='';update();},update); + }); + const update=()=>{ + Object.assign(installView,{mode:mode.value,account:account.value,route:route.value,file:file.value}); + account.closest('.field').hidden=mode.value!=='account';file.closest('.field').hidden=mode.value!=='custom';chooseFile.hidden=mode.value!=='custom'; + const requested=++revision;plan=null;blocked(install,true);install.textContent='Checking settings…';comparison.setAttribute('aria-busy','true');comparison.replaceChildren(node('p','Loading settings preview…'));authentication.textContent='';progress.textContent='Checking preview…'; + const invalid=mode.value==='custom'&&(!file.value||/[\r\n]/.test(file.value));fieldError(file,invalid?'Enter an absolute settings file path without line breaks.':''); + if(invalid||mode.value==='account'&&!account.value){comparison.replaceChildren(node('p',invalid?'Enter an absolute settings file path.':'No saved account is available. Sign in below or choose Detected settings.'));install.textContent='Preview required';comparison.setAttribute('aria-busy','false');progress.textContent='';blocked(restore,true);return;} + blocked(restore,true);restore.title=''; + const incompatible=snapshot.routes.find(r=>r.key===route.value)?.compatibility?.[tool.tool]; + // A backup can be restored without a route, so check its destination before Apply's route gates. + const requestedTarget=target(); + enqueue(async()=>{ + if(!c.isConnected||requested!==revision)return; + try{ + let backupReady=false;try{await readAction({kind:'preview_restore',...requestedTarget});backupReady=true;}catch(error){if(c.isConnected&&requested===revision)restore.title=String(error);} + if(!c.isConnected||requested!==revision)return;blocked(restore,!backupReady); + if(incompatible){comparison.replaceChildren(node('p',incompatible,'field-error'));install.textContent='Choose a compatible route';progress.textContent='Preview unavailable for this harness.';return;} + if(!route.value){comparison.replaceChildren(node('p','Choose a route to preview its settings.'));button(comparison,'Choose a route',()=>navigate('routes'));install.textContent='Choose a route';progress.textContent='';return;} + const requestedRoute=routeArgs(route); + const result=await readAction({kind:'preview_install',...requestedTarget,...requestedRoute});if(!c.isConnected||requested!==revision)return; + comparison.replaceChildren();const summaries=node('div',undefined,'install-summary');comparison.append(summaries); + for(const [title,value] of [['Current settings',result.data.current],['After applying routing',result.data.proposed]])if(value){const summary=node('div');summary.append(node('h4',title),node('pre',value));summaries.append(summary);} + for(const file of result.data.file_actions||[])summaries.append(node('p',`${file.action==='create'?'Will create':file.action==='preserve'?'Will preserve':'Will update'}: ${file.file}`)); + authentication.textContent=result.data.authentication||''; + if(result.data.error){comparison.append(node('p',result.data.error,'field-error'));install.textContent='Preview unavailable';progress.textContent='Preview unavailable. Review the error.';blocked(restore,!backupReady);return;} + const grouped=new Map();for(const change of result.data.changes){if(!grouped.has(change.file))grouped.set(change.file,[]);grouped.get(change.file).push(change);} + for(const [path,changes] of grouped){const details=node('details');details.append(node('summary',`${path} · ${changes.length} changed settings`));for(const change of changes)details.append(node('pre',change.key,'diff-heading'),node('pre',`− ${change.before}`,'diff-remove'),node('pre',`+ ${change.after}`,'diff-add'));comparison.append(details);} + if(!result.data.changes.length)comparison.append(node('p','Settings already match this route. Saved settings do not confirm that the server is reachable or that the harness has loaded them.')); + const ready=result.data.changes.length>0;if(ready)plan={data:result.data,action:{kind:'install',...requestedTarget,...requestedRoute,preview_token:result.data.preview_token}}; + install.textContent=ready?'Apply routing…':'Routing already applied';blocked(install,!ready);blocked(restore,!backupReady);progress.textContent=ready?'The preview shows the files and authentication above. Review them before applying routing.':'Routing already applied.'; + }catch(error){if(c.isConnected&&requested===revision){install.textContent='Preview unavailable';comparison.replaceChildren(node('p',String(error),'field-error'));progress.textContent='Preview unavailable.';button(comparison,'Retry preview',update);}} + finally{if(c.isConnected&&requested===revision)comparison.setAttribute('aria-busy','false');} + }); + }; + for(const control of [mode,account,route])control.onchange=update; + file.oninput=()=>{installView.file=file.value;revision++;plan=null;blocked(install,true);blocked(restore,true);install.textContent='Preview required';progress.textContent='Finish entering the file path, then press Enter or leave the field to preview.';};file.onchange=update;file.onkeydown=event=>{if(event.key==='Enter')update();};update(); +} +function renderAccounts() { + const c=card('Subscription accounts');c.append(node('p','Account folders keep separate harness settings. Folder existence does not confirm a completed login.')); + for(const tool of snapshot.tools.filter(t=>['codex_cli','claude'].includes(t.tool))){ + if(tool.discovery_error)c.append(node('p',`${tool.label}: ${tool.discovery_error}`,'field-error')); + for(const item of tool.account_details||tool.accounts.map(name=>({name,status:'Login status unknown'}))){const row=node('div',undefined,'route-row');row.append(node('h4',`${tool.label} · ${item.name}`),node('p',item.status||'Login status unknown'),node('pre',item.path||''));if(item.path){button(actions(row),'Copy account path',()=>copyText(item.path));button(actions(row),'Open folder',()=>run({kind:'open_account',tool:tool.tool,name:item.name},false));}mutation(actions(row),'Sign in again…',()=>run({kind:'login_account',tool:tool.tool,name:item.name}));mutation(actions(row),'Review archive…',async()=>{const result=await run({kind:'preview_account_archive',tool:tool.tool,name:item.name},false);if(result)confirmAction('Archive this account folder?',`${result.data.notice}\n\nAccount: ${result.data.name}\nDirectory: ${result.data.path}`,'Archive account',()=>run({kind:'archive_account',tool:tool.tool,name:item.name,preview_token:result.data?.preview_token}));});c.append(row);} + } + const disclosure=node('details');disclosure.append(node('summary','Sign in to another subscription account…'));c.append(disclosure); + disclosure.append(node('p','Finish login in the Terminal window. API routes use credentials configured on the server. The Codex app manages its own account.')); + const tool=field(disclosure,'Account harness',combobox(snapshot.tools.filter(t=>['codex_cli','claude'].includes(t.tool)).map(t=>[t.tool,t.label]),accountView.tool)); + const name=field(disclosure,'Name for the separate account',input(accountView.name));disclosure.append(node('small','Use 1–64 ASCII letters, digits, hyphens, or underscores.')); + const add=mutation(actions(disclosure),'Open login in Terminal…',async()=>{const result=await run({kind:'add_account',tool:tool.value,name:name.value});if(result){accountView.name='';render();}}); + const changed=()=>{accountView.name=name.value;accountView.tool=tool.value;const error=!/^[A-Za-z0-9_-]{1,64}$/.test(name.value)?'Use 1–64 letters, digits, hyphens, or underscores.':snapshot.tools.find(t=>t.tool===tool.value)?.accounts.includes(name.value)?'That account name already exists. Choose another name.':'';fieldError(name,error);blocked(add,Boolean(error)||!snapshot.tools.find(t=>t.tool===tool.value)?.available);};name.oninput=changed;tool.onchange=changed;changed(); +} +// Every algorithm retains its complete choices until the user saves or discards the draft. +function modelListKey(client){return `${snapshot.settings?.config_file||generation}:${snapshot.clients.find(endpoint=>endpoint.name===client)?.host||''}:${client}`;} +function roleChoice(draft,route,role,index) { + return draft.byAlgorithm?.get(draft.algorithm)?.[index] || (role.tier && draft.memory.get(role.tier)) || + ((draft.algorithm===route.kind||draft.loadedAlgorithm===draft.algorithm)&&draft.choices[index]) || + {client:route.choices[0]?.client||snapshot.clients[0]?.name||'',model:''}; +} +function draftDirty(draft,route){return draft.algorithm!==route.kind||JSON.stringify(draft.choices)!==JSON.stringify(route.choices);} +function renderRoute(route,parent) { + const c=card(route.id);c.classList.add('route-editor');c.querySelector('h3').tabIndex=-1;parent.append(c);const key=draftKey(route),loadedGeneration=generation; + if(route.generated){c.append(node('p',`Generated by ${route.generated}. Edit the generator’s source to keep changes across regeneration; generated output may replace edits here.`,'warning-note'));button(actions(c),'Open server config',()=>run({kind:'open_config'},false));} + const remove=()=>confirmAction(`Delete route “${route.id}”?`,`This removes the public model ID from the server. Targets and harness settings are kept. Any harness using ${route.id} must choose another route. ${route.dependents?.length?`Known settings: ${route.dependents.map(d=>typeof d==='string'?d:`${d.label}${d.account?` (${d.account})`:''}: ${d.files.join(', ')}`).join('; ')}.`:''}`,'Delete route',()=>run({kind:'remove',generation:loadedGeneration,route:route.key,id:route.id,revision:drafts.get(key)?.revision||route.revision})); + if(!route.editable){c.append(node('p','This routing method has no visual editor. Edit its fields in the server config.'));button(actions(c),'Open server config',()=>run({kind:'open_config'},false));mutation(actions(c),'Delete route…',remove).classList.add('danger');return;} + let draft=drafts.get(key); + if(!draft){draft={algorithm:route.kind,choices:route.choices.map(c=>({...c})),memory:new Map(),byAlgorithm:new Map([[route.kind,route.choices.map(c=>({...c}))]]),revision:route.revision,generation:loadedGeneration,dirty:false};route.tiers?.forEach((tier,index)=>{if(tier)draft.memory.set(tier,{...route.choices[index]});});drafts.set(key,draft);} + const algorithm=field(c,'Routing algorithm',combobox(snapshot.algorithms.map(a=>[a.kind,a.title,a.summary]),draft.algorithm),'The algorithm decides which of the models below answers each request.'); + const description=node('p',undefined,'description'),diagram=node('div',undefined,'route-diagram'),roles=node('div',undefined,'role-fields'),editorState=node('p',undefined,'editor-state');editorState.setAttribute('role','status');c.append(description,diagram,roles);c.querySelector('h3').after(editorState); + const a=actions(c);a.classList.add('editor-footer');a.append(node('small','Saving a route restarts the server.','restart-note'));let revision=0; + if(draft.generation!==loadedGeneration){c.append(node('p','This draft was retained while another server config was selected. Review it against the current saved route before saving.','warning-note'));button(a,'Review restored draft…',()=>confirmAction('Use the current saved route as the baseline?',`Current saved route: ${route.kind} · ${routeSummary(route)}\n\nRetained draft: ${draft.algorithm} · ${draft.choices.map(choice=>`${choice.client} / ${choice.model}`).join(' → ')}\n\nThe next Save review will show settings removed or retained from the current file.`,'Use current baseline',()=>{draft.revision=route.revision;draft.generation=loadedGeneration;render();}));} + const updateDirty=()=>{draft.dirty=draftDirty(draft,route);editorState.textContent=draft.dirty?'Unsaved edits':'Saved route';blocked(save,!draft.dirty||draft.generation!==loadedGeneration);blocked(revert,!draft.dirty);const badge=document.querySelector(`[data-route="${CSS.escape(route.key)}"] .draft-badge`);if(badge)badge.hidden=!draft.dirty;}; + const updateDiagramChoices=()=>{diagram.querySelectorAll('[data-role-link]').forEach(item=>{const choice=draft.choices[Number(item.dataset.roleLink)];item.querySelector('small').textContent=choice?`${choice.client} / ${choice.model||'Choose model'}`:'';});}; + const saveChoices=()=>{roles.querySelectorAll('[data-role]').forEach(row=>{const controls=row.querySelectorAll('[role="combobox"]');const choice={client:controls[0].value,model:controls[1].value};draft.choices[Number(row.dataset.role)]=choice;if(row.dataset.tier)draft.memory.set(row.dataset.tier,choice);});draft.byAlgorithm.set(draft.algorithm,draft.choices.map(c=>({...c})));updateDiagramChoices();updateDirty();}; + const save=mutation(a,'Review changes…',async()=>{ + saveChoices();let valid=true;roles.querySelectorAll('[data-role] .model-id').forEach(control=>{valid=fieldError(control,!control.value.trim()?'Enter a model ID. Manual IDs are allowed.':'')&&valid;});if(!valid)return; + const args={generation:loadedGeneration,route:route.key,algorithm:draft.algorithm,choices:draft.choices.map(choice=>({...choice})),revision:draft.revision}; + const preview=await run({kind:'preview_route',...args},false);if(!preview)return; + if(draft.algorithm!==args.algorithm||JSON.stringify(draft.choices)!==JSON.stringify(args.choices)){notify('The route draft changed during review. Review the current edits again.','warning');return;} + confirmAction(`Save route “${route.id}”?`,[`Changed file: ${preview.data.file}.`,...preview.data.notes,'Switchyard validates the config, saves a backup, and restarts the server.'].join('\n\n'),'Save and restart',async()=>{const result=await run({kind:'apply',...args});if(result){if(draft.algorithm===args.algorithm&&JSON.stringify(draft.choices)===JSON.stringify(args.choices))drafts.delete(key);else{draft.revision=undefined;notify('Saved the reviewed route. Your newer edits remain unsaved.','warning',result.data?.details||[]);}render();document.querySelector('#title').focus();}}); + },true); + const revert=mutation(a,'Discard unsaved edits…',()=>{if(!draft.dirty)return;confirmAction('Discard route edits?','This returns the route editor to the saved configuration.','Discard edits',()=>{drafts.delete(key);refresh();});}); + button(a,'Reload saved route…',()=>confirmAction('Reload the saved route?','This discards the current route draft and reads the latest server config.','Reload route',()=>{drafts.delete(key);refresh();})); + mutation(a,'Duplicate…',()=>routeIdentity('duplicate_route',route));mutation(a,'Rename…',()=>routeIdentity('rename_route',route));mutation(a,'Delete route…',remove).classList.add('danger'); + const draw=async()=>{ + const current=++revision,chosen=draft.algorithm;editorState.textContent='Loading routing roles…';roles.setAttribute('aria-busy','true'); + try{const result=await readAction({kind:'editor',generation:loadedGeneration,route:route.key,algorithm:chosen});if(!c.isConnected||current!==revision)return; + description.textContent=result.message;if(!draft.revision)draft.revision=result.data.revision;roles.replaceChildren(); + let roleList=result.data.roles; + if(chosen==='random'&&draft.byAlgorithm.has(chosen)){const template=roleList[0];roleList=draft.byAlgorithm.get(chosen).map((_,i)=>({...template,label:`Model ${i+1}`,tier:null}));} + draft.choices=roleList.map((role,index)=>({...roleChoice(draft,route,role,index)})); + diagram.replaceChildren();diagram.append(node('small','Routing flow','diagram-caption')); + const flow=node('div',undefined,'diagram-flow');diagram.append(flow); + const connect=(label)=>{flow.append(node('span',label,'diagram-connector'));}; + const roleNode=index=>{const role=roleList[index],item=button(flow,'',()=>{const row=roles.querySelector(`[data-role="${index}"]`);row?.scrollIntoView({block:'nearest',behavior:'instant'});row?.querySelector('[role="combobox"]')?.focus();});item.dataset.roleLink=index;item.title=role.hint;item.append(node('strong',role.label),node('small'));return item;}; + flow.append(node('span','Request','diagram-request')); + if(chosen==='advisor'){connect('→');roleNode(0);connect('→ final answer review');roleNode(1);} + else if(chosen==='plan_execute'){connect('→');roleNode(0);connect('→ first file edit');roleNode(1);} + else {connect('→');if(roleList[0]?.tier==='Judge'){roleNode(0);connect('→ chooses');}const choices=node('div',undefined,'diagram-choices');flow.append(choices);for(let index=roleList[0]?.tier==='Judge'?1:0;index{ + const choice=draft.choices[index],row=node('div');row.dataset.role=index;row.dataset.tier=role.tier||'';row.id=`role-${route.key}-${index}`;row.append(node('h4',role.label,'role-heading'),node('p',role.hint,'role-hint')); + const client=field(row,`${role.label} endpoint`,combobox(clientOptions(),choice.client,{mono:true}),fieldHelp.endpoint); + const model=modelField(row,role.label==='Model'?'Model':`${role.label} model`,choice.client,choice.model); + const guidance=node('div'),listStatus=node('small',undefined,'model-list-status');row.append(guidance,listStatus); + const fill=()=>{const cached=lists.get(modelListKey(client.value)),values=cached?.models||snapshot.clients.find(c=>c.name===client.value)?.models||[];model.setOptions(values.map(value=>[value]));listStatus.textContent=`${values.length} suggestions · ${cached?`${cached.source==='cache'?'Switchyard cached suggestions':cached.source==='local'?'Codex cached suggestions':cached.source==='config'?'Configured targets':'Endpoint suggestions'} · ${Number.isFinite(cached.fetchedAt)&&cached.fetchedAt>0?`fetched ${new Date(cached.fetchedAt*1000).toLocaleString()}`:'fetch time unavailable'}${cached.warning?` · refresh failed at ${cached.time}`:''}`:'configured targets'}${values.length?'':' · enter a model ID manually'}.`; + }; + const endpointGuidance=()=>{guidance.replaceChildren();const endpoint=snapshot.clients.find(e=>e.name===client.value); + if(endpoint?.note)guidance.append(node('p',endpoint.note,'warning-note')); + if(endpoint?.accepts_key){const details=node('details');details.append(node('summary','Key for model suggestions'));const keyInput=field(details,'Model-list key (stored in macOS Keychain)',input('','password'));keyInput.autocomplete='off';details.append(node('small','This key loads model suggestions. Configure server credentials in the server config.')); + mutation(actions(details),'Save key and load models',async()=>{const endpoint=client.value;const result=await run({kind:'models',client:endpoint,refresh:true,key:keyInput.value},false);if(result){keyInput.value='';rememberModels(endpoint,result.data);fill();}});guidance.append(details);} + }; + client.onchange=()=>{fill();endpointGuidance();saveChoices();if(model.value)guidance.append(node('p','The endpoint changed. Check that it supports the retained model ID before saving.','warning-note'));};model.oninput=()=>{fieldError(model,'');saveChoices();};fill();endpointGuidance(); + mutation(actions(row),'Refresh suggestions',async()=>{const endpoint=client.value;listStatus.textContent='Loading suggestions…';const result=await run({kind:'models',client:endpoint,refresh:true,key:null},false);if(result){rememberModels(endpoint,result.data);fill();}else listStatus.textContent='Suggestions unavailable. Retry or enter a model ID manually.';}); + if(!choice.model)row.append(node('p','New role: choose its endpoint and model before saving.','warning-note')); + if(chosen==='random')mutation(actions(row),'Remove model',()=>{saveChoices();if(draft.choices.length<=2){notify('Random routing needs at least two models.','warning');return;}draft.choices.splice(index,1);draft.byAlgorithm.set(chosen,draft.choices.map(c=>({...c})));draw();}); + roles.append(row); + });draft.loadedAlgorithm=chosen;draft.byAlgorithm.set(chosen,draft.choices.map(c=>({...c}))); + if(chosen==='random')mutation(roles,'Add model',()=>{saveChoices();draft.choices.push({client:snapshot.clients[0]?.name||'',model:''});draft.byAlgorithm.set(chosen,draft.choices.map(c=>({...c})));draw();}); + updateDirty(); + }catch(error){if(c.isConnected&¤t===revision){editorState.textContent=`Could not load the editor: ${error}`;button(roles,'Retry editor',draw);blocked(save,true);}} + finally{roles.setAttribute('aria-busy','false');} + }; + algorithm.onchange=()=>{saveChoices();draft.algorithm=algorithm.value;draft.choices=draft.byAlgorithm.get(draft.algorithm)?.map(c=>({...c}))||[];draw();};updateDirty();enqueue(draw); +} +document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page)); +document.querySelector('#refresh').onclick=refresh; +document.querySelector('#dismiss-notification').onclick=dismissNotification; +document.querySelector('#dismiss-notification').onmousedown=event=>event.preventDefault(); +window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener('change',()=>{if(page==='overview'&&snapshot){const saved=capturePageState();render();restorePageState(saved);}}); +window.addEventListener?.('switchyard-notice',event=>notify(String(event.detail),'warning')); +window.addEventListener?.('switchyard-page',event=>{ + const next=typeof event.detail==='string'?event.detail:event.detail?.page; + if(!Object.hasOwn(pageNames,next))return; + if(next==='overview')Object.assign(analyticsView,{period:'all',group:'model',search:'',model:typeof event.detail==='object'?event.detail.model||null:null}); + navigate(next);if(!snapshot)refresh(); +}); +// The menu bar popup forwards a session row; the window reviews it like any other hand-off. +window.addEventListener?.('switchyard-continue',event=>{ + const detail=event.detail||{};if(!detail.source||!detail.destination?.tool)return; + navigate('sessions'); + const start=()=>continueSession(detail.source,detail.destination.tool); + if(snapshot)start();else refresh().then(start); +}); +// A session quit in Terminal appears on top when the window comes back. +window.addEventListener?.('focus',()=>{if(page==='sessions'&&handoffView.container?.isConnected&&Date.now()-handoffView.loadedAt>RECENT_AGE_MS)loadRecentSessions(handoffView.container);}); +async function poll() { + if(!document.querySelector('[role="dialog"]')&&!busy&&document.activeElement?.tagName!=='SELECT')await refresh(false); + setTimeout(poll,(snapshot?.refresh_seconds||30)*1000); +} +content.append(node('p','Loading Switchyard…','empty-state')); +refresh().finally(()=>setTimeout(poll,(snapshot?.refresh_seconds||30)*1000)); + +window.addEventListener('switchyard-theme',()=>{const control=document.getElementById('appearance-select');if(control)control.value=window.switchyardAppearance.mode;if(snapshot&&['overview','usage'].includes(page)&&!busy)render();}); diff --git a/crates/switchyard-desktop/ui/appearance.css b/crates/switchyard-desktop/ui/appearance.css new file mode 100644 index 0000000000..0a069c62d4 --- /dev/null +++ b/crates/switchyard-desktop/ui/appearance.css @@ -0,0 +1,36 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Charts read these colors through getComputedStyle, so every value is a hex color that ECharts + can parse. */ +:root { + --bg: var(--canvas); --selected: var(--selection); --amber: var(--status); + --font: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + --radius: 6px; --radius-lg: 8px; --radius-xl: 12px; + --accent: #CD744F; --accent-hover: #D37D56; --accent-text: #FFFFFF; --copper: #CD744F; + --model-1: #2662D9; --model-2: #2EB88A; --model-3: #E88C30; --model-4: #AF57DB; + --model-5: #E23670; --model-6: #07BDD5; --model-7: #E2A108; --model-8: #7C3BED; +} +:root, :root[data-theme="canary"] { + color-scheme: dark; + --canvas: #070607; --sidebar: #111013; --panel: #111013; --raised: #141414; + --control: #111013; --text: #E5E5E4; --strong: #FFFFFF; --soft: #B4B7B7; --muted: #848787; + --line: #22221F; --control-border: #2B2B2A; --hover: #1B1A1D; --selection: #282528; + --focus: #CD744F; --status: #F59E0B; --success: #36D399; --danger: #E25050; + --danger-bg: #CC33331F; --danger-border: #CC333380; --diff-add-bg: #16A34A26; --diff-remove-bg: #CC333326; + --chrome: #111013; --chrome-text: #E5E5E4; --chrome-muted: #848787; --chrome-line: #22221F; + --scrollbar: #484848; +} +:root[data-theme="light"] { + color-scheme: light; + --canvas: #FFFFFF; --sidebar: #F7F7F7; --panel: #FAFAFA; --raised: #F5F5F5; + --control: #FFFFFF; --text: #1A1A1A; --strong: #000000; --soft: #4A4A4A; --muted: #6F6F6F; + --line: #E6E6E6; --control-border: #D9D9D9; --hover: #F0F0F0; --selection: #EBEBEB; + --focus: #CD744F; --status: #B45309; --success: #15803D; --danger: #CC3333; + --danger-bg: #CC333314; --danger-border: #CC333366; --diff-add-bg: #16A34A1A; --diff-remove-bg: #CC33331A; + --chrome: #F7F7F7; --chrome-text: #1A1A1A; --chrome-muted: #6F6F6F; --chrome-line: #E6E6E6; + --scrollbar: #C4C4C4; +} + +:root[data-reduce-motion="true"] *, :root[data-reduce-motion="true"] *::before, :root[data-reduce-motion="true"] *::after { animation:none !important; transition:none !important; scroll-behavior:auto !important; } +:root[data-reduce-transparency="true"] .app-sidebar { background:var(--chrome); } diff --git a/crates/switchyard-desktop/ui/appearance.js b/crates/switchyard-desktop/ui/appearance.js new file mode 100644 index 0000000000..effe76090d --- /dev/null +++ b/crates/switchyard-desktop/ui/appearance.js @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +'use strict'; +window.switchyardAppearance = (() => { + const options = [['system','System'],['canary','Dark'],['light','Light']]; + const media = matchMedia('(prefers-color-scheme: dark)'); + let mode = 'system', system = media.matches ? 'dark' : 'light', lastError = ''; + // OS changes must not discard a pending reply that contains a newer saved preference. + let preferenceRevision = 0, systemRevision = 0; + function apply(value = {}) { + const previousMode = mode; + if (options.some(([id]) => id === value.mode)) mode = value.mode; + if (['dark','light'].includes(value.system?.toLowerCase())) system = value.system.toLowerCase(); + const theme = mode === 'system' ? (system === 'dark' ? 'canary' : 'light') : mode; + const changed = previousMode !== mode || document.documentElement.dataset.theme !== theme; + document.documentElement.dataset.theme = theme; + if (document.body) document.body.dataset.theme = theme; + if (changed) window.dispatchEvent(new CustomEvent('switchyard-theme', {detail:{mode,theme}})); + } + function applyReply(value, revision, appearanceRevision) { + apply({mode: revision === preferenceRevision ? value.mode : undefined, + system: appearanceRevision === systemRevision ? value.system : undefined}); + } + async function load() { + const revision = preferenceRevision, appearanceRevision = systemRevision; + try { + const value = await window.__TAURI__.core.invoke('appearance'); + applyReply(value, revision, appearanceRevision); + } + catch (error) { report(error); } + } + function report(error) { + const target = document.getElementById('appearance-error'); + lastError = `Switchyard could not update the appearance: ${error}`; + if (target) target.textContent = lastError; + } + async function choose(selected) { + if (!options.some(([id]) => id === selected)) return; + const revision = ++preferenceRevision, appearanceRevision = systemRevision; + try { + const value = await window.__TAURI__.core.invoke('appearance', {mode:selected}); + applyReply(value, revision, appearanceRevision); + const target = document.getElementById('appearance-error'); + lastError = ''; + if (target) target.textContent = ''; + } catch (error) { report(error); } + } + window.addEventListener('switchyard-appearance', event => {preferenceRevision++; systemRevision++; apply(event.detail);}); + window.addEventListener('switchyard-system-theme', event => {systemRevision++; apply({system:event.detail});}); + media.addEventListener('change', event => {systemRevision++; apply({system:event.matches ? 'dark' : 'light'});}); + window.addEventListener('focus', load); + // Opaque chrome is the safe default until the native accessibility check succeeds. + document.documentElement.dataset.reduceTransparency = 'true'; + let accessibilityPending = false; + async function accessibility() { + if (accessibilityPending) return; + accessibilityPending = true; + try { + const value = await window.__TAURI__.core.invoke('accessibility'); + document.documentElement.dataset.nativeMaterial = String(value.native_material === true); + document.documentElement.dataset.reduceTransparency = String(value.reduce_transparency !== false); + document.documentElement.dataset.reduceMotion = String(value.reduce_motion === true); + } catch { + document.documentElement.dataset.reduceTransparency = 'true'; + } finally { accessibilityPending = false; } + } + window.addEventListener('focus', accessibility); + window.addEventListener('switchyard-tray-open', accessibility); + // macOS sends accessibility updates outside the focused webview, so a visible window checks + // every second. A hidden window stops checking and checks once when it becomes visible. + let poll; + function watch() { + clearInterval(poll); + poll = document.hidden ? undefined : setInterval(accessibility, 1000); + } + if (typeof setInterval === 'function') { + document.addEventListener('visibilitychange', () => { watch(); if (!document.hidden) accessibility(); }); + watch(); + } + apply(); + load(); + accessibility(); + return {options, choose, get mode(){return mode;}, get error(){return lastError;}}; +})(); diff --git a/crates/switchyard-desktop/ui/basic.css b/crates/switchyard-desktop/ui/basic.css deleted file mode 100644 index d8e88ae37e..0000000000 --- a/crates/switchyard-desktop/ui/basic.css +++ /dev/null @@ -1,11 +0,0 @@ -/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ -/* SPDX-License-Identifier: Apache-2.0 */ -:root { color-scheme: light dark; font-family: system-ui, sans-serif; } -body { margin: 0; background: Canvas; color: CanvasText; } -main { max-width: 42rem; padding: 2rem; margin: auto; } -h1 { margin-top: 0; } -section { margin-block: 2rem; } -.actions { display: flex; flex-wrap: wrap; gap: .6rem; } -button { font: inherit; padding: .5rem .8rem; cursor: pointer; } -#server-url, #message { overflow-wrap: anywhere; } -#message { white-space: pre-wrap; } diff --git a/crates/switchyard-desktop/ui/basic.js b/crates/switchyard-desktop/ui/basic.js deleted file mode 100644 index 96c2495324..0000000000 --- a/crates/switchyard-desktop/ui/basic.js +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -const invoke = window.__TAURI__.core.invoke; -const message = document.getElementById('message'); -let timer; -let busy = false; - -/** Missing usage stays unavailable so a failed read cannot appear as zero. */ -function counts(label, value) { - return value - ? `${label}: ${value.requests.toLocaleString()} requests · ${value.tokens.toLocaleString()} tokens` - : `${label}: usage unavailable`; -} - -/** Each refresh updates the window and schedules the next check, including after errors. */ -async function refresh() { - if (busy) return; - busy = true; - clearTimeout(timer); - let seconds = 30; - try { - const view = await invoke('snapshot'); - document.getElementById('server').textContent = view.health.state === 'running' - ? 'Server is running' : `Server is not responding: ${view.health.reason}`; - document.getElementById('server-url').textContent = view.server_url; - document.getElementById('today').textContent = counts('Today', view.today); - document.getElementById('week').textContent = counts('Last 7 days', view.week); - message.textContent = view.errors.join('\n'); - seconds = view.refresh_seconds; - } catch (error) { - document.getElementById('server').textContent = 'Server status unavailable'; - document.getElementById('server-url').textContent = ''; - document.getElementById('today').textContent = counts('Today', null); - document.getElementById('week').textContent = counts('Last 7 days', null); - message.textContent = String(error); - } finally { - busy = false; - timer = setTimeout(refresh, Math.min(seconds * 1000, 2147483647)); - } -} - -document.getElementById('refresh').addEventListener('click', refresh); -for (const [id, kind] of [['open-config', 'config'], ['open-logs', 'logs']]) { - document.getElementById(id).addEventListener('click', async () => { - try { - await invoke('open_file', { kind }); - message.textContent = ''; - } catch (error) { - message.textContent = String(error); - } - }); -} -document.getElementById('quit').addEventListener('click', () => invoke('quit_app')); -refresh(); diff --git a/crates/switchyard-desktop/ui/combobox.js b/crates/switchyard-desktop/ui/combobox.js new file mode 100644 index 0000000000..0ab322baf5 --- /dev/null +++ b/crates/switchyard-desktop/ui/combobox.js @@ -0,0 +1,239 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +'use strict'; +// A combobox is a text field with a filtered option list. It replaces native . A free combobox keeps any typed text and offers the list as +// suggestions, which model ID fields need because endpoints accept unlisted IDs. + +// This function returns a score and the matched character positions when every query +// character appears in `text` in order. Substring matches rank above scattered matches. +function fuzzyMatch(query, text) { + const q = query.toLowerCase(), t = text.toLowerCase(); + const boundary = index => index === 0 || /[^a-z0-9]/.test(t[index - 1]); + const at = t.indexOf(q); + if (at >= 0) return {score: 1000 + (boundary(at) ? 300 : 0) - at - t.length / 100, positions: Array.from(q, (_, i) => at + i)}; + let best = null; + for (let start = t.indexOf(q[0]); start >= 0; start = t.indexOf(q[0], start + 1)) { + const positions = [start]; + for (let i = 1, j = start + 1; i < q.length; i++, j++) { + j = t.indexOf(q[i], j); + if (j < 0) break; + positions.push(j); + } + // A later start leaves fewer characters, so it cannot match when this one failed. + if (positions.length !== q.length) break; + let score = 500 - start / 10; + positions.forEach((p, i) => { + if (i) score += p === positions[i - 1] + 1 ? 15 : -Math.min(p - positions[i - 1], 10); + if (boundary(p)) score += 10; + }); + if (!best || score > best.score) best = {score, positions}; + } + return best; +} + +// Every whitespace-separated token must match the label or the option's other search text. +// Label matches rank higher and are the only ones highlighted. +function rankOptions(options, query) { + const tokens = query.trim().split(/\s+/).filter(Boolean); + if (!tokens.length) return options.map(option => ({option, positions: []})); + const ranked = []; + options.forEach((option, index) => { + let score = 0; + const positions = new Set(); + for (const token of tokens) { + const label = fuzzyMatch(token, option.label), other = fuzzyMatch(token, option.search); + if (!label && !other) return; + if (label && (!other || label.score * 1.5 >= other.score)) { + score += label.score * 1.5; + label.positions.forEach(p => positions.add(p)); + } else score += other.score; + } + ranked.push({option, positions: [...positions], score, index}); + }); + return ranked.sort((a, b) => b.score - a.score || a.index - b.index); +} + +// Options are [value, label, detail, keywords] arrays. Only value is required. +function comboboxOption(option) { + const [value, label = value, detail = '', keywords = ''] = option; + return {value: String(value), label: String(label), detail: detail || '', search: [value, detail, keywords].filter(Boolean).join(' ')}; +} + +let comboboxCount = 0; +// This function returns the text input. Its `host` property holds the element to insert into +// the page, and `setOptions` replaces the list. A strict combobox reports the chosen option's +// value through `value` and fires `change` only when that value changes. +function combobox(options, chosen, {free = false, mono = false, placeholder = '', limit = 200} = {}) { + const id = `combobox-${++comboboxCount}`; + // The strict field replaces `value`, so it reads and writes its visible text through the prototype. + const nativeInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); + const host = document.createElement('div'); + host.className = `combobox${mono ? ' mono' : ''}${free ? ' free' : ''}`; + const input = document.createElement('input'); + Object.assign(input, {type: 'text', autocomplete: 'off', spellcheck: false, placeholder}); + for (const [name, value] of [['role', 'combobox'], ['aria-autocomplete', 'list'], ['aria-expanded', 'false'], ['aria-controls', `${id}-list`], ['autocapitalize', 'off']]) input.setAttribute(name, value); + const toggle = document.createElement('button'); + Object.assign(toggle, {type: 'button', tabIndex: -1, className: 'combobox-toggle'}); + toggle.setAttribute('aria-hidden', 'true'); + const list = document.createElement('div'); + Object.assign(list, {id: `${id}-list`, className: 'combobox-list', hidden: true}); + list.setAttribute('role', 'listbox'); + host.append(input, toggle, list); + + let source = [], items = [], selected = '', shown = [], active = -1, query = ''; + const typed = () => nativeInputValue.get.call(input); + const current = () => free ? typed() : selected; + const show = value => nativeInputValue.set.call(input, free ? value : items.find(item => item.value === value)?.label ?? value); + function setOptions(next) { + source = next; + items = next.map(comboboxOption); + if (!free && selected && !items.some(item => item.value === selected)) items.push({value: selected, label: selected, detail: 'Unavailable in current data', search: selected}); + if (!list.hidden) render(); + } + function note(text) { + const row = document.createElement('p'); + row.className = 'combobox-note'; + row.textContent = text; + list.append(row); + } + function activate(index, scroll = true) { + list.querySelector('.active')?.classList.remove('active'); + active = index; + const row = index >= 0 ? list.querySelector(`#${id}-option-${index}`) : null; + if (!row) { input.removeAttribute('aria-activedescendant'); return; } + row.classList.add('active'); + input.setAttribute('aria-activedescendant', row.id); + if (scroll) row.scrollIntoView?.({block: 'nearest'}); + } + function render() { + const ranked = rankOptions(items, query); + shown = ranked.slice(0, limit); + list.replaceChildren(); + const custom = free && query.trim() && !items.some(item => item.value === query.trim()); + if (custom) note(`Uses “${query.trim()}” as typed, or choose a suggestion.`); + shown.forEach(({option, positions}, index) => { + const row = document.createElement('div'); + row.id = `${id}-option-${index}`; + row.className = 'combobox-option'; + row.setAttribute('role', 'option'); + row.setAttribute('aria-selected', String(option.value === current())); + const label = document.createElement('span'); + label.className = 'combobox-label'; + const marked = new Set(positions); + let run = '', highlighted = false; + const flush = () => { if (!run) return; if (highlighted) { const mark = document.createElement('mark'); mark.textContent = run; label.append(mark); } else label.append(run); run = ''; }; + [...option.label].forEach((character, index) => { if (marked.has(index) !== highlighted) { flush(); highlighted = !highlighted; } run += character; }); + flush(); + row.append(label); + if (option.detail) { const detail = document.createElement('small'); detail.textContent = option.detail; row.append(detail); } + row.onmousedown = event => event.preventDefault(); + row.onclick = () => commit(option.value); + row.onmousemove = () => { if (active !== index) activate(index, false); }; + list.append(row); + }); + if (!shown.length && !custom) note(items.length ? 'No matches. Try fewer letters.' : free ? 'No suggestions loaded. Type an ID.' : 'No options available.'); + if (ranked.length > shown.length) note(`Showing ${shown.length} of ${ranked.length}. Keep typing to narrow the list.`); + // Typed text in a free field stays the value until the user picks a suggestion. + const selectedIndex = shown.findIndex(entry => entry.option.value === current()); + activate(query ? (free || !shown.length ? -1 : 0) : selectedIndex, false); + } + // The list opens below the field unless the nearest scrolling container has more room above. + function place() { + const box = input.getBoundingClientRect(); + let top = 0, bottom = window.innerHeight; + for (let parent = host.parentElement; parent && parent !== document.body; parent = parent.parentElement) { + if (!['auto', 'scroll'].includes(getComputedStyle(parent).overflowY)) continue; + const bounds = parent.getBoundingClientRect(); + top = Math.max(top, bounds.top); + bottom = Math.min(bottom, bounds.bottom); + break; + } + const below = bottom - box.bottom - 12, above = box.top - top - 12, upward = below < 200 && above > below; + host.dataset.placement = upward ? 'top' : 'bottom'; + list.style.maxHeight = `${Math.round(Math.max(120, Math.min(320, upward ? above : below)))}px`; + } + function open() { + if (input.disabled || (free && !items.length)) return; + if (list.hidden) { + list.hidden = false; + input.setAttribute('aria-expanded', 'true'); + host.dataset.open = 'true'; + } + render(); + place(); + if (active >= 0) list.querySelector(`#${id}-option-${active}`)?.scrollIntoView?.({block: 'nearest'}); + } + function close() { + list.hidden = true; + input.setAttribute('aria-expanded', 'false'); + delete host.dataset.open; + input.removeAttribute('aria-activedescendant'); + query = ''; + active = -1; + // A focused strict field keeps its label selected so typing starts a new search. + if (!free && document.activeElement === input) input.select(); + } + function revert() { + if (!free) show(selected); + close(); + } + function commit(value) { + const changed = value !== current(); + if (!free) selected = value; + show(value); + close(); + if (!changed) return; + if (free) input.dispatchEvent(new Event('input', {bubbles: true})); + input.dispatchEvent(new Event('change', {bubbles: true})); + } + + // A strict field's typed text is only a search query, so the browser's own change event is + // not a selection. The capture listener runs before any `onchange` handler and stops it. + if (!free) input.addEventListener('change', event => { if (event.isTrusted) event.stopImmediatePropagation(); }, true); + input.addEventListener('input', event => { if (!event.isTrusted) return; query = typed(); open(); }); + input.addEventListener('focus', () => { if (!free) input.select(); }); + // Reopening a strict field selects its label so typing starts a new search. + input.addEventListener('click', () => { if (!list.hidden) return; if (!free) input.select(); open(); }); + input.addEventListener('keydown', event => { + const isOpen = !list.hidden; + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + if (!isOpen) { open(); return; } + if (!shown.length) return; + const step = event.key === 'ArrowDown' ? 1 : -1; + activate(active < 0 ? (step > 0 ? 0 : shown.length - 1) : Math.max(0, Math.min(shown.length - 1, active + step))); + } else if (event.key === 'Enter' && isOpen) { + if (active >= 0) { event.preventDefault(); commit(shown[active].option.value); } + else if (!free) { event.preventDefault(); revert(); } + else close(); + } else if (event.key === 'Escape' && isOpen) { + // The list closes first; a second Escape reaches the surrounding dialog. + event.preventDefault(); + event.stopPropagation(); + revert(); + } else if (event.key === 'Tab' && isOpen) { + if (!free && query && active >= 0) commit(shown[active].option.value); + else revert(); + } + }); + host.addEventListener('focusout', event => { if (!host.contains(event.relatedTarget)) revert(); }); + toggle.onmousedown = event => { + event.preventDefault(); + if (input.disabled) return; + if (list.hidden) { input.focus(); open(); } else revert(); + }; + + if (!free) selected = chosen !== undefined ? String(chosen) : options.length ? String(options[0][0]) : ''; + setOptions(options); + show(free ? String(chosen ?? '') : selected); + if (!free) Object.defineProperty(input, 'value', { + configurable: true, + get: () => selected, + set: value => { selected = value == null ? '' : String(value); setOptions(source); show(selected); }, + }); + input.host = host; + input.setOptions = setOptions; + return input; +} diff --git a/crates/switchyard-desktop/ui/index.html b/crates/switchyard-desktop/ui/index.html index 589aa4769e..15e9f1938e 100644 --- a/crates/switchyard-desktop/ui/index.html +++ b/crates/switchyard-desktop/ui/index.html @@ -1,32 +1,55 @@ - + Switchyard - - + + + + + + + +
-

Switchyard

-

Checking the server…

-

-
-

Recorded usage

-

Today: loading…

-

Last 7 days: loading…

-

Requests count answers. Tokens include classifier calls.

-
-
- - - - + +
+

Overview

+
Loading…
+
+ + + -

+
diff --git a/crates/switchyard-desktop/ui/tray.css b/crates/switchyard-desktop/ui/tray.css new file mode 100644 index 0000000000..bce746a2b1 --- /dev/null +++ b/crates/switchyard-desktop/ui/tray.css @@ -0,0 +1,66 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ +/* The popup follows macOS menu bar menus: the system font and label colors, the system + light or dark appearance, the native material behind transparent content, and menu rows + that highlight with the accent color. AppKit clips the panel to rounded corners, so the + page itself draws nothing outside #popup. */ +:root { + --label: rgba(0, 0, 0, .85); --secondary: rgba(0, 0, 0, .55); --separator: rgba(0, 0, 0, .1); + --fill: rgba(0, 0, 0, .05); --opaque: #ECECEC; --success: #1A8B3A; --warning: #B25000; --danger: #C42B1C; + --bar: #CD744F; + font: 13px/1.35 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + -webkit-font-smoothing: antialiased; -webkit-user-select: none; user-select: none; cursor: default; +} +@media (prefers-color-scheme: dark) { + :root { --label: rgba(255, 255, 255, .88); --secondary: rgba(255, 255, 255, .58); --separator: rgba(255, 255, 255, .12); + --fill: rgba(255, 255, 255, .07); --opaque: #2C2C2E; --success: #32D74B; --warning: #FF9F0A; --danger: #FF6961; } +} +* { box-sizing: border-box; } +/* A background on html or body would fill the whole rectangular page, so only #popup paints. */ +html, body { margin: 0; overflow: hidden; background: transparent; } +#popup { display: flex; flex-direction: column; max-height: 100vh; padding: 5px; border-radius: 12px; color: var(--label); } +/* macOS materials already become opaque with Reduce Transparency. Windows has no material and + an opaque rectangular window, so the popup fills it. */ +:root[data-native-material="false"] #popup { border-radius: 0; background: var(--opaque); } + +header { flex-shrink: 0; display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 6px 9px 2px; } +header strong { font-weight: 600; } +.connection { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--secondary); white-space: nowrap; } +.connection::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--warning); } +.connection.running::before { background: var(--success); } + +main { flex: 0 1 auto; min-height: 0; overflow: auto; padding: 0 9px 4px; color-scheme: light dark; } +p { margin: 0; } +.detail { font-size: 12px; color: var(--secondary); overflow-wrap: anywhere; } +#server-url { margin-bottom: 2px; } +#error { margin: 6px 0; padding: 6px 8px; border-radius: 6px; background: var(--fill); font-size: 12px; color: var(--danger); white-space: pre-line; overflow-wrap: anywhere; } +#empty, #note { margin-top: 8px; } +h2 { margin: 10px 0 2px; font-size: 12px; font-weight: 600; color: var(--secondary); } +.period p { font-variant-numeric: tabular-nums; } +.period p + p { margin-top: 1px; } +.period .saved { color: var(--success); } +.period .detail { overflow-wrap: anywhere; } + +#model-list { display: grid; gap: 5px; margin: 4px 0 0; padding: 0; list-style: none; } +#model-list li { display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 8px; row-gap: 3px; } +#model-list .model { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#model-list .share { font-size: 12px; color: var(--secondary); font-variant-numeric: tabular-nums; } +#model-list .track { grid-column: 1 / -1; height: 4px; border-radius: 2px; background: var(--fill); overflow: hidden; } +#model-list .track i { display: block; height: 100%; border-radius: 2px; background: var(--bar); } + +/* Session rows are menu items inside the scrolling main area, aligned with the footer rows. */ +#session-list { display: grid; gap: 1px; margin: 2px -9px 0; } +#session-list button { height: auto; padding: 3px 9px; line-height: 1.3; text-align: left; white-space: normal; } +#session-list button:hover:not(:disabled) { background: var(--fill); } +#session-list button:disabled { color: var(--secondary); } +#session-list button .detail { display: block; color: inherit; opacity: .72; } +footer { flex-shrink: 0; } +nav { display: grid; padding: 4px 0; } +/* Like menu separators, the line stops short of the rounded edges. */ +nav::before { content: ""; height: 1px; margin: 0 9px 4px; background: var(--separator); } +nav:last-child { padding-bottom: 0; } +button { all: unset; display: block; height: 24px; padding: 0 9px; border-radius: 6px; line-height: 24px; color: var(--label); white-space: nowrap; } +button.active:not(:disabled) { background: #0A84FF; background: -apple-system-control-accent; color: #FFFFFF; } +button:focus { outline: none; } +#status:empty { display: none; } +#status { padding: 2px 9px 4px; font-size: 12px; color: var(--secondary); white-space: pre-line; overflow-wrap: anywhere; } diff --git a/crates/switchyard-desktop/ui/tray.html b/crates/switchyard-desktop/ui/tray.html new file mode 100644 index 0000000000..066c22d57e --- /dev/null +++ b/crates/switchyard-desktop/ui/tray.html @@ -0,0 +1,37 @@ + + + + + + +Switchyard usage + + + + + + diff --git a/crates/switchyard-desktop/ui/tray.js b/crates/switchyard-desktop/ui/tray.js new file mode 100644 index 0000000000..17d950080f --- /dev/null +++ b/crates/switchyard-desktop/ui/tray.js @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +'use strict'; +const invoke=window.__TAURI__.core.invoke,$=id=>document.getElementById(id); +let refreshing=false,refreshTimer,running=false; +function node(tag,text,className) {const element=document.createElement(tag);if(text!==undefined)element.textContent=text;if(className)element.className=className;return element;} +function message(text) {$('error').textContent=text;$('error').hidden=!text;} +function show(id,text) {$(id).textContent=text||'';$(id).hidden=!text;} +// The tray menu shows the same overview as text rows; the popup groups it into sections. +function render(view) { + const overview=view.overview; + message(view.errors.join('\n')); + $('connection').textContent=overview?(overview.running?'Server running':'Server not responding'):'Usage unavailable'; + $('connection').classList.toggle('running',Boolean(overview?.running)); + show('server-url',overview?.server_url); + show('empty',overview?.empty); + $('periods').replaceChildren(...(overview?.periods||[]).map(period=>{ + const section=node('section',undefined,'period'); + section.append(node('h2',period.name),node('p',period.traffic),node('p',period.spent,'detail')); + if(period.saved)section.append(node('p',period.saved,'detail saved')); + return section; + })); + const models=overview?.models||[]; + $('models').hidden=!models.length; + $('model-list').replaceChildren(...models.map(share=>{ + const row=node('li'),name=node('span',share.model,'model'),track=node('span',undefined,'track'),bar=node('i'); + name.title=share.model;bar.style.width=`${share.percent}%`;track.append(bar); + row.append(name,node('span',`${share.percent.toFixed(0)}%`,'share'),track); + return row; + })); + show('note',overview?.note); + // Each row continues a session in the other harness. The window reviews it before writing. + const sessions=view.sessions||[]; + $('sessions').hidden=!sessions.length; + $('session-list').replaceChildren(...sessions.map(session=>{ + const row=node('button'),destination=session.destination||{},folder=(session.cwd||'').replace(/\/+$/,'').split('/').pop(); + row.type='button';row.title=session.cwd||''; + row.append(node('span',`${session.label} · ${folder||'(directory unknown)'} → ${destination.label||'?'}`), + node('span',[session.branch,ago(session.last_activity),session.title].filter(Boolean).join(' · '),'detail')); + if(destination.reason){row.disabled=true;row.title=destination.reason;} + const source=session.managed_id?{kind:'managed',id:session.managed_id}:{kind:'transcript',path:session.transcript}; + row.onclick=()=>command('tray_continue',{request:{source,destination}}); + return row; + })); +} +function ago(iso) { + if(!iso)return '';const minutes=Math.round((Date.now()-Date.parse(iso))/60000); + if(!(minutes>=0))return '';if(minutes<1)return 'just now';if(minutes<60)return `${minutes} min ago`; + const hours=Math.round(minutes/60);return hours<24?`${hours} h ago`:`${Math.round(hours/24)} d ago`; +} +async function refresh() { + clearTimeout(refreshTimer);if(refreshing)return;refreshing=true;let seconds=5; + try {const view=await invoke('tray_overview');seconds=view.refresh_seconds;render(view);} + catch(error){message(`Could not read usage: ${error}`);} + finally{refreshing=false;if(document.hasFocus())refreshTimer=setTimeout(refresh,seconds*1000);} +} +async function command(name,args) {try{await invoke(name,args);}catch(error){message(`Could not open or close the window: ${error}`);}} +const actionButtons=[...document.querySelectorAll('[data-action]')]; +for(const button of actionButtons)button.onclick=async()=>{ + if(running)return;running=true;actionButtons.forEach(other=>{other.disabled=true;}); + $('status').textContent=button.dataset.action==='restart'?'Restarting the server…':'Opening the server config…'; + try {const reply=await invoke('action',{action:{kind:button.dataset.action}});$('status').textContent=reply.message;} + catch(error){$('status').textContent=String(error);} + finally{running=false;actionButtons.forEach(other=>{other.disabled=false;});refresh();} +}; +for(const button of document.querySelectorAll('[data-page]'))button.onclick=()=>command('tray_page',{page:button.dataset.page}); +$('quit').onclick=()=>command('quit_app'); +// Rows highlight like menu items: under the pointer or from the arrow keys, and never left +// over from the last time the popup was open. +const rows=[...document.querySelectorAll('nav button')]; +function highlight(row) {rows.forEach(other=>other.classList.toggle('active',other===row));if(row)row.focus({preventScroll:true});else document.activeElement?.blur();} +for(const row of rows){row.onpointerenter=()=>highlight(row);row.onpointerleave=()=>highlight(null);} +document.addEventListener('keydown',event=>{ + if(event.key==='Escape'){command('dismiss_tray');return;} + if(event.key!=='ArrowDown'&&event.key!=='ArrowUp')return; + event.preventDefault(); + const enabled=rows.filter(row=>!row.disabled),current=enabled.indexOf(document.activeElement); + const next=event.key==='ArrowDown'?(current+1)%enabled.length:(current<=0?enabled.length:current)-1; + highlight(enabled[next]); +}); +window.addEventListener('switchyard-tray-open',()=>{highlight(null);refresh();});window.addEventListener('focus',refresh); +window.addEventListener('blur',()=>{clearTimeout(refreshTimer);highlight(null);}); +// Loading once at startup gives the first opening its final height instead of a resize. +refresh(); + +// The native panel takes its height from the content, up to the limit that tray_size applies. +let measuredHeight=0; +function sizePopup() { + const style=getComputedStyle($('popup')),padding=parseFloat(style.paddingTop)+parseFloat(style.paddingBottom); + const height=Math.ceil(padding+document.querySelector('header').offsetHeight+document.querySelector('main').scrollHeight+document.querySelector('footer').offsetHeight); + if(height!==measuredHeight){measuredHeight=height;command('tray_size',{height});} +} +if(typeof ResizeObserver==='function') { + const observer=new ResizeObserver(sizePopup); + for(const tag of ['header','main','footer'])observer.observe(document.querySelector(tag)); + new MutationObserver(()=>requestAnimationFrame(sizePopup)).observe(document.body,{childList:true,subtree:true,characterData:true}); +} +window.addEventListener('switchyard-tray-open',sizePopup); diff --git a/crates/switchyard-desktop/ui/vendor/LICENSE.echarts b/crates/switchyard-desktop/ui/vendor/LICENSE.echarts new file mode 100644 index 0000000000..c633765305 --- /dev/null +++ b/crates/switchyard-desktop/ui/vendor/LICENSE.echarts @@ -0,0 +1,222 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + + +======================================================================== +Apache ECharts Subcomponents: + +The Apache ECharts project contains subcomponents with separate copyright +notices and license terms. Your use of the source code for these +subcomponents is also subject to the terms and conditions of the following +licenses. + +BSD 3-Clause (d3.js): +The following files embed [d3.js](https://github.com/d3/d3) BSD 3-Clause: + `/src/chart/treemap/treemapLayout.ts`, + `/src/chart/tree/layoutHelper.ts`, + `/src/chart/graph/forceHelper.ts`, + `/src/util/number.ts` +See `/licenses/LICENSE-d3` for details of the license. diff --git a/crates/switchyard-desktop/ui/vendor/LICENSE.lucide b/crates/switchyard-desktop/ui/vendor/LICENSE.lucide new file mode 100644 index 0000000000..718bb3f0e4 --- /dev/null +++ b/crates/switchyard-desktop/ui/vendor/LICENSE.lucide @@ -0,0 +1,43 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/switchyard-desktop/ui/vendor/NOTICE.echarts b/crates/switchyard-desktop/ui/vendor/NOTICE.echarts new file mode 100644 index 0000000000..c6a6e5e43b --- /dev/null +++ b/crates/switchyard-desktop/ui/vendor/NOTICE.echarts @@ -0,0 +1,5 @@ +Apache ECharts +Copyright 2017-2026 The Apache Software Foundation + +This product includes software developed at +The Apache Software Foundation (https://www.apache.org/). \ No newline at end of file diff --git a/crates/switchyard-desktop/ui/vendor/echarts.min.js b/crates/switchyard-desktop/ui/vendor/echarts.min.js new file mode 100644 index 0000000000..3b8ed4bcd1 --- /dev/null +++ b/crates/switchyard-desktop/ui/vendor/echarts.min.js @@ -0,0 +1,45 @@ + +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).echarts={})}(this,function(t){"use strict"; +/*! ***************************************************************************** + Copyright (c) Microsoft Corporation. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM + LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR + OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + PERFORMANCE OF THIS SOFTWARE. + ***************************************************************************** */var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},e(t,n)};function n(t,n){if("function"!=typeof n&&null!==n)throw new TypeError("Class extends value "+String(n)+" is not a constructor or null");function i(){this.constructor=t}e(t,n),t.prototype=null===n?Object.create(n):(i.prototype=n.prototype,new i)}var i=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},r=new function(){this.browser=new i,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(r.wxa=!0,r.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?r.worker=!0:!r.hasGlobalWindow||"Deno"in window||"undefined"!=typeof navigator&&"string"==typeof navigator.userAgent&&navigator.userAgent.indexOf("Node.js")>-1?(r.node=!0,r.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11);var s=e.domSupported="undefined"!=typeof document;if(s){var l=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in l||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in l)&&!("OTransition"in l),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}}(navigator.userAgent,r);var o="sans-serif",a="12px "+o;var s,l,u=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n=0)o=r*t.length;else for(var h=0;h=M&&(S=0),S++}function T(){for(var t=[],e=0;e>1)%2;a.style.cssText=["position: absolute","visibility: hidden","padding: 0","margin: 0","border-width: 0","user-select: none","width:0","height:0",i[s]+":0",r[l]+":0",i[1-s]+":auto",r[1-l]+":auto",""].join("!important;"),t.appendChild(a),n.push(a)}return e.clearMarkers=function(){B(n,function(t){t.parentNode&&t.parentNode.removeChild(t)})},n}(e,a),l=function(t,e,n){for(var i=n?"invTrans":"trans",r=e[i],o=e.srcCoords,a=[],s=[],l=!0,u=0;u<4;u++){var c=t[u].getBoundingClientRect(),h=2*u,d=c.left,p=c.top;a.push(d,p),l=l&&o&&d===o[h]&&p===o[h+1],s.push(t[u].offsetLeft,t[u].offsetTop)}return l&&r?r:(e.srcCoords=a,e[i]=n?te(s,a):te(a,s))}(s,a,o);if(l)return l(t,n,i),!0}return!1}function re(t){return"CANVAS"===t.nodeName.toUpperCase()}var oe=/([&<>"'])/g,ae={"&":"&","<":"<",">":">",'"':""","'":"'"};function se(t){return null==t?"":(t+"").replace(oe,function(t,e){return ae[e]})}var le=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,ue=[],ce=r.browser.firefox&&+r.browser.version.split(".")[0]<39;function he(t,e,n,i){return n=n||{},i?de(t,e,n):ce&&null!=e.layerX&&e.layerX!==e.offsetX?(n.zrX=e.layerX,n.zrY=e.layerY):null!=e.offsetX?(n.zrX=e.offsetX,n.zrY=e.offsetY):de(t,e,n),n}function de(t,e,n){if(r.domSupported&&t.getBoundingClientRect){var i=e.clientX,o=e.clientY;if(re(t)){var a=t.getBoundingClientRect();return n.zrX=i-a.left,void(n.zrY=o-a.top)}if(ie(ue,t,i,o))return n.zrX=ue[0],void(n.zrY=ue[1])}n.zrX=n.zrY=0}function pe(t){return t||window.event}function fe(t,e,n){if(null!=(e=pe(e)).zrX)return e;var i=e.type;if(i&&i.indexOf("touch")>=0){var r="touchend"!==i?e.targetTouches[0]:e.changedTouches[0];r&&he(t,r,e,n)}else{he(t,e,e,n);var o=function(t){var e=t.wheelDelta;if(e)return e;var n=t.deltaX,i=t.deltaY;if(null==n||null==i)return e;return 3*(0!==i?Math.abs(i):Math.abs(n))*(i>0?-1:i<0?1:n>0?-1:1)}(e);e.zrDelta=o?o/120:-(e.detail||0)/3}var a=e.button;return null==e.which&&void 0!==a&&le.test(e.type)&&(e.which=1&a?1:2&a?3:4&a?2:0),e}function ge(t,e,n,i){t.addEventListener(e,n,i)}function ve(t,e,n,i){t.removeEventListener(e,n,i)}var ye=function(t){t.preventDefault(),t.stopPropagation(),t.cancelBubble=!0};function me(t){return 2===t.which||3===t.which}var xe=function(){function t(){this._track=[]}return t.prototype.recognize=function(t,e,n){return this._doTrack(t,e,n),this._recognize(t)},t.prototype.clear=function(){return this._track.length=0,this},t.prototype._doTrack=function(t,e,n){var i=t.touches;if(i){for(var r={points:[],touches:[],target:e,event:t},o=0,a=i.length;o1&&r&&r.length>1){var a=_e(r)/_e(o);!isFinite(a)&&(a=1),e.pinchScale=a;var s=[((i=r)[0][0]+i[1][0])/2,(i[0][1]+i[1][1])/2];return e.pinchX=s[0],e.pinchY=s[1],{type:"pinch",target:t[0].target,event:e}}}}};function we(){return[1,0,0,1,0,0]}function Se(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t}function Me(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4],t[5]=e[5],t}function Ie(t,e,n){var i=e[0]*n[0]+e[2]*n[1],r=e[1]*n[0]+e[3]*n[1],o=e[0]*n[2]+e[2]*n[3],a=e[1]*n[2]+e[3]*n[3],s=e[0]*n[4]+e[2]*n[5]+e[4],l=e[1]*n[4]+e[3]*n[5]+e[5];return t[0]=i,t[1]=r,t[2]=o,t[3]=a,t[4]=s,t[5]=l,t}function Te(t,e,n){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4]+n[0],t[5]=e[5]+n[1],t}function Ce(t,e,n,i){void 0===i&&(i=[0,0]);var r=e[0],o=e[2],a=e[4],s=e[1],l=e[3],u=e[5],c=Math.sin(n),h=Math.cos(n);return t[0]=r*h+s*c,t[1]=-r*c+s*h,t[2]=o*h+l*c,t[3]=-o*c+h*l,t[4]=h*(a-i[0])+c*(u-i[1])+i[0],t[5]=h*(u-i[1])-c*(a-i[0])+i[1],t}function De(t,e,n){var i=n[0],r=n[1];return t[0]=e[0]*i,t[1]=e[1]*r,t[2]=e[2]*i,t[3]=e[3]*r,t[4]=e[4]*i,t[5]=e[5]*r,t}function Ae(t,e){var n=e[0],i=e[2],r=e[4],o=e[1],a=e[3],s=e[5],l=n*a-o*i;return l?(l=1/l,t[0]=a*l,t[1]=-o*l,t[2]=-i*l,t[3]=n*l,t[4]=(i*s-a*r)*l,t[5]=(o*r-n*s)*l,t):null}function ke(t){var e=[1,0,0,1,0,0];return Me(e,t),e}var Le=Object.freeze({__proto__:null,create:we,identity:Se,copy:Me,mul:Ie,translate:Te,rotate:Ce,scale:De,invert:Ae,clone:ke}),Pe=function(){function t(t,e){this.x=t||0,this.y=e||0}return t.prototype.copy=function(t){return this.x=t.x,this.y=t.y,this},t.prototype.clone=function(){return new t(this.x,this.y)},t.prototype.set=function(t,e){return this.x=t,this.y=e,this},t.prototype.equal=function(t){return t.x===this.x&&t.y===this.y},t.prototype.add=function(t){return this.x+=t.x,this.y+=t.y,this},t.prototype.scale=function(t){this.x*=t,this.y*=t},t.prototype.scaleAndAdd=function(t,e){this.x+=t.x*e,this.y+=t.y*e},t.prototype.sub=function(t){return this.x-=t.x,this.y-=t.y,this},t.prototype.dot=function(t){return this.x*t.x+this.y*t.y},t.prototype.len=function(){return Math.sqrt(this.x*this.x+this.y*this.y)},t.prototype.lenSquare=function(){return this.x*this.x+this.y*this.y},t.prototype.normalize=function(){var t=this.len();return this.x/=t,this.y/=t,this},t.prototype.distance=function(t){var e=this.x-t.x,n=this.y-t.y;return Math.sqrt(e*e+n*n)},t.prototype.distanceSquare=function(t){var e=this.x-t.x,n=this.y-t.y;return e*e+n*n},t.prototype.negate=function(){return this.x=-this.x,this.y=-this.y,this},t.prototype.transform=function(t){if(t){var e=this.x,n=this.y;return this.x=t[0]*e+t[2]*n+t[4],this.y=t[1]*e+t[3]*n+t[5],this}},t.prototype.toArray=function(t){return t[0]=this.x,t[1]=this.y,t},t.prototype.fromArray=function(t){this.x=t[0],this.y=t[1]},t.set=function(t,e,n){t.x=e,t.y=n},t.copy=function(t,e){t.x=e.x,t.y=e.y},t.len=function(t){return Math.sqrt(t.x*t.x+t.y*t.y)},t.lenSquare=function(t){return t.x*t.x+t.y*t.y},t.dot=function(t,e){return t.x*e.x+t.y*e.y},t.add=function(t,e,n){t.x=e.x+n.x,t.y=e.y+n.y},t.sub=function(t,e,n){t.x=e.x-n.x,t.y=e.y-n.y},t.scale=function(t,e,n){t.x=e.x*n,t.y=e.y*n},t.scaleAndAdd=function(t,e,n,i){t.x=e.x+n.x*i,t.y=e.y+n.y*i},t.lerp=function(t,e,n,i){var r=1-i;t.x=r*e.x+i*n.x,t.y=r*e.y+i*n.y},t}(),Oe=Math.min,Re=Math.max,Ne=Math.abs,Ee=["x","y"],ze=["width","height"],Be=new Pe,Ve=new Pe,Ge=new Pe,Fe=new Pe,He=rn(),We=He.minTv,Ue=He.maxTv,Ye=[0,0],Xe=function(){function t(t,e,n,i){je(this,t,e,n,i)}return t.set=function(t,e,n,i,r){return i<0&&(e+=i,i=-i),r<0&&(n+=r,r=-r),t.x=e,t.y=n,t.width=i,t.height=r,t},t.prototype.union=function(t){var e=Oe(t.x,this.x),n=Oe(t.y,this.y);isFinite(this.x)&&isFinite(this.width)?this.width=Re(t.x+t.width,this.x+this.width)-e:this.width=t.width,isFinite(this.y)&&isFinite(this.height)?this.height=Re(t.y+t.height,this.y+this.height)-n:this.height=t.height,this.x=e,this.y=n},t.prototype.applyTransform=function(e){t.applyTransform(this,this,e)},t.prototype.calculateTransform=function(t){return Ke([1,0,0,1,0,0],this,t)},t.prototype.intersect=function(e,n,i){return t.intersect(this,e,n,i)},t.intersect=function(e,n,i,r){i&&Pe.set(i,0,0);var o=r&&r.outIntersectRect||null,a=r&&r.clamp;if(o&&(o.x=o.y=o.width=o.height=NaN),!e||!n)return!1;e instanceof t||(e=je(Qe,e.x,e.y,e.width,e.height)),n instanceof t||(n=je(tn,n.x,n.y,n.width,n.height));var s=!!i;He.reset(r,s);var l=He.touchThreshold,u=e.x+l,c=e.x+e.width-l,h=e.y+l,d=e.y+e.height-l,p=n.x+l,f=n.x+n.width-l,g=n.y+l,v=n.y+n.height-l;if(u>c||h>d||p>f||g>v)return!1;var y=!(c=t.x&&e<=t.x+t.width&&n>=t.y&&n<=t.y+t.height},t.prototype.contain=function(e,n){return t.contain(this,e,n)},t.prototype.clone=function(){return new t(this.x,this.y,this.width,this.height)},t.prototype.copy=function(t){qe(this,t)},t.prototype.plain=function(){return{x:this.x,y:this.y,width:this.width,height:this.height}},t.prototype.isFinite=function(){return isFinite(this.x)&&isFinite(this.y)&&isFinite(this.width)&&isFinite(this.height)},t.prototype.isZero=function(){return 0===this.width||0===this.height},t.create=function(e){return new t(e?e.x:0,e?e.y:0,e?e.width:0,e?e.height:0)},t.copy=function(t,e){return t.x=e.x,t.y=e.y,t.width=e.width,t.height=e.height,t},t.applyTransform=function(t,e,n){if(n){if(n[1]<1e-5&&n[1]>-1e-5&&n[2]<1e-5&&n[2]>-1e-5){var i=n[0],r=n[3],o=n[4],a=n[5];return t.x=e.x*i+o,t.y=e.y*r+a,t.width=e.width*i,t.height=e.height*r,t.width<0&&(t.x+=t.width,t.width=-t.width),void(t.height<0&&(t.y+=t.height,t.height=-t.height))}Be.x=Ge.x=e.x,Be.y=Fe.y=e.y,Ve.x=Fe.x=e.x+e.width,Ve.y=Ge.y=e.y+e.height,Be.transform(n),Fe.transform(n),Ve.transform(n),Ge.transform(n),t.x=Oe(Be.x,Ve.x,Ge.x,Fe.x),t.y=Oe(Be.y,Ve.y,Ge.y,Fe.y);var s=Re(Be.x,Ve.x,Ge.x,Fe.x),l=Re(Be.y,Ve.y,Ge.y,Fe.y);t.width=s-t.x,t.height=l-t.y}else t!==e&&qe(t,e)},t.calculateTransform=function(t,e,n){var i=n.width/e.width,r=n.height/e.height;return Te(t=Se(t||[]),t,kt(en,-e.x,-e.y)),De(t,t,kt(en,i,r)),Te(t,t,kt(en,n.x,n.y)),t},t}(),Ze=Xe.create,je=Xe.set,qe=Xe.copy,Ke=Xe.calculateTransform,$e=Xe.applyTransform,Je=Xe.contain,Qe=new Xe(0,0,0,0),tn=new Xe(0,0,0,0),en=[];function nn(t,e,n,i,r,o,a,s){var l=Ne(e-n),u=Ne(i-t),c=Oe(l,u),h=Ee[r],d=Ee[1-r],p=ze[r];e=u||!He.bidirectional)&&(We[h]=-u,We[d]=0,He.useDir&&He.calcDirMTV())))}function rn(){var t=0,e=new Pe,n=new Pe,i={minTv:new Pe,maxTv:new Pe,useDir:!1,dirMinTv:new Pe,touchThreshold:0,bidirectional:!0,negativeSize:!1,reset:function(r,o){i.touchThreshold=0,r&&null!=r.touchThreshold&&(i.touchThreshold=Re(0,r.touchThreshold)),i.negativeSize=!1,o&&(i.minTv.set(1/0,1/0),i.maxTv.set(0,0),i.useDir=!1,r&&null!=r.direction&&(i.useDir=!0,i.dirMinTv.copy(i.minTv),n.copy(i.minTv),t=r.direction,i.bidirectional=null==r.bidirectional||!!r.bidirectional,i.bidirectional||e.set(Math.cos(t),Math.sin(t))))},calcDirMTV:function(){var o=i.minTv,a=i.dirMinTv,s=o.y*o.y+o.x*o.x,l=Math.sin(t),u=Math.cos(t),c=l*o.y+u*o.x;r(c)?r(o.x)&&r(o.y)&&a.set(0,0):(n.x=s*u/c,n.y=s*l/c,r(n.x)&&r(n.y)?a.set(0,0):(i.bidirectional||e.dot(n)>0)&&n.len()=0;u--){var c=i[u];c===n||c.ignore||c.ignoreCoarsePointer||c.parent&&c.parent.ignoreCoarsePointer||(cn.copy(c.getBoundingRect()),c.transform&&cn.applyTransform(c.transform),cn.intersect(l)&&o.push(c))}if(o.length)for(var h=Math.PI/12,d=2*Math.PI,p=0;p=0;o--){var a=t[o],s=void 0;if(a!==r&&!a.ignore&&(s=dn(a,n,i))&&(!e.topTarget&&(e.topTarget=a),s!==on)){e.target=a;break}}}function fn(t,e,n){var i=t.painter;return e<0||e>i.getWidth()||n<0||n>i.getHeight()}B(["click","mousedown","mouseup","mousewheel","dblclick","contextmenu"],function(t){hn.prototype[t]=function(e){var n,i,r=e.zrX,o=e.zrY,a=fn(this,r,o);if("mouseup"===t&&a||(i=(n=this.findHover(r,o)).target),"mousedown"===t)this._downEl=i,this._downPoint=[e.zrX,e.zrY],this._upEl=i;else if("mouseup"===t)this._upEl=i;else if("click"===t){if(this._downEl!==this._upEl||!this._downPoint||Ft(this._downPoint,[e.zrX,e.zrY])>4)return;this._downPoint=null}this.dispatchToElement(n,t,e)}});function gn(t,e,n,i){var r=e+1;if(r===n)return 1;if(i(t[r++],t[e])<0){for(;r=0;)r++;return r-e}function vn(t,e,n,i,r){for(i===e&&i++;i>>1])<0?l=o:s=o+1;var u=i-s;switch(u){case 3:t[s+3]=t[s+2];case 2:t[s+2]=t[s+1];case 1:t[s+1]=t[s];break;default:for(;u>0;)t[s+u]=t[s+u-1],u--}t[s]=a}}function yn(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])>0){for(s=i-r;l0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}else{for(s=r+1;ls&&(l=s);var u=a;a=r-l,l=r-u}for(a++;a>>1);o(t,e[n+c])>0?a=c+1:l=c}return l}function mn(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])<0){for(s=r+1;ls&&(l=s);var u=a;a=r-l,l=r-u}else{for(s=i-r;l=0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}for(a++;a>>1);o(t,e[n+c])<0?l=c:a=c+1}return l}function xn(t,e){var n,i,r=7,o=0,a=[];function s(s){var l=n[s],u=i[s],c=n[s+1],h=i[s+1];i[s]=u+h,s===o-3&&(n[s+1]=n[s+2],i[s+1]=i[s+2]),o--;var d=mn(t[c],t,l,u,0,e);l+=d,0!==(u-=d)&&0!==(h=yn(t[l+u-1],t,c,h,h-1,e))&&(u<=h?function(n,i,o,s){var l=0;for(l=0;l=7||p>=7);if(f)break;g<0&&(g=0),g+=2}if((r=g)<1&&(r=1),1===i){for(l=0;l=0;l--)t[p+l]=t[d+l];return void(t[h]=a[c])}var f=r;for(;;){var g=0,v=0,y=!1;do{if(e(a[c],t[u])<0){if(t[h--]=t[u--],g++,v=0,0===--i){y=!0;break}}else if(t[h--]=a[c--],v++,g=0,1===--s){y=!0;break}}while((g|v)=0;l--)t[p+l]=t[d+l];if(0===i){y=!0;break}}if(t[h--]=a[c--],1===--s){y=!0;break}if(0!==(v=s-yn(t[u],a,0,s,s-1,e))){for(s-=v,p=(h-=v)+1,d=(c-=v)+1,l=0;l=7||v>=7);if(y)break;f<0&&(f=0),f+=2}(r=f)<1&&(r=1);if(1===s){for(p=(h-=i)+1,d=(u-=i)+1,l=i-1;l>=0;l--)t[p+l]=t[d+l];t[h]=a[c]}else{if(0===s)throw new Error;for(d=h-(s-1),l=0;l1;){var t=o-2;if(t>=1&&i[t-1]<=i[t]+i[t+1]||t>=2&&i[t-2]<=i[t]+i[t-1])i[t-1]i[t+1])break;s(t)}},forceMergeRuns:function(){for(;o>1;){var t=o-2;t>0&&i[t-1]=32;)e|=1&t,t>>=1;return t+e}(r);do{if((o=gn(t,n,i,e))s&&(l=s),vn(t,n,n+l,n+o,e),o=l}a.pushRun(n,o),a.mergeRuns(),r-=o,n+=o}while(0!==r);a.forceMergeRuns()}}}var bn=!1;function wn(){bn||(bn=!0,console.warn("z / z2 / zlevel of displayable is invalid, which may cause unexpected errors"))}function Sn(t,e){return t.zlevel===e.zlevel?t.z===e.z?t.z2-e.z2:t.z-e.z:t.zlevel-e.zlevel}var Mn=function(){function t(){this._roots=[],this._displayList=[],this._displayListLen=0,this.displayableSortFunc=Sn}return t.prototype.traverse=function(t,e){for(var n=0;n=0&&this._roots.splice(i,1)}},t.prototype.delAllRoots=function(){this._roots=[],this._displayList=[],this._displayListLen=0},t.prototype.getRoots=function(){return this._roots},t.prototype.dispose=function(){this._displayList=null,this._roots=null},t}(),In=r.hasGlobalWindow&&(window.requestAnimationFrame&&window.requestAnimationFrame.bind(window)||window.msRequestAnimationFrame&&window.msRequestAnimationFrame.bind(window)||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(t){return setTimeout(t,16)},Tn={linear:function(t){return t},quadraticIn:function(t){return t*t},quadraticOut:function(t){return t*(2-t)},quadraticInOut:function(t){return(t*=2)<1?.5*t*t:-.5*(--t*(t-2)-1)},cubicIn:function(t){return t*t*t},cubicOut:function(t){return--t*t*t+1},cubicInOut:function(t){return(t*=2)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},quarticIn:function(t){return t*t*t*t},quarticOut:function(t){return 1- --t*t*t*t},quarticInOut:function(t){return(t*=2)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2)},quinticIn:function(t){return t*t*t*t*t},quinticOut:function(t){return--t*t*t*t*t+1},quinticInOut:function(t){return(t*=2)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},sinusoidalIn:function(t){return 1-Math.cos(t*Math.PI/2)},sinusoidalOut:function(t){return Math.sin(t*Math.PI/2)},sinusoidalInOut:function(t){return.5*(1-Math.cos(Math.PI*t))},exponentialIn:function(t){return 0===t?0:Math.pow(1024,t-1)},exponentialOut:function(t){return 1===t?1:1-Math.pow(2,-10*t)},exponentialInOut:function(t){return 0===t?0:1===t?1:(t*=2)<1?.5*Math.pow(1024,t-1):.5*(2-Math.pow(2,-10*(t-1)))},circularIn:function(t){return 1-Math.sqrt(1-t*t)},circularOut:function(t){return Math.sqrt(1- --t*t)},circularInOut:function(t){return(t*=2)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},elasticIn:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),-n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/.4))},elasticOut:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),n*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/.4)+1)},elasticInOut:function(t){var e,n=.1,i=.4;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=i*Math.asin(1/n)/(2*Math.PI),(t*=2)<1?n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*-.5:n*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*.5+1)},backIn:function(t){var e=1.70158;return t*t*((e+1)*t-e)},backOut:function(t){var e=1.70158;return--t*t*((e+1)*t+e)+1},backInOut:function(t){var e=2.5949095;return(t*=2)<1?t*t*((e+1)*t-e)*.5:.5*((t-=2)*t*((e+1)*t+e)+2)},bounceIn:function(t){return 1-Tn.bounceOut(1-t)},bounceOut:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},bounceInOut:function(t){return t<.5?.5*Tn.bounceIn(2*t):.5*Tn.bounceOut(2*t-1)+.5}},Cn=Math.pow,Dn=Math.sqrt,An=1e-8,kn=1e-4,Ln=Dn(3),Pn=1/3,On=Ct(),Rn=Ct(),Nn=Ct();function En(t){return t>-1e-8&&tAn||t<-1e-8}function Bn(t,e,n,i,r){var o=1-r;return o*o*(o*t+3*r*e)+r*r*(r*i+3*o*n)}function Vn(t,e,n,i,r){var o=1-r;return 3*(((e-t)*o+2*(n-e)*r)*o+(i-n)*r*r)}function Gn(t,e,n,i,r,o){var a=i+3*(e-n)-t,s=3*(n-2*e+t),l=3*(e-t),u=t-r,c=s*s-3*a*l,h=s*l-9*a*u,d=l*l-3*s*u,p=0;if(En(c)&&En(h)){if(En(s))o[0]=0;else(M=-l/s)>=0&&M<=1&&(o[p++]=M)}else{var f=h*h-4*c*d;if(En(f)){var g=h/c,v=-g/2;(M=-s/a+g)>=0&&M<=1&&(o[p++]=M),v>=0&&v<=1&&(o[p++]=v)}else if(f>0){var y=Dn(f),m=c*s+1.5*a*(-h+y),x=c*s+1.5*a*(-h-y);(M=(-s-((m=m<0?-Cn(-m,Pn):Cn(m,Pn))+(x=x<0?-Cn(-x,Pn):Cn(x,Pn))))/(3*a))>=0&&M<=1&&(o[p++]=M)}else{var _=(2*c*s-3*a*h)/(2*Dn(c*c*c)),b=Math.acos(_)/3,w=Dn(c),S=Math.cos(b),M=(-s-2*w*S)/(3*a),I=(v=(-s+w*(S+Ln*Math.sin(b)))/(3*a),(-s+w*(S-Ln*Math.sin(b)))/(3*a));M>=0&&M<=1&&(o[p++]=M),v>=0&&v<=1&&(o[p++]=v),I>=0&&I<=1&&(o[p++]=I)}}return p}function Fn(t,e,n,i,r){var o=6*n-12*e+6*t,a=9*e+3*i-3*t-9*n,s=3*e-3*t,l=0;if(En(a)){if(zn(o))(c=-s/o)>=0&&c<=1&&(r[l++]=c)}else{var u=o*o-4*a*s;if(En(u))r[0]=-o/(2*a);else if(u>0){var c,h=Dn(u),d=(-o-h)/(2*a);(c=(-o+h)/(2*a))>=0&&c<=1&&(r[l++]=c),d>=0&&d<=1&&(r[l++]=d)}}return l}function Hn(t,e,n,i,r,o){var a=(e-t)*r+t,s=(n-e)*r+e,l=(i-n)*r+n,u=(s-a)*r+a,c=(l-s)*r+s,h=(c-u)*r+u;o[0]=t,o[1]=a,o[2]=u,o[3]=h,o[4]=h,o[5]=c,o[6]=l,o[7]=i}function Wn(t,e,n,i,r,o,a,s,l,u,c){var h,d,p,f,g,v=.005,y=1/0;On[0]=l,On[1]=u;for(var m=0;m<1;m+=.05)Rn[0]=Bn(t,n,r,a,m),Rn[1]=Bn(e,i,o,s,m),(f=Wt(On,Rn))=0&&f=0&&v=1?1:Gn(0,i,o,1,t,s)&&Bn(0,r,a,1,s[0])}}}var Qn=function(){function t(t){this._inited=!1,this._startTime=0,this._pausedTime=0,this._paused=!1,this._life=t.life||1e3,this._delay=t.delay||0,this.loop=t.loop||!1,this.onframe=t.onframe||St,this.ondestroy=t.ondestroy||St,this.onrestart=t.onrestart||St,t.easing&&this.setEasing(t.easing)}return t.prototype.step=function(t,e){if(this._inited||(this._startTime=t+this._delay,this._inited=!0),!this._paused){var n=this._life,i=t-this._startTime-this._pausedTime,r=i/n;r<0&&(r=0),r=Math.min(r,1);var o=this.easingFunc,a=o?o(r):r;if(this.onframe(a),1===r){if(!this.loop)return!0;var s=i%n;this._startTime=t-s,this._pausedTime=0,this.onrestart()}return!1}this._pausedTime+=e},t.prototype.pause=function(){this._paused=!0},t.prototype.resume=function(){this._paused=!1},t.prototype.setEasing=function(t){this.easing=t,this.easingFunc=Z(t)?t:Tn[t]||Jn(t)},t}(),ti=function(t){this.value=t},ei=function(){function t(){this._len=0}return t.prototype.insert=function(t){var e=new ti(t);return this.insertEntry(e),e},t.prototype.insertEntry=function(t){this.head?(this.tail.next=t,t.prev=this.tail,t.next=null,this.tail=t):this.head=this.tail=t,this._len++},t.prototype.remove=function(t){var e=t.prev,n=t.next;e?e.next=n:this.head=n,n?n.prev=e:this.tail=e,t.next=t.prev=null,this._len--},t.prototype.len=function(){return this._len},t.prototype.clear=function(){this.head=this.tail=null,this._len=0},t}(),ni=function(){function t(t){this._list=new ei,this._maxSize=10,this._map={},this._maxSize=t}return t.prototype.put=function(t,e){var n=this._list,i=this._map,r=null;if(null==i[t]){var o=n.len(),a=this._lastRemovedEntry;if(o>=this._maxSize&&o>0){var s=n.head;n.remove(s),delete i[s.key],r=s.value,this._lastRemovedEntry=s}a?a.value=e:a=new ti(e),a.key=t,n.insertEntry(a),i[t]=a}return r},t.prototype.get=function(t){var e=this._map[t],n=this._list;if(null!=e)return e!==n.tail&&(n.remove(e),n.insertEntry(e)),e.value},t.prototype.clear=function(){this._list.clear(),this._map={}},t.prototype.len=function(){return this._list.len()},t}(),ii={transparent:[0,0,0,0],aliceblue:[240,248,255,1],antiquewhite:[250,235,215,1],aqua:[0,255,255,1],aquamarine:[127,255,212,1],azure:[240,255,255,1],beige:[245,245,220,1],bisque:[255,228,196,1],black:[0,0,0,1],blanchedalmond:[255,235,205,1],blue:[0,0,255,1],blueviolet:[138,43,226,1],brown:[165,42,42,1],burlywood:[222,184,135,1],cadetblue:[95,158,160,1],chartreuse:[127,255,0,1],chocolate:[210,105,30,1],coral:[255,127,80,1],cornflowerblue:[100,149,237,1],cornsilk:[255,248,220,1],crimson:[220,20,60,1],cyan:[0,255,255,1],darkblue:[0,0,139,1],darkcyan:[0,139,139,1],darkgoldenrod:[184,134,11,1],darkgray:[169,169,169,1],darkgreen:[0,100,0,1],darkgrey:[169,169,169,1],darkkhaki:[189,183,107,1],darkmagenta:[139,0,139,1],darkolivegreen:[85,107,47,1],darkorange:[255,140,0,1],darkorchid:[153,50,204,1],darkred:[139,0,0,1],darksalmon:[233,150,122,1],darkseagreen:[143,188,143,1],darkslateblue:[72,61,139,1],darkslategray:[47,79,79,1],darkslategrey:[47,79,79,1],darkturquoise:[0,206,209,1],darkviolet:[148,0,211,1],deeppink:[255,20,147,1],deepskyblue:[0,191,255,1],dimgray:[105,105,105,1],dimgrey:[105,105,105,1],dodgerblue:[30,144,255,1],firebrick:[178,34,34,1],floralwhite:[255,250,240,1],forestgreen:[34,139,34,1],fuchsia:[255,0,255,1],gainsboro:[220,220,220,1],ghostwhite:[248,248,255,1],gold:[255,215,0,1],goldenrod:[218,165,32,1],gray:[128,128,128,1],green:[0,128,0,1],greenyellow:[173,255,47,1],grey:[128,128,128,1],honeydew:[240,255,240,1],hotpink:[255,105,180,1],indianred:[205,92,92,1],indigo:[75,0,130,1],ivory:[255,255,240,1],khaki:[240,230,140,1],lavender:[230,230,250,1],lavenderblush:[255,240,245,1],lawngreen:[124,252,0,1],lemonchiffon:[255,250,205,1],lightblue:[173,216,230,1],lightcoral:[240,128,128,1],lightcyan:[224,255,255,1],lightgoldenrodyellow:[250,250,210,1],lightgray:[211,211,211,1],lightgreen:[144,238,144,1],lightgrey:[211,211,211,1],lightpink:[255,182,193,1],lightsalmon:[255,160,122,1],lightseagreen:[32,178,170,1],lightskyblue:[135,206,250,1],lightslategray:[119,136,153,1],lightslategrey:[119,136,153,1],lightsteelblue:[176,196,222,1],lightyellow:[255,255,224,1],lime:[0,255,0,1],limegreen:[50,205,50,1],linen:[250,240,230,1],magenta:[255,0,255,1],maroon:[128,0,0,1],mediumaquamarine:[102,205,170,1],mediumblue:[0,0,205,1],mediumorchid:[186,85,211,1],mediumpurple:[147,112,219,1],mediumseagreen:[60,179,113,1],mediumslateblue:[123,104,238,1],mediumspringgreen:[0,250,154,1],mediumturquoise:[72,209,204,1],mediumvioletred:[199,21,133,1],midnightblue:[25,25,112,1],mintcream:[245,255,250,1],mistyrose:[255,228,225,1],moccasin:[255,228,181,1],navajowhite:[255,222,173,1],navy:[0,0,128,1],oldlace:[253,245,230,1],olive:[128,128,0,1],olivedrab:[107,142,35,1],orange:[255,165,0,1],orangered:[255,69,0,1],orchid:[218,112,214,1],palegoldenrod:[238,232,170,1],palegreen:[152,251,152,1],paleturquoise:[175,238,238,1],palevioletred:[219,112,147,1],papayawhip:[255,239,213,1],peachpuff:[255,218,185,1],peru:[205,133,63,1],pink:[255,192,203,1],plum:[221,160,221,1],powderblue:[176,224,230,1],purple:[128,0,128,1],red:[255,0,0,1],rosybrown:[188,143,143,1],royalblue:[65,105,225,1],saddlebrown:[139,69,19,1],salmon:[250,128,114,1],sandybrown:[244,164,96,1],seagreen:[46,139,87,1],seashell:[255,245,238,1],sienna:[160,82,45,1],silver:[192,192,192,1],skyblue:[135,206,235,1],slateblue:[106,90,205,1],slategray:[112,128,144,1],slategrey:[112,128,144,1],snow:[255,250,250,1],springgreen:[0,255,127,1],steelblue:[70,130,180,1],tan:[210,180,140,1],teal:[0,128,128,1],thistle:[216,191,216,1],tomato:[255,99,71,1],turquoise:[64,224,208,1],violet:[238,130,238,1],wheat:[245,222,179,1],white:[255,255,255,1],whitesmoke:[245,245,245,1],yellow:[255,255,0,1],yellowgreen:[154,205,50,1]};function ri(t){return(t=Math.round(t))<0?0:t>255?255:t}function oi(t){return t<0?0:t>1?1:t}function ai(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?ri(parseFloat(e)/100*255):ri(parseInt(e,10))}function si(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?oi(parseFloat(e)/100):oi(parseFloat(e))}function li(t,e,n){return n<0?n+=1:n>1&&(n-=1),6*n<1?t+(e-t)*n*6:2*n<1?e:3*n<2?t+(e-t)*(2/3-n)*6:t}function ui(t,e,n){return t+(e-t)*n}function ci(t,e,n,i,r){return t[0]=e,t[1]=n,t[2]=i,t[3]=r,t}function hi(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t}var di=new ni(20),pi=null;function fi(t,e){pi&&hi(pi,e),pi=di.put(t,pi||e.slice())}function gi(t,e){if(t){e=e||[];var n=di.get(t);if(n)return hi(e,n);var i=(t+="").replace(/ /g,"").toLowerCase();if(i in ii)return hi(e,ii[i]),fi(t,e),e;var r,o=i.length;if("#"===i.charAt(0))return 4===o||5===o?(r=parseInt(i.slice(1,4),16))>=0&&r<=4095?(ci(e,(3840&r)>>4|(3840&r)>>8,240&r|(240&r)>>4,15&r|(15&r)<<4,5===o?parseInt(i.slice(4),16)/15:1),fi(t,e),e):void ci(e,0,0,0,1):7===o||9===o?(r=parseInt(i.slice(1,7),16))>=0&&r<=16777215?(ci(e,(16711680&r)>>16,(65280&r)>>8,255&r,9===o?parseInt(i.slice(7),16)/255:1),fi(t,e),e):void ci(e,0,0,0,1):void 0;var a=i.indexOf("("),s=i.indexOf(")");if(-1!==a&&s+1===o){var l=i.substr(0,a),u=i.substr(a+1,s-(a+1)).split(","),c=1;switch(l){case"rgba":if(4!==u.length)return 3===u.length?ci(e,+u[0],+u[1],+u[2],1):ci(e,0,0,0,1);c=si(u.pop());case"rgb":return u.length>=3?(ci(e,ai(u[0]),ai(u[1]),ai(u[2]),3===u.length?c:si(u[3])),fi(t,e),e):void ci(e,0,0,0,1);case"hsla":return 4!==u.length?void ci(e,0,0,0,1):(u[3]=si(u[3]),vi(u,e),fi(t,e),e);case"hsl":return 3!==u.length?void ci(e,0,0,0,1):(vi(u,e),fi(t,e),e);default:return}}ci(e,0,0,0,1)}}function vi(t,e){var n=(parseFloat(t[0])%360+360)%360/360,i=si(t[1]),r=si(t[2]),o=r<=.5?r*(i+1):r+i-r*i,a=2*r-o;return ci(e=e||[],ri(255*li(a,o,n+1/3)),ri(255*li(a,o,n)),ri(255*li(a,o,n-1/3)),1),4===t.length&&(e[3]=t[3]),e}function yi(t,e){var n=gi(t);if(n){for(var i=0;i<3;i++)n[i]=e<0?n[i]*(1-e)|0:(255-n[i])*e+n[i]|0,n[i]>255?n[i]=255:n[i]<0&&(n[i]=0);return Mi(n,4===n.length?"rgba":"rgb")}}function mi(t,e,n){if(e&&e.length&&t>=0&&t<=1){n=n||[];var i=t*(e.length-1),r=Math.floor(i),o=Math.ceil(i),a=e[r],s=e[o],l=i-r;return n[0]=ri(ui(a[0],s[0],l)),n[1]=ri(ui(a[1],s[1],l)),n[2]=ri(ui(a[2],s[2],l)),n[3]=oi(ui(a[3],s[3],l)),n}}var xi=mi;function _i(t,e,n){if(e&&e.length&&t>=0&&t<=1){var i=t*(e.length-1),r=Math.floor(i),o=Math.ceil(i),a=gi(e[r]),s=gi(e[o]),l=i-r,u=Mi([ri(ui(a[0],s[0],l)),ri(ui(a[1],s[1],l)),ri(ui(a[2],s[2],l)),oi(ui(a[3],s[3],l))],"rgba");return n?{color:u,leftIndex:r,rightIndex:o,value:i}:u}}var bi=_i;function wi(t,e,n,i){var r=gi(t);if(t)return r=function(t){if(t){var e,n,i=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(i,r,o),s=Math.max(i,r,o),l=s-a,u=(s+a)/2;if(0===l)e=0,n=0;else{n=u<.5?l/(s+a):l/(2-s-a);var c=((s-i)/6+l/2)/l,h=((s-r)/6+l/2)/l,d=((s-o)/6+l/2)/l;i===s?e=d-h:r===s?e=1/3+c-d:o===s&&(e=2/3+h-c),e<0&&(e+=1),e>1&&(e-=1)}var p=[360*e,n,u];return null!=t[3]&&p.push(t[3]),p}}(r),null!=e&&(r[0]=function(t){return(t=Math.round(t))<0?0:t>360?360:t}(Z(e)?e(r[0]):e)),null!=n&&(r[1]=si(Z(n)?n(r[1]):n)),null!=i&&(r[2]=si(Z(i)?i(r[2]):i)),Mi(vi(r),"rgba")}function Si(t,e){var n=gi(t);if(n&&null!=e)return n[3]=oi(e),Mi(n,"rgba")}function Mi(t,e){if(t&&t.length){var n=t[0]+","+t[1]+","+t[2];return"rgba"!==e&&"hsva"!==e&&"hsla"!==e||(n+=","+t[3]),e+"("+n+")"}}function Ii(t,e){var n=gi(t);return n?(.299*n[0]+.587*n[1]+.114*n[2])*n[3]/255+(1-n[3])*e:0}var Ti=new ni(100);function Ci(t){if(j(t)){var e=Ti.get(t);return e||(e=yi(t,-.1),Ti.put(t,e)),e}if(et(t)){var n=k({},t);return n.colorStops=V(t.colorStops,function(t){return{offset:t.offset,color:yi(t.color,-.1)}}),n}return t}var Di=Object.freeze({__proto__:null,parseCssInt:ai,parseCssFloat:si,parse:gi,lift:yi,toHex:function(t){var e=gi(t);if(e)return((1<<24)+(e[0]<<16)+(e[1]<<8)+ +e[2]).toString(16).slice(1)},fastLerp:mi,fastMapToColor:xi,lerp:_i,mapToColor:bi,modifyHSL:wi,modifyAlpha:Si,stringify:Mi,lum:Ii,random:function(){return Mi([Math.round(255*Math.random()),Math.round(255*Math.random()),Math.round(255*Math.random())],"rgb")},liftColor:Ci}),Ai=Math.round;function ki(t){var e;if(t&&"transparent"!==t){if("string"==typeof t&&t.indexOf("rgba")>-1){var n=gi(t);n&&(t="rgb("+n[0]+","+n[1]+","+n[2]+")",e=n[3])}}else t="none";return{color:t,opacity:null==e?1:e}}var Li=1e-4;function Pi(t){return t-1e-4}function Oi(t){return Ai(1e3*t)/1e3}function Ri(t){return Ai(1e4*t)/1e4}var Ni={left:"start",right:"end",center:"middle",middle:"middle"};function Ei(t){return t&&!!t.image}function zi(t){return Ei(t)||function(t){return t&&!!t.svgElement}(t)}function Bi(t){return"linear"===t.type}function Vi(t){return"radial"===t.type}function Gi(t){return t&&("linear"===t.type||"radial"===t.type)}function Fi(t){return"url(#"+t+")"}function Hi(t){var e=t.getGlobalScale(),n=Math.max(e[0],e[1]);return Math.max(Math.ceil(Math.log(n)/Math.log(10)),1)}function Wi(t){var e=t.x||0,n=t.y||0,i=(t.rotation||0)*Mt,r=at(t.scaleX,1),o=at(t.scaleY,1),a=t.skewX||0,s=t.skewY||0,l=[];return(e||n)&&l.push("translate("+e+"px,"+n+"px)"),i&&l.push("rotate("+i+")"),1===r&&1===o||l.push("scale("+r+","+o+")"),(a||s)&&l.push("skew("+Ai(a*Mt)+"deg, "+Ai(s*Mt)+"deg)"),l.join(" ")}var Ui="undefined"!=typeof Buffer&&"function"==typeof Buffer.from?function(t){return Buffer.from(t).toString("base64")}:"function"==typeof btoa&&"function"==typeof unescape&&"function"==typeof encodeURIComponent?function(t){return btoa(unescape(encodeURIComponent(t)))}:function(t){return null},Yi=Array.prototype.slice;function Xi(t,e,n){return(e-t)*n+t}function Zi(t,e,n,i){for(var r=e.length,o=0;oi?e:t,o=Math.min(n,i),a=r[o-1]||{color:[0,0,0,0],offset:0},s=o;sa)i.length=a;else for(var s=o;s=1},t.prototype.getAdditiveTrack=function(){return this._additiveTrack},t.prototype.addKeyframe=function(t,e,n){this._needsSort=!0;var i=this.keyframes,r=i.length,o=!1,a=6,s=e;if(z(e)){var l=function(t){return z(t&&t[0])?2:1}(e);a=l,(1===l&&!K(e[0])||2===l&&!K(e[0][0]))&&(o=!0)}else if(K(e)&&!rt(e))a=0;else if(j(e))if(isNaN(+e)){var u=gi(e);u&&(s=u,a=3)}else a=0;else if(et(e)){var c=k({},s);c.colorStops=V(e.colorStops,function(t){return{offset:t.offset,color:gi(t.color)}}),Bi(e)?a=4:Vi(e)&&(a=5),s=c}0===r?this.valType=a:a===this.valType&&6!==a||(o=!0),this.discrete=this.discrete||o;var h={time:t,value:s,rawValue:e,percent:0};return n&&(h.easing=n,h.easingFunc=Z(n)?n:Tn[n]||Jn(n)),i.push(h),h},t.prototype.prepare=function(t,e){var n=this.keyframes;this._needsSort&&n.sort(function(t,e){return t.time-e.time});for(var i=this.valType,r=n.length,o=n[r-1],a=this.discrete,s=er(i),l=tr(i),u=0;u=0&&!(l[n].percent<=e);n--);n=p(n,u-2)}else{for(n=d;ne);n++);n=p(n-1,u-2)}r=l[n+1],i=l[n]}if(i&&r){this._lastFr=n,this._lastFrP=e;var f=r.percent-i.percent,g=0===f?1:p((e-i.percent)/f,1);r.easingFunc&&(g=r.easingFunc(g));var v=o?this._additiveValue:h?nr:t[c];if(!er(s)&&!h||v||(v=this._additiveValue=[]),this.discrete)t[c]=g<1?i.rawValue:r.rawValue;else if(er(s))1===s?Zi(v,i[a],r[a],g):function(t,e,n,i){for(var r=e.length,o=r&&e[0].length,a=0;a0&&s.addKeyframe(0,Ji(l),i),this._trackKeys.push(a)}s.addKeyframe(t,Ji(e[a]),i)}return this._maxTime=Math.max(this._maxTime,t),this},t.prototype.pause=function(){this._clip.pause(),this._paused=!0},t.prototype.resume=function(){this._clip.resume(),this._paused=!1},t.prototype.isPaused=function(){return!!this._paused},t.prototype.duration=function(t){return this._maxTime=t,this._force=!0,this},t.prototype._doneCallback=function(){this._setTracksFinished(),this._clip=null;var t=this._doneCbs;if(t)for(var e=t.length,n=0;n0)){this._started=1;for(var e=this,n=[],i=this._maxTime||0,r=0;r1){var a=o.pop();r.addKeyframe(a.time,t[i]),r.prepare(this._maxTime,r.getAdditiveTrack())}}}},t}();function or(){return(new Date).getTime()}var ar,sr,lr=function(t){function e(e){var n=t.call(this)||this;return n._running=!1,n._time=0,n._pausedTime=0,n._pauseStart=0,n._paused=!1,e=e||{},n.stage=e.stage||{},n}return n(e,t),e.prototype.addClip=function(t){t.animation&&this.removeClip(t),this._head?(this._tail.next=t,t.prev=this._tail,t.next=null,this._tail=t):this._head=this._tail=t,t.animation=this},e.prototype.addAnimator=function(t){t.animation=this;var e=t.getClip();e&&this.addClip(e)},e.prototype.removeClip=function(t){if(t.animation){var e=t.prev,n=t.next;e?e.next=n:this._head=n,n?n.prev=e:this._tail=e,t.next=t.prev=t.animation=null}},e.prototype.removeAnimator=function(t){var e=t.getClip();e&&this.removeClip(e),t.animation=null},e.prototype.update=function(t){for(var e=or()-this._pausedTime,n=e-this._time,i=this._head;i;){var r=i.next;i.step(e,n)?(i.ondestroy(),this.removeClip(i),i=r):i=r}this._time=e,t||(this.trigger("frame",n),this.stage.update&&this.stage.update())},e.prototype._startLoop=function(){var t=this;this._running=!0,In(function e(){t._running&&(In(e),!t._paused&&t.update())})},e.prototype.start=function(){this._running||(this._time=or(),this._pausedTime=0,this._startLoop())},e.prototype.stop=function(){this._running=!1},e.prototype.pause=function(){this._paused||(this._pauseStart=or(),this._paused=!0)},e.prototype.resume=function(){this._paused&&(this._pausedTime+=or()-this._pauseStart,this._paused=!1)},e.prototype.clear=function(){for(var t=this._head;t;){var e=t.next;t.prev=t.next=t.animation=null,t=e}this._head=this._tail=null},e.prototype.isFinished=function(){return null==this._head},e.prototype.animate=function(t,e){e=e||{},this.start();var n=new rr(t,e.loop);return this.addAnimator(n),n},e}($t),ur=r.domSupported,cr=(sr={pointerdown:1,pointerup:1,pointermove:1,pointerout:1},{mouse:ar=["click","dblclick","mousewheel","wheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],touch:["touchstart","touchend","touchmove"],pointer:V(ar,function(t){var e=t.replace("mouse","pointer");return sr.hasOwnProperty(e)?e:t})}),hr=["mousemove","mouseup"],dr=["pointermove","pointerup"],pr=!1;function fr(t){var e=t.pointerType;return"pen"===e||"touch"===e}function gr(t){t&&(t.zrByTouch=!0)}function vr(t,e){for(var n=e,i=!1;n&&9!==n.nodeType&&!(i=n.domBelongToZr||n!==e&&n===t.painterRoot);)n=n.parentNode;return i}var yr=function(t,e){this.stopPropagation=St,this.stopImmediatePropagation=St,this.preventDefault=St,this.type=e.type,this.target=this.currentTarget=t.dom,this.pointerType=e.pointerType,this.clientX=e.clientX,this.clientY=e.clientY},mr={mousedown:function(t){t=fe(this.dom,t),this.__mayPointerCapture=[t.zrX,t.zrY],this.trigger("mousedown",t)},mousemove:function(t){t=fe(this.dom,t);var e=this.__mayPointerCapture;!e||t.zrX===e[0]&&t.zrY===e[1]||this.__togglePointerCapture(!0),this.trigger("mousemove",t)},mouseup:function(t){t=fe(this.dom,t),this.__togglePointerCapture(!1),this.trigger("mouseup",t)},mouseout:function(t){vr(this,(t=fe(this.dom,t)).toElement||t.relatedTarget)||(this.__pointerCapturing&&(t.zrEventControl="no_globalout"),this.trigger("mouseout",t))},wheel:function(t){pr=!0,t=fe(this.dom,t),this.trigger("mousewheel",t)},mousewheel:function(t){pr||(t=fe(this.dom,t),this.trigger("mousewheel",t))},touchstart:function(t){gr(t=fe(this.dom,t)),this.__lastTouchMoment=new Date,this.handler.processGesture(t,"start"),mr.mousemove.call(this,t),mr.mousedown.call(this,t)},touchmove:function(t){gr(t=fe(this.dom,t)),this.handler.processGesture(t,"change"),mr.mousemove.call(this,t)},touchend:function(t){gr(t=fe(this.dom,t)),this.handler.processGesture(t,"end"),mr.mouseup.call(this,t),+new Date-+this.__lastTouchMoment<300&&mr.click.call(this,t)},pointerdown:function(t){mr.mousedown.call(this,t)},pointermove:function(t){fr(t)||mr.mousemove.call(this,t)},pointerup:function(t){mr.mouseup.call(this,t)},pointerout:function(t){fr(t)||mr.mouseout.call(this,t)}};B(["click","dblclick","contextmenu"],function(t){mr[t]=function(e){e=fe(this.dom,e),this.trigger(t,e)}});var xr={pointermove:function(t){fr(t)||xr.mousemove.call(this,t)},pointerup:function(t){xr.mouseup.call(this,t)},mousemove:function(t){this.trigger("mousemove",t)},mouseup:function(t){var e=this.__pointerCapturing;this.__togglePointerCapture(!1),this.trigger("mouseup",t),e&&(t.zrEventControl="only_globalout",this.trigger("mouseout",t))}};function _r(t,e){var n=e.domHandlers;r.pointerEventsSupported?B(cr.pointer,function(i){wr(e,i,function(e){n[i].call(t,e)})}):(r.touchEventsSupported&&B(cr.touch,function(i){wr(e,i,function(r){n[i].call(t,r),function(t){t.touching=!0,null!=t.touchTimer&&(clearTimeout(t.touchTimer),t.touchTimer=null),t.touchTimer=setTimeout(function(){t.touching=!1,t.touchTimer=null},700)}(e)})}),B(cr.mouse,function(i){wr(e,i,function(r){r=pe(r),e.touching||n[i].call(t,r)})}))}function br(t,e){function n(n){wr(e,n,function(i){i=pe(i),vr(t,i.target)||(i=function(t,e){return fe(t.dom,new yr(t,e),!0)}(t,i),e.domHandlers[n].call(t,i))},{capture:!0})}r.pointerEventsSupported?B(dr,n):r.touchEventsSupported||B(hr,n)}function wr(t,e,n,i){t.mounted[e]=n,t.listenerOpts[e]=i,ge(t.domTarget,e,n,i)}function Sr(t){var e=t.mounted;for(var n in e)e.hasOwnProperty(n)&&ve(t.domTarget,n,e[n],t.listenerOpts[n]);t.mounted={}}var Mr=function(t,e){this.mounted={},this.listenerOpts={},this.touching=!1,this.domTarget=t,this.domHandlers=e},Ir=function(t){function e(e,n){var i=t.call(this)||this;return i.__pointerCapturing=!1,i.dom=e,i.painterRoot=n,i._localHandlerScope=new Mr(e,mr),ur&&(i._globalHandlerScope=new Mr(document,xr)),_r(i,i._localHandlerScope),i}return n(e,t),e.prototype.dispose=function(){Sr(this._localHandlerScope),ur&&Sr(this._globalHandlerScope)},e.prototype.setCursor=function(t){this.dom.style&&(this.dom.style.cursor=t||"default")},e.prototype.__togglePointerCapture=function(t){if(this.__mayPointerCapture=null,ur&&+this.__pointerCapturing^+t){this.__pointerCapturing=t;var e=this._globalHandlerScope;t?br(this,e):Sr(e)}},e}($t),Tr=1;r.hasGlobalWindow&&(Tr=Math.max(window.devicePixelRatio||window.screen&&window.screen.deviceXDPI/window.screen.logicalXDPI||1,1));var Cr=Tr,Dr="#333",Ar="#ccc",kr=Se,Lr=5e-5;function Pr(t){return t>Lr||t<-5e-5}var Or=[],Rr=[],Nr=[1,0,0,1,0,0],Er=Math.abs,zr=function(){function t(){}var e;return t.prototype.getLocalTransform=function(t){return Br(this,t)},t.prototype.setPosition=function(t){this.x=t[0],this.y=t[1]},t.prototype.setScale=function(t){this.scaleX=t[0],this.scaleY=t[1]},t.prototype.setSkew=function(t){this.skewX=t[0],this.skewY=t[1]},t.prototype.setOrigin=function(t){this.originX=t[0],this.originY=t[1]},t.prototype.needLocalTransform=function(){return Pr(this.rotation)||Pr(this.x)||Pr(this.y)||Pr(this.scaleX-1)||Pr(this.scaleY-1)||Pr(this.skewX)||Pr(this.skewY)},t.prototype.updateTransform=function(){var t=this.parent&&this.parent.transform,e=this.needLocalTransform(),n=this.transform;e||t?(n=n||[1,0,0,1,0,0],e?this.getLocalTransform(n):kr(n),t&&(e?Ie(n,t,n):Me(n,t)),this.transform=n,this._resolveGlobalScaleRatio(n),this.invTransform=this.invTransform||[1,0,0,1,0,0],Ae(this.invTransform,n)):n&&(kr(n),this.invTransform=null)},t.prototype._resolveGlobalScaleRatio=function(t){var e=this.globalScaleRatio;if(null!=e&&1!==e){this.getGlobalScale(Or);var n=Or[0]<0?-1:1,i=Or[1]<0?-1:1,r=((Or[0]-n)*e+n)/Or[0]||0,o=((Or[1]-i)*e+i)/Or[1]||0;t[0]*=r,t[1]*=r,t[2]*=o,t[3]*=o}},t.prototype.getComputedTransform=function(){for(var t=this,e=[];t;)e.push(t),t=t.parent;for(;t=e.pop();)t.updateTransform();return this.transform},t.prototype.setLocalTransform=function(t){if(t){var e=t[0]*t[0]+t[1]*t[1],n=t[2]*t[2]+t[3]*t[3],i=Math.atan2(t[1],t[0]),r=Math.PI/2+i-Math.atan2(t[3],t[2]);n=Math.sqrt(n)*Math.cos(r),e=Math.sqrt(e),this.skewX=r,this.skewY=0,this.rotation=-i,this.x=+t[4],this.y=+t[5],this.scaleX=e,this.scaleY=n,this.originX=0,this.originY=0}},t.prototype.decomposeTransform=function(){if(this.transform){var t=this.parent,e=this.transform;t&&t.transform&&(t.invTransform=t.invTransform||[1,0,0,1,0,0],Ie(Rr,t.invTransform,e),e=Rr);var n=this.originX,i=this.originY;(n||i)&&(Nr[4]=n,Nr[5]=i,Ie(Rr,e,Nr),Rr[4]-=n,Rr[5]-=i,e=Rr),this.setLocalTransform(e)}},t.prototype.getGlobalScale=function(t){var e=this.transform;return t=t||[],e?(t[0]=Math.sqrt(e[0]*e[0]+e[1]*e[1]),t[1]=Math.sqrt(e[2]*e[2]+e[3]*e[3]),e[0]<0&&(t[0]=-t[0]),e[3]<0&&(t[1]=-t[1]),t):(t[0]=1,t[1]=1,t)},t.prototype.transformCoordToLocal=function(t,e){var n=[t,e],i=this.invTransform;return i&&Yt(n,n,i),n},t.prototype.transformCoordToGlobal=function(t,e){var n=[t,e],i=this.transform;return i&&Yt(n,n,i),n},t.prototype.getLineScale=function(){var t=this.transform;return t&&Er(t[0]-1)>1e-10&&Er(t[3]-1)>1e-10?Math.sqrt(Er(t[0]*t[3]-t[2]*t[1])):1},t.prototype.copyTransform=function(t){Hr(this,t)},t.getLocalTransform=function(t,e){e=e||[];var n=t.originX||0,i=t.originY||0,r=t.scaleX,o=t.scaleY,a=t.anchorX,s=t.anchorY,l=t.rotation||0,u=t.x,c=t.y,h=t.skewX?Math.tan(t.skewX):0,d=t.skewY?Math.tan(-t.skewY):0;if(n||i||a||s){var p=n+a,f=i+s;e[4]=-p*r-h*f*o,e[5]=-f*o-d*p*r}else e[4]=e[5]=0;return e[0]=r,e[3]=o,e[1]=d*r,e[2]=h*o,l&&Ce(e,e,l),e[4]+=n+u,e[5]+=i+c,e},t.initDefaultProps=((e=t.prototype).scaleX=e.scaleY=e.globalScaleRatio=1,void(e.x=e.y=e.originX=e.originY=e.skewX=e.skewY=e.rotation=e.anchorX=e.anchorY=0)),t}(),Br=zr.getLocalTransform;function Vr(){return new zr}var Gr,Fr=["x","y","originX","originY","anchorX","anchorY","rotation","scaleX","scaleY","skewX","skewY"];function Hr(t,e){return L(t,e,Fr)}function Wr(t){Gr||(Gr=new ni(100)),t=t||a;var e=Gr.get(t);return e||(e={font:t,strWidthCache:new ni(500),asciiWidthMap:null,asciiWidthMapTried:!1,stWideCharWidth:c.measureText("国",t).width,asciiCharWidth:c.measureText("a",t).width},Gr.put(t,e)),e}var Ur=0,Yr=5;function Xr(t,e){return t.asciiWidthMapTried||(t.asciiWidthMap=function(t){if(!(Ur>=Yr)){t=t||a;for(var e=[],n=+new Date,i=0;i<=127;i++)e[i]=c.measureText(String.fromCharCode(i),t).width;var r=+new Date-n;return r>16?Ur=Yr:r>2&&Ur++,e}}(t.font),t.asciiWidthMapTried=!0),0<=e&&e<=127?null!=t.asciiWidthMap?t.asciiWidthMap[e]:t.asciiCharWidth:t.stWideCharWidth}function Zr(t,e){var n=t.strWidthCache,i=n.get(e);return null==i&&(i=c.measureText(e,t.font).width,n.put(e,i)),i}function jr(t,e,n,i){var r=Zr(Wr(e),t),o=Jr(e),a=Kr(0,r,n),s=$r(0,o,i);return new Xe(a,s,r,o)}function qr(t,e,n,i){var r=((t||"")+"").split("\n");if(1===r.length)return jr(r[0],e,n,i);for(var o=new Xe(0,0,0,0),a=0;a=0?parseFloat(t)/100*e:parseFloat(t):t}function to(t,e,n){var i=e.position||"inside",r=null!=e.distance?e.distance:5,o=n.height,a=n.width,s=o/2,l=n.x,u=n.y,c="left",h="top";if(i instanceof Array)l+=Qr(i[0],n.width),u+=Qr(i[1],n.height),c=null,h=null;else switch(i){case"left":l-=r,u+=s,c="right",h="middle";break;case"right":l+=r+a,u+=s,h="middle";break;case"top":l+=a/2,u-=r,c="center",h="bottom";break;case"bottom":l+=a/2,u+=o+r,c="center";break;case"inside":l+=a/2,u+=s,c="center",h="middle";break;case"insideLeft":l+=r,u+=s,h="middle";break;case"insideRight":l+=a-r,u+=s,c="right",h="middle";break;case"insideTop":l+=a/2,u+=r,c="center";break;case"insideBottom":l+=a/2,u+=o-r,c="center",h="bottom";break;case"insideTopLeft":l+=r,u+=r;break;case"insideTopRight":l+=a-r,u+=r,c="right";break;case"insideBottomLeft":l+=r,u+=o-r,h="bottom";break;case"insideBottomRight":l+=a-r,u+=o-r,c="right",h="bottom"}return(t=t||{}).x=l,t.y=u,t.align=c,t.verticalAlign=h,t}var eo="__zr_normal__",no=Fr.concat(["ignore"]),io=G(Fr,function(t,e){return t[e]=!0,t},{ignore:!1}),ro={},oo=new Xe(0,0,0,0),ao=[],so=function(){function t(t){this.id=I(),this.animators=[],this.currentStates=[],this.states={},this._init(t)}return t.prototype._init=function(t){this.attr(t)},t.prototype.drift=function(t,e,n){switch(this.draggable){case"horizontal":e=0;break;case"vertical":t=0}var i=this.transform;i||(i=this.transform=[1,0,0,1,0,0]),i[4]+=t,i[5]+=e,this.decomposeTransform(),this.markRedraw()},t.prototype.beforeUpdate=function(){},t.prototype.afterUpdate=function(){},t.prototype.update=function(){this.updateTransform(),this.__dirty&&this.updateInnerText()},t.prototype.updateInnerText=function(t){var e=this._textContent;if(e&&(!e.ignore||t)){this.textConfig||(this.textConfig={});var n=this.textConfig,i=n.local,r=e.innerTransformable,o=void 0,a=void 0,s=!1;r.parent=i?this:null;var l=!1;r.copyTransform(e);var u=null!=n.position,c=n.autoOverflowArea,h=void 0;if((c||u)&&(h=oo,n.layoutRect?h.copy(n.layoutRect):h.copy(this.getBoundingRect()),i||h.applyTransform(this.transform)),u){this.calculateTextPosition?this.calculateTextPosition(ro,n,h):to(ro,n,h),r.x=ro.x,r.y=ro.y,o=ro.align,a=ro.verticalAlign;var d=n.origin;if(d&&null!=n.rotation){var p=void 0,f=void 0;"center"===d?(p=.5*h.width,f=.5*h.height):(p=Qr(d[0],h.width),f=Qr(d[1],h.height)),l=!0,r.originX=-r.x+p+(i?0:h.x),r.originY=-r.y+f+(i?0:h.y)}}null!=n.rotation&&(r.rotation=n.rotation);var g=n.offset;g&&(r.x+=g[0],r.y+=g[1],l||(r.originX=-g[0],r.originY=-g[1]));var v=this._innerTextDefaultStyle||(this._innerTextDefaultStyle={});if(c){var y=v.overflowRect=v.overflowRect||new Xe(0,0,0,0);r.getLocalTransform(ao),Ae(ao,ao),Xe.copy(y,h),y.applyTransform(ao)}else v.overflowRect=null;var m=void 0,x=void 0,_=void 0;(null==n.inside?"string"==typeof n.position&&n.position.indexOf("inside")>=0:n.inside)&&this.canBeInsideText()?(m=n.insideFill,x=n.insideStroke,null!=m&&"auto"!==m||(m=this.getInsideTextFill()),null!=x&&"auto"!==x||(x=this.getInsideTextStroke(m),_=!0)):(m=n.outsideFill,x=n.outsideStroke,null!=m&&"auto"!==m||(m=this.getOutsideFill()),null!=x&&"auto"!==x||(x=this.getOutsideStroke(m),_=!0)),(m=m||"#000")===v.fill&&x===v.stroke&&_===v.autoStroke&&o===v.align&&a===v.verticalAlign||(s=!0,v.fill=m,v.stroke=x,v.autoStroke=_,v.align=o,v.verticalAlign=a,e.setDefaultTextStyle(v)),e.__dirty|=1,s&&e.dirtyStyle(!0)}},t.prototype.canBeInsideText=function(){return!0},t.prototype.getInsideTextFill=function(){return"#fff"},t.prototype.getInsideTextStroke=function(t){return"#000"},t.prototype.getOutsideFill=function(){return this.__zr&&this.__zr.isDarkMode()?Ar:Dr},t.prototype.getOutsideStroke=function(t){var e=this.__zr&&this.__zr.getBackgroundColor(),n="string"==typeof e&&gi(e);n||(n=[255,255,255,1]);for(var i=n[3],r=this.__zr.isDarkMode(),o=0;o<3;o++)n[o]=n[o]*i+(r?0:255)*(1-i);return n[3]=1,Mi(n,"rgba")},t.prototype.traverse=function(t,e){},t.prototype.attrKV=function(t,e){"textConfig"===t?this.setTextConfig(e):"textContent"===t?this.setTextContent(e):"clipPath"===t?this.setClipPath(e):"extra"===t?(this.extra=this.extra||{},k(this.extra,e)):this[t]=e},t.prototype.hide=function(){this.ignore=!0,this.markRedraw()},t.prototype.show=function(){this.ignore=!1,this.markRedraw()},t.prototype.attr=function(t,e){if("string"==typeof t)this.attrKV(t,e);else if($(t))for(var n=W(t),i=0;i0},t.prototype.getState=function(t){return this.states[t]},t.prototype.ensureState=function(t){var e=this.states;return e[t]||(e[t]={}),e[t]},t.prototype.clearStates=function(t){this.useState(eo,!1,t)},t.prototype.useState=function(t,e,n,i){var r=t===eo;if(this.hasState()||!r){var o=this.currentStates,a=this.stateTransition;if(!(R(o,t)>=0)||!e&&1!==o.length){var s;if(this.stateProxy&&!r&&(s=this.stateProxy(t)),s||(s=this.states&&this.states[t]),s||r){r||this.saveCurrentToNormalState(s);var l=this._textContent,u=po(this,l,s,i);u&&!this.__inHover&&(this.__inHover=u),this._applyStateObj(t,s,this._normalState,e,go(this,n,a),a);var c=this._textGuide;return l&&l.useState(t,e,n,!!u),c&&c.useState(t,e,n,!!u),r?(this.currentStates=[],this._normalState={}):e?this.currentStates.push(t):this.currentStates=[t],this._updateAnimationTargets(),this.markRedraw(),!u&&this.__inHover&&(this.__inHover=0,this.__dirty&=-2),s}T("State "+t+" not exists.")}}},t.prototype.useStates=function(t,e,n){if(t.length){var i=[],r=this.currentStates,o=t.length,a=o===r.length;if(a)for(var s=0;s=0){var n=this.currentStates.slice();n.splice(e,1),this.useStates(n)}},t.prototype.replaceState=function(t,e,n){var i=this.currentStates.slice(),r=R(i,t),o=R(i,e)>=0;r>=0?o?i.splice(r,1):i[r]=e:n&&!o&&i.push(e),this.useStates(i)},t.prototype.toggleState=function(t,e){e?this.useState(t,!0):this.removeState(t)},t.prototype._mergeStates=function(t){for(var e,n={},i=0;i=0&&e.splice(n,1)}),this.animators.push(t),n&&n.animation.addAnimator(t),n&&n.wakeUp()},t.prototype.updateDuringAnimation=function(t){this.markRedraw()},t.prototype.stopAnimation=function(t,e){for(var n=this.animators,i=n.length,r=[],o=0;o0&&n.during&&o[0].during(function(t,e){n.during(e)});for(var d=0;d0||r.force&&!a.length){var w,S=void 0,M=void 0,I=void 0;if(s){M={},d&&(S={});for(_=0;_0}E(so,$t),E(so,zr);var vo=function(t){function e(e){var n=t.call(this)||this;return n.isGroup=!0,n._children=[],n.attr(e),n}return n(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.children=function(){return this._children.slice()},e.prototype.childAt=function(t){return this._children[t]},e.prototype.childOfName=function(t){for(var e=this._children,n=0;n=0&&(n.splice(i,0,t),this._doAdd(t))}return this},e.prototype.replace=function(t,e){var n=R(this._children,t);return n>=0&&this.replaceAt(e,n),this},e.prototype.replaceAt=function(t,e){var n=this._children,i=n[e];if(t&&t!==this&&t.parent!==this&&t!==i){n[e]=t,i.parent=null;var r=this.__zr;r&&i.removeSelfFromZr(r),this._doAdd(t)}return this},e.prototype._doAdd=function(t){t.parent&&t.parent.remove(t),t.parent=this;var e=this.__zr;e&&e!==t.__zr&&t.addSelfToZr(e),e&&e.refresh()},e.prototype.remove=function(t){var e=this.__zr,n=this._children,i=R(n,t);return i<0||(n.splice(i,1),t.parent=null,e&&t.removeSelfFromZr(e),e&&e.refresh()),this},e.prototype.removeAll=function(){for(var t=this._children,e=this.__zr,n=0;n0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||this._refresh({animUpdate:!1,refresh:!1,refreshHover:!0})},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*u+a}var Vo=function(t,e,n){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="0%";break;case"right":case"bottom":t="100%"}return Go(t,e,n)};function Go(t,e,n){return j(t)?Fo(t)?parseFloat(t)/100*e+(n||0):parseFloat(t):null==t?NaN:+t}function Fo(t){return!!(e=t,e.replace(/^\s+|\s+$/g,"")).match(/%$/);var e}function Ho(t,e,n){return isNaN(e)?n?""+t:+t:(e=Co(Do(0,e),20),t=(+t).toFixed(e),n?t:+t)}function Wo(t){return t.sort(function(t,e){return t-e}),t}function Uo(t){if(t=+t,isNaN(t))return 0;if(t>1e-14)for(var e=1,n=0;n<15;n++,e*=10)if(ko(t*e)/e===t)return n;return Yo(t)}function Yo(t){var e=t.toString().toLowerCase(),n=e.indexOf("e"),i=n>0?+e.slice(n+1):0,r=n>0?n:e.length,o=e.indexOf(".");return Do(0,(o<0?0:r-1-o)-i)}function Xo(t,e,n){var i=Ao(t[1]-t[0]);if(!isFinite(i)||0===i)return NaN;var r=Ro(2*Ao(n||1)*Ao(i))/No,o=Ro(Ao(e))/No,a=Do(0,Po(-r+o));return isFinite(a)||(a=NaN),a}function Zo(t,e){var n=G(t,function(t,e){return t+(isNaN(e)?0:e)},0);if(0===n)return[];for(var i=Oo(10,e),r=V(t,function(t){return(isNaN(t)?0:t)/n*i*100}),o=100*i,a=V(r,function(t){return Lo(t)}),s=G(a,function(t,e){return t+e},0),l=V(r,function(t,e){return t-a[e]});su&&(u=l[h],c=h);++a[c],l[c]=0,++s}return V(a,function(t){return t/i})}function jo(t,e){var n=Do(Uo(t),Uo(e)),i=t+e;return n>20?i:Ho(i,n)}var qo=Oo(2,53)-1;function Ko(t){var e=2*Eo;return(t%e+e)%e}function $o(t){return t>-1e-4&&t=10&&e++,e}function na(t,e){var n=ea(t),i=Oo(10,n),r=t/i;return Ho(t=(2===e?1:e?r<1.5?1:r<2.5?2:r<4?3:r<7?5:10:r<1?1:r<2?2:r<3?3:r<5?5:10)*i,-n)}function ia(t,e){var n=(t.length-1)*e+1,i=Lo(n),r=+t[i-1],o=n-i;return o?r+o*(t[i]-r):r}function ra(t){t.sort(function(t,e){return s(t,e,0)?-1:1});for(var e=-1/0,n=1,i=0;i0?t.length:0,this.item=null,this.key=NaN,this},t.prototype.next=function(){return(this._step>0?this._idx=this._end)&&(this.item=this._list[this._idx],this.key=this._idx=this._idx+this._step,!0)},t}();function Ua(t){t.option=t.parentModel=t.ecModel=null}function Ya(){return[1/0,-1/0]}function Xa(t,e){qa(e)&&(et[1]&&(t[1]=e))}function Za(t,e){qa(e)&&et[1]&&(t[1]=e)}function qa(t){return null!=t&&isFinite(t)}function Ka(t,e){return qa(t)&&qa(e)&&t<=e}function $a(t){Ka(t[0],t[1])&&t[0]>t[1]&&(t[0]=t[1])}function Ja(){var t="__ec_once_"+Qa++;return function(e,n){wt(e,t)||(e[t]=1,n())}}var Qa=sa();function ts(t,e,n){var i=mt(),r=0;B(t,function(o){var a=e(o);var s=i.get(a)||0;n&&n(o,s),s||n||(t[r++]=o),i.set(a,s+1)}),n||(t.length=r)}function es(t){return t.value+""}function ns(t){return t+""}function is(t,e){return at(e,!0)?t.seriesIndex+2:0}function rs(t,e,n){var i=t.getData().count();return{progressiveRender:n.progressiveEnabled&&e.incrementalPrepareRender&&i>=n.threshold,large:t.get("large")&&i>=t.get("largeThreshold"),modDataCount:"mod"===t.get("progressiveChunkMode")?t.getData().count():null}}function os(t,e){return{seriesType:t,overallReset:e}}function as(t){return{overallReset:t}}var ss="___EC__COMPONENT__CONTAINER___",ls="___EC__EXTENDED_CLASS___";function us(t){var e={main:"",sub:""};if(t){var n=t.split(".");e.main=n[0]||"",e.sub=n[1]||""}return e}function cs(t,e){t.$constructor=t,t.extend=function(t){var e,i,r=this;return Z(i=r)&&/^class\s/.test(Function.prototype.toString.call(i))?e=function(t){function e(){return t.apply(this,arguments)||this}return n(e,t),e}(r):(e=function(){(t.$constructor||r).apply(this,arguments)},N(e,this)),k(e.prototype,t),e[ls]=!0,e.extend=this.extend,e.superCall=ps,e.superApply=fs,e.superClass=r,e}}function hs(t,e){t.extend=e.extend}var ds=Math.round(10*Math.random());function ps(t,e){for(var n=[],i=2;i=0||r&&R(r,s)<0)){var l=n.getShallow(s,e);null!=l&&(o[t[a][0]]=l)}}return o}}var ys=vs([["fill","color"],["shadowBlur"],["shadowOffsetX"],["shadowOffsetY"],["opacity"],["shadowColor"]]),ms=function(){function t(){}return t.prototype.getAreaStyle=function(t,e){return ys(this,t,e)},t}(),xs=new ni(50);function _s(t){if("string"==typeof t){var e=xs.get(t);return e&&e.image}return t}function bs(t,e,n,i,r){if(t){if("string"==typeof t){if(e&&e.__zrImageSrc===t||!n)return e;var o=xs.get(t),a={hostEl:n,cb:i,cbPayload:r};return o?!Ss(e=o.image)&&o.pending.push(a):((e=c.loadImage(t,ws,ws)).__zrImageSrc=t,xs.put(t,e.__cachedImgObj={image:e,pending:[a]})),e}return t}return e}function ws(){var t=this.__cachedImgObj;this.onload=this.onerror=this.__cachedImgObj=null;for(var e=0;e=s;u++)l-=s;var c=Zr(a,n);return c>l&&(n="",c=0),l=t-c,r.ellipsis=n,r.ellipsisWidth=c,r.contentWidth=l,r.containerWidth=t,r}function Cs(t,e,n){var i=n.containerWidth,r=n.contentWidth,o=n.fontMeasureInfo;if(!i)return t.textLine="",void(t.isTruncated=!1);var a=Zr(o,e);if(a<=i)return t.textLine=e,void(t.isTruncated=!1);for(var s=0;;s++){if(a<=r||s>=n.maxIterations){e+=n.ellipsis;break}var l=0===s?Ds(e,r,o):a>0?Math.floor(e.length*r/a):0;a=Zr(o,e=e.substr(0,l))}""===e&&(e=n.placeholder),t.textLine=e,t.isTruncated=!0}function Ds(t,e,n){for(var i=0,r=0,o=t.length;r0&&f+i.accumWidth>i.width&&(o=e.split("\n"),h=!0),i.accumWidth=f}else{var g=Ns(e,c,i.width,i.breakAll,i.accumWidth);i.accumWidth=g.accumWidth+p,a=g.linesWidths,o=g.lines}}o||(o=e.split("\n"));for(var v=Wr(c),y=0;y=32&&e<=591||e>=880&&e<=4351||e>=4608&&e<=5119||e>=7680&&e<=8303}(t)||!!Os[t]}function Ns(t,e,n,i,r){for(var o=[],a=[],s="",l="",u=0,c=0,h=Wr(e),d=0;dn:r+c+f>n)?c?(s||l)&&(g?(s||(s=l,l="",c=u=0),o.push(s),a.push(c-u),l+=p,s="",c=u+=f):(l&&(s+=l,l="",u=0),o.push(s),a.push(c),s=p,c=f)):g?(o.push(l),a.push(u),l=p,u=f):(o.push(p),a.push(f)):(c+=f,g?(l+=p,u+=f):(l&&(s+=l,l="",u=0),s+=p))}else l&&(s+=l,c+=u),o.push(s),a.push(c),s="",l="",u=0,c=0}return l&&(s+=l),s&&(o.push(s),a.push(c)),1===o.length&&(c+=r),{accumWidth:c,lines:o,linesWidths:a}}function Es(t,e,n,i,r,o){if(t.baseX=n,t.baseY=i,t.outerWidth=t.outerHeight=null,e){var a=2*e.width,s=2*e.height;Xe.set(zs,Kr(n,a,r),$r(i,s,o),a,s),Xe.intersect(e,zs,null,Bs);var l=Bs.outIntersectRect;t.outerWidth=l.width,t.outerHeight=l.height,t.baseX=Kr(l.x,l.width,r,!0),t.baseY=$r(l.y,l.height,o,!0)}}var zs=new Xe(0,0,0,0),Bs={outIntersectRect:{},clamp:!0};function Vs(t){return null!=t?t+="":t=""}function Gs(t,e,n,i){var r=new Xe(Kr(t.x||0,e,t.textAlign),$r(t.y||0,n,t.textBaseline),e,n),o=null!=i?i:Fs(t)?t.lineWidth:0;return o>0&&(r.x-=o/2,r.y-=o/2,r.width+=o,r.height+=o),r}function Fs(t){var e=t.stroke;return null!=e&&"none"!==e&&t.lineWidth>0}var Hs="__zr_style_"+Math.round(10*Math.random()),Ws={shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,shadowColor:"#000",opacity:1,blend:"source-over"},Us={style:{shadowBlur:!0,shadowOffsetX:!0,shadowOffsetY:!0,shadowColor:!0,opacity:!0}};Ws[Hs]=!0;var Ys=["z","z2","invisible"],Xs=["invisible"],Zs=function(t){function e(e){return t.call(this,e)||this}var i;return n(e,t),e.prototype._init=function(e){for(var n=W(e),i=0;i1e-4)return s[0]=t-n,s[1]=e-i,l[0]=t+n,void(l[1]=e+i);if(el[0]=Qs(r)*n+t,el[1]=Js(r)*i+e,nl[0]=Qs(o)*n+t,nl[1]=Js(o)*i+e,u(s,el,nl),c(l,el,nl),(r%=tl)<0&&(r+=tl),(o%=tl)<0&&(o+=tl),r>o&&!a?o+=tl:rr&&(il[0]=Qs(p)*n+t,il[1]=Js(p)*i+e,u(s,il,s),c(l,il,l))}var hl={M:1,L:2,C:3,Q:4,A:5,Z:6,R:7},dl=[],pl=[],fl=[],gl=[],vl=[],yl=[],ml=Math.min,xl=Math.max,_l=Math.cos,bl=Math.sin,wl=Math.abs,Sl=Math.PI,Ml=2*Sl,Il="undefined"!=typeof Float32Array,Tl=[];function Cl(t){return Math.round(t/Sl*1e8)/1e8%2*Sl}function Dl(t,e){var n=Cl(t[0]);n<0&&(n+=Ml);var i=n-t[0],r=t[1];r+=i,!e&&r-n>=Ml?r=n+Ml:e&&n-r>=Ml?r=n-Ml:!e&&n>r?r=n+(Ml-Cl(n-r)):e&&n0&&(this._ux=wl(n/Cr/t)||0,this._uy=wl(n/Cr/e)||0)},t.prototype.setDPR=function(t){this.dpr=t},t.prototype.setContext=function(t){this._ctx=t},t.prototype.getContext=function(){return this._ctx},t.prototype.beginPath=function(){return this._ctx&&this._ctx.beginPath(),this.reset(),this},t.prototype.reset=function(){this._saveData&&(this._len=0),this._pathSegLen&&(this._pathSegLen=null,this._pathLen=0),this._version++},t.prototype.moveTo=function(t,e){return this._drawPendingPt(),this.addData(hl.M,t,e),this._ctx&&this._ctx.moveTo(t,e),this._x0=t,this._y0=e,this._xi=t,this._yi=e,this},t.prototype.lineTo=function(t,e){var n=wl(t-this._xi),i=wl(e-this._yi),r=n>this._ux||i>this._uy;if(this.addData(hl.L,t,e),this._ctx&&r&&this._ctx.lineTo(t,e),r)this._xi=t,this._yi=e,this._pendingPtDist=0;else{var o=n*n+i*i;o>this._pendingPtDist&&(this._pendingPtX=t,this._pendingPtY=e,this._pendingPtDist=o)}return this},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){return this._drawPendingPt(),this.addData(hl.C,t,e,n,i,r,o),this._ctx&&this._ctx.bezierCurveTo(t,e,n,i,r,o),this._xi=r,this._yi=o,this},t.prototype.quadraticCurveTo=function(t,e,n,i){return this._drawPendingPt(),this.addData(hl.Q,t,e,n,i),this._ctx&&this._ctx.quadraticCurveTo(t,e,n,i),this._xi=n,this._yi=i,this},t.prototype.arc=function(t,e,n,i,r,o){this._drawPendingPt(),Tl[0]=i,Tl[1]=r,Dl(Tl,o),i=Tl[0];var a=(r=Tl[1])-i;return this.addData(hl.A,t,e,n,n,i,a,0,o?0:1),this._ctx&&this._ctx.arc(t,e,n,i,r,o),this._xi=_l(r)*n+t,this._yi=bl(r)*n+e,this},t.prototype.arcTo=function(t,e,n,i,r){return this._drawPendingPt(),this._ctx&&this._ctx.arcTo(t,e,n,i,r),this},t.prototype.rect=function(t,e,n,i){return this._drawPendingPt(),this._ctx&&this._ctx.rect(t,e,n,i),this.addData(hl.R,t,e,n,i),this},t.prototype.closePath=function(){this._drawPendingPt(),this.addData(hl.Z);var t=this._ctx,e=this._x0,n=this._y0;return t&&t.closePath(),this._xi=e,this._yi=n,this},t.prototype.fill=function(t){t&&t.fill(),this.toStatic()},t.prototype.stroke=function(t){t&&t.stroke(),this.toStatic()},t.prototype.len=function(){return this._len},t.prototype.setData=function(t){if(this._saveData){var e=t.length;this.data&&this.data.length===e||!Il||(this.data=new Float32Array(e));for(var n=0;n0&&o))for(var a=0;au.length&&(this._expandData(),u=this.data);for(var c=0;c0&&(this._ctx&&this._ctx.lineTo(this._pendingPtX,this._pendingPtY),this._pendingPtDist=0)},t.prototype._expandData=function(){if(!(this.data instanceof Array)){for(var t=[],e=0;e11&&(this.data=new Float32Array(t)))}},t.prototype.getBoundingRect=function(){fl[0]=fl[1]=vl[0]=vl[1]=Number.MAX_VALUE,gl[0]=gl[1]=yl[0]=yl[1]=-Number.MAX_VALUE;var t,e=this.data,n=0,i=0,r=0,o=0;for(t=0;tn||wl(v)>i||h===e-1)&&(f=Math.sqrt(A*A+v*v),r=g,o=x);break;case hl.C:var y=t[h++],m=t[h++],x=(g=t[h++],t[h++]),_=t[h++],b=t[h++];f=Un(r,o,y,m,g,x,_,b,10),r=_,o=b;break;case hl.Q:f=Kn(r,o,y=t[h++],m=t[h++],g=t[h++],x=t[h++],10),r=g,o=x;break;case hl.A:var w=t[h++],S=t[h++],M=t[h++],I=t[h++],T=t[h++],C=t[h++],D=C+T;h+=1,p&&(a=_l(T)*M+w,s=bl(T)*I+S),f=xl(M,I)*ml(Ml,Math.abs(C)),r=_l(D)*M+w,o=bl(D)*I+S;break;case hl.R:a=r=t[h++],s=o=t[h++],f=2*t[h++]+2*t[h++];break;case hl.Z:var A=a-r;v=s-o;f=Math.sqrt(A*A+v*v),r=a,o=s}f>=0&&(l[c++]=f,u+=f)}return this._pathLen=u,u},t.prototype.rebuildPath=function(t,e){var n,i,r,o,a,s,l,u,c,h,d=this.data,p=this._ux,f=this._uy,g=this._len,v=e<1,y=0,m=0,x=0;if(!v||(this._pathSegLen||this._calculateLength(),l=this._pathSegLen,u=e*this._pathLen))t:for(var _=0;_0&&(t.lineTo(c,h),x=0),b){case hl.M:n=r=d[_++],i=o=d[_++],t.moveTo(r,o);break;case hl.L:a=d[_++],s=d[_++];var S=wl(a-r),M=wl(s-o);if(S>p||M>f){if(v){if(y+(j=l[m++])>u){var I=(u-y)/j;t.lineTo(r*(1-I)+a*I,o*(1-I)+s*I);break t}y+=j}t.lineTo(a,s),r=a,o=s,x=0}else{var T=S*S+M*M;T>x&&(c=a,h=s,x=T)}break;case hl.C:var C=d[_++],D=d[_++],A=d[_++],k=d[_++],L=d[_++],P=d[_++];if(v){if(y+(j=l[m++])>u){Hn(r,C,A,L,I=(u-y)/j,dl),Hn(o,D,k,P,I,pl),t.bezierCurveTo(dl[1],pl[1],dl[2],pl[2],dl[3],pl[3]);break t}y+=j}t.bezierCurveTo(C,D,A,k,L,P),r=L,o=P;break;case hl.Q:C=d[_++],D=d[_++],A=d[_++],k=d[_++];if(v){if(y+(j=l[m++])>u){jn(r,C,A,I=(u-y)/j,dl),jn(o,D,k,I,pl),t.quadraticCurveTo(dl[1],pl[1],dl[2],pl[2]);break t}y+=j}t.quadraticCurveTo(C,D,A,k),r=A,o=k;break;case hl.A:var O=d[_++],R=d[_++],N=d[_++],E=d[_++],z=d[_++],B=d[_++],V=d[_++],G=!d[_++],F=N>E?N:E,H=wl(N-E)>.001,W=z+B,U=!1;if(v)y+(j=l[m++])>u&&(W=z+B*(u-y)/j,U=!0),y+=j;if(H&&t.ellipse?t.ellipse(O,R,N,E,V,z,W,G):t.arc(O,R,F,z,W,G),U)break t;w&&(n=_l(z)*N+O,i=bl(z)*E+R),r=_l(W)*N+O,o=bl(W)*E+R;break;case hl.R:n=r=d[_],i=o=d[_+1],a=d[_++],s=d[_++];var Y=d[_++],X=d[_++];if(v){if(y+(j=l[m++])>u){var Z=u-y;t.moveTo(a,s),t.lineTo(a+ml(Z,Y),s),(Z-=Y)>0&&t.lineTo(a+Y,s+ml(Z,X)),(Z-=X)>0&&t.lineTo(a+xl(Y-Z,0),s+X),(Z-=Y)>0&&t.lineTo(a,s+xl(X-Z,0));break t}y+=j}t.rect(a,s,Y,X);break;case hl.Z:if(v){var j;if(y+(j=l[m++])>u){I=(u-y)/j;t.lineTo(r*(1-I)+n*I,o*(1-I)+i*I);break t}y+=j}t.closePath(),r=n,o=i}}},t.prototype.clone=function(){var e=new t,n=this.data;return e.data=n.slice?n.slice():Array.prototype.slice.call(n),e._len=this._len,e},t.prototype.canSave=function(){return!!this._saveData},t.CMD=hl,t.initDefaultProps=((e=t.prototype)._saveData=!0,e._ux=0,e._uy=0,e._pendingPtDist=0,void(e._version=0)),t}();function kl(t,e,n,i,r,o,a){if(0===r)return!1;var s=r,l=0;if(a>e+s&&a>i+s||at+s&&o>n+s||oe+h&&c>i+h&&c>o+h&&c>s+h||ct+h&&u>n+h&&u>r+h&&u>a+h||ue+u&&l>i+u&&l>o+u||lt+u&&s>n+u&&s>r+u||sn||c+ur&&(r+=Nl);var d=Math.atan2(l,s);return d<0&&(d+=Nl),d>=i&&d<=r||d+Nl>=i&&d+Nl<=r}function zl(t,e,n,i,r,o){if(o>e&&o>i||or?s:0}var Bl=Al.CMD,Vl=2*Math.PI;var Gl=[-1,-1,-1],Fl=[-1,-1];function Hl(){var t=Fl[0];Fl[0]=Fl[1],Fl[1]=t}function Wl(t,e,n,i,r,o,a,s,l,u){if(u>e&&u>i&&u>o&&u>s||u1&&Hl(),p=Bn(e,i,o,s,Fl[0]),d>1&&(f=Bn(e,i,o,s,Fl[1]))),2===d?ve&&s>i&&s>o||s=0&&c<=1&&(r[l++]=c);else{var u=a*a-4*o*s;if(En(u))(c=-a/(2*o))>=0&&c<=1&&(r[l++]=c);else if(u>0){var c,h=Dn(u),d=(-a-h)/(2*o);(c=(-a+h)/(2*o))>=0&&c<=1&&(r[l++]=c),d>=0&&d<=1&&(r[l++]=d)}}return l}(e,i,o,s,Gl);if(0===l)return 0;var u=Zn(e,i,o);if(u>=0&&u<=1){for(var c=0,h=Yn(e,i,o,u),d=0;dn||s<-n)return 0;var l=Math.sqrt(n*n-s*s);Gl[0]=-l,Gl[1]=l;var u=Math.abs(i-r);if(u<1e-4)return 0;if(u>=Vl-1e-4){i=0,r=Vl;var c=o?1:-1;return a>=Gl[0]+t&&a<=Gl[1]+t?c:0}if(i>r){var h=i;i=r,r=h}i<0&&(i+=Vl,r+=Vl);for(var d=0,p=0;p<2;p++){var f=Gl[p];if(f+t>a){var g=Math.atan2(s,f);c=o?1:-1;g<0&&(g=Vl+g),(g>=i&&g<=r||g+Vl>=i&&g+Vl<=r)&&(g>Math.PI/2&&g<1.5*Math.PI&&(c=-c),d+=c)}}return d}function Xl(t,e,n,i,r){for(var o,a,s,l,u=t.data,c=t.len(),h=0,d=0,p=0,f=0,g=0,v=0;v1&&(n||(h+=zl(d,p,f,g,i,r))),m&&(f=d=u[v],g=p=u[v+1]),y){case Bl.M:d=f=u[v++],p=g=u[v++];break;case Bl.L:if(n){if(kl(d,p,u[v],u[v+1],e,i,r))return!0}else h+=zl(d,p,u[v],u[v+1],i,r)||0;d=u[v++],p=u[v++];break;case Bl.C:if(n){if(Ll(d,p,u[v++],u[v++],u[v++],u[v++],u[v],u[v+1],e,i,r))return!0}else h+=Wl(d,p,u[v++],u[v++],u[v++],u[v++],u[v],u[v+1],i,r)||0;d=u[v++],p=u[v++];break;case Bl.Q:if(n){if(Pl(d,p,u[v++],u[v++],u[v],u[v+1],e,i,r))return!0}else h+=Ul(d,p,u[v++],u[v++],u[v],u[v+1],i,r)||0;d=u[v++],p=u[v++];break;case Bl.A:var x=u[v++],_=u[v++],b=u[v++],w=u[v++],S=u[v++],M=u[v++];v+=1;var I=!!(1-u[v++]);o=Math.cos(S)*b+x,a=Math.sin(S)*w+_,m?(f=o,g=a):h+=zl(d,p,o,a,i,r);var T=(i-x)*w/b+x;if(n){if(El(x,_,w,S,S+M,I,e,T,r))return!0}else h+=Yl(x,_,w,S,S+M,I,T,r);d=Math.cos(S+M)*b+x,p=Math.sin(S+M)*w+_;break;case Bl.R:if(f=d=u[v++],g=p=u[v++],o=f+u[v++],a=g+u[v++],n){if(kl(f,g,o,g,e,i,r)||kl(o,g,o,a,e,i,r)||kl(o,a,f,a,e,i,r)||kl(f,a,f,g,e,i,r))return!0}else h+=zl(o,g,o,a,i,r),h+=zl(f,a,f,g,i,r);break;case Bl.Z:if(n){if(kl(d,p,f,g,e,i,r))return!0}else h+=zl(d,p,f,g,i,r);d=f,p=g}}return n||(s=p,l=g,Math.abs(s-l)<1e-4)||(h+=zl(d,p,f,g,i,r)||0),0!==h}var Zl=P({fill:"#000",stroke:null,strokePercent:1,fillOpacity:1,strokeOpacity:1,lineDashOffset:0,lineWidth:1,lineCap:"butt",miterLimit:10,strokeNoScale:!1,strokeFirst:!1},Ws),jl={style:P({fill:!0,stroke:!0,strokePercent:!0,fillOpacity:!0,strokeOpacity:!0,lineDashOffset:!0,lineWidth:!0,miterLimit:!0},Us.style)},ql=Fr.concat(["invisible","culling","z","z2","zlevel","parent"]),Kl=function(t){function e(e){return t.call(this,e)||this}var i;return n(e,t),e.prototype.update=function(){var n=this;t.prototype.update.call(this);var i=this.style;if(i.decal){var r=this._decalEl=this._decalEl||new e;r.buildPath===e.prototype.buildPath&&(r.buildPath=function(t){n.buildPath(t,n.shape)}),r.silent=!0;var o=r.style;for(var a in i)o[a]!==i[a]&&(o[a]=i[a]);o.fill=i.fill?i.decal:null,o.decal=null,o.shadowColor=null,i.strokeFirst&&(o.stroke=null);for(var s=0;s.5?Dr:e>.2?"#eee":Ar}if(t)return Ar}return Dr},e.prototype.getInsideTextStroke=function(t){var e=this.style.fill;if(j(e)){var n=this.__zr;if(!(!n||!n.isDarkMode())===Ii(t,0)<.4)return e}},e.prototype.buildPath=function(t,e,n){},e.prototype.pathUpdated=function(){this.__dirty&=-5},e.prototype.getUpdatedPathProxy=function(t){return!this.path&&this.createPathProxy(),this.path.beginPath(),this.buildPath(this.path,this.shape,t),this.path},e.prototype.createPathProxy=function(){this.path=new Al(!1)},e.prototype.hasStroke=function(){var t=this.style,e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))},e.prototype.hasFill=function(){var t=this.style.fill;return null!=t&&"none"!==t},e.prototype.getBoundingRect=function(){var t=this._rect,e=this.style,n=!t;if(n){var i=!1;this.path||(i=!0,this.createPathProxy());var r=this.path;(i||4&this.__dirty)&&(r.beginPath(),this.buildPath(r,this.shape,!1),this.pathUpdated()),t=r.getBoundingRect()}if(this._rect=t,this.hasStroke()&&this.path&&this.path.len()>0){var o=this._rectStroke||(this._rectStroke=t.clone());if(this.__dirty||n){o.copy(t);var a=e.strokeNoScale?this.getLineScale():1,s=e.lineWidth;if(!this.hasFill()){var l=this.strokeContainThreshold;s=Math.max(s,null==l?4:l)}a>1e-10&&(o.width+=s/a,o.height+=s/a,o.x-=s/a/2,o.y-=s/a/2)}return o}return t},e.prototype.contain=function(t,e){var n=this.transformCoordToLocal(t,e),i=this.getBoundingRect(),r=this.style;if(t=n[0],e=n[1],i.contain(t,e)){var o=this.path;if(this.hasStroke()){var a=r.lineWidth,s=r.strokeNoScale?this.getLineScale():1;if(s>1e-10&&(this.hasFill()||(a=Math.max(a,this.strokeContainThreshold)),function(t,e,n,i){return Xl(t,e,!0,n,i)}(o,a/s,t,e)))return!0}if(this.hasFill())return function(t,e,n){return Xl(t,0,!1,e,n)}(o,t,e)}return!1},e.prototype.dirtyShape=function(){this.__dirty|=4,this._rect&&(this._rect=null),this._decalEl&&this._decalEl.dirtyShape(),this.markRedraw()},e.prototype.dirty=function(){this.dirtyStyle(),this.dirtyShape()},e.prototype.animateShape=function(t){return this.animate("shape",t)},e.prototype.updateDuringAnimation=function(t){"style"===t?this.dirtyStyle():"shape"===t?this.dirtyShape():this.markRedraw()},e.prototype.attrKV=function(e,n){"shape"===e?this.setShape(n):t.prototype.attrKV.call(this,e,n)},e.prototype.setShape=function(t,e){var n=this.shape;return n||(n=this.shape={}),"string"==typeof t?n[t]=e:k(n,t),this.dirtyShape(),this},e.prototype.shapeChanged=function(){return!!(4&this.__dirty)},e.prototype.createStyle=function(t){return _t(Zl,t)},e.prototype._innerSaveToNormal=function(e){t.prototype._innerSaveToNormal.call(this,e);var n=this._normalState;e.shape&&!n.shape&&(n.shape=k({},this.shape))},e.prototype._applyStateObj=function(e,n,i,r,o,a){if(t.prototype._applyStateObj.call(this,e,n,i,r,o,a),1!==this.__inHover){var s,l=!(n&&r);if(n&&n.shape?o?r?s=n.shape:(s=k({},i.shape),k(s,n.shape)):(s=k({},r?this.shape:i.shape),k(s,n.shape)):l&&(s=i.shape),s)if(o){this.shape=k({},this.shape);for(var u={},c=W(s),h=0;hu&&(n*=u/(a=n+i),i*=u/a),r+o>u&&(r*=u/(a=r+o),o*=u/a),i+r>c&&(i*=c/(a=i+r),r*=c/a),n+o>c&&(n*=c/(a=n+o),o*=c/a),t.moveTo(s+n,l),t.lineTo(s+u-i,l),0!==i&&t.arc(s+u-i,l+i,i,-Math.PI/2,0),t.lineTo(s+u,l+c-r),0!==r&&t.arc(s+u-r,l+c-r,r,0,Math.PI/2),t.lineTo(s+o,l+c),0!==o&&t.arc(s+o,l+c-o,o,Math.PI/2,Math.PI),t.lineTo(s,l+n),0!==n&&t.arc(s+n,l+n,n,Math.PI,1.5*Math.PI),t.closePath()}(t,e):t.rect(n,i,r,o)},e.prototype.isZeroArea=function(){return!this.shape.width||!this.shape.height},e}(Kl);lu.prototype.type="rect";var uu={fill:"#000"},cu={},hu={style:P({fill:!0,stroke:!0,fillOpacity:!0,strokeOpacity:!0,lineWidth:!0,fontSize:!0,lineHeight:!0,width:!0,height:!0,textShadowColor:!0,textShadowBlur:!0,textShadowOffsetX:!0,textShadowOffsetY:!0,backgroundColor:!0,padding:!0,borderColor:!0,borderWidth:!0,borderRadius:!0},Us.style)},du=function(t){function e(e){var n=t.call(this)||this;return n.type="text",n._children=[],n._defaultStyle=uu,n.attr(e),n}return n(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.update=function(){t.prototype.update.call(this),this.styleChanged()&&this._updateSubTexts();for(var e=0;ey&&p){var x=Math.floor(y/d);f=f||v.length>x,m=(v=v.slice(0,x)).length*d}if(r&&c&&null!=g)for(var _=Ts(g,u,e.ellipsis,{minChar:e.truncateMinChar,placeholder:e.placeholder}),b={},w=0;w0,T=0;Tg&&Ps(o,a.substring(g,v),e,f),Ps(o,d[2],e,f,d[1]),g=Ms.lastIndex}gh){var R=o.lines.length;D>0?(I.tokens=I.tokens.slice(0,D),S(I,C,T),o.lines=o.lines.slice(0,M+1)):o.lines=o.lines.slice(0,M),o.isTruncated=o.isTruncated||o.lines.length=0&&"right"===(C=x[T]).align;)this._placeToken(C,t,b,f,I,"right",v),w-=C.width,I-=C.width,T--;for(M+=(s-(M-p)-(g-I)-w)/2;S<=T;)C=x[S],this._placeToken(C,t,b,f,M+C.width/2,"center",v),M+=C.width,S++;f+=b}},e.prototype._placeToken=function(t,e,n,i,r,o,s){var l=e.rich[t.styleName]||{};l.text=t.text;var u=t.verticalAlign,c=i+n/2;"top"===u?c=i+t.height/2:"bottom"===u&&(c=i+n-t.height/2),!t.isLineHolder&&Mu(l)&&this._renderBackground(l,e,"right"===o?r-t.width:"center"===o?r-t.width/2:r,c-t.height/2,t.width,t.height);var h=!!l.backgroundColor,d=t.textPadding;d&&(r=wu(r,o,d),c-=t.height/2-d[0]-t.innerHeight/2);var p=this._getOrCreateChild(Jl),f=p.createStyle();p.useStyle(f);var g=this._defaultStyle,v=!1,y=0,m=!1,x=bu("fill"in l?l.fill:"fill"in e?e.fill:(v=!0,g.fill)),_=_u("stroke"in l?l.stroke:"stroke"in e?e.stroke:h||s||g.autoStroke&&!v?null:(y=2,m=!0,g.stroke)),b=l.textShadowBlur>0||e.textShadowBlur>0;f.text=t.text,f.x=r,f.y=c,b&&(f.shadowBlur=l.textShadowBlur||e.textShadowBlur||0,f.shadowColor=l.textShadowColor||e.textShadowColor||"transparent",f.shadowOffsetX=l.textShadowOffsetX||e.textShadowOffsetX||0,f.shadowOffsetY=l.textShadowOffsetY||e.textShadowOffsetY||0),f.textAlign=o,f.textBaseline="middle",f.font=t.font||a,f.opacity=st(l.opacity,e.opacity,1),yu(f,l),_&&(f.lineWidth=st(l.lineWidth,e.lineWidth,y),f.lineDash=at(l.lineDash,e.lineDash),f.lineDashOffset=e.lineDashOffset||0,f.stroke=_),x&&(f.fill=x),p.setBoundingRect(Gs(f,t.contentWidth,t.contentHeight,m?0:null))},e.prototype._renderBackground=function(t,e,n,i,r,o){var a,s,l,u=t.backgroundColor,c=t.borderWidth,h=t.borderColor,d=u&&u.image,p=u&&!d,f=t.borderRadius,g=this;if(p||t.lineHeight||c&&h){(a=this._getOrCreateChild(lu)).useStyle(a.createStyle()),a.style.fill=null;var v=a.shape;v.x=n,v.y=i,v.width=r,v.height=o,v.r=f,a.dirtyShape()}if(p)(l=a.style).fill=u||null,l.fillOpacity=at(t.fillOpacity,1);else if(d){(s=this._getOrCreateChild(eu)).onload=function(){g.dirtyStyle()};var y=s.style;y.image=u.image,y.x=n,y.y=i,y.width=r,y.height=o}c&&h&&((l=a.style).lineWidth=c,l.stroke=h,l.strokeOpacity=at(t.strokeOpacity,1),l.lineDash=t.borderDash,l.lineDashOffset=t.borderDashOffset||0,a.strokeContainThreshold=0,a.hasFill()&&a.hasStroke()&&(l.strokeFirst=!0,l.lineWidth*=2));var m=(a||s).style;m.shadowBlur=t.shadowBlur||0,m.shadowColor=t.shadowColor||"transparent",m.shadowOffsetX=t.shadowOffsetX||0,m.shadowOffsetY=t.shadowOffsetY||0,m.opacity=st(t.opacity,e.opacity,1)},e.makeFont=function(t){var e="";return mu(t)&&(e=[t.fontStyle,t.fontWeight,vu(t.fontSize),t.fontFamily||"sans-serif"].join(" ")),e&&ht(e)||t.textFont||t.font},e}(Zs),pu={left:!0,right:1,center:1},fu={top:1,bottom:1,middle:1},gu=["fontStyle","fontWeight","fontSize","fontFamily"];function vu(t){return"string"!=typeof t||-1===t.indexOf("px")&&-1===t.indexOf("rem")&&-1===t.indexOf("em")?isNaN(+t)?"12px":t+"px":t}function yu(t,e){for(var n=0;n=0,o=!1;if(t instanceof Kl){var a=Uu(t),s=r&&a.selectFill||a.normalFill,l=r&&a.selectStroke||a.normalStroke;if(ec(s)||ec(l)){var u=(i=i||{}).style||{};"inherit"===u.fill?(o=!0,i=k({},i),(u=k({},u)).fill=s):!ec(u.fill)&&ec(s)?(o=!0,i=k({},i),(u=k({},u)).fill=Ci(s)):!ec(u.stroke)&&ec(l)&&(o||(i=k({},i),u=k({},u)),u.stroke=Ci(l)),i.style=u}}if(i&&null==i.z2){o||(i=k({},i));var c=t.z2EmphasisLift;i.z2=t.z2+(null!=c?c:ju)}return i}(this,0,e,n);if("blur"===t)return function(t,e,n){var i=R(t.currentStates,e)>=0,r=t.style.opacity,o=i?null:function(t,e,n,i){for(var r=t.style,o={},a=0;a0){var o={dataIndex:r,seriesIndex:t.seriesIndex};null!=i&&(o.dataType=i),e.push(o)}})}),e}function Ac(t,e,n){Nc(t,!0),cc(t,pc),Lc(t,e,n)}function kc(t,e,n,i){i?function(t){Nc(t,!1)}(t):Ac(t,e,n)}function Lc(t,e,n){var i=Iu(t);null!=e?(i.focus=e,i.blurScope=n):i.focus&&(i.focus=null)}var Pc=["emphasis","blur","select"],Oc={itemStyle:"getItemStyle",lineStyle:"getLineStyle",areaStyle:"getAreaStyle"};function Rc(t,e,n,i){n=n||"itemStyle";for(var r=0;r1&&(a*=Uc(f),s*=Uc(f));var g=(r===o?-1:1)*Uc((a*a*(s*s)-a*a*(p*p)-s*s*(d*d))/(a*a*(p*p)+s*s*(d*d)))||0,v=g*a*p/s,y=g*-s*d/a,m=(t+n)/2+Xc(h)*v-Yc(h)*y,x=(e+i)/2+Yc(h)*v+Xc(h)*y,_=Kc([1,0],[(d-v)/a,(p-y)/s]),b=[(d-v)/a,(p-y)/s],w=[(-1*d-v)/a,(-1*p-y)/s],S=Kc(b,w);if(qc(b,w)<=-1&&(S=Zc),qc(b,w)>=1&&(S=0),S<0){var M=Math.round(S/Zc*1e6)/1e6;S=2*Zc+M%2*Zc}c.addData(u,m,x,a,s,_,S,h,o)}var Jc=/([mlvhzcqtsa])([^mlvhzcqtsa]*)/gi,Qc=/-?([0-9]*\.)?[0-9]+([eE]-?[0-9]+)?/g;var th=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.applyTransform=function(t){},e}(Kl);function eh(t){return null!=t.setData}function nh(t,e){var n=function(t){var e=new Al;if(!t)return e;var n,i=0,r=0,o=i,a=r,s=Al.CMD,l=t.match(Jc);if(!l)return e;for(var u=0;uk*k+L*L&&(M=T,I=C),{cx:M,cy:I,x0:-c,y0:-h,x1:M*(r/b-1),y1:I*(r/b-1)}}function bh(t,e){var n,i=yh(e.r,0),r=yh(e.r0||0,0),o=i>0;if(o||r>0){if(o||(i=r,r=0),r>i){var a=i;i=r,r=a}var s=e.startAngle,l=e.endAngle;if(!isNaN(s)&&!isNaN(l)){var u=e.cx,c=e.cy,h=!!e.clockwise,d=gh(l-s),p=d>ch&&d%ch;if(p>xh&&(d=p),i>xh)if(d>ch-xh)t.moveTo(u+i*dh(s),c+i*hh(s)),t.arc(u,c,i,s,l,!h),r>xh&&(t.moveTo(u+r*dh(l),c+r*hh(l)),t.arc(u,c,r,l,s,h));else{var f=void 0,g=void 0,v=void 0,y=void 0,m=void 0,x=void 0,_=void 0,b=void 0,w=void 0,S=void 0,M=void 0,I=void 0,T=void 0,C=void 0,D=void 0,A=void 0,k=i*dh(s),L=i*hh(s),P=r*dh(l),O=r*hh(l),R=d>xh;if(R){var N=e.cornerRadius;N&&(n=function(t){var e;if(X(t)){var n=t.length;if(!n)return t;e=1===n?[t[0],t[0],0,0]:2===n?[t[0],t[0],t[1],t[1]]:3===n?t.concat(t[2]):t}else e=[t,t,t,t];return e}(N),f=n[0],g=n[1],v=n[2],y=n[3]);var E=gh(i-r)/2;if(m=mh(E,v),x=mh(E,y),_=mh(E,f),b=mh(E,g),M=w=yh(m,x),I=S=yh(_,b),(w>xh||S>xh)&&(T=i*dh(l),C=i*hh(l),D=r*dh(s),A=r*hh(s),dxh){var U=mh(v,M),Y=mh(y,M),Z=_h(D,A,k,L,i,U,h),j=_h(T,C,P,O,i,Y,h);t.moveTo(u+Z.cx+Z.x0,c+Z.cy+Z.y0),M0&&t.arc(u+Z.cx,c+Z.cy,U,fh(Z.y0,Z.x0),fh(Z.y1,Z.x1),!h),t.arc(u,c,i,fh(Z.cy+Z.y1,Z.cx+Z.x1),fh(j.cy+j.y1,j.cx+j.x1),!h),Y>0&&t.arc(u+j.cx,c+j.cy,Y,fh(j.y1,j.x1),fh(j.y0,j.x0),!h))}else t.moveTo(u+k,c+L),t.arc(u,c,i,s,l,!h);else t.moveTo(u+k,c+L);if(r>xh&&R)if(I>xh){U=mh(f,I),Z=_h(P,O,T,C,r,-(Y=mh(g,I)),h),j=_h(k,L,D,A,r,-U,h);t.lineTo(u+Z.cx+Z.x0,c+Z.cy+Z.y0),I0&&t.arc(u+Z.cx,c+Z.cy,Y,fh(Z.y0,Z.x0),fh(Z.y1,Z.x1),!h),t.arc(u,c,r,fh(Z.cy+Z.y1,Z.cx+Z.x1),fh(j.cy+j.y1,j.cx+j.x1),h),U>0&&t.arc(u+j.cx,c+j.cy,U,fh(j.y1,j.x1),fh(j.y0,j.x0),!h))}else t.lineTo(u+P,c+O),t.arc(u,c,r,l,s,h);else t.lineTo(u+P,c+O)}else t.moveTo(u,c);t.closePath()}}}var wh=function(){this.cx=0,this.cy=0,this.r0=0,this.r=0,this.startAngle=0,this.endAngle=2*Math.PI,this.clockwise=!0,this.cornerRadius=0},Sh=function(t){function e(e){return t.call(this,e)||this}return n(e,t),e.prototype.getDefaultShape=function(){return new wh},e.prototype.buildPath=function(t,e){bh(t,e)},e.prototype.isZeroArea=function(){return this.shape.startAngle===this.shape.endAngle||this.shape.r===this.shape.r0},e}(Kl);Sh.prototype.type="sector";var Mh=function(){this.cx=0,this.cy=0,this.r=0,this.r0=0},Ih=function(t){function e(e){return t.call(this,e)||this}return n(e,t),e.prototype.getDefaultShape=function(){return new Mh},e.prototype.buildPath=function(t,e){var n=e.cx,i=e.cy,r=2*Math.PI;t.moveTo(n+e.r,i),t.arc(n,i,e.r,0,r,!1),t.moveTo(n+e.r0,i),t.arc(n,i,e.r0,0,r,!0)},e}(Kl);function Th(t,e,n){var i=e.smooth,r=e.points;if(r&&r.length>=2){if(i){var o=function(t,e,n,i){var r,o,a,s,l=[],u=[],c=[],h=[];if(i){a=[1/0,1/0],s=[-1/0,-1/0];for(var d=0,p=t.length;djh[1]){if(r=!1,qh.negativeSize||n)return r;var s=Xh(jh[0]-Zh[1]),l=Xh(Zh[0]-jh[1]);Uh(s,l)>$h.len()&&(s=l||!qh.bidirectional)&&(Pe.scale(Kh,a,-l*i),qh.useDir&&qh.calcDirMTV()))}}return r},t.prototype._getProjMinMaxOnAxis=function(t,e,n){for(var i=this._axes[t],r=this._origin,o=e[0].dot(i)+r[t],a=o,s=o,l=1;l0){var h={duration:c.duration,delay:c.delay||0,easing:c.easing,done:o,force:!!o||!!a,setToFinal:!u,scope:t,during:a};l?e.animateFrom(n,h):e.animateTo(n,h)}else e.stopAnimation(),!l&&e.attr(n),a&&a(1),o&&o()}function rd(t,e,n,i,r,o){id("update",t,e,n,i,r,o)}function od(t,e,n,i,r,o){id("enter",t,e,n,i,r,o)}function ad(t){if(!t.__zr)return!0;for(var e=0;eAo(o[1])?o[0]>0?"right":"left":o[1]>0?"bottom":"top"}function Ad(t){return!t.isGroup}function kd(t,e,n){if(t&&e){var i,r=(i={},t.traverse(function(t){Ad(t)&&t.anid&&(i[t.anid]=t)}),i);e.traverse(function(t){if(Ad(t)&&t.anid){var e=r[t.anid];if(e){var i=o(t);t.attr(o(e)),rd(t,i,n,Iu(t).dataIndex)}}})}function o(t){var e={x:t.x,y:t.y,rotation:t.rotation};return function(t){return null!=t.shape}(t)&&(e.shape=C(t.shape)),e}}function Ld(t,e){return V(t,function(t){var n=t[0];n=Do(n,e.x),n=Co(n,e.x+e.width);var i=t[1];return i=Do(i,e.y),[n,i=Co(i,e.y+e.height)]})}function Pd(t,e){var n=Do(t.x,e.x),i=Co(t.x+t.width,e.x+e.width),r=Do(t.y,e.y),o=Co(t.y+t.height,e.y+e.height);if(i>=n&&o>=r)return{x:n,y:r,width:i-n,height:o-r}}function Od(t,e,n){var i=k({rectHover:!0},e),r=i.style={strokeNoScale:!0};if(n=n||{x:-1,y:-1,width:2,height:2},t)return 0===t.indexOf("image://")?(r.image=t.slice(8),P(r,n),new eu(i)):xd(t.replace("path://",""),i,n,"center")}function Rd(t,e,n,i,r){for(var o=0,a=r[r.length-1];o=-1e-6)return!1;var f=t-r,g=e-o,v=Ed(f,g,u,c)/p;if(v<0||v>1)return!1;var y=Ed(f,g,h,d)/p;return!(y<0||y>1)}function Ed(t,e,n,i){return t*i-n*e}function zd(t,e,n,i,r){return null==e||(K(e)?Bd[0]=Bd[1]=Bd[2]=Bd[3]=e:(Bd[0]=e[0],Bd[1]=e[1],Bd[2]=e[2],Bd[3]=e[3]),i&&(Bd[0]=Do(0,Bd[0]),Bd[1]=Do(0,Bd[1]),Bd[2]=Do(0,Bd[2]),Bd[3]=Do(0,Bd[3])),n&&(Bd[0]=-Bd[0],Bd[1]=-Bd[1],Bd[2]=-Bd[2],Bd[3]=-Bd[3]),Vd(t,Bd,"x","width",3,1,r&&r[0]||0),Vd(t,Bd,"y","height",0,2,r&&r[1]||0)),t}var Bd=[0,0,0,0];function Vd(t,e,n,i,r,o,a){var s=e[o]+e[r],l=t[i];t[i]+=s,a=Do(0,Co(a,l)),t[i]=0?-e[r]:e[o]>=0?l+e[o]:Ao(s)>1e-8?(l-a)*e[r]/s:0):t[n]-=e[r]}function Gd(t){var e=t.itemTooltipOption,n=t.componentModel,i=t.itemName,r=j(e)?{formatter:e}:e,o=n.mainType,a=n.componentIndex,s={componentType:o,name:i,$vars:["name"]};s[o+"Index"]=a;var l=t.formatterParamsExtra;l&&B(W(l),function(t){wt(s,t)||(s[t]=l[t],s.$vars.push(t))});var u=Iu(t.el);u.componentMainType=o,u.componentIndex=a,u.tooltipConfig={name:i,option:P({content:i,encodeHTMLContent:!0,formatterParams:s},r)}}function Fd(t,e){var n;t.isGroup&&(n=e(t)),n||t.traverse(e)}function Hd(t,e){if(t)if(X(t))for(var n=0;ne&&(e=i),ie&&(n=e=0),{min:n,max:e}}function qd(t,e,n){Kd(t,e,n,-1/0)}function Kd(t,e,n,i){if(t.ignoreModelZ)return i;var r=t.getTextContent(),o=t.getTextGuideLine();if(t.isGroup)for(var a=t.childrenRef(),s=0;s-1?Rp:Ep;function Gp(t,e){t=t.toUpperCase(),Bp[t]=new kp(e),zp[t]=e}function Fp(t){return Bp[t]}Gp(Np,{time:{month:["January","February","March","April","May","June","July","August","September","October","November","December"],monthAbbr:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayOfWeek:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayOfWeekAbbr:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]},legend:{selector:{all:"All",inverse:"Inv"}},toolbox:{brush:{title:{rect:"Box Select",polygon:"Lasso Select",lineX:"Horizontally Select",lineY:"Vertically Select",keep:"Keep Selections",clear:"Clear Selections"}},dataView:{title:"Data View",lang:["Data View","Close","Refresh"]},dataZoom:{title:{zoom:"Zoom",back:"Zoom Reset"}},magicType:{title:{line:"Switch to Line Chart",bar:"Switch to Bar Chart",stack:"Stack",tiled:"Tile"}},restore:{title:"Restore"},saveAsImage:{title:"Save as Image",lang:["Right Click to Save Image"]}},series:{typeNames:{pie:"Pie chart",bar:"Bar chart",line:"Line chart",scatter:"Scatter plot",effectScatter:"Ripple scatter plot",radar:"Radar chart",tree:"Tree",treemap:"Treemap",boxplot:"Boxplot",candlestick:"Candlestick",k:"K line chart",heatmap:"Heat map",map:"Map",parallel:"Parallel coordinate map",lines:"Line graph",graph:"Relationship graph",sankey:"Sankey diagram",funnel:"Funnel chart",gauge:"Gauge",pictorialBar:"Pictorial bar",themeRiver:"Theme River Map",sunburst:"Sunburst",custom:"Custom chart",chart:"Chart"}},aria:{general:{withTitle:'This is a chart about "{title}"',withoutTitle:"This is a chart"},series:{single:{prefix:"",withName:" with type {seriesType} named {seriesName}.",withoutName:" with type {seriesType}."},multiple:{prefix:". It consists of {seriesCount} series count.",withName:" The {seriesId} series is a {seriesType} representing {seriesName}.",withoutName:" The {seriesId} series is a {seriesType}.",separator:{middle:"",end:""}}},data:{allData:"The data is as follows: ",partialData:"The first {displayCnt} items are: ",withName:"the data for {name} is {value}",withoutName:"{value}",separator:{middle:", ",end:". "}}}}),Gp(Rp,{time:{month:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],monthAbbr:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayOfWeek:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"],dayOfWeekAbbr:["日","一","二","三","四","五","六"]},legend:{selector:{all:"全选",inverse:"反选"}},toolbox:{brush:{title:{rect:"矩形选择",polygon:"圈选",lineX:"横向选择",lineY:"纵向选择",keep:"保持选择",clear:"清除选择"}},dataView:{title:"数据视图",lang:["数据视图","关闭","刷新"]},dataZoom:{title:{zoom:"区域缩放",back:"区域缩放还原"}},magicType:{title:{line:"切换为折线图",bar:"切换为柱状图",stack:"切换为堆叠",tiled:"切换为平铺"}},restore:{title:"还原"},saveAsImage:{title:"保存为图片",lang:["右键另存为图片"]}},series:{typeNames:{pie:"饼图",bar:"柱状图",line:"折线图",scatter:"散点图",effectScatter:"涟漪散点图",radar:"雷达图",tree:"树图",treemap:"矩形树图",boxplot:"箱型图",candlestick:"K线图",k:"K线图",heatmap:"热力图",map:"地图",parallel:"平行坐标图",lines:"线图",graph:"关系图",sankey:"桑基图",funnel:"漏斗图",gauge:"仪表盘图",pictorialBar:"象形柱图",themeRiver:"主题河流图",sunburst:"旭日图",custom:"自定义图表",chart:"图表"}},aria:{general:{withTitle:"这是一个关于“{title}”的图表。",withoutTitle:"这是一个图表,"},series:{single:{prefix:"",withName:"图表类型是{seriesType},表示{seriesName}。",withoutName:"图表类型是{seriesType}。"},multiple:{prefix:"它由{seriesCount}个图表系列组成。",withName:"第{seriesId}个系列是一个表示{seriesName}的{seriesType},",withoutName:"第{seriesId}个系列是一个{seriesType},",separator:{middle:";",end:"。"}}},data:{allData:"其数据是——",partialData:"其中,前{displayCnt}项是——",withName:"{name}的数据是{value}",withoutName:"{value}",separator:{middle:",",end:""}}}});var Hp=null;function Wp(){return Hp}function Up(t,e){var n=Wp(),i=e.breakOption,r=e.breakParsed;return!r&&n&&(r=n.parseAxisBreakOption(i,t)),r}function Yp(t){var e=t.brk;return e?e.breaks:[]}function Xp(t){var e=t.brk;return!!e&&e.hasBreaks()}var Zp=1e3,jp=6e4,qp=36e5,Kp=864e5,$p=31536e6,Jp={year:/({yyyy}|{yy})/,month:/({MMMM}|{MMM}|{MM}|{M})/,day:/({dd}|{d})/,hour:/({HH}|{H}|{hh}|{h})/,minute:/({mm}|{m})/,second:/({ss}|{s})/,millisecond:/({SSS}|{S})/},Qp={year:"{yyyy}",month:"{MMM}",day:"{d}",hour:"{HH}:{mm}",minute:"{HH}:{mm}",second:"{HH}:{mm}:{ss}",millisecond:"{HH}:{mm}:{ss} {SSS}"},tf="{yyyy}-{MM}-{dd}",ef={year:"{yyyy}",month:"{yyyy}-{MM}",day:tf,hour:tf+" "+Qp.hour,minute:tf+" "+Qp.minute,second:tf+" "+Qp.second,millisecond:"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss} {SSS}"},nf=["year","month","day","hour","minute","second","millisecond"],rf=["year","half-year","quarter","month","week","half-week","day","half-day","quarter-day","hour","minute","second","millisecond"];function of(t){return j(t)||Z(t)?t:function(t){t=t||{};var e={},n=!0;return B(nf,function(e){n&&(n=null==t[e])}),B(nf,function(i,r){var o=t[i];e[i]={};for(var a=null,s=r;s>=0;s--){var l=nf[s],u=$(o)&&!X(o)?o[l]:o,c=void 0;X(u)?a=(c=u.slice())[0]||"":j(u)?c=[a=u]:(null==a?a=Qp[i]:Jp[l].test(a)||(a=e[l][l][0]+" "+a),c=[a],n&&(c[1]="{primary|"+a+"}")),e[i][l]=c}}),e}(t)}function af(t,e){return"0000".substr(0,e-(t+="").length)+t}function sf(t){switch(t){case"half-year":case"quarter":return"month";case"week":case"half-week":return"day";case"half-day":case"quarter-day":return"hour";default:return t}}function lf(t){return t===sf(t)}function uf(t,e,n,i){var r=Qo(t),o=r[df(n)](),a=r[pf(n)]()+1,s=Math.floor((a-1)/3)+1,l=r[ff(n)](),u=r["get"+(n?"UTC":"")+"Day"](),c=r[gf(n)](),h=(c-1)%12+1,d=r[vf(n)](),p=r[yf(n)](),f=r[mf(n)](),g=c>=12?"pm":"am",v=g.toUpperCase(),y=(i instanceof kp?i:Fp(i||Vp)||Bp[Ep]).getModel("time"),m=y.get("month"),x=y.get("monthAbbr"),_=y.get("dayOfWeek"),b=y.get("dayOfWeekAbbr");return(e||"").replace(/{a}/g,g+"").replace(/{A}/g,v+"").replace(/{yyyy}/g,o+"").replace(/{yy}/g,af(o%100+"",2)).replace(/{Q}/g,s+"").replace(/{MMMM}/g,m[a-1]).replace(/{MMM}/g,x[a-1]).replace(/{MM}/g,af(a,2)).replace(/{M}/g,a+"").replace(/{dd}/g,af(l,2)).replace(/{d}/g,l+"").replace(/{eeee}/g,_[u]).replace(/{ee}/g,b[u]).replace(/{e}/g,u+"").replace(/{HH}/g,af(c,2)).replace(/{H}/g,c+"").replace(/{hh}/g,af(h+"",2)).replace(/{h}/g,h+"").replace(/{mm}/g,af(d,2)).replace(/{m}/g,d+"").replace(/{ss}/g,af(p,2)).replace(/{s}/g,p+"").replace(/{SSS}/g,af(f,3)).replace(/{S}/g,f+"")}function cf(t,e){var n=Qo(t),i=n[pf(e)]()+1,r=n[ff(e)](),o=n[gf(e)](),a=n[vf(e)](),s=n[yf(e)](),l=0===n[mf(e)](),u=l&&0===s,c=u&&0===a,h=c&&0===o,d=h&&1===r;return d&&1===i?"year":d?"month":h?"day":c?"hour":u?"minute":l?"second":"millisecond"}function hf(t,e,n){switch(e){case"year":t[_f(n)](0);case"month":t[bf(n)](1);case"day":t[wf(n)](0);case"hour":t[Sf(n)](0);case"minute":t[Mf(n)](0);case"second":t[If(n)](0)}return t}function df(t){return t?"getUTCFullYear":"getFullYear"}function pf(t){return t?"getUTCMonth":"getMonth"}function ff(t){return t?"getUTCDate":"getDate"}function gf(t){return t?"getUTCHours":"getHours"}function vf(t){return t?"getUTCMinutes":"getMinutes"}function yf(t){return t?"getUTCSeconds":"getSeconds"}function mf(t){return t?"getUTCMilliseconds":"getMilliseconds"}function xf(t){return t?"setUTCFullYear":"setFullYear"}function _f(t){return t?"setUTCMonth":"setMonth"}function bf(t){return t?"setUTCDate":"setDate"}function wf(t){return t?"setUTCHours":"setHours"}function Sf(t){return t?"setUTCMinutes":"setMinutes"}function Mf(t){return t?"setUTCSeconds":"setSeconds"}function If(t){return t?"setUTCMilliseconds":"setMilliseconds"}function Tf(t){if(!aa(t))return j(t)?t:"-";var e=(t+"").split(".");return e[0].replace(/(\d{1,3})(?=(?:\d{3})+(?!\d))/g,"$1,")+(e.length>1?"."+e[1]:"")}function Cf(t,e){return t=(t||"").toLowerCase().replace(/-(.)/g,function(t,e){return e.toUpperCase()}),e&&t&&(t=t.charAt(0).toUpperCase()+t.slice(1)),t}var Df=ut;function Af(t,e,n){function i(t){return t&&ht(t)?t:"-"}function r(t){return ca(t)}var o="time"===e,a=t instanceof Date;if(o||a){var s=o?Qo(t):t;if(!isNaN(+s))return uf(s,"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}",n);if(a)return"-"}if("ordinal"===e)return q(t)?i(t):K(t)&&r(t)?t+"":"-";var l=oa(t);return r(l)?Tf(l):q(t)?i(t):"boolean"==typeof t?t+"":"-"}var kf=["a","b","c","d","e","f","g"],Lf=function(t,e){return"{"+t+(null==e?"":e)+"}"};function Pf(t,e,n){X(e)||(e=[e]);var i=e.length;if(!i)return"";for(var r=e[0].$vars||[],o=0;o':'':{renderMode:o,content:"{"+(n.markerId||"markerX")+"|} ",style:"subItem"===r?{width:4,height:4,borderRadius:2,backgroundColor:i}:{width:10,height:10,borderRadius:5,backgroundColor:i}}:""}function Nf(t,e){return e=e||"transparent",j(t)?t:$(t)&&t.colorStops&&(t.colorStops[0]||{}).color||e}function Ef(t,e){if("_blank"===e||"blank"===e){var n=window.open();n.opener=null,n.location.href=t}else window.open(t,e)}var zf={},Bf={},Vf=function(){function t(){this._normalMasterList=[],this._nonSeriesBoxMasterList=[]}return t.prototype.create=function(t,e){function n(n,i){var r=[];return B(n,function(n,i){var o=n.create(t,e);r=r.concat(o||[])}),r}this._nonSeriesBoxMasterList=n(zf,!0),this._normalMasterList=n(Bf,!1)},t.prototype.update=function(t,e){B(this._normalMasterList,function(n){n.update&&n.update(t,e)})},t.prototype.getCoordinateSystems=function(){return this._normalMasterList.concat(this._nonSeriesBoxMasterList)},t.register=function(t,e){"matrix"!==t&&"calendar"!==t?Bf[t]=e:zf[t]=e},t.get=function(t){return Bf[t]||zf[t]},t}();var Gf=mt();function Ff(t){var e=t.getShallow("coord",!0),n=1;if(null==e){var i=Gf.get(t.type);i&&i.getCoord2&&(n=2,e=i.getCoord2(t))}return{coord:e,from:n}}function Hf(t,e){var n=t.getShallow("coordinateSystem"),i=t.getShallow("coordinateSystemUsage",!0),r=0;if(n){var o="series"===t.mainType;null==i&&(i=o?"data":"box"),"data"===i?(r=1,o||(r=0)):"box"===i&&(r=2,o||function(t){return!!zf[t]}(n)||(r=0))}return{coordSysType:n,kind:r}}function Wf(t){var e=t.targetModel,n=t.coordSysType,i=t.coordSysProvider,r=t.isDefaultDataCoordSys;t.allowNotFound;var o=Hf(e),a=o.kind,s=o.coordSysType;if(r&&1!==a&&(a=1,s=n),0===a||s!==n)return 0;var l=i(n,e);return l?(1===a?e.coordinateSystem=l:e.boxCoordinateSystem=l,a):0}var Uf=function(t,e){var n=e.getReferringComponents(t,Ea).models[0];return n&&n.coordinateSystem},Yf=B,Xf=["left","right","top","bottom","width","height"],Zf=[["width","left","right"],["height","top","bottom"]];function jf(t,e,n,i,r){var o=0,a=0;null==i&&(i=1/0),null==r&&(r=1/0);var s=0;e.eachChild(function(l,u){var c,h,d=l.getBoundingRect(),p=e.childAt(u+1),f=p&&p.getBoundingRect();if("horizontal"===t){var g=d.width+(f?-f.x+d.x:0);(c=o+g)>i||l.newline?(o=0,c=g,a+=s+n,s=d.height):s=Math.max(s,d.height)}else{var v=d.height+(f?-f.y+d.y:0);(h=a+v)>r||l.newline?(o+=s+n,a=0,h=v,s=d.width):s=Math.max(s,d.width)}l.newline||(l.x=o,l.y=a,l.markRedraw(),"horizontal"===t?o=c+n:a=h+n)})}var qf=jf;Y(jf,"vertical"),Y(jf,"horizontal");function Kf(t,e){return{left:t.getShallow("left",e),top:t.getShallow("top",e),right:t.getShallow("right",e),bottom:t.getShallow("bottom",e),width:t.getShallow("width",e),height:t.getShallow("height",e)}}function $f(t,e){var n=function(t,e){var n,i,r=eg(t,e,{enableLayoutOnlyByCenter:!0}),o=t.getBoxLayoutParams();if(r.type===tg.point)i=r.refPoint,n=Jf(o,{width:e.getWidth(),height:e.getHeight()});else{var a=t.get("center"),s=X(a)?a:[a,a];n=Jf(o,r.refContainer),i=2===r.boxCoordFrom?r.refPoint:[Vo(s[0],n.width)+n.x,Vo(s[1],n.height)+n.y]}return{viewRect:n,center:i}}(t,e),i=n.viewRect,r=n.center,o=t.get("radius");X(o)||(o=[0,o]);var a=Vo(i.width,e.getWidth()),s=Vo(i.height,e.getHeight()),l=Math.min(a,s),u=Vo(o[0],l/2),c=Vo(o[1],l/2);return{cx:r[0],cy:r[1],r0:u,r:c,viewRect:i}}function Jf(t,e,n){n=Df(n||0);var i=e.width,r=e.height,o=Vo(t.left,i),a=Vo(t.top,r),s=Vo(t.right,i),l=Vo(t.bottom,r),u=Vo(t.width,i),c=Vo(t.height,r),h=n[2]+n[0],d=n[1]+n[3],p=t.aspect;switch(isNaN(u)&&(u=i-s-d-o),isNaN(c)&&(c=r-l-h-a),null!=p&&(isNaN(u)&&isNaN(c)&&(p>i/r?u=.8*i:c=.8*r),isNaN(u)&&(u=p*c),isNaN(c)&&(c=u/p)),isNaN(o)&&(o=i-s-u-d),isNaN(a)&&(a=r-l-c-h),t.left||t.right){case"center":o=i/2-u/2-n[3];break;case"right":o=i-u-d}switch(t.top||t.bottom){case"middle":case"center":a=r/2-c/2-n[0];break;case"bottom":a=r-c-h}o=o||0,a=a||0,isNaN(u)&&(u=i-d-o-(s||0)),isNaN(c)&&(c=r-h-a-(l||0));var f=new Xe((e.x||0)+o+n[3],(e.y||0)+a+n[0],u,c);return f.margin=n,f}function Qf(t,e,n){var i=t.getShallow("preserveAspect",!0);if(!i)return e;var r=e.width/e.height;if(Math.abs(Math.atan(n)-Math.atan(r))<1e-9)return e;var o=t.getShallow("preserveAspectAlign",!0),a=t.getShallow("preserveAspectVerticalAlign",!0),s={width:e.width,height:e.height},l="cover"===i;return r>n&&!l||r=2)return o;for(var c=0;c=0;a--)o=D(o,n[a],!0);e.defaultOption=o}return e.defaultOption},e.prototype.getReferringComponents=function(t,e){var n=t+"Index",i=t+"Id";return Ba(this.ecModel,t,{index:this.get(n,!0),id:this.get(i,!0)},e)},e.prototype.getBoxLayoutParams=function(){return Kf(this,!1)},e.prototype.getZLevelKey=function(){return""},e.prototype.setZLevel=function(t){this.option.zlevel=t},e.protoInitialize=((i=e.prototype).type="component",i.id="",i.name="",i.mainType="",i.subType="",void(i.componentIndex=0)),e}(kp);hs(lg,kp),gs(lg),function(t){var e={};t.registerSubTypeDefaulter=function(t,n){var i=us(t);e[i.main]=n},t.determineSubType=function(n,i){var r=i.type;if(!r){var o=us(n).main;t.hasSubTypes(n)&&e[o]&&(r=e[o](i))}return r}}(lg),function(t,e){function n(t,e){return t[e]||(t[e]={predecessor:[],successor:[]}),t[e]}t.topologicalTravel=function(t,i,r,o){if(t.length){var a=function(t){var i={},r=[];return B(t,function(o){var a=n(i,o),s=function(t,e){var n=[];return B(t,function(t){R(e,t)>=0&&n.push(t)}),n}(a.originalDeps=e(o),t);a.entryCount=s.length,0===a.entryCount&&r.push(o),B(s,function(t){R(a.predecessor,t)<0&&a.predecessor.push(t);var e=n(i,t);R(e.successor,t)<0&&e.successor.push(o)})}),{graph:i,noEntryList:r}}(i),s=a.graph,l=a.noEntryList,u={};for(B(t,function(t){u[t]=!0});l.length;){var c=l.pop(),h=s[c],d=!!u[c];d&&(r.call(o,c,h.originalDeps.slice()),delete u[c]),B(h.successor,d?f:p)}B(u,function(){var t="";throw new Error(t)})}function p(t){s[t].entryCount--,0===s[t].entryCount&&l.push(t)}function f(t){u[t]=!0,p(t)}}}(lg,function(t){var e=[];B(lg.getClassesByMainType(t),function(t){e=e.concat(t.dependencies||t.prototype.dependencies||[])}),e=V(e,function(t){return us(t).main}),"dataset"!==t&&R(e,"dataset")<=0&&e.unshift("dataset");return e});var ug={color:{},darkColor:{},size:{}},cg=ug.color={theme:["#5070dd","#b6d634","#505372","#ff994d","#0ca8df","#ffd10a","#fb628b","#785db0","#3fbe95"],neutral00:"#fff",neutral05:"#f4f7fd",neutral10:"#e8ebf0",neutral15:"#dbdee4",neutral20:"#cfd2d7",neutral25:"#c3c5cb",neutral30:"#b7b9be",neutral35:"#aaacb2",neutral40:"#9ea0a5",neutral45:"#929399",neutral50:"#86878c",neutral55:"#797b7f",neutral60:"#6d6e73",neutral65:"#616266",neutral70:"#54555a",neutral75:"#48494d",neutral80:"#3c3c41",neutral85:"#303034",neutral90:"#232328",neutral95:"#17171b",neutral99:"#000",accent05:"#eff1f9",accent10:"#e0e4f2",accent15:"#d0d6ec",accent20:"#c0c9e6",accent25:"#b1bbdf",accent30:"#a1aed9",accent35:"#91a0d3",accent40:"#8292cc",accent45:"#7285c6",accent50:"#6578ba",accent55:"#5c6da9",accent60:"#536298",accent65:"#4a5787",accent70:"#404c76",accent75:"#374165",accent80:"#2e3654",accent85:"#252b43",accent90:"#1b2032",accent95:"#121521",transparent:"rgba(0,0,0,0)",highlight:"rgba(255,231,130,0.8)"};for(var hg in k(cg,{primary:cg.neutral80,secondary:cg.neutral70,tertiary:cg.neutral60,quaternary:cg.neutral50,disabled:cg.neutral20,border:cg.neutral30,borderTint:cg.neutral20,borderShade:cg.neutral40,background:cg.neutral05,backgroundTint:"rgba(234,237,245,0.5)",backgroundTransparent:"rgba(255,255,255,0)",backgroundShade:cg.neutral10,shadow:"rgba(0,0,0,0.2)",shadowTint:"rgba(129,130,136,0.2)",axisLine:cg.neutral70,axisLineTint:cg.neutral40,axisTick:cg.neutral70,axisTickMinor:cg.neutral60,axisLabel:cg.neutral70,axisSplitLine:cg.neutral15,axisMinorSplitLine:cg.neutral05}),cg)if(cg.hasOwnProperty(hg)){var dg=cg[hg];"theme"===hg?ug.darkColor.theme=cg.theme.slice():"highlight"===hg?ug.darkColor.highlight="rgba(255,231,130,0.4)":0===hg.indexOf("accent")?ug.darkColor[hg]=wi(dg,null,function(t){return.5*t},function(t){return Math.min(1,1.3-t)}):ug.darkColor[hg]=wi(dg,null,function(t){return.9*t},function(t){return 1-Math.pow(t,1.5)})}ug.size={xxs:2,xs:5,s:10,m:15,l:20,xl:30,xxl:40,xxxl:50};var pg="";"undefined"!=typeof navigator&&(pg=navigator.platform||"");var fg="rgba(0, 0, 0, 0.2)",gg=ug.color.theme[0],vg=wi(gg,null,null,.9),yg={darkMode:"auto",colorBy:"series",color:ug.color.theme,gradientColor:[vg,gg],aria:{decal:{decals:[{color:fg,dashArrayX:[1,0],dashArrayY:[2,5],symbolSize:1,rotation:Math.PI/6},{color:fg,symbol:"circle",dashArrayX:[[8,8],[0,8,8,0]],dashArrayY:[6,0],symbolSize:.8},{color:fg,dashArrayX:[1,0],dashArrayY:[4,3],rotation:-Math.PI/4},{color:fg,dashArrayX:[[6,6],[0,6,6,0]],dashArrayY:[6,0]},{color:fg,dashArrayX:[[1,0],[1,6]],dashArrayY:[1,0,6,0],rotation:Math.PI/4},{color:fg,symbol:"triangle",dashArrayX:[[9,9],[0,9,9,0]],dashArrayY:[7,2],symbolSize:.75}]}},textStyle:{fontFamily:pg.match(/^Win/)?"Microsoft YaHei":"sans-serif",fontSize:12,fontStyle:"normal",fontWeight:"normal"},blendMode:null,stateAnimation:{duration:300,easing:"cubicOut"},animation:"auto",animationDuration:1e3,animationDurationUpdate:500,animationEasing:"cubicInOut",animationEasingUpdate:"cubicInOut",animationThreshold:2e3,progressiveThreshold:3e3,progressive:400,hoverLayerThreshold:3e3,useUTC:!1},mg=1,xg=2,_g=3,bg=Pa();function wg(t,e,n){var i={},r=Mg(e);if(!r||!t)return i;var o,a,s=[],l=[],u=e.ecModel,c=bg(u).datasetMap,h=r.uid+"_"+n.seriesLayoutBy;B(t=t.slice(),function(e,n){var r=$(e)?e:t[n]={name:e};"ordinal"===r.type&&null==o&&(o=n,a=f(r)),i[r.name]=[]});var d=c.get(h)||c.set(h,{categoryWayDim:a,valueWayDim:0});function p(t,e,n){for(var i=0;ie)return t[i];return t[n-1]}(i,a):n;if((c=c||n)&&c.length){var h=c[l];return r&&(u[r]=h),s.paletteIdx=(l+1)%c.length,h}}var Eg="\0_ec_inner";var zg=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.init=function(t,e,n,i,r,o){i=i||{},this.option=null,this._theme=new kp(i),this._locale=new kp(r),this._optionManager=o},e.prototype.setOption=function(t,e,n){var i=Gg(e);this._optionManager.setOption(t,n,i),this._resetOption(null,i)},e.prototype.resetOption=function(t,e){return this._resetOption(t,Gg(e))},e.prototype._resetOption=function(t,e){var n=!1,i=this._optionManager;if(!t||"recreate"===t){var r=i.mountOption("recreate"===t);0,this.option&&"recreate"!==t?(this.restoreData(),this._mergeOption(r,e)):kg(this,r),n=!0}if("timeline"!==t&&"media"!==t||this.restoreData(),!t||"recreate"===t||"timeline"===t){var o=i.getTimelineOption(this);o&&(n=!0,this._mergeOption(o,e))}if(!t||"recreate"===t||"media"===t){var a=i.getMediaOption(this);a.length&&B(a,function(t){n=!0,this._mergeOption(t,e)},this)}return n},e.prototype.mergeOption=function(t){this._mergeOption(t,null)},e.prototype._mergeOption=function(t,e){var n=this.option,i=this._componentsMap,r=this._componentsCount,o=[],a=mt(),s=e&&e.replaceMergeMainTypeMap;bg(this).datasetMap=mt(),B(t,function(t,e){null!=t&&(lg.hasClass(e)?e&&(o.push(e),a.set(e,!0)):n[e]=null==n[e]?C(t):D(n[e],t,!0))}),s&&s.each(function(t,e){lg.hasClass(e)&&!a.get(e)&&(o.push(e),a.set(e,!0))}),lg.topologicalTravel(o,lg.getAllClassMainTypes(),function(e){var o=function(t,e,n){var i=Cg.get(e);if(!i)return n;var r=i(t);return r?n.concat(r):n}(this,e,_a(t[e])),a=i.get(e),l=a?s&&s.get(e)?"replaceMerge":"normalMerge":"replaceAll",u=Ia(a,o,l);(function(t,e,n){B(t,function(t){var i=t.newOption;$(i)&&(t.keyInfo.mainType=e,t.keyInfo.subType=function(t,e,n,i){return e.type?e.type:n?n.subType:i.determineSubType(t,e)}(e,i,t.existing,n))})})(u,e,lg),n[e]=null,i.set(e,null),r.set(e,0);var c,h=[],d=[],p=0;B(u,function(t,n){var i=t.existing,r=t.newOption;if(r){var o="series"===e,a=lg.getClass(e,t.keyInfo.subType,!o);if(!a)return;if("tooltip"===e){if(c)return void 0;c=!0}if(i&&i.constructor===a)i.name=t.keyInfo.name,i.mergeOption(r,this),i.optionUpdated(r,!1);else{var s=k({componentIndex:n},t.keyInfo);k(i=new a(r,this,this,s),s),t.brandNew&&(i.__requireNewView=!0),i.init(r,this,this),i.optionUpdated(null,!0)}}else i&&(i.mergeOption({},this),i.optionUpdated({},!1));i?(h.push(i.option),d.push(i),p++):(h.push(void 0),d.push(void 0))},this),n[e]=h,i.set(e,d),r.set(e,p),"series"===e&&Dg(this)},this),this._seriesIndices||Dg(this)},e.prototype.getOption=function(){var t=C(this.option);return B(t,function(e,n){if(lg.hasClass(n)){for(var i=_a(e),r=i.length,o=!1,a=r-1;a>=0;a--)i[a]&&!ka(i[a])?o=!0:(i[a]=null,!o&&r--);i.length=r,t[n]=i}}),delete t[Eg],t},e.prototype.setTheme=function(t){this._theme=new kp(t),this._resetOption("recreate",null)},e.prototype.getTheme=function(){return this._theme},e.prototype.getLocaleModel=function(){return this._locale},e.prototype.setUpdatePayload=function(t){this._payload=t},e.prototype.getUpdatePayload=function(){return this._payload},e.prototype.getComponent=function(t,e){var n=this._componentsMap.get(t);if(n){var i=n[e||0];if(i)return i;if(null==e)for(var r=0;r=e:"max"===n?t<=e:t===e})(i[a],t,o)||(r=!1)}}),r}var Ug=B,Yg=$,Xg=["areaStyle","lineStyle","nodeStyle","linkStyle","chordStyle","label","labelLine"];function Zg(t){var e=t&&t.itemStyle;if(e)for(var n=0,i=Xg.length;n0?t[n-1].seriesModel:null)}),function(t){B(t,function(e,n){var i=[],r=[NaN,NaN],o=[e.stackResultDimension,e.stackedOverDimension],a=e.data,s=e.isStackedByIndex,l=e.seriesModel.get("stackStrategy")||"samesign";a.modify(o,function(o,u,c){var h,d,p=a.get(e.stackedDimension,c);if(isNaN(p))return r;s?d=a.getRawIndex(c):h=a.get(e.stackedByDimension,c);for(var f=NaN,g=n-1;g>=0;g--){var v=t[g];if(s||(d=v.data.rawIndexOf(v.stackedByDimension,h)),d>=0){var y=v.data.getByRawIndex(v.stackResultDimension,d);if("all"===l||"positive"===l&&y>0||"negative"===l&&y<0||"samesign"===l&&p>=0&&y>0||"samesign"===l&&p<=0&&y<0){p=jo(p,y),f=y;break}}}return i[0]=p,i[1]=f,i})})}(t))})});var hv,dv,pv,fv,gv,vv,yv=function(t){this.data=t.data||(t.sourceFormat===Ou?{}:[]),this.sourceFormat=t.sourceFormat||Nu,this.seriesLayoutBy=t.seriesLayoutBy||Eu,this.startIndex=t.startIndex||0,this.dimensionsDetectedCount=t.dimensionsDetectedCount,this.metaRawOption=t.metaRawOption;var e=this.dimensionsDefine=t.dimensionsDefine;if(e)for(var n=0;nu&&(u=p)}s[0]=l,s[1]=u}},i=function(){return this._data?this._data.length/this._dimSize:0};function r(t){for(var e=0;e=0&&(s=o.interpolatedValue[l])}return null!=s?s+"":""}):void 0},t.prototype.getRawValue=function(t,e){return zv(this.getData(e),t)},t.prototype.formatTooltip=function(t,e,n){},t}();function Gv(t){var e,n;return $(t)?t.type&&(n=t):e=t,{text:e,frag:n}}function Fv(t){return new Hv(t)}var Hv=function(){function t(t){t=t||{},this._reset=t.reset,this._plan=t.plan,this._count=t.count,this._onDirty=t.onDirty,this._dirty=!0}return t.prototype.perform=function(t){var e,n=this._upstream,i=t&&t.skip;if(this._dirty&&n){var r=this.context;r.data=r.outputData=n.context.outputData}this.__pipeline&&(this.__pipeline.currentTask=this),this._plan&&!i&&(e=this._plan(this.context));var o,a=c(this._modBy),s=this._modDataCount||0,l=c(t&&t.modBy),u=t&&t.modDataCount||0;function c(t){return!(t>=1)&&(t=1),t}a===l&&s===u||(e="reset"),(this._dirty||"reset"===e)&&(this._dirty=!1,o=this._doReset(i)),this._modBy=l,this._modDataCount=u;var h=t&&t.step;if(this._dueEnd=n?n._outputDueEnd:this._count?this._count(this.context):1/0,this._progress){var d=this._dueIndex,p=Math.min(null!=h?this._dueIndex+h:1/0,this._dueEnd);if(!i&&(o||d1&&i>0?s:a}};return o;function a(){return e=t?null:oe},gte:function(t,e){return t>=e}},jv=function(){function t(t,e){if(!K(e)){var n="";0,va(n)}this._opFn=Zv[t],this._rvalFloat=oa(e)}return t.prototype.evaluate=function(t){return K(t)?this._opFn(t,this._rvalFloat):this._opFn(oa(t),this._rvalFloat)},t}(),qv=function(){function t(t,e){var n="desc"===t;this._resultLT=n?1:-1,null==e&&(e=n?"min":"max"),this._incomparable="min"===e?-1/0:1/0}return t.prototype.evaluate=function(t,e){var n=K(t)?t:oa(t),i=K(e)?e:oa(e),r=isNaN(n),o=isNaN(i);if(r&&(n=this._incomparable),o&&(i=this._incomparable),r&&o){var a=j(t),s=j(e);a&&(n=s?t:0),s&&(i=a?e:0)}return ni?-this._resultLT:0},t}(),Kv=function(){function t(t,e){this._rval=e,this._isEQ=t,this._rvalTypeof=typeof e,this._rvalFloat=oa(e)}return t.prototype.evaluate=function(t){var e=t===this._rval;if(!e){var n=typeof t;n===this._rvalTypeof||"number"!==n&&"number"!==this._rvalTypeof||(e=oa(t)===this._rvalFloat)}return this._isEQ?e:!e},t}();function $v(t,e){return"eq"===t||"ne"===t?new Kv("eq"===t,e):wt(Zv,t)?new jv(t,e):null}function Jv(t){var e="",n=-1/0,i=-1/0,r=1/0,o=1/0;return t&&(null!=t.g&&(e+="G"+t.g,n=t.g),null!=t.ge&&(e+="GE"+t.ge,i=t.ge),null!=t.l&&(e+="L"+t.l,r=t.l),null!=t.le&&(e+="LE"+t.le,o=t.le)),{key:e,g:n,ge:i,l:r,le:o}}function Qv(t,e){return e>t.g&&e>=t.ge&&e65535?uy:cy}function gy(t){var e=t.constructor;return e===Array?t.slice():new e(t)}function vy(t,e,n,i,r){var o=py[n||"float"];if(r){var a=t[e],s=a&&a.length;if(s!==i){for(var l=new o(i),u=0;ug[1]&&(g[1]=f)}return this._rawCount=this._count=s,{start:a,end:s}},t.prototype._initDataFromProvider=function(t,e,n){for(var i=this._provider,r=this._chunks,o=this._dimensions,a=o.length,s=this._rawExtent,l=V(o,function(t){return t.property}),u=0;uv[1]&&(v[1]=g)}}!i.persistent&&i.clean&&i.clean(),this._rawCount=this._count=e,this._extent=[]},t.prototype.count=function(){return this._count},t.prototype.get=function(t,e){if(!(e>=0&&e=0&&e=this._rawCount||t<0)return-1;if(!this._indices)return t;var e=this._indices,n=e[t];if(null!=n&&nt))return o;r=o-1}}return-1},t.prototype.getIndices=function(){var t,e=this._indices;if(e){var n=e.constructor,i=this._count;if(n===Array){t=new n(i);for(var r=0;r=u&&x<=c||isNaN(x))&&(a[s++]=p),p++}d=!0}else if(2===r){f=h[i[0]];var v=h[i[1]],y=t[i[1]][0],m=t[i[1]][1];for(g=0;g=u&&x<=c||isNaN(x))&&(_>=y&&_<=m||isNaN(_))&&(a[s++]=p),p++}d=!0}}if(!d)if(1===r)for(g=0;g=u&&x<=c||isNaN(x))&&(a[s++]=b)}else for(g=0;gt[M][1])&&(w=!1)}w&&(a[s++]=e.getRawIndex(g))}return sv[1]&&(v[1]=g)}}}},t.prototype.lttbDownSample=function(t,e){var n,i,r,o=this.clone([t],!0),a=o._chunks[t],s=this.count(),l=0,u=Math.floor(1/e),c=this.getRawIndex(0),h=new(fy(this._rawCount))(Math.min(2*(Math.ceil(s/u)+2),s));h[l++]=c;for(var d=1;dn&&(n=i,r=I)}M>0&&M<_-x&&(h[l++]=Math.min(S,r),r=Math.max(S,r)),h[l++]=r,c=r}return h[l++]=this.getRawIndex(s-1),o._count=l,o._indices=h,o.getRawIndex=this._getRawIdx,o},t.prototype.minmaxDownSample=function(t,e){for(var n=this.clone([t],!0),i=n._chunks,r=Math.floor(1/e),o=i[t],a=this.count(),s=new(fy(this._rawCount))(2*Math.ceil(a/r)),l=0,u=0;ua&&(f=a-u);for(var g=0;gp&&(p=v,d=u+g)}var y=this.getRawIndex(c),m=this.getRawIndex(d);cu-p&&(s=u-p,a.length=s);for(var f=0;fc[1]&&(c[1]=v),h[d++]=y}return r._count=d,r._indices=h,r._updateGetRawIdx(),r},t.prototype.each=function(t,e){if(this._count)for(var n=t.length,i=this._chunks,r=0,o=this.count();rh&&(h=p))}return a[l]=[c,h]},t.prototype.getRawDataItem=function(t){var e=this.getRawIndex(t);if(this._provider.persistent)return this._provider.getItem(e);for(var n=[],i=this._chunks,r=0;r=0?this._indices[t]:-1},t.prototype._updateGetRawIdx=function(){this.getRawIndex=this._indices?this._getRawIdx:this._getRawIdxIdentity},t.internalField=function(){function t(t,e,n,i){return Uv(t[i],this._dimensions[i])}ly={arrayRows:t,objectRows:function(t,e,n,i){return Uv(t[e],this._dimensions[i])},keyedColumns:t,original:function(t,e,n,i){var r=t&&(null==t.value?t:t.value);return Uv(r instanceof Array?r[i]:r,this._dimensions[i])},typedArray:function(t,e,n,i){return t[i]}}}(),t}(),my=function(){function t(t){this._sourceList=[],this._storeList=[],this._upstreamSignList=[],this._versionSignBase=0,this._dirty=!0,this._sourceHost=t}return t.prototype.dirty=function(){this._setLocalSource([],[]),this._storeList=[],this._dirty=!0},t.prototype._setLocalSource=function(t,e){this._sourceList=t,this._upstreamSignList=e,this._versionSignBase++,this._versionSignBase>9e10&&(this._versionSignBase=0)},t.prototype._getVersionSign=function(){return this._sourceHost.uid+"_"+this._versionSignBase},t.prototype.prepareSource=function(){this._isDirty()&&(this._createSource(),this._dirty=!1)},t.prototype._createSource=function(){this._setLocalSource([],[]);var t,e,n=this._sourceHost,i=this._getUpstreamSourceManagers(),r=!!i.length;if(_y(n)){var o=n,a=void 0,s=void 0,l=void 0;if(r){var u=i[0];u.prepareSource(),a=(l=u.getSource()).data,s=l.sourceFormat,e=[u._getVersionSign()]}else s=Q(a=o.get("data",!0))?Ru:ku,e=[];var c=this._getSourceMetaRawOption()||{},h=l&&l.metaRawOption||{},d=at(c.seriesLayoutBy,h.seriesLayoutBy)||null,p=at(c.sourceHeader,h.sourceHeader),f=at(c.dimensions,h.dimensions);t=d!==h.seriesLayoutBy||!!p!=!!h.sourceHeader||f?[xv(a,{seriesLayoutBy:d,sourceHeader:p,dimensions:f},s)]:[]}else{var g=n;if(r){var v=this._applyTransform(i);t=v.sourceList,e=v.upstreamSignList}else{t=[xv(g.get("source",!0),this._getSourceMetaRawOption(),null)],e=[]}}this._setLocalSource(t,e)},t.prototype._applyTransform=function(t){var e,n=this._sourceHost,i=n.get("transform",!0),r=n.get("fromTransformResult",!0);if(null!=r){var o="";1!==t.length&&by(o)}var a,s=[],l=[];return B(t,function(t){t.prepareSource();var e=t.getSource(r||0),n="";null==r||e||by(n),s.push(e),l.push(t._getVersionSign())}),i?e=function(t,e){var n=_a(t),i=n.length,r="";i||va(r);for(var o=0,a=i;o1||n>0&&!t.noHeader;return B(t.blocks,function(t){var n=Ay(t);n>=e&&(e=n+ +(i&&(!n||Cy(t)&&!t.noHeader)))}),e}return 0}function ky(t,e,n,i){var r,o=e.noHeader,a=(r=Ay(e),{html:My[r],richText:Iy[r]}),s=[],l=e.blocks||[];ct(!l||X(l)),l=l||[];var u=t.orderMode;if(e.sortBlocks&&u){l=l.slice();var c={valueAsc:"asc",valueDesc:"desc"};if(wt(c,u)){var h=new qv(c[u],null);l.sort(function(t,e){return h.evaluate(t.sortParam,e.sortParam)})}else"seriesDesc"===u&&l.reverse()}B(l,function(n,r){var o=e.valueFormatter,l=Dy(n)(o?k(k({},t),{valueFormatter:o}):t,n,r>0?a.html:0,i);null!=l&&s.push(l)});var d="richText"===t.renderMode?s.join(a.richText):Oy(i,s.join(""),o?n:a.html);if(o)return d;var p=Af(e.header,"ordinal",t.useUTC),f=Sy(i,t.renderMode).nameStyle,g=wy(i);return"richText"===t.renderMode?Ry(t,p,f)+a.richText+d:Oy(i,'
'+se(p)+"
"+d,n)}function Ly(t,e,n,i){var r=t.renderMode,o=e.noName,a=e.noValue,s=!e.markerType,l=e.name,u=t.useUTC,c=e.valueFormatter||t.valueFormatter||function(t){return V(t=X(t)?t:[t],function(t,e){return Af(t,X(p)?p[e]:p,u)})};if(!o||!a){var h=s?"":t.markupStyleCreator.makeTooltipMarker(e.markerType,e.markerColor||ug.color.secondary,r),d=o?"":Af(l,"ordinal",u),p=e.valueType,f=a?[]:c(e.value,e.rawDataIndex),g=!s||!o,v=!s&&o,y=Sy(i,r),m=y.nameStyle,x=y.valueStyle;return"richText"===r?(s?"":h)+(o?"":Ry(t,d,m))+(a?"":function(t,e,n,i,r){var o=[r],a=i?10:20;return n&&o.push({padding:[0,0,0,a],align:"right"}),t.markupStyleCreator.wrapRichTextStyle(X(e)?e.join(" "):e,o)}(t,f,g,v,x)):Oy(i,(s?"":h)+(o?"":function(t,e,n){return''+se(t)+""}(d,!s,m))+(a?"":function(t,e,n,i){var r=n?"10px":"20px",o=e?"float:right;margin-left:"+r:"";return t=X(t)?t:[t],''+V(t,function(t){return se(t)}).join("  ")+""}(f,g,v,x)),n)}}function Py(t,e,n,i,r,o){if(t)return Dy(t)({useUTC:r,renderMode:n,orderMode:i,markupStyleCreator:e,valueFormatter:t.valueFormatter},t,0,o)}function Oy(t,e,n){return'
'+e+'
'}function Ry(t,e,n){return t.markupStyleCreator.wrapRichTextStyle(e,n)}function Ny(t,e){return Nf(t.getData().getItemVisual(e,"style")[t.visualDrawType])}function Ey(t,e){var n=t.get("padding");return null!=n?n:"richText"===e?[8,10]:10}var zy=function(){function t(){this.richTextStyles={},this._nextStyleNameId=sa()}return t.prototype._generateStyleName=function(){return"__EC_aUTo_"+this._nextStyleNameId++},t.prototype.makeTooltipMarker=function(t,e,n){var i="richText"===n?this._generateStyleName():null,r=Rf({color:e,type:t,renderMode:n,markerId:i});return j(r)?r:(this.richTextStyles[i]=r.style,r.content)},t.prototype.wrapRichTextStyle=function(t,e){var n={};X(e)?B(e,function(t){return k(n,t)}):k(n,e);var i=this._generateStyleName();return this.richTextStyles[i]=n,"{"+i+"|"+t+"}"},t}();function By(t){var e,n,i,r,o=t.series,a=t.dataIndex,s=t.multipleSeries,l=o.getData(),u=l.mapDimensionsAll("defaultedTooltip"),c=u.length,h=o.getRawValue(a),d=X(h),p=Ny(o,a);if(c>1||d&&!c){var f=function(t,e,n,i,r){var o=e.getData(),a=G(t,function(t,e,n){var i=o.getDimensionInfo(n);return t||i&&!1!==i.tooltip&&null!=i.displayName},!1),s=[],l=[],u=[];function c(t,e){var n=o.getDimensionInfo(e);n&&!1!==n.otherDims.tooltip&&(a?u.push(Ty("nameValue",{markerType:"subItem",markerColor:r,name:n.displayName,value:t,valueType:n.type})):(s.push(t),l.push(n.type)))}return i.length?B(i,function(t){c(zv(o,n,t),t)}):B(t,c),{inlineValues:s,inlineValueTypes:l,blocks:u}}(h,o,a,u,p);e=f.inlineValues,n=f.inlineValueTypes,i=f.blocks,r=f.inlineValues[0]}else if(c){var g=l.getDimensionInfo(u[0]);r=e=zv(l,a,u[0]),n=g.type}else r=e=d?h[0]:h;var v=Aa(o),y=v&&o.name||"",m=l.getName(a),x=s?y:m;return Ty("section",{header:y,noHeader:s||!v,sortParam:r,blocks:[Ty("nameValue",{markerType:"item",markerColor:p,name:x,noName:!ht(x),value:e,valueType:n,rawDataIndex:l.getRawIndex(a)})].concat(i||[])})}var Vy=Pa();function Gy(t,e){return t.getName(e)||t.getId(e)}var Fy="__universalTransitionEnabled",Hy=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e._selectedDataIndicesMap={},e}var i;return n(e,t),e.prototype.init=function(t,e,n){this.seriesIndex=this.componentIndex,this.dataTask=Fv({count:Uy,reset:Yy}),this.dataTask.context={model:this},this.mergeDefaultAndTheme(t,n),(Vy(this).sourceManager=new my(this)).prepareSource();var i=this.getInitialData(t,n);Zy(i,this),this.dataTask.context.data=i,Vy(this).dataBeforeProcessed=i,Wy(this),this._initSelectedMapFromData(i)},e.prototype.mergeDefaultAndTheme=function(t,e){var n=ig(this),i=n?og(t):{},r=this.subType;lg.hasClass(r)&&(r+="Series"),D(t,e.getTheme().get(this.subType)),D(t,this.getDefaultOption()),ba(t,"label",["show"]),this.fillDataTextStyle(t.data),n&&rg(t,i,n)},e.prototype.mergeOption=function(t,e){t=D(this.option,t,!0),this.fillDataTextStyle(t.data);var n=ig(this);n&&rg(this.option,t,n);var i=Vy(this).sourceManager;i.dirty(),i.prepareSource();var r=this.getInitialData(t,e);Zy(r,this),this.dataTask.dirty(),this.dataTask.context.data=r,Vy(this).dataBeforeProcessed=r,Wy(this),this._initSelectedMapFromData(r)},e.prototype.fillDataTextStyle=function(t){if(t&&!Q(t))for(var e=["show"],n=0;n=0&&c<0)&&(u=m,c=y,h=0),y===c&&(l[h++]=f))}return l.length=h,l},e.prototype.formatTooltip=function(t,e,n){return By({series:this,dataIndex:t,multipleSeries:e})},e.prototype.isAnimationEnabled=function(){var t=this.ecModel;if(r.node&&(!t||!t.ssr))return!1;var e=this.getShallow("animation");return e&&this.getData().count()>this.getShallow("animationThreshold")&&(e=!1),!!e},e.prototype.restoreData=function(){this.dataTask.dirty()},e.prototype.getColorFromPalette=function(t,e,n){var i=this.ecModel,r=Og.prototype.getColorFromPalette.call(this,t,e,n);return r||(r=i.getColorFromPalette(t,e,n)),r},e.prototype.coordDimToDataDim=function(t){return this.getRawData().mapDimensionsAll(t)},e.prototype.getProgressive=function(){return this.get("progressive")},e.prototype.getProgressiveThreshold=function(){return this.get("progressiveThreshold")},e.prototype.select=function(t,e){this._innerSelect(this.getData(e),t)},e.prototype.unselect=function(t,e){var n=this.option.selectedMap;if(n){var i=this.option.selectedMode,r=this.getData(e);if("series"===i||"all"===n)return this.option.selectedMap={},void(this._selectedDataIndicesMap={});for(var o=0;o=0&&n.push(r)}return n},e.prototype.isSelected=function(t,e){var n=this.option.selectedMap;if(!n)return!1;var i=this.getData(e);return("all"===n||n[Gy(i,t)])&&!i.getItemModel(t).get(["select","disabled"])},e.prototype.isUniversalTransitionEnabled=function(){if(this[Fy])return!0;var t=this.option.universalTransition;return!!t&&(!0===t||t&&t.enabled)},e.prototype._innerSelect=function(t,e){var n,i,r=this.option,o=r.selectedMode,a=e.length;if(o&&a)if("series"===o)r.selectedMap="all";else if("multiple"===o){$(r.selectedMap)||(r.selectedMap={});for(var s=r.selectedMap,l=0;l0&&this._innerSelect(t,e)}},e.registerClass=function(t){return lg.registerClass(t)},e.protoInitialize=((i=e.prototype).type="series.__base__",i.seriesIndex=0,i.ignoreStyleOnData=!1,i.hasSymbolVisual=!1,i.defaultSymbol="circle",i.visualStyleAccessPath="itemStyle",void(i.visualDrawType="fill")),e}(lg);function Wy(t){var e=t.name;Aa(t)||(t.name=function(t){var e=t.getRawData(),n=e.mapDimensionsAll("seriesName"),i=[];return B(n,function(t){var n=e.getDimensionInfo(t);n.displayName&&i.push(n.displayName)}),i.join(" ")}(t)||e)}function Uy(t){return t.model.getRawData().count()}function Yy(t){var e=t.model;return e.setData(e.getRawData().cloneShallow()),Xy}function Xy(t,e){e.outputData&&t.end>e.outputData.count()&&e.model.getRawData().cloneShallow(e.outputData)}function Zy(t,e){B(xt(t.CHANGABLE_METHODS,t.DOWNSAMPLE_METHODS),function(n){t.wrapMethod(n,Y(jy,e))})}function jy(t,e){var n=qy(t);return n&&n.setOutputEnd((e||this).count()),e}function qy(t){var e=(t.ecModel||{}).scheduler,n=e&&e.getPipeline(t.uid);if(n){var i=n.currentTask;if(i){var r=i.agentStubMap;r&&(i=r.get(t.uid))}return i}}E(Hy,Vv),E(Hy,Og),hs(Hy,lg);var Ky=function(){function t(){this.group=new vo,this.uid=Pp("viewComponent")}return t.prototype.init=function(t,e){},t.prototype.render=function(t,e,n,i){},t.prototype.dispose=function(t,e){},t.prototype.updateView=function(t,e,n,i){},t.prototype.updateLayout=function(t,e,n,i){},t.prototype.updateVisual=function(t,e,n,i){},t.prototype.toggleBlurSeries=function(t,e,n){},t.prototype.eachRendered=function(t){var e=this.group;e&&e.traverse(t)},t}();function $y(){var t=Pa();return function(e){var n=t(e),i=e.pipelineContext,r=!!n.large,o=!!n.progressiveRender,a=n.large=!(!i||!i.large),s=n.progressiveRender=!(!i||!i.progressiveRender);return!(r===a&&o===s)&&"reset"}}cs(Ky),gs(Ky);var Jy=Pa(),Qy=$y(),tm=function(){function t(){this.group=new vo,this.uid=Pp("viewChart"),this.renderTask=Fv({plan:im,reset:rm}),this.renderTask.context={view:this}}return t.prototype.init=function(t,e){},t.prototype.render=function(t,e,n,i){0},t.prototype.highlight=function(t,e,n,i){var r=t.getData(i&&i.dataType);r&&nm(r,i,"emphasis")},t.prototype.downplay=function(t,e,n,i){var r=t.getData(i&&i.dataType);r&&nm(r,i,"normal")},t.prototype.remove=function(t,e){this.group.removeAll()},t.prototype.dispose=function(t,e){},t.prototype.updateView=function(t,e,n,i){this.render(t,e,n,i)},t.prototype.updateVisual=function(t,e,n,i){this.render(t,e,n,i)},t.prototype.eachRendered=function(t){Hd(this.group,t)},t.markUpdateMethod=function(t,e){Jy(t).updateMethod=e},t.protoInitialize=void(t.prototype.type="chart"),t}();function em(t,e,n){t&&Ec(t)&&("emphasis"===e?vc:yc)(t,n)}function nm(t,e,n){var i=La(t,e),r=e&&null!=e.highlightKey?function(t){var e=Wu[t];return null==e&&Hu<=32&&(e=Wu[t]=Hu++),e}(e.highlightKey):null;null!=i?B(_a(i),function(e){em(t.getItemGraphicEl(e),n,r)}):t.eachItemGraphicEl(function(t){em(t,n,r)})}function im(t){return Qy(t.model)}function rm(t){var e=t.model,n=t.ecModel,i=t.api,r=t.payload,o=e.pipelineContext.progressiveRender,a=t.view,s=r&&Jy(r).updateMethod,l=o?"incrementalPrepareRender":s&&a[s]?s:"render";return"render"!==l&&a[l](e,n,i,r),om[l]}cs(tm),gs(tm);var om={incrementalPrepareRender:{progress:function(t,e){e.view.incrementalRender(t,e.model,e.ecModel,e.api,e.payload)}},render:{forceFirstProgress:!0,progress:function(t,e){e.view.render(e.model,e.ecModel,e.api,e.payload)}}},am="\0__throttleOriginMethod",sm="\0__throttleRate",lm="\0__throttleType";function um(t,e,n){var i,r,o,a,s,l=0,u=0,c=null;function h(){u=(new Date).getTime(),c=null,t.apply(o,a||[])}e=e||0;var d=function(){for(var t=[],d=0;d=0?h():c=setTimeout(h,-r),l=i};return d.clear=function(){c&&(clearTimeout(c),c=null)},d.debounceNextCall=function(t){s=t},d}function cm(t,e,n,i){var r=t[e];if(r){var o=r[am]||r,a=r[lm];if(r[sm]!==n||a!==i){if(null==n||!i)return t[e]=o;(r=t[e]=um(o,n,"debounce"===i))[am]=o,r[lm]=i,r[sm]=n}return r}}function hm(t,e){var n=t[e];n&&n[am]&&(n.clear&&n.clear(),t[e]=n[am])}var dm=Pa(),pm={itemStyle:vs(Cp,!0),lineStyle:vs(Mp,!0)},fm={lineStyle:"stroke",itemStyle:"fill"};function gm(t,e){var n=t.visualStyleMapper||pm[e];return n||(console.warn("Unknown style type '"+e+"'."),pm.itemStyle)}function vm(t,e){var n=t.visualDrawType||fm[e];return n||(console.warn("Unknown style type '"+e+"'."),"fill")}var ym={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=t.getModel(i),o=gm(t,i)(r),a=r.getShallow("decal");a&&(n.setVisual("decal",a),a.dirty=!0);var s=vm(t,i),l=o[s],u=Z(l)?l:null,c="auto"===o.fill||"auto"===o.stroke;if(!o[s]||u||c){var h=t.getColorFromPalette(t.name,null,e.getSeriesCount());o[s]||(o[s]=h,n.setVisual("colorFromPalette",!0)),o.fill="auto"===o.fill||Z(o.fill)?h:o.fill,o.stroke="auto"===o.stroke||Z(o.stroke)?h:o.stroke}if(n.setVisual("style",o),n.setVisual("drawType",s),!e.isSeriesFiltered(t)&&u)return n.setVisual("colorFromPalette",!1),{dataEach:function(e,n){var i=t.getDataParams(n),r=k({},o);r[s]=u(i),e.setItemVisual(n,"style",r)}}}},mm=new kp,xm={createOnAllSeries:!0,reset:function(t,e){if(!t.ignoreStyleOnData){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=gm(t,i),o=n.getVisual("drawType");return{dataEach:n.hasItemOption?function(t,e){var n=t.getRawDataItem(e);if(n&&n[i]){mm.option=n[i];var a=r(mm);k(t.ensureUniqueItemVisual(e,"style"),a),mm.option.decal&&(t.setItemVisual(e,"decal",mm.option.decal),mm.option.decal.dirty=!0),o in a&&t.setItemVisual(e,"colorFromPalette",!1)}}:null}}}},_m={performRawSeries:!0,overallReset:function(t){var e=mt();t.eachSeries(function(t){if(!t.isColorBySeries()){var n=t.type+"-"+t.getColorBy();dm(t).scope=e.get(n)||e.set(n,{})}}),t.eachSeries(function(t){if(!t.isColorBySeries()){var e=t.getRawData(),n={},i=t.getData(),r=dm(t).scope,o=t.visualStyleAccessPath||"itemStyle",a=vm(t,o);i.each(function(t){var e=i.getRawIndex(t);n[e]=t}),e.each(function(o){var s=n[o];if(i.getItemVisual(s,"colorFromPalette")){var l=i.ensureUniqueItemVisual(s,"style"),u=e.getName(o)||o+"",c=e.count();l[a]=t.getColorFromPalette(u,r,c)}})}})}},bm=Math.PI;var wm=function(){function t(t,e,n,i){this._stageTaskMap=mt(),this.ecInstance=t,this.api=e,n=this._dataProcessorHandlers=n.slice(),i=this._visualHandlers=i.slice(),this._allHandlers=n.concat(i)}return t.prototype.restoreData=function(t,e){t.restoreData(e),this._stageTaskMap.each(function(t){var e=t.overallTask;e&&e.dirty()})},t.prototype.getPerformArgs=function(t,e){if(t.__pipeline){var n=this._pipelineMap.get(t.__pipeline.id),i=n.context,r=!e&&n.progressiveEnabled&&(!i||i.progressiveRender)&&t.__idxInPipeline>n.blockIndex?n.step:null,o=i&&i.modDataCount;return{step:r,modBy:null!=o?Math.ceil(o/r):null,modDataCount:o}}},t.prototype.getPipeline=function(t){return this._pipelineMap.get(t)},t.prototype.updateStreamModes=function(t,e){var n=this._pipelineMap.get(t.uid),i=t.__preparePipelineContext?t.__preparePipelineContext(e,n):rs(t,e,n);t.pipelineContext=n.context=i},t.prototype.restorePipelines=function(t,e){var n=this,i=n._pipelineMap=mt();e.eachSeries(function(e){var r="canvas"===t.painter.type&&e.getProgressive(),o=e.uid;i.set(o,{id:o,head:null,tail:null,threshold:e.getProgressiveThreshold(),progressiveEnabled:r&&!(e.preventIncremental&&e.preventIncremental()),blockIndex:-1,step:Math.round(r||700),count:0}),n._pipe(e,e.dataTask)})},t.prototype.prepareStageTasks=function(){var t=this._stageTaskMap,e=this.api.getModel(),n=this.api;B(this._allHandlers,function(i){var r=t.get(i.uid)||t.set(i.uid,{}),o="";ct(!(i.reset&&i.overallReset),o),i.reset&&this._createSeriesStageTask(i,r,e,n),i.overallReset&&this._createOverallStageTask(i,r,e,n)},this)},t.prototype.prepareView=function(t,e,n,i){var r=t.renderTask,o=r.context;o.model=e,o.ecModel=n,o.api=i,r.__block=!t.incrementalPrepareRender,this._pipe(e,r)},t.prototype.performDataProcessorTasks=function(t,e){this._performStageTasks(this._dataProcessorHandlers,t,e,{block:!0})},t.prototype.performVisualTasks=function(t,e,n){this._performStageTasks(this._visualHandlers,t,e,n)},t.prototype._performStageTasks=function(t,e,n,i){i=i||{};var r=!1,o=this;function a(t,e){return t.setDirty&&(!t.dirtyMap||t.dirtyMap.get(e.__pipeline.id))}B(t,function(t,s){if(!i.visualType||i.visualType===t.visualType){var l=o._stageTaskMap.get(t.uid),u=l.seriesTaskMap,c=l.overallTask;if(c){var h,d=c.agentStubMap;d.each(function(t){a(i,t)&&(t.dirty(),h=!0)}),h&&c.dirty(),o.updatePayload(c,n);var p=o.getPerformArgs(c,i.block);d.each(function(t){t.perform(p)}),c.perform(p)&&(r=!0)}else u&&u.each(function(s,l){a(i,s)&&s.dirty();var u=o.getPerformArgs(s,i.block);u.skip=!t.performRawSeries&&e.isSeriesFiltered(s.context.model),o.updatePayload(s,n),s.perform(u)&&(r=!0)})}}),this.unfinished=r||this.unfinished},t.prototype.performSeriesTasks=function(t){var e;t.eachSeries(function(t){e=t.dataTask.perform()||e}),this.unfinished=e||this.unfinished},t.prototype.plan=function(){this._pipelineMap.each(function(t){var e=t.tail;do{if(e.__block){t.blockIndex=e.__idxInPipeline;break}e=e.getUpstream()}while(e)})},t.prototype.updatePayload=function(t,e){"remain"!==e&&(t.context.payload=e)},t.prototype._createSeriesStageTask=function(t,e,n,i){var r=this,o=e.seriesTaskMap,a=e.seriesTaskMap=mt(),s=t.seriesType,l=t.getTargetSeries;function u(e){var s=e.uid,l=a.set(s,o&&o.get(s)||Fv({plan:Cm,reset:Dm,count:Lm}));l.context={model:e,ecModel:n,api:i,useClearVisual:t.isVisual&&!t.isLayout,plan:t.plan,reset:t.reset,scheduler:r},r._pipe(e,l)}t.createOnAllSeries?n.eachRawSeries(u):s?n.eachRawSeriesByType(s,u):l&&l(n,i).each(u)},t.prototype._createOverallStageTask=function(t,e,n,i){var r=this,o=e.overallTask=e.overallTask||Fv({reset:Sm});o.context={ecModel:n,api:i,overallReset:t.overallReset,scheduler:r};var a=o.agentStubMap,s=o.agentStubMap=mt(),l=t.seriesType,u=t.getTargetSeries,c=t.dirtyOnOverallProgress,h=!1,d="";function p(t){var e=t.uid,n=s.set(e,a&&a.get(e)||(h=!0,Fv({reset:Mm,onDirty:Tm})));n.context={model:t,dirtyOnOverallProgress:c},n.agent=o,n.__block=c,r._pipe(t,n)}ct(!t.createOnAllSeries,d),l?n.eachRawSeriesByType(l,p):u?u(n,i).each(p):B(n.getSeries(),p),h&&o.dirty()},t.prototype._pipe=function(t,e){var n=t.uid,i=this._pipelineMap.get(n);!i.head&&(i.head=e),i.tail&&i.tail.pipe(e),i.tail=e,e.__idxInPipeline=i.count++,e.__pipeline=i},t.wrapStageHandler=function(t,e){return Z(t)&&(t={overallReset:t,seriesType:Pm(t)}),t.uid=Pp("stageHandler"),e&&(t.visualType=e),t},t}();function Sm(t){t.overallReset(t.ecModel,t.api,t.payload)}function Mm(t){return t.dirtyOnOverallProgress&&Im}function Im(){this.agent.dirty(),this.getDownstream().dirty()}function Tm(){this.agent&&this.agent.dirty()}function Cm(t){return t.plan?t.plan(t.model,t.ecModel,t.api,t.payload):null}function Dm(t){t.useClearVisual&&t.data.clearAllVisual();var e=t.resetDefines=_a(t.reset(t.model,t.ecModel,t.api,t.payload));return e.length>1?V(e,function(t,e){return km(e)}):Am}var Am=km(0);function km(t){return function(e,n){var i=n.data,r=n.resetDefines[t];if(r&&r.dataEach)for(var o=e.start;o0&&c===r.length-u.length){var h=r.slice(0,c);"data"!==h&&(e.mainType=h,e[u.toLowerCase()]=t,s=!0)}}a.hasOwnProperty(r)&&(n[r]=t,s=!0),s||(i[r]=t)})}return{cptQuery:e,dataQuery:n,otherQuery:i}},t.prototype.filter=function(t,e){var n=this.eventInfo;if(!n)return!0;var i=n.targetEl,r=n.packedEvent,o=n.model,a=n.view;if(!o||!a)return!0;var s=e.cptQuery,l=e.dataQuery;return u(s,o,"mainType")&&u(s,o,"subType")&&u(s,o,"index","componentIndex")&&u(s,o,"name")&&u(s,o,"id")&&u(l,r,"name")&&u(l,r,"dataIndex")&&u(l,r,"dataType")&&(!a.filterForExposedEvent||a.filterForExposedEvent(t,e.otherQuery,i,r));function u(t,e,n,i){return null==t[n]||e[i||n]===t[n]}},t.prototype.afterTrigger=function(){this.eventInfo=null},t}(),Um=["symbol","symbolSize","symbolRotate","symbolOffset"],Ym=Um.concat(["symbolKeepAspect"]),Xm={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData();if(t.legendIcon&&n.setVisual("legendIcon",t.legendIcon),t.hasSymbolVisual){for(var i={},r={},o=!1,a=0;a=0&&Sx(l)?l:.5,t.createRadialGradient(a,s,0,a,s,l)}(t,e,n):function(t,e,n){var i=null==e.x?0:e.x,r=null==e.x2?1:e.x2,o=null==e.y?0:e.y,a=null==e.y2?0:e.y2;return e.global||(i=i*n.width+n.x,r=r*n.width+n.x,o=o*n.height+n.y,a=a*n.height+n.y),i=Sx(i)?i:0,r=Sx(r)?r:1,o=Sx(o)?o:0,a=Sx(a)?a:0,t.createLinearGradient(i,o,r,a)}(t,e,n),r=e.colorStops,o=0;o0&&(e=i.lineDash,n=i.lineWidth,e&&"solid"!==e&&n>0?"dashed"===e?[4*n,2*n]:"dotted"===e?[n]:K(e)?[e]:X(e)?e:null:null),o=i.lineDashOffset;if(r){var a=i.strokeNoScale&&t.getLineScale?t.getLineScale():1;a&&1!==a&&(r=V(r,function(t){return t/a}),o/=a)}return[r,o]}var Dx=new Al(!0);function Ax(t){var e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))}function kx(t){return"string"==typeof t&&"none"!==t}function Lx(t){var e=t.fill;return null!=e&&"none"!==e}function Px(t,e){if(null!=e.fillOpacity&&1!==e.fillOpacity){var n=t.globalAlpha;t.globalAlpha=e.fillOpacity*e.opacity,t.fill(),t.globalAlpha=n}else t.fill()}function Ox(t,e){if(null!=e.strokeOpacity&&1!==e.strokeOpacity){var n=t.globalAlpha;t.globalAlpha=e.strokeOpacity*e.opacity,t.stroke(),t.globalAlpha=n}else t.stroke()}function Rx(t,e,n){var i=bs(e.image,e.__image,n);if(Ss(i)){var r=t.createPattern(i,e.repeat||"repeat");if("function"==typeof DOMMatrix&&r&&r.setTransform){var o=new DOMMatrix;o.translateSelf(e.x||0,e.y||0),o.rotateSelf(0,0,(e.rotation||0)*Mt),o.scaleSelf(e.scaleX||1,e.scaleY||1),r.setTransform(o)}return r}}var Nx=["shadowBlur","shadowOffsetX","shadowOffsetY"],Ex=[["lineCap","butt"],["lineJoin","miter"],["miterLimit",10]];function zx(t,e,n,i,r){var o=!1;if(!i&&e===(n=n||{}))return!1;if(i||e.opacity!==n.opacity){Gx(t,r),o=!0;var a=Math.max(Math.min(e.opacity,1),0);t.globalAlpha=isNaN(a)?Ws.opacity:a}(i||e.blend!==n.blend)&&(o||(Gx(t,r),o=!0),t.globalCompositeOperation=e.blend||Ws.blend);for(var s=0;s0&&t.unfinished);t.unfinished||this._zr.flush()}}},e.prototype.getDom=function(){return this._dom},e.prototype.getId=function(){return this.id},e.prototype.getZr=function(){return this._zr},e.prototype.isSSR=function(){return this._ssr},e.prototype.setOption=function(t,e,n){if(!this[e_])if(this._disposed)R_(this.id);else{var i,r,o;if($(e)&&(n=e.lazyUpdate,i=e.silent,r=e.replaceMerge,o=e.transition,e=e.notMerge),this[e_]=!0,D_(this),!this._model||e){var a=new Hg(this._api),s=this._theme,l=this._model=new zg;l.scheduler=this._scheduler,l.ssr=this._ssr,l.init(null,null,null,s,this._locale,a)}this._model.setOption(t,{replaceMerge:r},V_);var u={seriesTransition:o,optionChanged:!0};if(n)this[i_]={silent:i,updateParams:u},this[e_]=!1,this.getZr().wakeUp();else{try{c_(this),p_.update.call(this,null,u)}catch(t){throw this[i_]=null,this[e_]=!1,t}this._ssr||this._zr.flush(),this[i_]=null,this[e_]=!1,y_.call(this,i),m_.call(this,i)}}},e.prototype.setTheme=function(t,e){if(!this[e_])if(this._disposed)R_(this.id);else{var n=this._model;if(n){var i=e&&e.silent,r=null;this[i_]&&(null==i&&(i=this[i_].silent),r=this[i_].updateParams,this[i_]=null),this[e_]=!0,D_(this);try{this._updateTheme(t),n.setTheme(this._theme),c_(this),p_.update.call(this,{type:"setTheme"},r)}catch(t){throw this[e_]=!1,t}this[e_]=!1,y_.call(this,i),m_.call(this,i)}}},e.prototype._updateTheme=function(t){j(t)&&(t=F_[t]),t&&((t=C(t))&&uv(t,!0),this._theme=t)},e.prototype.getModel=function(){return this._model},e.prototype.getOption=function(){return this._model&&this._model.getOption()},e.prototype.getWidth=function(){return this._zr.getWidth()},e.prototype.getHeight=function(){return this._zr.getHeight()},e.prototype.getDevicePixelRatio=function(){return this._zr.painter.dpr||r.hasGlobalWindow&&window.devicePixelRatio||1},e.prototype.getRenderedCanvas=function(t){return this.renderToCanvas(t)},e.prototype.renderToCanvas=function(t){t=t||{};var e=this._zr.painter;return e.getRenderedCanvas({backgroundColor:t.backgroundColor||this._model.get("backgroundColor"),pixelRatio:t.pixelRatio||this.getDevicePixelRatio()})},e.prototype.renderToSVGString=function(t){t=t||{};var e=this._zr.painter;return e.renderToString({useViewBox:t.useViewBox})},e.prototype.getSvgDataURL=function(){var t=this._zr;return B(t.storage.getDisplayList(),function(t){t.stopAnimation(null,!0)}),t.painter.toDataURL()},e.prototype.getDataURL=function(t){if(!this._disposed){var e=(t=t||{}).excludeComponents,n=this._model,i=[],r=this;B(e,function(t){n.eachComponent({mainType:t},function(t){var e=r._componentsMap[t.__viewId];e.group.ignore||(i.push(e),e.group.ignore=!0)})});var o="svg"===this._zr.painter.getType()?this.getSvgDataURL():this.renderToCanvas(t).toDataURL("image/"+(t&&t.type||"png"));return B(i,function(t){t.group.ignore=!1}),o}R_(this.id)},e.prototype.getConnectedDataURL=function(t){if(!this._disposed){var e="svg"===t.type,n=this.group,i=Math.min,r=Math.max,o=1/0;if(U_[n]){var a=o,s=o,l=-1/0,u=-1/0,h=[],d=t&&t.pixelRatio||this.getDevicePixelRatio();B(W_,function(o,c){if(o.group===n){var d=e?o.getZr().painter.getSvgDom().innerHTML:o.renderToCanvas(C(t)),p=o.getDom().getBoundingClientRect();a=i(p.left,a),s=i(p.top,s),l=r(p.right,l),u=r(p.bottom,u),h.push({dom:d,left:p.left,top:p.top})}});var p=(l*=d)-(a*=d),f=(u*=d)-(s*=d),g=c.createCanvas(),v=bo(g,{renderer:e?"svg":"canvas"});if(v.resize({width:p,height:f}),e){var y="";return B(h,function(t){var e=t.left-a,n=t.top-s;y+=''+t.dom+""}),v.painter.getSvgRoot().innerHTML=y,t.connectedBackgroundColor&&v.painter.setBackgroundColor(t.connectedBackgroundColor),v.refreshImmediately(),v.painter.toDataURL()}return t.connectedBackgroundColor&&v.add(new lu({shape:{x:0,y:0,width:p,height:f},style:{fill:t.connectedBackgroundColor}})),B(h,function(t){var e=new eu({style:{x:t.left*d-a,y:t.top*d-s,image:t.dom}});v.add(e)}),v.refreshImmediately(),g.toDataURL("image/"+(t&&t.type||"png"))}return this.getDataURL(t)}R_(this.id)},e.prototype.convertToPixel=function(t,e,n){return f_(this,"convertToPixel",t,e,n)},e.prototype.convertToLayout=function(t,e,n){return f_(this,"convertToLayout",t,e,n)},e.prototype.convertFromPixel=function(t,e,n){return f_(this,"convertFromPixel",t,e,n)},e.prototype.containPixel=function(t,e){var n;if(!this._disposed)return B(Ra(this._model,t),function(t,i){i.indexOf("Models")>=0&&B(t,function(t){var r=t.coordinateSystem;if(r&&r.containPoint)n=n||!!r.containPoint(e);else if("seriesModels"===i){var o=this._chartsMap[t.__viewId];o&&o.containPoint&&(n=n||o.containPoint(e,t))}else 0},this)},this),!!n;R_(this.id)},e.prototype.getVisual=function(t,e){var n=Ra(this._model,t,{defaultMainType:"series"}),i=n.seriesModel;var r=i.getData(),o=n.hasOwnProperty("dataIndexInside")?n.dataIndexInside:n.hasOwnProperty("dataIndex")?r.indexOfRawIndex(n.dataIndex):null;return null!=o?jm(r,o,e):qm(r,e)},e.prototype.getViewOfComponentModel=function(t){return this._componentsMap[t.__viewId]},e.prototype.getViewOfSeriesModel=function(t){return this._chartsMap[t.__viewId]},e.prototype._initEvents=function(){var t=this;B(O_,function(e){var n=function(n){var i,r=t.getModel(),o=n.target,a="globalout"===e;if(a?i={}:o&&Qm(o,function(t){var e=Iu(t);if(e&&null!=e.dataIndex){var n=e.dataModel||r.getSeriesByIndex(e.seriesIndex);return i=n&&n.getDataParams(e.dataIndex,e.dataType,o)||{},!0}if(e.eventData)return i=k({},e.eventData),!0},!0),i){var s=i.componentType,l=i.componentIndex;"markLine"!==s&&"markPoint"!==s&&"markArea"!==s||(s="series",l=i.seriesIndex);var u=s&&null!=l&&r.getComponent(s,l),c=u&&t["series"===u.mainType?"_chartsMap":"_componentsMap"][u.__viewId];0,i.event=n,i.type=e,t._$eventProcessor.eventInfo={targetEl:o,packedEvent:i,model:u,view:c},t.trigger(e,i)}};n.zrEventfulCallAtLast=!0,t._zr.on(e,n,t)});var e=this._messageCenter;B(z_,function(n,i){e.on(i,function(e){t.trigger(i,e)})}),function(t,e,n){t.on("selectchanged",function(t){var i=n.getModel();t.isFromClick?(Jm("map","selectchanged",e,i,t),Jm("pie","selectchanged",e,i,t)):"select"===t.fromAction?(Jm("map","selected",e,i,t),Jm("pie","selected",e,i,t)):"unselect"===t.fromAction&&(Jm("map","unselected",e,i,t),Jm("pie","unselected",e,i,t))})}(e,this,this._api)},e.prototype.isDisposed=function(){return this._disposed},e.prototype.clear=function(){this._disposed?R_(this.id):this.setOption({series:[]},!0)},e.prototype.dispose=function(){if(this._disposed)R_(this.id);else{this._disposed=!0,this.getDom()&&Ga(this.getDom(),Z_,"");var t=this,e=t._api,n=t._model;B(t._componentsViews,function(t){t.dispose(n,e)}),B(t._chartsViews,function(t){t.dispose(n,e)}),t._zr.dispose(),t._dom=t._model=t._chartsMap=t._componentsMap=t._chartsViews=t._componentsViews=t._scheduler=t._api=t._zr=t._throttledZrFlush=t._theme=t._coordSysMgr=t._messageCenter=null,delete W_[t.id]}},e.prototype.resize=function(t){if(!this[e_])if(this._disposed)R_(this.id);else{this._zr.resize(t);var e=this._model;if(this._loadingFX&&this._loadingFX.resize(),e){var n=e.resetOption("media"),i=t&&t.silent;this[i_]&&(null==i&&(i=this[i_].silent),n=!0,this[i_]=null),this[e_]=!0,D_(this);try{n&&c_(this),p_.update.call(this,{type:"resize",animation:k({duration:0},t&&t.animation)})}catch(t){throw this[e_]=!1,t}this[e_]=!1,y_.call(this,i),m_.call(this,i)}}},e.prototype.showLoading=function(t,e){if(this._disposed)R_(this.id);else if($(t)&&(e=t,t=""),t=t||"default",this.hideLoading(),H_[t]){var n=H_[t](this._api,e),i=this._zr;this._loadingFX=n,i.add(n)}},e.prototype.hideLoading=function(){this._disposed?R_(this.id):(this._loadingFX&&this._zr.remove(this._loadingFX),this._loadingFX=null)},e.prototype.makeActionFromEvent=function(t){var e=k({},t);return e.type=E_[t.type],e},e.prototype.dispatchAction=function(t,e){if(this._disposed)R_(this.id);else if($(e)||(e={silent:!!e}),N_[t.type]&&this._model)if(this[e_])this._pendingActions.push(t);else{var n=e.silent;v_.call(this,t,n);var i=e.flush;i?this._zr.flush():!1!==i&&r.browser.weChat&&this._throttledZrFlush(),y_.call(this,n),m_.call(this,n)}},e.prototype.updateLabelLayout=function(){tx.trigger("series:layoutlabels",this._model,this._api,{updatedSeries:[]})},e.prototype.appendData=function(t){if(this._disposed)R_(this.id);else{var e=t.seriesIndex,n=this.getModel().getSeriesByIndex(e);0,n.appendData(t),this._scheduler.unfinished=!0,this.getZr().wakeUp()}},e.internalField=function(){function t(t){t.clearColorPalette(),t.eachSeries(function(t){t.clearColorPalette()})}function e(t){for(var e=[],n=t.currentStates,i=0;i0?{duration:o,delay:i.get("delay"),easing:i.get("easing")}:null;n.eachRendered(function(t){if(t.states&&t.states.emphasis){if(ad(t))return;if(t instanceof Kl&&function(t){var e=Uu(t);e.normalFill=t.style.fill,e.normalStroke=t.style.stroke;var n=t.states.select||{};e.selectFill=n.style&&n.style.fill||null,e.selectStroke=n.style&&n.style.stroke||null}(t),t.__dirty){var n=t.prevStates;n&&t.useStates(n)}if(r){t.stateTransition=a;var i=t.getTextContent(),o=t.getTextGuideLine();i&&(i.stateTransition=a),o&&(o.stateTransition=a)}t.__dirty&&e(t)}})}c_=function(t){var e;e=t._model,ox(e).prepare={};var n=t._scheduler;n.restorePipelines(t._zr,t._model),n.prepareStageTasks(),h_(t,!0),h_(t,!1),n.plan()},h_=function(t,e){for(var n=t._model,i=t._scheduler,r=e?t._componentsViews:t._chartsViews,o=e?t._componentsMap:t._chartsMap,a=t._zr,s=t._api,l=0;lat(e.get("hoverLayerThreshold"),yg.hoverLayerThreshold)&&!r.node&&!r.worker;(t._usingTHL||a)&&(e.eachSeries(function(e){if(!e.preventUsingHoverLayer){var n=t._chartsMap[e.__viewId];n.__alive&&n.eachRendered(function(t){var e=t.states.emphasis;e&&2!==e.hoverLayer&&(e.hoverLayer=a?1:0)})}}),t._usingTHL=a)}(t,e),tx.trigger("series:afterupdate",e,n,l)},T_=function(t){t[r_]=!0,t.getZr().wakeUp()},D_=function(t){t[n_]=(t[n_]+1)%1e6},C_=function(t){t[r_]&&(t.getZr().storage.traverse(function(t){ad(t)||e(t)}),t[r_]=!1)},M_=function(t){return new(function(e){function i(){return null!==e&&e.apply(this,arguments)||this}return n(i,e),i.prototype.getCoordinateSystems=function(){return t._coordSysMgr.getCoordinateSystems()},i.prototype.getComponentByElement=function(e){for(;e;){var n=e.__ecComponentInfo;if(null!=n)return t._model.getComponent(n.mainType,n.index);e=e.parent}},i.prototype.enterEmphasis=function(e,n){vc(e,n),T_(t)},i.prototype.leaveEmphasis=function(e,n){yc(e,n),T_(t)},i.prototype.enterBlur=function(e){mc(e),T_(t)},i.prototype.leaveBlur=function(e){xc(e),T_(t)},i.prototype.enterSelect=function(e){_c(e),T_(t)},i.prototype.leaveSelect=function(e){bc(e),T_(t)},i.prototype.getModel=function(){return t.getModel()},i.prototype.getViewOfComponentModel=function(e){return t.getViewOfComponentModel(e)},i.prototype.getViewOfSeriesModel=function(e){return t.getViewOfSeriesModel(e)},i.prototype.getECUpdateCycleVersion=function(){return t[n_]},i.prototype.usingTHL=function(){return t._usingTHL},i}(Gu))(t)},I_=function(t){function e(t,e){for(var n=0;n=0)){sb.push(n);var a=wm.wrapStageHandler(n,r);a.__prio=e,a.__raw=n,t.push(a)}}function ub(t,e){H_[t]=e}function cb(t,e,n){var i=nx("registerMap");i&&i(t,e,n)}var hb=function(t){var e=(t=C(t)).type,n="";e||va(n);var i=e.split(":");2!==i.length&&va(n);var r=!1;"echarts"===i[0]&&(e=i[1],r=!0),t.__isBuiltIn=r,oy.set(e,t)};function db(t,e,n,i){return{eventContent:{selected:Dc(n),isFromClick:e.isFromClick||!1}}}ab(Jx,ym),ab(Qx,xm),ab(Qx,_m),ab(Jx,Xm),ab(Qx,Zm),ab(7e3,$x),J_(uv),Q_(900,cv),ub("default",function(t,e){P(e=e||{},{text:"loading",textColor:ug.color.primary,fontSize:12,fontWeight:"normal",fontStyle:"normal",fontFamily:"sans-serif",maskColor:"rgba(255,255,255,0.8)",showSpinner:!0,color:ug.color.theme[0],spinnerRadius:10,lineWidth:5,zlevel:0});var n=new vo,i=new lu({style:{fill:e.maskColor},zlevel:e.zlevel,z:1e4});n.add(i);var r,o=new du({style:{text:e.text,fill:e.textColor,fontSize:e.fontSize,fontWeight:e.fontWeight,fontStyle:e.fontStyle,fontFamily:e.fontFamily},zlevel:e.zlevel,z:10001}),a=new lu({style:{fill:"none"},textContent:o,textConfig:{position:"right",distance:10},zlevel:e.zlevel,z:10001});return n.add(a),e.showSpinner&&((r=new Vh({shape:{startAngle:-bm/2,endAngle:-bm/2+.1,r:e.spinnerRadius},style:{stroke:e.color,lineCap:"round",lineWidth:e.lineWidth},zlevel:e.zlevel,z:10001})).animateShape(!0).when(1e3,{endAngle:3*bm/2}).start("circularInOut"),r.animateShape(!0).when(1e3,{startAngle:3*bm/2}).delay(300).start("circularInOut"),n.add(r)),n.resize=function(){var n=o.getBoundingRect().width,s=e.showSpinner?e.spinnerRadius:0,l=(t.getWidth()-2*s-(e.showSpinner&&n?10:0)-n)/2-(e.showSpinner&&n?0:5+n/2)+(e.showSpinner?0:n/2)+(n?0:s),u=t.getHeight()/2;e.showSpinner&&r.setShape({cx:l,cy:u}),a.setShape({x:l-s,y:u-s,width:2*s,height:2*s}),i.setShape({x:0,y:0,width:t.getWidth(),height:t.getHeight()})},n.resize(),n}),ib({type:qu,event:qu,update:qu},St),ib({type:Ku,event:Ku,update:Ku},St),ib({type:$u,event:tc,update:$u,action:St,refineEvent:db,publishNonRefinedEvent:!0}),ib({type:Ju,event:tc,update:Ju,action:St,refineEvent:db,publishNonRefinedEvent:!0}),ib({type:Qu,event:tc,update:Qu,action:St,refineEvent:db,publishNonRefinedEvent:!0}),$_("default",{}),$_("dark",Hm);var pb=[],fb={registerPreprocessor:J_,registerProcessor:Q_,registerPostInit:tb,registerPostUpdate:eb,registerUpdateLifecycle:nb,registerAction:ib,registerCoordinateSystem:rb,registerLayout:ob,registerVisual:ab,registerTransform:hb,registerLoading:ub,registerMap:cb,registerImpl:function(t,e){ex[t]=e},PRIORITY:t_,ComponentModel:lg,ComponentView:Ky,SeriesModel:Hy,ChartView:tm,registerComponentModel:function(t){lg.registerClass(t)},registerComponentView:function(t){Ky.registerClass(t)},registerSeriesModel:function(t){Hy.registerClass(t)},registerChartView:function(t){tm.registerClass(t)},registerCustomSeries:function(t,e){rx(t,e)},registerSubTypeDefaulter:function(t,e){lg.registerSubTypeDefaulter(t,e)},registerPainter:function(t,e){wo(t,e)}};function gb(t){X(t)?B(t,function(t){gb(t)}):R(pb,t)>=0||(pb.push(t),Z(t)&&(t={install:t}),t.install(fb))}function vb(t){return null==t?0:t.length||1}function yb(t){return t}var mb=function(){function t(t,e,n,i,r,o){this._old=t,this._new=e,this._oldKeyGetter=n||yb,this._newKeyGetter=i||yb,this.context=r,this._diffModeMultiple="multiple"===o}return t.prototype.add=function(t){return this._add=t,this},t.prototype.update=function(t){return this._update=t,this},t.prototype.updateManyToOne=function(t){return this._updateManyToOne=t,this},t.prototype.updateOneToMany=function(t){return this._updateOneToMany=t,this},t.prototype.updateManyToMany=function(t){return this._updateManyToMany=t,this},t.prototype.remove=function(t){return this._remove=t,this},t.prototype.execute=function(){this[this._diffModeMultiple?"_executeMultiple":"_executeOneToOne"]()},t.prototype._executeOneToOne=function(){var t=this._old,e=this._new,n={},i=new Array(t.length),r=new Array(e.length);this._initIndexMap(t,null,i,"_oldKeyGetter"),this._initIndexMap(e,n,r,"_newKeyGetter");for(var o=0;o1){var u=s.shift();1===s.length&&(n[a]=s[0]),this._update&&this._update(u,o)}else 1===l?(n[a]=null,this._update&&this._update(s,o)):this._remove&&this._remove(o)}this._performRestAdd(r,n)},t.prototype._executeMultiple=function(){var t=this._old,e=this._new,n={},i={},r=[],o=[];this._initIndexMap(t,n,r,"_oldKeyGetter"),this._initIndexMap(e,i,o,"_newKeyGetter");for(var a=0;a1&&1===h)this._updateManyToOne&&this._updateManyToOne(u,l),i[s]=null;else if(1===c&&h>1)this._updateOneToMany&&this._updateOneToMany(u,l),i[s]=null;else if(1===c&&1===h)this._update&&this._update(u,l),i[s]=null;else if(c>1&&h>1)this._updateManyToMany&&this._updateManyToMany(u,l),i[s]=null;else if(c>1)for(var d=0;d1)for(var a=0;a30}var kb,Lb,Pb,Ob,Rb,Nb,Eb,zb=$,Bb=V,Vb="undefined"==typeof Int32Array?Array:Int32Array,Gb=["hasItemOption","_nameList","_idList","_invertedIndicesMap","_dimSummary","userOutput","_rawData","_dimValueGetter","_nameDimIdx","_idDimIdx","_nameRepeatCount"],Fb=["_approximateExtent"],Hb=function(){function t(t,e){var n;this.type="list",this._dimOmitted=!1,this._nameList=[],this._idList=[],this._visual={},this._layout={},this._itemVisuals=[],this._itemLayouts=[],this._graphicEls=[],this._approximateExtent={},this._calculationInfo={},this.hasItemOption=!1,this.TRANSFERABLE_METHODS=["cloneShallow","downSample","minmaxDownSample","lttbDownSample","map"],this.CHANGABLE_METHODS=["filterSelf","selectRange"],this.DOWNSAMPLE_METHODS=["downSample","minmaxDownSample","lttbDownSample"];var i=!1;Tb(t)?(n=t.dimensions,this._dimOmitted=t.isDimensionOmitted(),this._schema=t):(i=!0,n=t),n=n||["x","y"];for(var r={},o=[],a={},s=!1,l={},u=0;u=e)){var n=this._store.getProvider();this._updateOrdinalMeta();var i=this._nameList,r=this._idList;if(n.getSource().sourceFormat===ku&&!n.pure)for(var o=[],a=t;a0},t.prototype.ensureUniqueItemVisual=function(t,e){var n=this._itemVisuals,i=n[t];i||(i=n[t]={});var r=i[e];return null==r&&(X(r=this.getVisual(e))?r=r.slice():zb(r)&&(r=k({},r)),i[e]=r),r},t.prototype.setItemVisual=function(t,e,n){var i=this._itemVisuals[t]||{};this._itemVisuals[t]=i,zb(e)?k(i,e):i[e]=n},t.prototype.clearAllVisual=function(){this._visual={},this._itemVisuals=[]},t.prototype.setLayout=function(t,e){zb(t)?k(this._layout,t):this._layout[t]=e},t.prototype.getLayout=function(t){return this._layout[t]},t.prototype.getItemLayout=function(t){return this._itemLayouts[t]},t.prototype.setItemLayout=function(t,e,n){this._itemLayouts[t]=n?k(this._itemLayouts[t]||{},e):e},t.prototype.clearItemLayouts=function(){this._itemLayouts.length=0},t.prototype.setItemGraphicEl=function(t,e){var n=this.hostModel&&this.hostModel.seriesIndex;Tu(n,this.dataType,t,e),this._graphicEls[t]=e},t.prototype.getItemGraphicEl=function(t){return this._graphicEls[t]},t.prototype.eachItemGraphicEl=function(t,e){B(this._graphicEls,function(n,i){n&&t&&t.call(e,n,i)})},t.prototype.cloneShallow=function(e){return e||(e=new t(this._schema?this._schema:Bb(this.dimensions,this._getDimInfo,this),this.hostModel)),Rb(e,this),e._store=this._store,e},t.prototype.wrapMethod=function(t,e){var n=this[t];Z(n)&&(this.__wrappedMethods=this.__wrappedMethods||[],this.__wrappedMethods.push(t),this[t]=function(){var t=n.apply(this,arguments);return e.apply(this,[t].concat(lt(arguments)))})},t.internalField=(kb=function(t){var e=t._invertedIndicesMap;B(e,function(n,i){var r=t._dimInfos[i],o=r.ordinalMeta,a=t._store;if(o){n=e[i]=new Vb(o.categories.length);for(var s=0;s1&&(s+="__ec__"+u),i[e]=s}})),t}();function Wb(t,e){mv(t)||(t=_v(t));var n=(e=e||{}).coordDimensions||[],i=e.dimensionsDefine||t.dimensionsDefine||[],r=mt(),o=[],a=function(t,e,n,i){var r=Math.max(t.dimensionsDetectedCount||1,e.length,n.length,i||0);return B(e,function(t){var e;$(t)&&(e=t.dimsDef)&&(r=Math.max(r,e.length))}),r}(t,n,i,e.dimensionsCount),s=e.canOmitUnusedDimensions&&Ab(a),l=i===t.dimensionsDefine,u=l?Db(t):Cb(i),c=e.encodeDefine;!c&&e.encodeDefaulter&&(c=e.encodeDefaulter(t,a));for(var h=mt(c),d=new hy(a),p=0;p0&&(t.name=t.name+(e-1))}),new Ib({source:t,dimensions:o,fullDimensionCount:a,dimensionOmitted:s})}function Ub(t,e,n){if(n||e.hasKey(t)){for(var i=0;e.hasKey(t+i);)i++;t+=i}return e.set(t,!0),t}var Yb=function(t){this.coordSysDims=[],this.axisMap=mt(),this.categoryAxisMap=mt(),this.coordSysName=t};var Xb={cartesian2d:function(t,e,n,i){var r=t.getReferringComponents("xAxis",Ea).models[0],o=t.getReferringComponents("yAxis",Ea).models[0];e.coordSysDims=["x","y"],n.set("x",r),n.set("y",o),Zb(r)&&(i.set("x",r),e.firstCategoryDimIndex=0),Zb(o)&&(i.set("y",o),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},singleAxis:function(t,e,n,i){var r=t.getReferringComponents("singleAxis",Ea).models[0];e.coordSysDims=["single"],n.set("single",r),Zb(r)&&(i.set("single",r),e.firstCategoryDimIndex=0)},polar:function(t,e,n,i){var r=t.getReferringComponents("polar",Ea).models[0],o=r.findAxisModel("radiusAxis"),a=r.findAxisModel("angleAxis");e.coordSysDims=["radius","angle"],n.set("radius",o),n.set("angle",a),Zb(o)&&(i.set("radius",o),e.firstCategoryDimIndex=0),Zb(a)&&(i.set("angle",a),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},geo:function(t,e,n,i){e.coordSysDims=["lng","lat"]},parallel:function(t,e,n,i){var r=t.ecModel,o=r.getComponent("parallel",t.get("parallelIndex")),a=e.coordSysDims=o.dimensions.slice();B(o.parallelAxisIndex,function(t,o){var s=r.getComponent("parallelAxis",t),l=a[o];n.set(l,s),Zb(s)&&(i.set(l,s),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=o))})},matrix:function(t,e,n,i){var r=t.getReferringComponents("matrix",Ea).models[0];e.coordSysDims=["x","y"];var o=r.getDimensionModel("x"),a=r.getDimensionModel("y");n.set("x",o),n.set("y",a),i.set("x",o),i.set("y",a)}};function Zb(t){return"category"===t.get("type")}function jb(t,e,n){var i,r,o,a=(n=n||{}).byIndex,s=n.stackedCoordDimension;!function(t){return!Tb(t.schema)}(e)?(r=e.schema,i=r.dimensions,o=e.store):i=e;var l,u,c,h,d=!(!t||!t.get("stack")),p=!0;function f(t){return"ordinal"!==t.type&&"time"!==t.type}if(B(i,function(t,e){j(t)&&(i[e]=t={name:t}),f(t)||(p=!1)}),B(i,function(t,e){d&&!t.isExtraCoord&&(a||l||!t.ordinalMeta||(l=t),u||!f(t)||p&&("x"===t.coordDim||"angle"===t.coordDim)||s&&s!==t.coordDim||(u=t))}),!u||a||l||(a=!0),u){c="__\0ecstackresult_"+t.id,h="__\0ecstackedover_"+t.id,l&&(l.createInvertedIndices=!0);var g=u.coordDim,v=u.type,y=0;B(i,function(t){t.coordDim===g&&y++});var m={name:c,coordDim:g,coordDimIndex:y,type:v,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length},x={name:h,coordDim:h,coordDimIndex:y+1,type:v,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length+1};r?(o&&(m.storeDimIndex=o.ensureCalculationDimension(h,v),x.storeDimIndex=o.ensureCalculationDimension(c,v)),r.appendCalculationDimension(m),r.appendCalculationDimension(x)):(i.push(m),i.push(x))}return{stackedDimension:u&&u.name,stackedByDimension:l&&l.name,isStackedByIndex:a,stackedOverDimension:h,stackResultDimension:c}}function qb(t,e){return!!e&&e===t.getCalculationInfo("stackedDimension")}function Kb(t,e){return qb(t,e)?t.getCalculationInfo("stackResultDimension"):e}function $b(t,e,n){n=n||{};var i,r=e.getSourceManager(),o=!1;t?(o=!0,i=_v(t)):o=(i=r.getSource()).sourceFormat===ku;var a=function(t){var e=t.get("coordinateSystem"),n=new Yb(e),i=Xb[e];if(i)return i(t,n,n.axisMap,n.categoryAxisMap),n}(e),s=function(t,e){var n,i=t.get("coordinateSystem"),r=Vf.get(i);return e&&e.coordSysDims&&(n=V(e.coordSysDims,function(t){var n={name:t},i=e.axisMap.get(t);if(i){var r=i.get("type");n.type=bb(r)}return n})),n||(n=r&&(r.getDimensionsInfo?r.getDimensionsInfo():r.dimensions.slice())||["x","y"]),n}(e,a),l=n.useEncodeDefaulter,u=Z(l)?l:l?Y(wg,s,e):null,c=Wb(i,{coordDimensions:s,generateCoord:n.generateCoord,encodeDefine:e.getEncode(),encodeDefaulter:u,canOmitUnusedDimensions:!o}),h=function(t,e,n){var i,r;return n&&B(t,function(t,o){var a=t.coordDim,s=n.categoryAxisMap.get(a);s&&(null==i&&(i=o),t.ordinalMeta=s.getOrdinalMeta(),e&&(t.createInvertedIndices=!0)),null!=t.otherDims.itemName&&(r=!0)}),r||null==i||(t[i].otherDims.itemName=0),i}(c.dimensions,n.createInvertedIndices,a),d=o?null:r.getSharedDataStore(c),p=jb(e,{schema:c,store:d}),f=new Hb(c,e);f.setCalculationInfo(p);var g=null!=h&&function(t){if(t.sourceFormat===ku){var e=function(t){var e=0;for(;e=e[0]&&t<=e[1]},getExtent:function(){return this._extents[0].slice()},getExtentUnsafe:function(t){return this._extents[t]},setExtent:function(t,e){hw(this._extents,0,t,e)},setExtent2:function(t,e,n){var i=this._extents;i[t]||(i[t]=i[0].slice()),hw(i,t,e,n)},freeze:function(){0}};function hw(t,e,n,i){Ka(n,i)&&(t[e][0]=n,t[e][1]=i)}function dw(t){return pw(t)||gw(t)}function pw(t){return"interval"===t.type}function fw(t){return"time"===t.type}function gw(t){return"log"===t.type}function vw(t){return"ordinal"===t.type}function yw(t){var e=ea(t),n=Oo(10,e),i=ko(t/n);return i?2===i?i=3:3===i?i=5:i*=2:i=1,Ho(i*n,-e)}function mw(t){return Uo(t)+2}function xw(t,e){return Ro(t)/Ro(e)}function _w(t,e,n){var i=n&&n.lookup;if(i)for(var r=0;r1&&o/a>2&&(r=Math.round(Math.ceil(r/a)*a)),r!==i[0]&&l(i[0],!0,!0);for(var s=r;s<=i[1];s+=a)l(s,!1,s===i[0]||s===i[1]);function l(t,e,i){n({value:t,offInterval:e},i)}s-a!==i[1]&&l(i[1],!0,!0)}var Mw=function(t){function e(n){var i=t.call(this)||this;i.type="ordinal",i.parse=e.parse,rw(i,e.decoratedMethods);var r=n.ordinalMeta;r||(r=new tw({})),X(r)&&(r=new tw({categories:V(r,function(t){return $(t)?t.value:t})})),i._ordinalMeta=r;var o=iw(null,null,n.extent||[0,r.categories.length-1]);return i._mapper=o.mapper,ow(i,o.mapper),i}return n(e,t),e.parse=function(t){return null==t?t=NaN:j(t)?null==(t=this._ordinalMeta.getOrdinal(t))&&(t=NaN):t=ko(t),t},e.prototype.getTicks=function(){var t=[];return Sw(this,0,function(e){t.push(e)}),t},e.prototype.getMinorTicks=function(t){},e.prototype.setSortInfo=function(t){if(null!=t){for(var e=t.ordinalNumbers,n=this._ordinalNumbersByTick=[],i=this._ticksByOrdinalNumber=[],r=0,o=this._ordinalMeta.categories.length,a=Co(o,e.length);r=0&&t=0&&t=0&&ta[0]&&fr[1]||!isFinite(d)||!isFinite(r[1]))break}else{if(p>h)break;d=Co(d,r[1]),p===h&&(d=r[1])}if(u.push({value:d}),d=Ho(d+n,o),s){var f=s.calcNiceTickMultiple(d,c);f>=0&&(d=Ho(d+f*n,o))}if(u.length>0&&d===u[u.length-1].value)break;if(u.length>3e3)return[]}var g=u.length?u[u.length-1].value:r[1];return i[1]>g&&u.push({value:t.expandToNicedExtent?Ho(g+n,o):i[1]}),l&&a.pruneTicksByBreak(t.pruneByBreak,u,s.breaks,function(t){return t.value},e.interval,i),l&&"none"!==t.breakTicks&&a.addBreaksToTicks(u,s.breaks,i),u},e.prototype.getMinorTicks=function(t){return Iw(this,t,Yp(this),this._cfg.interval)},e.prototype.getLabel=function(t,e){if(null==t)return"";var n=e&&e.precision;return null==n?n=Uo(t.value)||0:"auto"===n&&(n=this._cfg.intervalPrecision),Tf(Ho(t.value,n,!0))},e.type="interval",e}(Jb);Jb.registerClass(Tw);var Cw=function(t){function e(n){var i=t.call(this)||this;i.type="time",i.parse=e.parse,i._locale=n.locale,i._useUTC=n.useUTC,i._interval=0;var r=iw(i,Up(i,n),null);return i.brk=r.brk,i}return n(e,t),e.prototype.getLabel=function(t){return uf(t.value,ef[function(t){switch(t){case"year":case"month":return"day";case"millisecond":return"millisecond";default:return"second"}}(sf(this._minLevelUnit))]||ef.second,this._useUTC,this._locale)},e.prototype.getFormattedLabel=function(t,e,n){return function(t,e,n,i,r){var o=null;if(j(n))o=n;else if(Z(n)){var a={time:t.time,level:t.time?t.time.level:0},s=Wp();s&&s.makeAxisLabelFormatterParamBreak(a,t.break),o=n(t.value,e,a)}else{var l=t.time;if(l){var u=n[l.lowerTimeUnit][l.upperTimeUnit];o=u[Math.min(l.level,u.length-1)]||""}else{var c=cf(t.value,r);o=n[c][c][0]}}return uf(new Date(t.value),o,r,i)}(t,e,n,this._locale,this._useUTC)},e.prototype.getTicks=function(t){t=t||{};var e=this._interval,n=aw(this),i=Wp(),r=this.brk,o=i&&r,a=[];if(!e)return a;var s=this._useUTC;if(o&&"only_break"===t.breakTicks)return Wp().addBreaksToTicks(a,r.breaks,n),a;a=function(t,e,n,i,r,o){var a=3e3,s=rf,l=0;function u(t,e,n,r,s,u,c){for(var h=function(t,e){var n=new Date(0);n[t](1);var i=n.getTime();n[t](1+e);var r=n.getTime()-i;return function(t,e){return Math.max(0,Math.round((e-t)/r))}}(s,t),d=e,p=new Date(d);da){0;break}if(p[s](p[r]()+t),d=p.getTime(),o){var f=o.calcNiceTickMultiple(d,h);f>0&&(p[s](p[r]()+f*t),d=p.getTime())}}c.push({value:d,notAdd:d>i[1]})}function c(t,r,o){var a=[],s=!r.length;if(!Aw(sf(t),i[0],i[1],n)){s&&(r=[{value:Nw(i[0],t,n)},{value:i[1]}]);for(var l=0;l=i[0]&&c<=i[1]&&u(d,c,h,p,f,g,a),"year"===t&&o.length>1&&0===l&&o.unshift({value:o[0].value-d})}}for(l=0;l=i[0]&&x<=i[1]&&p++)}var _=r/e;if(p>1.5*_&&f>_/1.5)break;if(h.push(y),p>_||t===s[g])break}d=[]}}var b=F(V(h,function(t){return F(t,function(t){return t.value>=i[0]&&t.value<=i[1]&&!t.notAdd})}),function(t){return t.length>0}),w=b.length-1,S=[];for(g=0;gi[0])&&S.unshift({value:i[0],time:{level:0,upperTimeUnit:A,lowerTimeUnit:A},notNice:!0});(!D||D.value16?16:t>7.5?7:t>3.5?4:t>1.5?2:1}function Lw(t){return(t/=2592e6)>6?6:t>3?3:t>2?2:1}function Pw(t){return(t/=qp)>12?12:t>6?6:t>3.5?4:t>2?2:1}function Ow(t,e){return(t/=e?jp:Zp)>30?30:t>20?20:t>15?15:t>10?10:t>5?5:t>2?2:1}function Rw(t){return Do(na(t,!0),1)}function Nw(t,e,n){var i=Math.max(0,R(nf,e)-1);return hf(new Date(t),nf[i],n).getTime()}Jb.registerClass(Cw);var Ew=function(t){function e(n){var i=t.call(this)||this;i.type="log",i.parse=Tw.parse,i.base=n.logBase||10;var r=[],o=[],a=i._lookup={from:r,to:o};r[0]=r[1]=o[0]=o[1]=NaN,rw(i,e.mapperMethods);var s=Wp(),l=n.breakOption,u={lookup:a};return s&&s.parseAxisBreakOptionInwardTransform(l,i,{noNegative:!0},2,u),i.powStub=new Tw({breakParsed:u.original}),i.intervalStub=new Tw({breakParsed:u.transformed}),ow(i,i.intervalStub),i}return n(e,t),e.prototype.getTicks=function(t){var e=this.base,n=this.powStub,i=Wp(),r=this.intervalStub,o={lookup:{from:r.getExtent(),to:n.getExtent()}};return V(r.getTicks(t||{}),function(t){var r,a=_w(t.value,e,o);if(i){var s=i.getTicksBreakOutwardTransform(this,t,Yp(n),this._lookup);s&&(r=s.vBreak,a=s.tickVal)}return{value:a,break:r}},this)},e.prototype.getMinorTicks=function(t){return Iw(this,t,Yp(this.powStub),this.intervalStub.getConfig().interval)},e.prototype.getLabel=function(t,e){return this.intervalStub.getLabel(t,e)},e.type="log",e.mapperMethods={needTransform:function(){return!0},normalize:function(t){return this.intervalStub.normalize(xw(t,this.base))},scale:function(t){return _w(this.intervalStub.scale(t),this.base,null)},transformIn:function(t,e){return t=xw(t,this.base),e&&2===e.depth?t:this.intervalStub.transformIn(t,e)},transformOut:function(t,e){var n=e?e.depth:null;return zw.depth=n,Bw.lookup=this._lookup,_w(2===n?t:this.intervalStub.transformOut(t,zw),this.base,Bw)},contain:function(t){return this.powStub.contain(t)},setExtent:function(t,e){this.setExtent2(0,t,e)},setExtent2:function(t,e,n){if(!(!Ka(e,n)||e<=0||n<=0)){var i=Vw,r=Vw;if(0===t){var o=this._lookup;i=o.to,r=o.from}this.powStub.setExtent2(t,i[0]=e,i[1]=n);var a=this.base;this.intervalStub.setExtent2(t,r[0]=xw(e,a),r[1]=xw(n,a))}},getFilter:function(){return{g:0}},sanitize:function(t,e){return Ka(e[0],e[1])&&ca(t)&&t<=0&&(t=e[0]),t},getDefaultStartValue:function(){return 1},getExtent:function(){return this.powStub.getExtent()},getExtentUnsafe:function(t,e){return null===e?this.powStub.getExtentUnsafe(t,null):this.intervalStub.getExtentUnsafe(t,e)}},e}(Jb);Jb.registerClass(Ew);var zw={},Bw={},Vw=[],Gw={value:1,category:1,time:1,log:1},Fw=Pa();function Hw(t){var e=t.get("type");return null!=e&&(wt(Gw,e)||Jb.getClass(e))||(e="value"),e}function Ww(t,e,n){var i;switch(Wp()&&(i=Jw(t,e,n)),e){case"category":return new Mw({ordinalMeta:t.getOrdinalMeta?t.getOrdinalMeta():t.getCategories(),extent:[1/0,-1/0]});case"time":return new Cw({locale:t.ecModel.getLocaleModel(),useUTC:t.ecModel.get("useUTC"),breakOption:i});case"log":return new Ew({logBase:t.get("logBase"),breakOption:i});case"value":return new Tw({breakOption:i});default:return new(Jb.getClass(e)||Tw)({})}}var Uw=1,Yw=2,Xw=3;function Zw(t){var e=t.getLabelModel().get("formatter");if("time"===t.type){var n=of(e);return function(e,i){return t.scale.getFormattedLabel(e,i,n)}}if(j(e))return function(n){var i=t.scale.getLabel(n);return e.replace("{value}",null!=i?i:"")};if(Z(e)){if("category"===t.type)return function(n,i){return e(jw(t,n),n.value-t.scale.getExtent()[0],null)};var i=Wp();return function(n,r){var o=null;return i&&(o=i.makeAxisLabelFormatterParamBreak(o,n.break)),e(jw(t,n),r,o)}}return function(e){return t.scale.getLabel(e)}}function jw(t,e){var n=t.scale;return vw(n)?n.getLabel(e):e.value}function qw(t){var e=t.get("interval");return null==e?"auto":e}function Kw(t){return"middle"===t||"center"===t}function $w(t){return t.getShallow("show")}function Jw(t,e,n){var i=t.get("breaks",!0);if(null!=i){if(!Wp())return void 0;if(!n||!function(t){return"category"!==t}(e))return;return i}}function Qw(t,e,n,i,r,o){var a,s,l=gw(t),u=l?t.intervalStub:t;if(u.setExtent(i[0],i[1]),l){var c=t.powStub,h={depth:2},d=t.transformOut(i[0],h),p=t.transformOut(i[1],h),f=(s=i,[(a=n)[0]!==s[0],a[1]!==s[1]]);e[0]&&!f[0]&&(d=r[0]),e[1]&&!f[1]&&(p=r[1]),c.setExtent(d,p)}u.setConfig(o)}function tS(t,e){return vw(t)?t.getRawOrdinalNumber(e.value):e.value}function eS(t,e){return vw(t)&&!!e.get("boundaryGap")}var nS=function(){function t(){}return t.prototype.needIncludeZero=function(){return!this.option.scale},t.prototype.getCoordSysModel=function(){},t}(),iS=Ja(),rS="|&",oS=Pa(),aS=Pa();function sS(t,e){var n=t.model,i=oS(sx(n.ecModel)).keyed,r=i&&i.get(e);return r&&r.get(n.uid)}function lS(t,e){return cS(sS(t,e))}function uS(t,e){var n=oS(sx(t)).keyed;n&&n.each(function(t,n){t.each(function(t,i){e(t,n,i)})})}function cS(t){return{liPosMinGap:t?t.liPosMinGap:void 0}}function hS(t,e,n){var i=sS(t,e);i&&dS(t.model.ecModel,i.sers,n)}function dS(t,e,n){if(e)for(var i=0;i=0}(u)||(u[0]=u[1]=NaN);var c=[],h=[!1,!1],d=e.get("min",!0);"dataMin"===d?(c[0]=u[0],h[0]=!0):(c[0]=MS(t,Z(d)?d({min:u[0],max:u[1]}):d),h[0]=null!=c[0]);var p=e.get("max",!0);"dataMax"===p?(c[1]=u[1],h[1]=!0):(c[1]=MS(t,Z(p)?p({min:u[0],max:u[1]}):p),h[1]=null!=c[1]);var f=function(t,e){var n;if(vw(t))n=[0,0];else{var i=e.get("boundaryGap");"boolean"==typeof i&&(i=null),n=X(i)?i:[i,i]}return[IS(n[0]),IS(n[1])]}(t,e),g=a?null:u[1]-u[0]||Math.abs(u[0]);null==c[0]&&(c[0]=a?o?u[0]:s?0:NaN:u[0]-f[0]*g),null==c[1]&&(c[1]=a?o?u[1]:s?s-1:NaN:u[1]+f[1]*g),!qa(c[0])&&(c[0]=NaN),!qa(c[1])&&(c[1]=NaN);var v=o||rt(c[0])||rt(c[1])||a&&!s,y=pw(t),m=y&&e.needIncludeZero&&e.needIncludeZero();m&&(c[0]>0&&c[1]>0&&!h[0]&&(c[0]=0),c[0]<0&&c[1]<0&&!h[1]&&(c[1]=0));var x=!1;c[0]>c[1]&&(c.reverse(),x=!0);var _=MS(t,e.get("startValue",!0)),b=null!=_;!ca(_)&&i&&(_=t.getDefaultStartValue?t.getDefaultStartValue():0),ca(_)&&(b||!y||m)&&(_c[1]&&!h[1]&&(c[1]=_,h[1]=!0)),SS(this._i={scale:t,dataMM:u,noZoomEffMM:c,zoomMM:[],fixMM:h,zoomFixMM:[!1,!1],startValue:_,isBlank:v,incl0:m,tggAxInv:x,ctnShp:r},c)}return t.prototype.makeNoZoom=function(){return this._i.noZoomEffMM.slice()},t.prototype.makeFinal=function(){var t=this._i,e=t.zoomMM,n=t.noZoomEffMM,i=t.zoomFixMM,r=t.fixMM,o={fixMM:r,zoomFixMM:i,isBlank:t.isBlank,incl0:t.incl0,tggAxInv:t.tggAxInv,ctnShp:t.ctnShp,effMM:n.slice()},a=o.effMM;return null!=e[0]&&(a[0]=e[0],r[0]=i[0]=!0),null!=e[1]&&(a[1]=e[1],r[1]=i[1]=!0),SS(t,a),o},t.prototype.makeRenderInfo=function(){return{startValue:this._i.startValue}},t.prototype.setZoomMM=function(t,e){this._i.zoomMM[t]=e},t}();function SS(t,e){var n=t.scale,i=t.dataMM;n.sanitize&&(e[0]=n.sanitize(e[0],i),e[1]=n.sanitize(e[1],i),$a(e))}function MS(t,e){return null==e?null:rt(e)?NaN:t.parse(e)}function IS(t){return Qr("boolean"==typeof t?0:t,1)||0}function TS(t){var e=bS(t.scale);return e.extent||(e.extent=[1/0,-1/0]),e}function CS(t,e){var n=t.scale,i=t.model,r=t.dim;n.rawExtentInfo||function(t,e,n,i,r){var o=TS(e),a=o.extent,s=!1;!function(t,e){var n=t.model.ecModel,i=oS(sx(n)).axSer;i&&dS(n,i.get(t.model.uid),e)}(e,function(i){if(i.boxCoordinateSystem){var r=Ff(i).coord,l=o.dimIdxInCoord;if(l>=0){if(X(r)){var u=r[l];null==u||X(u)||Xa(a,t.parse(u))}}else 0}else if(i.coordinateSystem){var c=i.getData();if(c){var h=t.getFilter?t.getFilter():null;B(function(t,e){var n={};return B(t.mapDimensionsAll(e),function(e){n[Kb(t,e)]=!0}),W(n)}(c,n),function(t){var e,n;e=a,Ka((n=c.getApproximateExtent(t,h))[0],n[1])&&(n[0]e[1]&&(e[1]=n[1]))})}i.__requireStartValue&&i.__requireStartValue(e)&&(s=!0)}});var l=function(t,e,n){var i=eS(t,n),r=n.get("containShape",!0);null!=r||i||(r=!0);if(!r)return!1;var o=!1;return fS(e,function(t){o=!!kS.get(t)||o}),o}(t,e,i),u=new wS(t,i,a,s,l);DS(t,u,r),o.extent=null}(n,t,r,i,e)}function DS(t,e,n){t.rawExtentInfo=e,e.from=n}function AS(t,e){kS.set(t,e)}var kS=mt();function LS(t,e,n,i,r){t.rawExtentInfo||function(t,e){var n=t.scale;DS(n,new wS(n,t.model,e,!1,!1),3)}({scale:t,model:e},r||[1/0,-1/0]);var o=t.rawExtentInfo.makeFinal(),a=o.effMM;return t.setExtent(a[0],a[1]),t.setBlank(o.isBlank),i&&o.tggAxInv&&n&&!n.get("legacyMinMaxDontInverseAxis")&&(i.inverse=!i.inverse),o}function PS(t,e,n,i){var r;if(n.ctnShp&&(fS(t,function(e){var n=kS.get(e);if(n){var o=n(t,i);o&&(Za(r=r||[0,0],o[0]),ja(r,o[1]),function(t){Fw(t).noOnMyZero=!0}(t))}}),r)){var o=e.getExtent();if(vw(e))t.onBand||e.setExtent2(1,Co(o[0],o[0]+r[0]),Do(o[1],o[1]+r[1]));else{var a=o.slice();n.zoomFixMM[0]||(a[0]=Co(a[0],e.transformOut(e.transformIn(a[0],null)+r[0],null))),n.zoomFixMM[1]||(a[1]=Do(a[1],e.transformOut(e.transformIn(a[1],null)+r[1],null))),(a[0]o[1])&&e.setExtent2(1,a[0],a[1])}}}function OS(t,e){var n=gw(t),i=n?t.intervalStub:t,r=e.fixMinMax||[],o=n?t.getExtent():null,a=i.getExtent(),s=bw(a,r,e.rawExtentResult);i.setExtent(s[0],s[1]),s=i.getExtent();var l=n?function(t,e){var n=ww(e.splitNumber,10),i=t.getExtent(),r=lw(t);0;var o=Do(ta(r),1);n/r*o<=.5&&(o*=10);var a=mw(o),s=[Ho(Po(i[0]/o)*o,a),Ho(Lo(i[1]/o)*o,a)];return{intervalPrecision:a,interval:o,niceExtent:s}}(i,e):function(t,e){var n=ww(e.splitNumber,5),i=lw(t);0;var r=e.minInterval,o=e.maxInterval,a=na(i/n,!0);null!=r&&ao&&(a=o);var s=mw(a),l=t.getExtent(),u=[Ho(Po(l[0]/a)*a,s),Ho(Lo(l[1]/a)*a,s)];return{interval:a,intervalPrecision:s,niceExtent:u}}(i,e),u=l.intervalPrecision,c=l.interval,h=e.userInterval;null!=h&&(l.interval=h,l.intervalPrecision=mw(h)),r[0]||(s[0]=Ho(Lo(s[0]/c)*c,u)),r[1]||(s[1]=Ho(Po(s[1]/c)*c,u)),null!=h&&(l.niceExtent=s.slice()),Qw(t,r,a,s,o,l)}function RS(t){var e=t.scale,n=t.model,i=n.axis,r=n.ecModel;NS(e,n,i,r,null)}function NS(t,e,n,i,r){var o=LS(t,e,i,n,r),a=pw(t)||fw(t);ES(t,{splitNumber:e.get("splitNumber"),fixMinMax:o.fixMM,userInterval:e.get("interval"),minInterval:a?e.get("minInterval"):null,maxInterval:a?e.get("maxInterval"):null,rawExtentResult:o}),n&&i&&PS(n,t,o,i)}function ES(t,e){zS[t.type](t,e)}var zS={interval:OS,log:OS,time:function(t,e){var n=t.getExtent();if(n[0]===n[1]&&(n[0]-=Kp,n[1]+=Kp),n[1]===-1/0&&n[0]===1/0){var i=new Date;n[1]=+new Date(i.getFullYear(),i.getMonth(),i.getDate()),n[0]=n[1]-Kp}t.setExtent(n[0],n[1]);var r=ww(e.splitNumber,10),o=lw(t)/r,a=e.minInterval,s=e.maxInterval;null!=a&&os&&(o=s);var l=Dw.length,u=Math.min(function(t,e,n,i){for(;n>>1;t[r][1]n&&(t=r,n=a)}if(t)return function(t){for(var e=0,n=0,i=0,r=t.length,o=t[r-1][0],a=t[r-1][1],s=0;s>1^-(1&s),l=l>>1^-(1&l),r=s+=r,o=l+=o,i.push([s/n,l/n])}return i}function JS(t,e){return V(F((t=function(t){if(!t.UTF8Encoding)return t;var e=t,n=e.UTF8Scale;return null==n&&(n=1024),B(e.features,function(t){var e=t.geometry,i=e.encodeOffsets,r=e.coordinates;if(i)switch(e.type){case"LineString":e.coordinates=$S(r,i,n);break;case"Polygon":case"MultiLineString":KS(r,i,n);break;case"MultiPolygon":B(r,function(t,e){return KS(t,i[e],n)})}}),e.UTF8Encoding=!1,e}(t)).features,function(t){return t.geometry&&t.properties&&t.geometry.coordinates.length>0}),function(t){var n=t.properties,i=t.geometry,r=[];switch(i.type){case"Polygon":var o=i.coordinates;r.push(new XS(o[0],o.slice(1)));break;case"MultiPolygon":B(i.coordinates,function(t){t[0]&&r.push(new XS(t[0],t.slice(1)))});break;case"LineString":r.push(new ZS([i.coordinates]));break;case"MultiLineString":r.push(new ZS(i.coordinates))}var a=new jS(n[e||"name"],r,n.cp);return a.properties=n,a})}var QS=Object.freeze({__proto__:null,linearMap:Bo,round:function(t,e,n){return null==e&&(e=10),Ho(t,e,n)},asc:Wo,getPrecision:Uo,getPrecisionSafe:Yo,getPixelPrecision:function(t,e){var n=Lo(Ro(t[1]-t[0])/No),i=ko(Ro(Ao(e[1]-e[0]))/No),r=Co(Do(-n+i,0),20);return isFinite(r)?r:20},getPercentWithPrecision:function(t,e,n){return t[e]&&Zo(t,n)[e]||0},parsePercent:Vo,MAX_SAFE_INTEGER:qo,remRadian:Ko,isRadianAroundZero:$o,parseDate:Qo,quantity:ta,quantityExponent:ea,nice:na,quantile:ia,reformIntervals:ra,isNumeric:aa,numericToNumber:oa}),tM=Object.freeze({__proto__:null,parse:Qo,format:uf,roundTime:hf}),eM=Object.freeze({__proto__:null,extendShape:fd,extendPath:vd,makePath:xd,makeImage:_d,mergePath:wd,resizePath:Sd,createIcon:Od,updateProps:rd,initProps:od,getTransform:Td,clipPointsByRect:Ld,clipRectByRect:Pd,registerShape:yd,getShapeClass:md,Group:vo,Image:eu,Text:du,Circle:ah,Ellipse:lh,Sector:Sh,Ring:Ih,Polygon:Dh,Polyline:kh,Rect:lu,Line:Oh,BezierCurve:zh,Arc:Vh,IncrementalDisplayable:td,CompoundPath:Gh,LinearGradient:Hh,RadialGradient:Wh,BoundingRect:Xe}),nM=Object.freeze({__proto__:null,addCommas:Tf,toCamelCase:Cf,normalizeCssArray:Df,encodeHTML:se,formatTpl:Pf,getTooltipMarker:Rf,formatTime:function(t,e,n){"week"!==t&&"month"!==t&&"quarter"!==t&&"half-year"!==t&&"year"!==t||(t="MM-dd\nyyyy");var i=Qo(e),r=n?"getUTC":"get",o=i[r+"FullYear"](),a=i[r+"Month"]()+1,s=i[r+"Date"](),l=i[r+"Hours"](),u=i[r+"Minutes"](),c=i[r+"Seconds"](),h=i[r+"Milliseconds"]();return t=t.replace("MM",af(a,2)).replace("M",a).replace("yyyy",o).replace("yy",af(o%100+"",2)).replace("dd",af(s,2)).replace("d",s).replace("hh",af(l,2)).replace("h",l).replace("mm",af(u,2)).replace("m",u).replace("ss",af(c,2)).replace("s",c).replace("SSS",af(h,3))},capitalFirst:function(t){return t?t.charAt(0).toUpperCase()+t.substr(1):t},truncateText:function(t,e,n,i,r){var o={};return Is(o,t,e,n,i,r),o.text},getTextRect:function(t,e,n,i,r,o,a,s){return new du({style:{text:t,font:e,align:n,verticalAlign:i,padding:r,rich:o,overflow:a?"truncate":null,lineHeight:s}}).getBoundingRect()}}),iM=Object.freeze({__proto__:null,map:V,each:B,indexOf:R,inherits:N,reduce:G,filter:F,bind:U,curry:Y,isArray:X,isString:j,isObject:$,isFunction:Z,extend:k,defaults:P,clone:C,merge:D}),rM=Pa(),oM=Pa(),aM=1,sM=2;function lM(t){return{out:{noPxChangeTryDetermine:[]},kind:t}}function uM(t,e){var n=t.getLabelModel().get("customValues");if(n){var i=t.scale;return{labels:V(hM(n,i),function(e,n){return{formattedLabel:Zw(t)(e,n),rawLabel:i.getLabel(e),tick:e}})}}return"category"===t.type?function(t,e){var n=t.getLabelModel(),i=dM(t,n,e);return!n.get("show")||t.scale.isBlank()?{labels:[]}:i}(t,e):function(t){var e=t.scale.getTicks(),n=Zw(t);return{labels:V(e,function(e,i){return{formattedLabel:n(e,i),rawLabel:t.scale.getLabel(e),tick:e}})}}(t)}function cM(t,e,n){var i=t.scale,r=t.getTickModel().get("customValues");return r?{ticks:hM(r,i)}:"category"===t.type?function(t,e){var n,i,r=pM(t),o=qw(e),a=vM(r,o);if(a)return a;e.get("show")&&!t.scale.isBlank()||(n=[]);if(Z(o))n=_M(t,o,!0);else if("auto"===o){var s=dM(t,t.getLabelModel(),lM(sM));i=s.labelCategoryInterval,n=V(s.labels,function(t){return t.tick})}else n=_M(t,i=o,!0);return yM(r,o,{ticks:n,tickCategoryInterval:i})}(t,e):{ticks:i.getTicks(n)}}function hM(t,e){var n=e.getExtent(),i=[];return B(t,function(t){(t=e.parse(t))>=n[0]&&t<=n[1]&&i.push(t)}),ts(i,ns,null),Wo(i),V(i,function(t){return{value:t}})}function dM(t,e,n){var i,r,o=fM(t),a=qw(e),s=n.kind===aM;if(!s){var l=vM(o,a);if(l)return l}Z(a)?i=_M(t,a,!1):(r="auto"===a?function(t,e){if(e.kind===aM){var n=t.calculateCategoryInterval(e);return e.out.noPxChangeTryDetermine.push(function(){return oM(t).autoInterval=n,!0}),n}var i=oM(t).autoInterval;return null!=i?i:oM(t).autoInterval=t.calculateCategoryInterval(e)}(t,n):a,i=_M(t,r,!1));var u={labels:i,labelCategoryInterval:r};return s?n.out.noPxChangeTryDetermine.push(function(){return yM(o,a,u),!0}):yM(o,a,u),u}var pM=gM("axisTick"),fM=gM("axisLabel");function gM(t){return function(e){return oM(e)[t]||(oM(e)[t]={list:[]})}}function vM(t,e){for(var n=0;ne&&i.axisExtent0===r[0]&&i.axisExtent1===r[1])return o;i.lastTickCount=n,i.lastAutoInterval=e,i.axisExtent0=r[0],i.axisExtent1=r[1]}function _M(t,e,n){var i=Zw(t),r=t.scale,o=[],a=Z(e);return Sw(r,a?0:e,function(t,s){var l=r.getLabel(t);if(a){var u=!!e(t.value,l);if(t.offInterval=!u,!u&&!s)return}o.push(n?t:{formattedLabel:i(t),rawLabel:l,tick:t})}),o}function bM(t,e){e=e||{};var n,i={w:NaN,w2:NaN},r=t.scale,o=e.fromStat,a=e.min,s=(n=sw(r,3))[1]-n[0];ca(s)||(s=NaN);var l=t.getExtent(),u=Ao(l[1]-l[0]);return vw(r)?function(t,e,n,i){var r=e.onBand,o=n+(r?1:0);0===o&&(o=1),t.w=i/o,!r&&n&&i&&(t.w2=t.w*n/i)}(i,t,s,u):o&&function(t,e,n,i,r){0;var o=!1,a=-1/0;B(r.key?[lS(e,r.key)]:function(t,e){var n=[];return uS(t.model.ecModel,function(t){for(var i=0;i0?(e>a&&(a=e),o=!1):-2===e&&(o=!0))}),ca(n)&&n>0&&ca(a)?(t.w=i/n*a,t.w2=a):o&&(t.w=.8*i,t.w2=t.w*n/i)}(i,t,s,u,o),null!=a&&(i.w=ca(i.w)?Do(a,i.w):a),i}var wM=[0,1],SM=function(){function t(t,e,n){this.onBand=!1,this.inverse=!1,this.dim=t,this.scale=e,this._extent=n||[0,0]}return t.prototype.contain=function(t){var e=this._extent,n=Math.min(e[0],e[1]),i=Math.max(e[0],e[1]);return t>=n&&t<=i},t.prototype.containData=function(t){return this.scale.contain(this.scale.parse(t))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.setExtent=function(t,e){var n=this._extent;n[0]=t,n[1]=e},t.prototype.dataToCoord=function(t,e){var n=this.scale;return Bo(t=n.normalize(n.parse(t)),wM,MM(this),e)},t.prototype.coordToData=function(t,e){var n=Bo(t,MM(this),wM,e);return this.scale.scale(n)},t.prototype.pointToData=function(t,e){},t.prototype.getTicksCoords=function(t){var e=(t=t||{}).tickModel||this.getTickModel(),n=V(cM(this,e,{breakTicks:t.breakTicks,pruneByBreak:t.pruneByBreak}).ticks,function(t){return{coord:this.dataToCoord(tS(this.scale,t)),tick:t}},this),i=function(t,e,n){var i=e.length;if(!t.onBand||n||!i)return!1;var r=bM(t).w;if(!r)return!1;B(e,function(t){t.coord-=r/2});var o=t.scale.getExtent(),a=e[i-1];a.tick.offInterval&&e.pop();return e.push({coord:a.coord+r,tick:{value:o[1]+1}}),!0}(this,n,e.get("alignWithLabel"));return V(n,function(t){return{coord:t.coord,tickValue:t.tick.value,onBand:i}})},t.prototype.getMinorTicksCoords=function(){if(vw(this.scale))return[];var t=this.model.getModel("minorTick").get("splitNumber");return t>0&&t<100||(t=5),V(this.scale.getMinorTicks(t),function(t){return V(t,function(t){return{coord:this.dataToCoord(t),tickValue:t}},this)},this)},t.prototype.getViewLabels=function(t){return uM(this,t=t||lM(sM)).labels},t.prototype.getLabelModel=function(){return this.model.getModel("axisLabel")},t.prototype.getTickModel=function(){return this.model.getModel("axisTick")},t.prototype.getBandWidth=function(){return bM(this,{min:1}).w},t.prototype.calculateCategoryInterval=function(t){return function(t,e){var n=e.kind,i=function(t){var e=t.getLabelModel();return{axisRotate:t.getRotate?t.getRotate():t.isHorizontal&&!t.isHorizontal()?90:0,labelRotate:e.get("rotate")||0,font:e.getFont()}}(t),r=Zw(t),o=(i.axisRotate-i.labelRotate)/180*Math.PI,a=t.scale,s=a.getExtent(),l=a.count();if(s[1]-s[0]<1)return 0;var u=1;l>40&&(u=Math.max(1,Math.floor(l/40)));for(var c=s[0],h=t.dataToCoord(c+1)-t.dataToCoord(c),d=Math.abs(h*Math.cos(o)),p=Math.abs(h*Math.sin(o)),f=0,g=0;c<=s[1];c+=u){var v,y,m=qr(r({value:c}),i.font,"center","top");v=1.3*m.width,y=1.3*m.height,f=Math.max(f,v,7),g=Math.max(g,y,7)}var x=f/d,_=g/p;isNaN(x)&&(x=1/0),isNaN(_)&&(_=1/0);var b=Math.max(0,Math.floor(Math.min(x,_)));if(n===aM)return e.out.noPxChangeTryDetermine.push(U(mM,null,t,b,l)),b;var w=xM(t,b,l);return null!=w?w:b}(this,t=t||lM(sM))},t}();function MM(t){var e=t.getExtent();if(t.onBand){var n=(e[1]-e[0])/t.scale.count()/2;e[0]+=n,e[1]-=n}return e}var IM=2*Math.PI,TM=Al.CMD,CM=["top","right","bottom","left"];function DM(t,e,n,i,r){var o=n.width,a=n.height;switch(t){case"top":i.set(n.x+o/2,n.y-e),r.set(0,-1);break;case"bottom":i.set(n.x+o/2,n.y+a+e),r.set(0,1);break;case"left":i.set(n.x-e,n.y+a/2),r.set(-1,0);break;case"right":i.set(n.x+o+e,n.y+a/2),r.set(1,0)}}function AM(t,e,n,i,r,o,a,s,l){a-=t,s-=e;var u=Math.sqrt(a*a+s*s),c=(a/=u)*n+t,h=(s/=u)*n+e;if(Math.abs(i-r)%IM<1e-4)return l[0]=c,l[1]=h,u-n;if(o){var d=i;i=Rl(r),r=Rl(d)}else i=Rl(i),r=Rl(r);i>r&&(r+=IM);var p=Math.atan2(s,a);if(p<0&&(p+=IM),p>=i&&p<=r||p+IM>=i&&p+IM<=r)return l[0]=c,l[1]=h,u-n;var f=n*Math.cos(i)+t,g=n*Math.sin(i)+e,v=n*Math.cos(r)+t,y=n*Math.sin(r)+e,m=(f-a)*(f-a)+(g-s)*(g-s),x=(v-a)*(v-a)+(y-s)*(y-s);return m0){e=e/180*Math.PI,NM.fromArray(t[0]),EM.fromArray(t[1]),zM.fromArray(t[2]),Pe.sub(BM,NM,EM),Pe.sub(VM,zM,EM);var n=BM.len(),i=VM.len();if(!(n<.001||i<.001)){BM.scale(1/n),VM.scale(1/i);var r=BM.dot(VM);if(Math.cos(e)1&&Pe.copy(HM,zM),HM.toArray(t[1])}}}}function UM(t,e,n){if(n<=180&&n>0){n=n/180*Math.PI,NM.fromArray(t[0]),EM.fromArray(t[1]),zM.fromArray(t[2]),Pe.sub(BM,EM,NM),Pe.sub(VM,zM,EM);var i=BM.len(),r=VM.len();if(!(i<.001||r<.001))if(BM.scale(1/i),VM.scale(1/r),BM.dot(e)=a)Pe.copy(HM,zM);else{HM.scaleAndAdd(VM,o/Math.tan(Math.PI/2-s));var l=zM.x!==EM.x?(HM.x-EM.x)/(zM.x-EM.x):(HM.y-EM.y)/(zM.y-EM.y);if(isNaN(l))return;l<0?Pe.copy(HM,EM):l>1&&Pe.copy(HM,zM)}HM.toArray(t[1])}}}function YM(t,e,n,i){var r="normal"===n,o=r?t:t.ensureState(n);o.ignore=e;var a=i.get("smooth");a=!0===a?.3:Math.max(+a,0)||0,o.shape=o.shape||{},o.shape.smooth=a;var s=i.getModel("lineStyle").getLineStyle();r?t.useStyle(s):o.style=s}function XM(t,e){var n=e.smooth,i=e.points;if(i)if(t.moveTo(i[0][0],i[0][1]),n>0&&i.length>=3){var r=Ft(i[0],i[1]),o=Ft(i[1],i[2]);if(!r||!o)return t.lineTo(i[1][0],i[1][1]),void t.lineTo(i[2][0],i[2][1]);var a=Math.min(r,o)*n,s=Ut([],i[1],i[0],a/r),l=Ut([],i[1],i[2],a/o),u=Ut([],s,l,.5);t.bezierCurveTo(s[0],s[1],s[0],s[1],u[0],u[1]),t.bezierCurveTo(l[0],l[1],l[0],l[1],i[2][0],i[2][1])}else for(var c=1;c0&&r&&b(-h/o,0,o);var g,v,y=t[0],m=t[o-1];function x(){g=y.rect[a]-n,v=i-m.rect[a]-m.rect[s]}function _(t,e,n){if(t<0){var i=Math.min(e,-t);if(i>0){b(i*n,0,o);var r=i+t;r<0&&w(-r*n,1)}else w(-t*n,1)}}function b(e,n,i){0!==e&&(c=!0);for(var r=n;r0)for(l=0;l0;l--){b(-(i[l-1]*h),l,o)}}}function S(t){var e=t<0?-1:1;t=Math.abs(t);for(var n=Math.ceil(t/(o-1)),i=0;i0?b(n,0,i+1):b(-n,o-i-1,o),(t-=n)<=0)return}return x(),g<0&&w(-g,.8),v<0&&w(v,.8),x(),_(g,v,1),_(v,g,-1),x(),g<0&&S(-g),v<0&&S(v),c}function oI(t){var e=[];function n(t){if(!t.ignore){var e=t.ensureState("emphasis");null==e.ignore&&(e.ignore=!1)}t.ignore=!0}t.sort(function(t,e){return(e.suggestIgnore?1:0)-(t.suggestIgnore?1:0)||e.priority-t.priority});for(var i=0;i=0&&n.attr(p.oldLayoutSelect),R(u,"emphasis")>=0&&n.attr(p.oldLayoutEmphasis)),rd(n,s,e,a)}else if(n.attr(s),!fp(n).valueAnimation){var c=at(n.style.opacity,1);n.style.opacity=0,od(n,{style:{opacity:c}},e,a)}if(p.oldLayout=s,n.states.select){var h=p.oldLayoutSelect={};pI(h,s,fI),pI(h,n.states.select,fI)}if(n.states.emphasis){var d=p.oldLayoutEmphasis={};pI(d,s,fI),pI(d,n.states.emphasis,fI)}vp(n,a,l,e,e)}if(i&&!i.ignore&&!i.invisible){r=(p=dI(i)).oldLayout;var p,f={points:i.shape.points};r?(i.attr({shape:r}),rd(i,{shape:f},e)):(i.setShape(f),i.style.strokePercent=0,od(i,{style:{strokePercent:1}},e)),p.oldLayout=f}},t}(),vI=Pa();var yI=Math.sin,mI=Math.cos,xI=Math.PI,_I=2*Math.PI,bI=180/xI,wI=function(){function t(){}return t.prototype.reset=function(t){this._start=!0,this._d=[],this._str="",this._p=Math.pow(10,t||4)},t.prototype.moveTo=function(t,e){this._add("M",t,e)},t.prototype.lineTo=function(t,e){this._add("L",t,e)},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){this._add("C",t,e,n,i,r,o)},t.prototype.quadraticCurveTo=function(t,e,n,i){this._add("Q",t,e,n,i)},t.prototype.arc=function(t,e,n,i,r,o){this.ellipse(t,e,n,n,0,i,r,o)},t.prototype.ellipse=function(t,e,n,i,r,o,a,s){var l=a-o,u=!s,c=Math.abs(l),h=Pi(c-_I)||(u?l>=_I:-l>=_I),d=l>0?l%_I:l%_I+_I,p=!1;p=!!h||!Pi(c)&&d>=xI==!!u;var f=t+n*mI(o),g=e+i*yI(o);this._start&&this._add("M",f,g);var v=Math.round(r*bI);if(h){var y=1/this._p,m=(u?1:-1)*(_I-y);this._add("A",n,i,v,1,+u,t+n*mI(o+m),e+i*yI(o+m)),y>.01&&this._add("A",n,i,v,0,+u,f,g)}else{var x=t+n*mI(a),_=e+i*yI(a);this._add("A",n,i,v,+p,+u,x,_)}},t.prototype.rect=function(t,e,n,i){this._add("M",t,e),this._add("l",n,0),this._add("l",0,i),this._add("l",-n,0),this._add("Z")},t.prototype.closePath=function(){this._d.length>0&&this._add("Z")},t.prototype._add=function(t,e,n,i,r,o,a,s,l){for(var u=[],c=this._p,h=1;h"}(r,o)+("style"!==r?se(a):a||"")+(i?""+n+V(i,function(e){return t(e)}).join(n)+n:"")+("")}(t)}function RI(t){return{zrId:t,shadowCache:{},patternCache:{},gradientCache:{},clipPathCache:{},defs:{},cssNodes:{},cssAnims:{},cssStyleCache:{},cssAnimIdx:0,shadowIdx:0,gradientIdx:0,patternIdx:0,clipPathIdx:0}}function NI(t,e,n,i){return PI("svg","root",{width:t,height:e,xmlns:DI,"xmlns:xlink":AI,version:"1.1",baseProfile:"full",viewBox:!!i&&"0 0 "+t+" "+e},n)}var EI=0;function zI(){return EI++}var BI={cubicIn:"0.32,0,0.67,0",cubicOut:"0.33,1,0.68,1",cubicInOut:"0.65,0,0.35,1",quadraticIn:"0.11,0,0.5,0",quadraticOut:"0.5,1,0.89,1",quadraticInOut:"0.45,0,0.55,1",quarticIn:"0.5,0,0.75,0",quarticOut:"0.25,1,0.5,1",quarticInOut:"0.76,0,0.24,1",quinticIn:"0.64,0,0.78,0",quinticOut:"0.22,1,0.36,1",quinticInOut:"0.83,0,0.17,1",sinusoidalIn:"0.12,0,0.39,0",sinusoidalOut:"0.61,1,0.88,1",sinusoidalInOut:"0.37,0,0.63,1",exponentialIn:"0.7,0,0.84,0",exponentialOut:"0.16,1,0.3,1",exponentialInOut:"0.87,0,0.13,1",circularIn:"0.55,0,1,0.45",circularOut:"0,0.55,0.45,1",circularInOut:"0.85,0,0.15,1"},VI="transform-origin";function GI(t,e,n){var i=k({},t.shape);k(i,e),t.buildPath(n,i);var r=new wI;return r.reset(Hi(t)),n.rebuildPath(r,1),r.generateStr(),r.getStr()}function FI(t,e){var n=e.originX,i=e.originY;(n||i)&&(t[VI]=n+"px "+i+"px")}var HI={fill:"fill",opacity:"opacity",lineWidth:"stroke-width",lineDashOffset:"stroke-dashoffset"};function WI(t,e){var n=e.zrId+"-ani-"+e.cssAnimIdx++;return e.cssAnims[n]=t,n}function UI(t){return j(t)?BI[t]?"cubic-bezier("+BI[t]+")":Jn(t)?t:"":""}function YI(t,e,n,i){var r=t.animators,o=r.length,a=[];if(t instanceof Gh){var s=function(t,e,n){var i,r,o=t.shape.paths,a={};if(B(o,function(t){var e=RI(n.zrId);e.animation=!0,YI(t,{},e,!0);var o=e.cssAnims,s=e.cssNodes,l=W(o),u=l.length;if(u){var c=o[r=l[u-1]];for(var h in c){var d=c[h];a[h]=a[h]||{d:""},a[h].d+=d.d||""}for(var p in s){var f=s[p].animation;f.indexOf(r)>=0&&(i=f)}}}),i){e.d=!1;var s=WI(a,n);return i.replace(r,s)}}(t,e,n);if(s)a.push(s);else if(!o)return}else if(!o)return;for(var l={},u=0;u0}).length)return WI(c,n)+" "+r[0]+" both"}for(var v in l){(s=g(l[v]))&&a.push(s)}if(a.length){var y=n.zrId+"-cls-"+zI();n.cssNodes["."+y]={animation:a.join(",")},e.class=y}}function XI(t,e,n,i){var r=JSON.stringify(t),o=n.cssStyleCache[r];o||(o=n.zrId+"-cls-"+zI(),n.cssStyleCache[r]=o,n.cssNodes["."+o+(i?":hover":"")]=t),e.class=e.class?e.class+" "+o:o}var ZI=Math.round;function jI(t){return t&&j(t.src)}function qI(t){return t&&Z(t.toDataURL)}function KI(t,e,n,i){CI(function(r,o){var a="fill"===r||"stroke"===r;a&&Gi(o)?sT(e,t,r,i):a&&zi(o)?lT(n,t,r,i):t[r]=o,a&&i.ssr&&"none"===o&&(t["pointer-events"]="visible")},e,n,!1),function(t,e,n){var i=t.style;if(function(t){return t&&(t.shadowBlur||t.shadowOffsetX||t.shadowOffsetY)}(i)){var r=function(t){var e=t.style,n=t.getGlobalScale();return[e.shadowColor,(e.shadowBlur||0).toFixed(2),(e.shadowOffsetX||0).toFixed(2),(e.shadowOffsetY||0).toFixed(2),n[0],n[1]].join(",")}(t),o=n.shadowCache,a=o[r];if(!a){var s=t.getGlobalScale(),l=s[0],u=s[1];if(!l||!u)return;var c=i.shadowOffsetX||0,h=i.shadowOffsetY||0,d=i.shadowBlur,p=ki(i.shadowColor),f=p.opacity,g=p.color,v=d/2/l+" "+d/2/u;a=n.zrId+"-s"+n.shadowIdx++,n.defs[a]=PI("filter",a,{id:a,x:"-100%",y:"-100%",width:"300%",height:"300%"},[PI("feDropShadow","",{dx:c/l,dy:h/u,stdDeviation:v,"flood-color":g,"flood-opacity":f})]),o[r]=a}e.filter=Fi(a)}}(n,t,i)}function $I(t,e){var n=So(e);n&&(n.each(function(e,n){null!=e&&(t[(kI+n).toLowerCase()]=e+"")}),e.isSilent()&&(t[kI+"silent"]="true"))}function JI(t){return Pi(t[0]-1)&&Pi(t[1])&&Pi(t[2])&&Pi(t[3]-1)}function QI(t,e,n){if(e&&(!function(t){return Pi(t[4])&&Pi(t[5])}(e)||!JI(e))){var i=n?10:1e4;t.transform=JI(e)?"translate("+ZI(e[4]*i)/i+" "+ZI(e[5]*i)/i+")":function(t){return"matrix("+Oi(t[0])+","+Oi(t[1])+","+Oi(t[2])+","+Oi(t[3])+","+Ri(t[4])+","+Ri(t[5])+")"}(e)}}function tT(t,e,n){for(var i=t.points,r=[],o=0;o=0&&a||o;s&&(r=Ci(s))}var l=i.lineWidth;l&&(l/=!i.strokeNoScale&&t.transform?t.transform[0]:1);var u={cursor:"pointer"};r&&(u.fill=r),i.stroke&&(u.stroke=i.stroke),l&&(u["stroke-width"]=l),XI(u,e,n,!0)}}(t,o,e),PI(s,t.id+"",o)}function aT(t,e){return t instanceof Kl?oT(t,e):t instanceof eu?function(t,e){var n=t.style,i=n.image;if(i&&!j(i)&&(jI(i)?i=i.src:qI(i)&&(i=i.toDataURL())),i){var r=n.x||0,o=n.y||0,a={href:i,width:n.width,height:n.height};return r&&(a.x=r),o&&(a.y=o),QI(a,t.transform),KI(a,n,t,e),$I(a,t),e.animation&&YI(t,a,e),PI("image",t.id+"",a)}}(t,e):t instanceof Jl?function(t,e){var n=t.style,i=n.text;if(null!=i&&(i+=""),i&&!isNaN(n.x)&&!isNaN(n.y)){var r=n.font||a,s=n.x||0,l=function(t,e,n){return"top"===n?t+=e/2:"bottom"===n&&(t-=e/2),t}(n.y||0,Jr(r),n.textBaseline),u={"dominant-baseline":"central","text-anchor":Ni[n.textAlign]||n.textAlign};if(mu(n)){var c="",h=n.fontStyle,d=vu(n.fontSize);if(!parseFloat(d))return;var p=n.fontFamily||o,f=n.fontWeight;c+="font-size:"+d+";font-family:"+p+";",h&&"normal"!==h&&(c+="font-style:"+h+";"),f&&"normal"!==f&&(c+="font-weight:"+f+";"),u.style=c}else u.style="font: "+r;return i.match(/\s/)&&(u["xml:space"]="preserve"),s&&(u.x=s),l&&(u.y=l),QI(u,t.transform),KI(u,n,t,e),$I(u,t),e.animation&&YI(t,u,e),PI("text",t.id+"",u,void 0,i)}}(t,e):void 0}function sT(t,e,n,i){var r,o=t[n],a={gradientUnits:o.global?"userSpaceOnUse":"objectBoundingBox"};if(Bi(o))r="linearGradient",a.x1=o.x,a.y1=o.y,a.x2=o.x2,a.y2=o.y2;else{if(!Vi(o))return void 0;r="radialGradient",a.cx=at(o.x,.5),a.cy=at(o.y,.5),a.r=at(o.r,.5)}for(var s=o.colorStops,l=[],u=0,c=s.length;ul?ST(t,null==n[h+1]?null:n[h+1].elm,n,s,h):MT(t,e,a,l))}(n,i,r):xT(r)?(xT(t.text)&&vT(n,""),ST(n,null,r,0,r.length-1)):xT(i)?MT(n,i,0,i.length-1):xT(t.text)&&vT(n,""):t.text!==e.text&&(xT(i)&&MT(n,i,0,i.length-1),vT(n,e.text)))}var CT=0,DT=function(){function t(t,e,n){if(this.type="svg",this.configLayer=function(){},this.storage=e,this._opts=n=k({},n),this.root=t,this._id="zr"+CT++,this._oldVNode=NI(n.width,n.height),t&&!n.ssr){var i=this._viewport=document.createElement("div");i.style.cssText="position:relative;overflow:hidden";var r=this._svgDom=this._oldVNode.elm=LI("svg");IT(null,this._oldVNode),i.appendChild(r),t.appendChild(i)}this.resize(n.width,n.height)}return t.prototype.getType=function(){return this.type},t.prototype.getViewportRoot=function(){return this._viewport},t.prototype.getViewportRootOffset=function(){var t=this.getViewportRoot();if(t)return{offsetLeft:t.offsetLeft||0,offsetTop:t.offsetTop||0}},t.prototype.getSvgDom=function(){return this._svgDom},t.prototype.refresh=function(){if(this.root){var t=this.renderToVNode({willUpdate:!0});t.attrs.style="position:absolute;left:0;top:0;user-select:none",function(t,e){if(bT(t,e))TT(t,e);else{var n=t.elm,i=fT(n);wT(e),null!==i&&(hT(i,e.elm,gT(n)),MT(i,[t],0,0))}}(this._oldVNode,t),this._oldVNode=t}},t.prototype.renderOneToVNode=function(t){return aT(t,RI(this._id))},t.prototype.renderToVNode=function(t){t=t||{};var e=this.storage.getDisplayList(!0),n=this._width,i=this._height,r=RI(this._id);r.animation=t.animation,r.willUpdate=t.willUpdate,r.compress=t.compress,r.emphasis=t.emphasis,r.ssr=this._opts.ssr;var o=[],a=this._bgVNode=function(t,e,n,i){var r;if(n&&"none"!==n)if(r=PI("rect","bg",{width:t,height:e,x:"0",y:"0"}),Gi(n))sT({fill:n},r.attrs,"fill",i);else if(zi(n))lT({style:{fill:n},dirty:St,getBoundingRect:function(){return{width:t,height:e}}},r.attrs,"fill",i);else{var o=ki(n),a=o.color,s=o.opacity;r.attrs.fill=a,s<1&&(r.attrs["fill-opacity"]=s)}return r}(n,i,this._backgroundColor,r);a&&o.push(a);var s=t.compress?null:this._mainVNode=PI("g","main",{},[]);this._paintList(e,r,s?s.children:o),s&&o.push(s);var l=V(W(r.defs),function(t){return r.defs[t]});if(l.length&&o.push(PI("defs","defs",{},l)),t.animation){var u=function(t,e,n){var i=(n=n||{}).newline?"\n":"",r=" {"+i,o=i+"}",a=V(W(t),function(e){return e+r+V(W(t[e]),function(n){return n+":"+t[e][n]+";"}).join(i)+o}).join(i),s=V(W(e),function(t){return"@keyframes "+t+r+V(W(e[t]),function(n){return n+r+V(W(e[t][n]),function(i){var r=e[t][n][i];return"d"===i&&(r='path("'+r+'")'),i+":"+r+";"}).join(i)+o}).join(i)+o}).join(i);return a||s?[""].join(i):""}(r.cssNodes,r.cssAnims,{newline:!0});if(u){var c=PI("style","stl",{},[],u);o.push(c)}}return NI(n,i,o,t.useViewBox)},t.prototype.renderToString=function(t){return t=t||{},OI(this.renderToVNode({animation:at(t.cssAnimation,!0),emphasis:at(t.cssEmphasis,!0),willUpdate:!1,compress:!0,useViewBox:at(t.useViewBox,!0)}),{newline:!0})},t.prototype.setBackgroundColor=function(t){this._backgroundColor=t},t.prototype.getSvgRoot=function(){return this._mainVNode&&this._mainVNode.elm},t.prototype._paintList=function(t,e,n){for(var i,r,o=t.length,a=[],s=0,l=0,u=0;u=0&&(!h||!r||h[f]!==r[f]);f--);for(var g=p-1;g>f;g--)i=a[--s-1];for(var v=f+1;v=o)}}for(var u=LT(this),c=u.startIdx;c=0)&&(o=!0)}),o||r.__dirty){var a=n._opts.useDirtyRect&&!kT(r)?r.createRepaintRects(t,e,n._width,n._height):null,s=n._i.layerStack[0],l=!0;if(r.__dirty){l=!1,r.__dirty=!1;var u=r.zlevel===s.zl&&r.zlevel2===s.zl2?n._backgroundColor:null;r.clear(!1,u,a)}VT(r,function(e){var o=n._paintPerCursor(r,e,t,a,l);i=i&&o})}},YT),r.wxa&&FT(this._i,function(t){t&&t.ctx&&t.ctx.draw&&t.ctx.draw()}),i},t.prototype._paintPerCursor=function(t,e,n,i,r){var o=t.ctx;if(i)if(i.length)for(var a=this.dpr,s=0;s=e.endIdx},t.prototype._paintPerCursorInRect=function(t,e,n,i,r){for(var o={inHover:!1,allClipped:!1,prevEl:null,viewWidth:this._width,viewHeight:this._height,beforeBrushParam:{contentRetained:r}},a=t.ctx,s=kT(t),l=s&&c.getTime(),u=e.drawIdx,h=e.notClearIdx,d=h>=0?Math.min(h,u):u;d15){d++;break}}}Wx(a,o),e.drawIdx=Math.max(d,u)},t.prototype.getLayer=function(t,e){return this._ensureLayer(t,0,e)},t.prototype._ensureLayer=function(t,e,n){e=e||0;var i=this._singleCanvas;i&&!this._needsManuallyCompositing&&(t=RT,e=0);var r=GT(this._i,t)[e];return r||(r=ET("zr_"+t+"."+e,this,t,e),this._layerConfig[t]&&D(r,this._layerConfig[t],!0),(n||i&&t!==RT)&&(r.virtual=!0),this._insertLayer(r,t,e,!1),r.initContext()),r},t.prototype.insertLayer=function(t,e){this._insertLayer(e,t,0,!1)},t.prototype._insertLayer=function(t,e,n,i){var r=this._i,o=r.layers,a=r.layerStack,s=this._domRoot,l=null;if((!o[e]||!o[e][n])&&function(t){return!!t&&(!!t.__builtin__||"function"==typeof t.resize&&"function"==typeof t.refresh)}(t)){for(var u=a.length,c=0;c0&&(l=GT(r,a[c-1].zl)[a[c-1].zl2]),a.splice(c,0,{zl:e,zl2:n}),GT(r,e)[n]=t,!i&&!t.virtual)if(l){var h=l.dom;h.nextSibling?s.insertBefore(t.dom,h.nextSibling):s.appendChild(t.dom)}else s.firstChild?s.insertBefore(t.dom,s.firstChild):s.appendChild(t.dom);t.painter||(t.painter=this)}},t.prototype.eachLayer=function(t,e){return FT(this._i,function(n,i){t.call(e,n,i)})},t.prototype.eachBuiltinLayer=function(t,e){return FT(this._i,function(n,i){t.call(e,n,i)},HT)},t.prototype.eachOtherLayer=function(t,e){return FT(this._i,function(n,i){t.call(e,n,i)},WT)},t.prototype.getLayers=function(){var t={};return FT(this._i,function(e,n,i){t[e.id]=e}),t},t.prototype._updateLayerStatus=function(t,e){var n,i=this;if(i._singleCanvas)for(var r=1;r=0;o--){var a=r.get(n[o]);if(a.used){var s=a.endIdxNew;(kT(e)?s=0;i--){var r=e[i];if(r.zl===t){var o=n[t][r.zl2];if(o.__builtin__)continue;if(e.splice(i,1),n[t][r.zl2]=void 0,!o.virtual){var a=o.dom.parentNode;a&&a.removeChild(o.dom)}}}},t.prototype.resize=function(t,e){if(this._domRoot.style){var n=this._domRoot;n.style.display="none";var i=this._opts,r=this.root;null!=t&&(i.width=t),null!=e&&(i.height=e),t=Tx(r,0,i),e=Tx(r,1,i),n.style.display="",this._width===t&&e===this._height||(n.style.width=t+"px",n.style.height=e+"px",FT(this._i,function(n){n.resize(t,e)}),this.refresh({paintAll:!0})),this._width=t,this._height=e}else{if(null==t||null==e)return;this._width=t,this._height=e,this._ensureLayer(RT).resize(t,e)}return this},t.prototype.clearLayer=function(t){B(this._i.layers[t],function(t){t&&!t.__builtin__&&t.clear()})},t.prototype.dispose=function(){this.root.innerHTML="",this.root=this.storage=this._domRoot=this._i=null},t.prototype.getRenderedCanvas=function(t){if(t=t||{},this._singleCanvas&&!this._compositeManually)return this._i.layers[314159][0].dom;var e=new PT("image",this,t.pixelRatio||this.dpr);e.initContext(),e.clear(!1,t.backgroundColor||this._backgroundColor);var n=e.ctx;if(t.pixelRatio<=this.dpr){this.refresh();var i=e.dom.width,r=e.dom.height;FT(this._i,function(t){t.__builtin__?n.drawImage(t.dom,0,0,i,r):t.renderToCanvas&&(n.save(),t.renderToCanvas(n),n.restore())})}else{for(var o={inHover:!1,viewWidth:this._width,viewHeight:this._height,beforeBrushParam:{}},a=this.storage.getDisplayList(!0),s=0,l=a.length;s-1&&(s.style.stroke=s.style.fill,s.style.fill=ug.color.neutral00,s.style.lineWidth=2),e},e.type="series.line",e.dependencies=["grid","polar"],e.defaultOption={z:3,coordinateSystem:"cartesian2d",legendHoverLink:!0,clip:!0,label:{position:"top"},endLabel:{show:!1,valueAnimation:!0,distance:8},lineStyle:{width:2,type:"solid"},emphasis:{scale:!0},step:!1,smooth:!1,smoothMonotone:null,symbol:"emptyCircle",symbolSize:6,symbolRotate:null,showSymbol:!0,showAllSymbol:"auto",connectNulls:!1,sampling:"none",animationEasing:"linear",progressive:0,hoverLayerThreshold:1/0,universalTransition:{divideShape:"clone"},triggerLineEvent:!1,triggerEvent:!1},e}(Hy);function jT(t,e){var n=t.mapDimensionsAll("defaultedLabel"),i=n.length;if(1===i){var r=zv(t,e,n[0]);return null!=r?r+"":null}if(i){for(var o=[],a=0;a=0&&i.push(e[o])}return i.join(" ")}var KT=function(t){function e(e,n,i,r){var o=t.call(this)||this;return o.updateData(e,n,i,r),o}return n(e,t),e.prototype._createSymbol=function(t,e,n,i,r,o){this.removeAll();var a=xx(t,-1,-1,2,2,null,o);a.attr({z2:at(r,100),culling:!0,scaleX:i[0]/2,scaleY:i[1]/2}),a.drift=$T,this._symbolType=t,this.add(a)},e.prototype.stopSymbolAnimation=function(t){this.childAt(0).stopAnimation(null,t)},e.prototype.getSymbolType=function(){return this._symbolType},e.prototype.getSymbolPath=function(){return this.childAt(0)},e.prototype.highlight=function(){vc(this.childAt(0))},e.prototype.downplay=function(){yc(this.childAt(0))},e.prototype.setZ=function(t,e){var n=this.childAt(0);n.zlevel=t,n.z=e},e.prototype.setDraggable=function(t,e){var n=this.childAt(0);n.draggable=t,n.cursor=!e&&t?"move":n.cursor},e.prototype.updateData=function(t,n,i,r){this.silent=!1;var o=t.getItemVisual(n,"symbol")||"circle",a=t.hostModel,s=e.getSymbolSize(t,n),l=e.getSymbolZ2(t,n),u=o!==this._symbolType,c=r&&r.disableAnimation;if(u){var h=t.getItemVisual(n,"symbolKeepAspect");this._createSymbol(o,t,n,s,l,h)}else{(p=this.childAt(0)).silent=!1;var d={scaleX:s[0]/2,scaleY:s[1]/2};c?p.attr(d):rd(p,d,a,n),cd(p)}if(this._updateCommon(t,n,s,i,r),u){var p=this.childAt(0);if(!c){d={scaleX:this._sizeX,scaleY:this._sizeY,style:{opacity:p.style.opacity}};p.scaleX=p.scaleY=0,p.style.opacity=0,od(p,d,a,n)}}c&&this.childAt(0).stopAnimation("leave")},e.prototype._updateCommon=function(t,e,n,i,r){var o,a,s,l,u,c,h,d,p,f=this.childAt(0),g=t.hostModel;if(i&&(o=i.emphasisItemStyle,a=i.blurItemStyle,s=i.selectItemStyle,l=i.focus,u=i.blurScope,h=i.labelStatesModels,d=i.hoverScale,p=i.cursorStyle,c=i.emphasisDisabled),!i||t.hasItemOption){var v=i&&i.itemModel?i.itemModel:t.getItemModel(e),y=v.getModel("emphasis");o=y.getModel("itemStyle").getItemStyle(),s=v.getModel(["select","itemStyle"]).getItemStyle(),a=v.getModel(["blur","itemStyle"]).getItemStyle(),l=y.get("focus"),u=y.get("blurScope"),c=y.get("disabled"),h=ap(v),d=y.getShallow("scale"),p=v.getShallow("cursor")}var m=t.getItemVisual(e,"symbolRotate");f.attr("rotation",(m||0)*Math.PI/180||0);var x=bx(t.getItemVisual(e,"symbolOffset"),n);x&&(f.x=x[0],f.y=x[1]),p&&f.attr("cursor",p);var _=t.getItemVisual(e,"style"),b=_.fill;if(f instanceof eu){var w=f.style;f.useStyle(k({image:w.image,x:w.x,y:w.y,width:w.width,height:w.height},_))}else f.__isEmptyBrush?f.useStyle(k({},_)):f.useStyle(_),f.style.decal=null,f.setColor(b,r&&r.symbolInnerColor),f.style.strokeNoScale=!0;var S=t.getItemVisual(e,"liftZ"),M=this._z2;null!=S?null==M&&(this._z2=f.z2,f.z2+=S):null!=M&&(f.z2=M,this._z2=null);var I=r&&r.useNameLabel;op(f,h,{labelFetcher:g,labelDataIndex:e,defaultText:function(e){return I?t.getName(e):jT(t,e)},inheritColor:b,defaultOpacity:_.opacity}),this._sizeX=n[0]/2,this._sizeY=n[1]/2;var T=f.ensureState("emphasis");T.style=o,f.ensureState("select").style=s,f.ensureState("blur").style=a;var C=null==d||!0===d?Math.max(1.1,3/this._sizeY):isFinite(d)&&d>0?+d:1;T.scaleX=this._sizeX*C,T.scaleY=this._sizeY*C,this.setSymbolScale(1),kc(this,l,u,c)},e.prototype.setSymbolScale=function(t){this.scaleX=this.scaleY=t},e.prototype.fadeOut=function(t,e,n){var i=this.childAt(0),r=Iu(this).dataIndex,o=n&&n.animation;if(this.silent=i.silent=!0,n&&n.fadeLabel){var a=i.getTextContent();a&&sd(a,{style:{opacity:0}},e,{dataIndex:r,removeOpt:o,cb:function(){i.removeTextContent()}})}else i.removeTextContent();sd(i,{style:{opacity:0},scaleX:0,scaleY:0},e,{dataIndex:r,cb:t,removeOpt:o})},e.getSymbolSize=function(t,e){return _x(t.getItemVisual(e,"symbolSize"))},e.getSymbolZ2=function(t,e){return t.getItemVisual(e,"z2")},e}(vo);function $T(t,e){this.parent.drift(t,e)}function JT(t,e,n,i){return e&&!isNaN(e[0])&&!isNaN(e[1])&&!(i&&i.isIgnore&&i.isIgnore(n))&&!(i&&i.clipShape&&!i.clipShape.contain(e[0],e[1]))&&"none"!==t.getItemVisual(n,"symbol")}function QT(t){return null==t||$(t)||(t={isIgnore:t}),t||{}}function tC(t){var e=t.hostModel,n=e.getModel("emphasis");return{emphasisItemStyle:n.getModel("itemStyle").getItemStyle(),blurItemStyle:e.getModel(["blur","itemStyle"]).getItemStyle(),selectItemStyle:e.getModel(["select","itemStyle"]).getItemStyle(),focus:n.get("focus"),blurScope:n.get("blurScope"),emphasisDisabled:n.get("disabled"),hoverScale:n.get("scale"),labelStatesModels:ap(e),cursorStyle:e.get("cursor")}}function eC(t,e,n,i,r,o,a){var s=new t(e,n,i,r);return s.setPosition(o),e.setItemGraphicEl(n,s),a.add(s),s}var nC=function(){function t(t){this.group=new vo,this._SymbolCtor=t||KT}return t.prototype.updateData=function(t,e){this._progressiveEls=null,e=QT(e);var n=this.group,i=t.hostModel,r=this._data,o=this._SymbolCtor,a=e.disableAnimation,s=this._seriesScope=tC(t),l={disableAnimation:a},u=e.getSymbolPoint||function(e){return t.getItemLayout(e)};r||n.removeAll(),t.diff(r).add(function(i){var r=u(i);JT(t,r,i,e)&&eC(o,t,i,s,l,r,n)}).update(function(c,h){var d=r.getItemGraphicEl(h),p=u(c);if(JT(t,p,c,e)){var f=t.getItemVisual(c,"symbol")||"circle",g=d&&d.getSymbolType&&d.getSymbolType();if(!d||g&&g!==f)n.remove(d),(d=new o(t,c,s,l)).setPosition(p);else{d.updateData(t,c,s,l);var v={x:p[0],y:p[1]};a?d.attr(v):rd(d,v,i)}n.add(d),t.setItemGraphicEl(c,d)}else n.remove(d)}).remove(function(t){var e=r.getItemGraphicEl(t);e&&e.fadeOut(function(){n.remove(e)},i)}).execute(),this._getSymbolPoint=u,this._data=t},t.prototype.updateLayout=function(t){var e=this._data;if(e)for(var n=this,i=0,r=e.getStore().count();i0?n=i[0]:i[1]<0&&(n=i[1]);return n}(r,n),a=i.dim,s=r.dim,l=e.mapDimension(s),u=e.mapDimension(a),c="x"===s||"radius"===s?1:0,h=V(t.dimensions,function(t){return e.mapDimension(t)}),d=!1,p=e.getCalculationInfo("stackResultDimension");return qb(e,h[0])&&(d=!0,h[0]=p),qb(e,h[1])&&(d=!0,h[1]=p),{dataDimsForPoint:h,valueStart:o,valueAxisDim:s,baseAxisDim:a,stacked:!!d,valueDim:l,baseDim:u,baseDataOffset:c,stackedOverDimension:e.getCalculationInfo("stackedOverDimension")}}function rC(t,e,n,i){var r=NaN;t.stacked&&(r=n.get(n.getCalculationInfo("stackedOverDimension"),i)),isNaN(r)&&(r=t.valueStart);var o=t.baseDataOffset,a=[];return a[o]=n.get(t.baseDim,i),a[1-o]=r,e.dataToPoint(a)}function oC(t,e){return!isFinite(t)||!isFinite(e)}var aC=typeof Float32Array!==Cu?Float32Array:void 0,sC=typeof Float64Array!==Cu?Float64Array:void 0;function lC(t){return uC({ctor:aC},t).arr}function uC(t,e){var n=t.arr,i=t.ctor;if(e>qo&&(e=qo),!n||t.typed&&n.length=r||g<0)break;if(oC(y,m)){if(l){g+=o;continue}break}if(g===n)t[o>0?"moveTo":"lineTo"](y,m),h=y,d=m;else{var x=y-u,_=m-c;if(x*x+_*_<.5){g+=o;continue}if(a>0){for(var b=g+o,w=e[2*b],S=e[2*b+1];w===y&&S===m&&v=i||oC(w,S))p=y,f=m;else{T=w-u,C=S-c;var k=y-u,L=w-y,P=m-c,O=S-m,R=void 0,N=void 0;if("x"===s){var E=T>0?1:-1;p=y-E*(R=Math.abs(k))*a,f=m,D=y+E*(N=Math.abs(L))*a,A=m}else if("y"===s){var z=C>0?1:-1;p=y,f=m-z*(R=Math.abs(P))*a,D=y,A=m+z*(N=Math.abs(O))*a}else R=Math.sqrt(k*k+P*P),p=y-T*a*(1-(I=(N=Math.sqrt(L*L+O*O))/(N+R))),f=m-C*a*(1-I),A=m+C*a*I,D=cC(D=y+T*a*I,hC(w,y)),A=cC(A,hC(S,m)),D=hC(D,cC(w,y)),f=m-(C=(A=hC(A,cC(S,m)))-m)*R/N,p=cC(p=y-(T=D-y)*R/N,hC(u,y)),f=cC(f,hC(c,m)),D=y+(T=y-(p=hC(p,cC(u,y))))*N/R,A=m+(C=m-(f=hC(f,cC(c,m))))*N/R}t.bezierCurveTo(h,d,p,f,y,m),h=D,d=A}else t.lineTo(y,m)}u=y,c=m,g+=o}return v}var pC=function(){this.smooth=0,this.smoothConstraint=!0},fC=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polyline",n}return n(e,t),e.prototype.getDefaultStyle=function(){return{stroke:ug.color.neutral99,fill:null}},e.prototype.getDefaultShape=function(){return new pC},e.prototype.buildPath=function(t,e){var n=e.points,i=0,r=n.length/2;if(e.connectNulls){for(;r>0&&oC(n[2*r-2],n[2*r-1]);r--);for(;i=0){var v=a?(c-i)*g+i:(u-n)*g+n;return a?[t,v]:[v,t]}n=u,i=c;break;case o.C:u=r[l++],c=r[l++],h=r[l++],d=r[l++],p=r[l++],f=r[l++];var y=a?Gn(n,u,h,p,t,s):Gn(i,c,d,f,t,s);if(y>0)for(var m=0;m=0){v=a?Bn(i,c,d,f,x):Bn(n,u,h,p,x);return a?[t,v]:[v,t]}}n=p,i=f}}},e}(Kl),gC=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e}(pC),vC=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polygon",n}return n(e,t),e.prototype.getDefaultShape=function(){return new gC},e.prototype.buildPath=function(t,e){var n=e.points,i=e.stackedOnPoints,r=0,o=n.length/2,a=e.smoothMonotone;if(e.connectNulls){for(;o>0&&oC(n[2*o-2],n[2*o-1]);o--);for(;r=0,o=t.fill||ug.color.neutral99;DC(i,e);var a=null==i.textFill;return r?a&&(i.textFill=n.insideFill||ug.color.neutral00,!i.textStroke&&n.insideStroke&&(i.textStroke=n.insideStroke),!i.textStroke&&(i.textStroke=o),null==i.textStrokeWidth&&(i.textStrokeWidth=2)):(a&&(i.textFill=t.fill||n.outsideFill||ug.color.neutral00),!i.textStroke&&n.outsideStroke&&(i.textStroke=n.outsideStroke)),i.text=e.text,i.rich=e.rich,B(e.rich,function(t){DC(t,t)}),i}function DC(t,e){e&&(wt(e,"fill")&&(t.textFill=e.fill),wt(e,"stroke")&&(t.textStroke=e.fill),wt(e,"lineWidth")&&(t.textStrokeWidth=e.lineWidth),wt(e,"font")&&(t.font=e.font),wt(e,"fontStyle")&&(t.fontStyle=e.fontStyle),wt(e,"fontWeight")&&(t.fontWeight=e.fontWeight),wt(e,"fontSize")&&(t.fontSize=e.fontSize),wt(e,"fontFamily")&&(t.fontFamily=e.fontFamily),wt(e,"align")&&(t.textAlign=e.align),wt(e,"verticalAlign")&&(t.textVerticalAlign=e.verticalAlign),wt(e,"lineHeight")&&(t.textLineHeight=e.lineHeight),wt(e,"width")&&(t.textWidth=e.width),wt(e,"height")&&(t.textHeight=e.height),wt(e,"backgroundColor")&&(t.textBackgroundColor=e.backgroundColor),wt(e,"padding")&&(t.textPadding=e.padding),wt(e,"borderColor")&&(t.textBorderColor=e.borderColor),wt(e,"borderWidth")&&(t.textBorderWidth=e.borderWidth),wt(e,"borderRadius")&&(t.textBorderRadius=e.borderRadius),wt(e,"shadowColor")&&(t.textBoxShadowColor=e.shadowColor),wt(e,"shadowBlur")&&(t.textBoxShadowBlur=e.shadowBlur),wt(e,"shadowOffsetX")&&(t.textBoxShadowOffsetX=e.shadowOffsetX),wt(e,"shadowOffsetY")&&(t.textBoxShadowOffsetY=e.shadowOffsetY),wt(e,"textShadowColor")&&(t.textShadowColor=e.textShadowColor),wt(e,"textShadowBlur")&&(t.textShadowBlur=e.textShadowBlur),wt(e,"textShadowOffsetX")&&(t.textShadowOffsetX=e.textShadowOffsetX),wt(e,"textShadowOffsetY")&&(t.textShadowOffsetY=e.textShadowOffsetY))}function AC(t,e){if(t.length===e.length){for(var n=0;n=0;a--){var s=t.getDimensionInfo(i[a].dimension);if("x"===(r=s&&s.coordDim)||"y"===r){o=i[a];break}}if(o){var l=e.getAxis(r),u=V(o.stops,function(t){return{coord:l.toGlobalCoord(l.dataToCoord(t.value)),color:t.color}}),c=u.length,h=o.outerColors.slice();c&&u[0].coord>u[c-1].coord&&(u.reverse(),h.reverse());var d=function(t,e){var n,i,r=[],o=t.length;function a(t,e,n){var i=t.coord;return{coord:n,color:_i((n-i)/(e.coord-i),[t.color,e.color])}}for(var s=0;se){i?r.push(a(i,l,e)):n&&r.push(a(n,l,0),a(n,l,e));break}n&&(r.push(a(n,l,0)),n=null),r.push(l),i=l}}return r}(u,"x"===r?n.getWidth():n.getHeight()),p=d.length;if(!p&&c)return u[0].coord<0?h[1]?h[1]:u[c-1].color:h[0]?h[0]:u[0].color;var f=d[0].coord-10,g=d[p-1].coord+10,v=g-f;if(v<.001)return"transparent";B(d,function(t){t.offset=(t.coord-f)/v}),d.push({offset:p?d[p-1].offset:.5,color:h[1]||"transparent"}),d.unshift({offset:p?d[0].offset:.5,color:h[0]||"transparent"});var y=new Hh(0,0,0,0,d,!0);return y[r]=f,y[r+"2"]=g,y}}}function NC(t,e,n){var i=t.get("showAllSymbol"),r="auto"===i;if(!i||r){var o=n.getAxesByScale("ordinal")[0];if(o&&(!r||!function(t,e){var n=t.getExtent(),i=Math.abs(n[1]-n[0])/t.scale.count();isNaN(i)&&(i=0);for(var r=e.count(),o=Math.max(1,Math.round(r/5)),a=0;ai)return!1;return!0}(o,e))){var a=e.mapDimension(o.dim),s={};return B(o.getViewLabels(),function(t){t.tick.offInterval||(s[tS(o.scale,t.tick)]=1)}),function(t){return!s.hasOwnProperty(e.get(a,t))}}}}function EC(t,e){return[t[2*e],t[2*e+1]]}function zC(t){if(t.get(["endLabel","show"]))return!0;for(var e=0;e0&&"bolder"===t.get(["emphasis","lineStyle","width"]))&&(d.getState("emphasis").style.lineWidth=+d.style.lineWidth+1);Iu(d).seriesIndex=t.seriesIndex,kc(d,A,k,L);var O=PC(t.get("smooth")),R=t.get("smoothMonotone");if(d.setShape({smooth:O,smoothMonotone:R,connectNulls:b}),p){var N=o.getCalculationInfo("stackedOnSeries"),E=0;p.useStyle(P(s.getAreaStyle(),{fill:T,opacity:.7,lineJoin:"bevel",decal:o.getVisual("style").decal})),N&&(E=PC(N.get("smooth"))),p.setShape({smooth:O,stackedOnSmooth:E,smoothMonotone:R,connectNulls:b}),Rc(p,t,"areaStyle"),Iu(p).seriesIndex=t.seriesIndex,kc(p,A,k,L)}var z=this._changePolyState;o.eachItemGraphicEl(function(t){t&&(t.onHoverStateChange=z)}),this._polyline.onHoverStateChange=z,this._data=o,this._coordSys=i,this._stackedOnPoints=x,this._points=l,this._step=I,this._valueOrigin=y;var B=t.get("triggerEvent"),V=t.get("triggerLineEvent");var G=!0===V||!0===B||"line"===B,F=!0===V||!0===B||"area"===B;this.packEventData(t,d,G),p&&this.packEventData(t,p,F)},e.prototype.packEventData=function(t,e,n){Iu(e).eventData=n?{componentType:"series",componentSubType:"line",componentIndex:t.componentIndex,seriesIndex:t.seriesIndex,seriesName:t.name,seriesType:"line",selfType:e===this._polygon?"area":"line"}:null},e.prototype.highlight=function(t,e,n,i){var r=t.getData(),o=La(r,i);if(this._changePolyState("emphasis"),!(o instanceof Array)&&null!=o&&o>=0){var a=r.getLayout("points"),s=r.getItemGraphicEl(o);if(!s){var l=a[2*o],u=a[2*o+1];if(oC(l,u))return;if(this._clipShapeForSymbol&&!this._clipShapeForSymbol.contain(l,u))return;var c=t.get("zlevel")||0,h=t.get("z")||0;(s=new KT(r,o)).x=l,s.y=u,s.setZ(c,h);var d=s.getSymbolPath().getTextContent();d&&(d.zlevel=c,d.z=h,d.z2=this._polyline.z2+1),s.__temp=!0,r.setItemGraphicEl(o,s),s.stopSymbolAnimation(!0),this.group.add(s)}s.highlight()}else tm.prototype.highlight.call(this,t,e,n,i)},e.prototype.downplay=function(t,e,n,i){var r=t.getData(),o=La(r,i);if(this._changePolyState("normal"),null!=o&&o>=0){var a=r.getItemGraphicEl(o);a&&(a.__temp?(r.setItemGraphicEl(o,null),this.group.remove(a)):a.downplay())}else tm.prototype.downplay.call(this,t,e,n,i)},e.prototype._changePolyState=function(t){var e=this._polygon;hc(this._polyline,t),e&&hc(e,t)},e.prototype._newPolyline=function(t){var e=this._polyline;return e&&this._lineGroup.remove(e),e=new fC({shape:{points:t},segmentIgnoreThreshold:2,z2:10}),this._lineGroup.add(e),this._polyline=e,e},e.prototype._newPolygon=function(t,e){var n=this._polygon;return n&&this._lineGroup.remove(n),n=new vC({shape:{points:t,stackedOnPoints:e},segmentIgnoreThreshold:2}),this._lineGroup.add(n),this._polygon=n,n},e.prototype._initSymbolLabelAnimation=function(t,e,n){var i,r,o=e.getBaseAxis(),a=o.inverse;"cartesian2d"===e.type?(i=o.isHorizontal(),r=!1):"polar"===e.type&&(i="angle"===o.dim,r=!0);var s=t.hostModel,l=s.get("animationDuration");Z(l)&&(l=l(null));var u=s.get("animationDelay")||0,c=Z(u)?u(null):u;t.eachItemGraphicEl(function(t,o){var s=t;if(s){var h=[t.x,t.y],d=void 0,p=void 0,f=void 0;if(n)if(r){var g=n,v=e.pointToCoord(h);i?(d=g.startAngle,p=g.endAngle,f=-v[1]/180*Math.PI):(d=g.r0,p=g.r,f=v[0])}else{var y=n;i?(d=y.x,p=y.x+y.width,f=t.x):(d=y.y+y.height,p=y.y,f=t.y)}var m=p===d?0:(f-d)/(p-d);a&&(m=1-m);var x=Z(u)?u(o):l*m+c,_=s.getSymbolPath(),b=_.getTextContent();s.attr({scaleX:0,scaleY:0}),s.animateTo({scaleX:1,scaleY:1},{duration:200,setToFinal:!0,delay:x}),b&&b.animateFrom({style:{opacity:0}},{duration:300,delay:x}),_.disableLabelAnimation=!0}})},e.prototype._initOrUpdateEndLabel=function(t,e,n){var i=t.getModel("endLabel");if(zC(t)){var r=t.getData(),o=this._polyline,a=r.getLayout("points");if(!a)return o.removeTextContent(),void(this._endLabel=null);var s=this._endLabel;s||((s=this._endLabel=new du({z2:200})).ignoreClip=!0,o.setTextContent(this._endLabel),o.disableLabelAnimation=!0);var l=function(t){for(var e=t.length/2;e>0&&oC(t[2*e-2],t[2*e-1]);e--);return e-1}(a);l>=0&&(op(o,ap(t,"endLabel"),{inheritColor:n,labelFetcher:t,labelDataIndex:l,defaultText:function(t,e,n){return null!=n?qT(r,n):jT(r,t)},enableTextSetter:!0},function(t,e){var n=e.getBaseAxis(),i=n.isHorizontal(),r=n.inverse,o=i?r?"right":"left":"center",a=i?"middle":r?"top":"bottom";return{normal:{align:t.get("align")||o,verticalAlign:t.get("verticalAlign")||a}}}(i,e)),o.textConfig.position=null)}else this._endLabel&&(this._polyline.removeTextContent(),this._endLabel=null)},e.prototype._endLabelOnDuring=function(t,e,n,i,r,o,a){var s=this._endLabel,l=this._polyline;if(s){t<1&&null==i.originalX&&(i.originalX=s.x,i.originalY=s.y);var u=n.getLayout("points"),c=n.hostModel,h=c.get("connectNulls"),d=o.get("precision"),p=o.get("distance")||0,f=a.getBaseAxis(),g=f.isHorizontal(),v=f.inverse,y=e.shape,m=v?g?y.x:y.y+y.height:g?y.x+y.width:y.y,x=(g?p:0)*(v?-1:1),_=(g?0:-p)*(v?-1:1),b=g?"x":"y",w=function(t,e,n){for(var i,r,o=t.length/2,a="x"===n?0:1,s=0,l=-1,u=0;u=e||i>=e&&r<=e){l=u;break}s=u,i=r}else i=r;return{range:[s,l],t:(e-i)/(r-i)}}(u,m,b),S=w.range,M=S[1]-S[0],I=void 0;if(M>=1){if(M>1&&!h){var T=EC(u,S[0]);s.attr({x:T[0]+x,y:T[1]+_}),r&&(I=c.getRawValue(S[0]))}else{(T=l.getPointOn(m,b))&&s.attr({x:T[0]+x,y:T[1]+_});var C=c.getRawValue(S[0]),D=c.getRawValue(S[1]);r&&(I=Ha(n,d,C,D,w.t))}i.lastFrameIndex=S[0]}else{var A=1===t||i.lastFrameIndex>0?S[0]:0;T=EC(u,A);r&&(I=c.getRawValue(A)),s.attr({x:T[0]+x,y:T[1]+_})}if(r){var k=fp(s);"function"==typeof k.setLabelText&&k.setLabelText(I)}}},e.prototype._doUpdateAnimation=function(t,e,n,i,r,o,a){var s=this._polyline,l=this._polygon,u=t.hostModel,c=function(t,e,n,i,r,o,a){for(var s=function(t,e){var n=[];return e.diff(t).add(function(t){n.push({cmd:"+",idx:t})}).update(function(t,e){n.push({cmd:"=",idx:e,idx1:t})}).remove(function(t){n.push({cmd:"-",idx:t})}).execute(),n}(t,e),l=[],u=[],c=[],h=[],d=[],p=[],f=[],g=iC(r,e,a),v=t.getLayout("points")||[],y=e.getLayout("points")||[],m=0;m3e3||l&&LC(d,f)>3e3)return s.stopAnimation(),s.setShape({points:p}),void(l&&(l.stopAnimation(),l.setShape({points:p,stackedOnPoints:f})));s.shape.__points=c.current,s.shape.points=h;var g={shape:{points:p}};c.current!==h&&(g.shape.__points=c.next),s.stopAnimation(),rd(s,g,u),l&&(l.setShape({points:h,stackedOnPoints:d}),l.stopAnimation(),rd(l,{shape:{stackedOnPoints:f}},u),s.shape.points!==l.shape.points&&(l.shape.points=s.shape.points));for(var v=[],y=c.status,m=0;me&&(e=t[n]);return isFinite(e)?e:NaN},min:function(t){for(var e=1/0,n=0;n10&&"cartesian2d"===o.type&&r){var s=o.getBaseAxis(),l=o.getOtherAxis(s),u=s.getExtent(),c=n.getDevicePixelRatio(),h=Math.abs(u[1]-u[0])*(c||1),d=Math.round(a/h);if(isFinite(d)&&d>1){"lttb"===r?t.setData(i.lttbDownSample(i.mapDimension(l.dim),1/d)):"minmax"===r&&t.setData(i.minmaxDownSample(i.mapDimension(l.dim),1/d));var p=void 0;j(r)?p=FC[r]:Z(r)&&(p=r),p&&t.setData(i.downSample(i.mapDimension(l.dim),1/d,p,HC))}}}}}var UC=function(t){function e(e,n,i,r,o){var a=t.call(this,e,n,i)||this;return a.index=0,a.type=r||"value",a.position=o||"bottom",a}return n(e,t),e.prototype.isHorizontal=function(){var t=this.position;return"top"===t||"bottom"===t},e.prototype.getGlobalExtent=function(t){var e=this.getExtent();return e[0]=this.toGlobalCoord(e[0]),e[1]=this.toGlobalCoord(e[1]),t&&e[0]>e[1]&&e.reverse(),e},e.prototype.pointToData=function(t,e){return this.coordToData(this.toLocalCoord(t["x"===this.dim?0:1]),e)},e.prototype.setCategorySortInfo=function(t){if("category"!==this.type)return!1;this.model.option.categorySortInfo=t,this.scale.setSortInfo(t)},e}(SM),YC=null;function XC(){return YC}var ZC="expandAxisBreak",jC="collapseAxisBreak",qC="toggleAxisBreak",KC="axisbreakchanged",$C={type:ZC,event:KC,update:"update",refineEvent:tD},JC={type:jC,event:KC,update:"update",refineEvent:tD},QC={type:qC,event:KC,update:"update",refineEvent:tD};function tD(t,e,n,i){var r=[];return B(t,function(t){r=r.concat(t.eventBreaks)}),{eventContent:{breaks:r}}}var eD=Math.PI,nD=[[1,2,1,2],[5,3,5,3],[8,3,8,3]],iD=[[0,1,0,1],[0,3,0,3],[0,3,0,3]],rD=Pa(),oD=Pa(),aD=function(){function t(t){this.recordMap={},this.resolveAxisNameOverlap=t}return t.prototype.ensureRecord=function(t){var e=t.axis.dim,n=t.componentIndex,i=this.recordMap,r=i[e]||(i[e]=[]);return r[n]||(r[n]={ready:{}})},t}();var sD=[1,0,0,1,0,0],lD=new Xe(0,0,0,0),uD=function(t,e,n,i,r,o){if(Kw(t.nameLocation)){var a=o.stOccupiedRect;a&&cD(function(t,e,n){return t.transform=Xd(t.transform,n),t.localRect=Yd(t.localRect,e),t.rect=Yd(t.rect,e),n&&t.rect.applyTransform(n),t.axisAligned=Wd(n),t.obb=void 0,(t.label=t.label||{}).ignore=!1,t}({},a,o.transGroup.transform),i,r)}else hD(o.labelInfoList,o.dirVec,i,r)};function cD(t,e,n){var i=new Pe;aI(t,e,i,{direction:Math.atan2(n.y,n.x),bidirectional:!1,touchThreshold:.05})&&eI(e,i)}function hD(t,e,n,i){for(var r=Pe.dot(i,e)>=0,o=0,a=t.length;o0?"top":"bottom",i="center"):$o(o-eD)?(r=n>0?"bottom":"top",i="center"):(r="middle",i=o>0&&o0?"right":"left":n>0?"left":"right"),{rotation:o,textAlign:i,textVerticalAlign:r}},t.makeAxisEventDataBase=function(t){var e={componentType:t.mainType,componentIndex:t.componentIndex};return e[t.mainType+"Index"]=t.componentIndex,e},t.isLabelSilent=function(t){var e=t.get("tooltip");return t.get("silent")||!(t.get("triggerEvent")||e&&e.show)},t}(),pD=["axisLine","axisTickLabelEstimate","axisTickLabelDetermine","axisName"],fD={axisLine:function(t,e,n,i,r,o,a){var s=i.get(["axisLine","show"]);if("auto"===s&&(s=!0,null!=t.raw.axisLineAutoShow&&(s=!!t.raw.axisLineAutoShow)),s){var l=i.axis.getExtent(),u=o.transform,c=[l[0],0],h=[l[1],0],d=c[0]>h[0];u&&(Yt(c,c,u),Yt(h,h,u));var p=k({lineCap:"round"},i.getModel(["axisLine","lineStyle"]).getLineStyle()),f={strokeContainThreshold:t.raw.strokeContainThreshold||5,silent:!0,z2:1,style:p};if(i.get(["axisLine","breakLine"])&&Xp(i.axis.scale))XC().buildAxisBreakLine(i,r,o,f);else{var g=new Oh(k({shape:{x1:c[0],y1:c[1],x2:h[0],y2:h[1]}},f));Md(g.shape,g.style.lineWidth),g.anid="line",r.add(g)}var v=i.get(["axisLine","symbol"]);if(null!=v){var y=i.get(["axisLine","symbolSize"]);j(v)&&(v=[v,v]),(j(y)||K(y))&&(y=[y,y]);var m=bx(i.get(["axisLine","symbolOffset"])||0,y),x=y[0],_=y[1];B([{rotate:t.rotation+Math.PI/2,offset:m[0],r:0},{rotate:t.rotation-Math.PI/2,offset:m[1],r:Math.sqrt((c[0]-h[0])*(c[0]-h[0])+(c[1]-h[1])*(c[1]-h[1]))}],function(e,n){if("none"!==v[n]&&null!=v[n]){var i=xx(v[n],-x/2,-_/2,x,_,p.stroke,!0),o=e.r+e.offset,a=d?h:c;i.attr({rotation:e.rotate,x:a[0]+o*Math.cos(t.rotation),y:a[1]-o*Math.sin(t.rotation),silent:!0,z2:11}),r.add(i)}})}}},axisTickLabelEstimate:function(t,e,n,i,r,o,a,s){mD(e,r,s)&&gD(t,e,n,i,r,o,a,aM)},axisTickLabelDetermine:function(t,e,n,i,r,o,a,s){mD(e,r,s)&&gD(t,e,n,i,r,o,a,sM);var l=function(t,e,n,i){var r=i.axis,o=i.getModel("axisTick"),a=o.get("show");"auto"===a&&(a=!0,null!=t.raw.axisTickAutoShow&&(a=!!t.raw.axisTickAutoShow));if(!a||r.scale.isBlank())return[];for(var s=o.getModel("lineStyle"),l=t.tickDirection*o.get("length"),u=yD(r.getTicksCoords(),n.transform,l,P(s.getLineStyle(),{stroke:i.get(["axisLine","lineStyle","color"])}),"ticks"),c=0;ci[1],l="start"===e&&!s||"start"!==e&&s;$o(a-eD/2)?(o=l?"bottom":"top",r="center"):$o(a-1.5*eD)?(o=l?"top":"bottom",r="center"):(o="middle",r=a<1.5*eD&&a>eD/2?l?"left":"right":l?"right":"left");return{rotation:a,textAlign:r,textVerticalAlign:o}}(t.rotation,c,b||0,f),null!=(_=t.raw.axisNameAvailableWidth)&&(_=Math.abs(_/Math.sin(x.rotation)),!isFinite(_)&&(_=null)));var w=d.getFont(),S=i.get("nameTruncate",!0)||{},M=S.ellipsis,I=ot(t.raw.nameTruncateMaxWidth,S.maxWidth,_),T=s.nameMarginLevel||0,C=new du({x:v.x,y:v.y,rotation:x.rotation,silent:dD.isLabelSilent(i),style:sp(d,{text:u,font:w,overflow:"truncate",width:I,ellipsis:M,fill:d.getTextColor()||i.get(["axisLine","lineStyle","color"]),align:d.get("align")||x.textAlign,verticalAlign:d.get("verticalAlign")||x.textVerticalAlign}),z2:1});if(Gd({el:C,componentModel:i,itemName:u}),C.__fullText=u,C.anid="name",i.get("triggerEvent")){var D=dD.makeAxisEventDataBase(i);D.targetType="axisName",D.name=u,Iu(C).eventData=D}o.add(C),C.updateTransform(),e.nameEl=C;var A=l.nameLayout=JM({label:C,priority:C.z2,defaultAttr:{ignore:C.ignore},marginDefault:Kw(c)?nD[T]:iD[T]});if(l.nameLocation=c,r.add(C),C.decomposeTransform(),t.shouldNameMoveOverlap&&A){var k=n.ensureRecord(i);0,n.resolveAxisNameOverlap(t,n,i,A,y,k)}}}};function gD(t,e,n,i,r,o,a,s){xD(e)||function(t,e,n,i,r,o){var a=r.axis,s=ot(t.raw.axisLabelShow,r.get(["axisLabel","show"])),l=new vo;n.add(l);var u=lM(i);if(!s||a.scale.isBlank())return void _D(e,[],l,u);var c=r.getModel("axisLabel"),h=a.getViewLabels(u),d=(ot(t.raw.labelRotate,c.get("rotate"))||0)*eD/180,p=dD.innerTextLayout(t.rotation,d,t.labelDirection),f=r.getCategories&&r.getCategories(!0),g=[],v=r.get("triggerEvent"),y=1/0,m=-1/0;B(h,function(t,e){var n,i=t.tick,s=t.formattedLabel,u=t.rawLabel,d=c,x=tS(a.scale,i);if(f&&f[x]){var _=f[x];$(_)&&_.textStyle&&(d=new kp(_.textStyle,c,r.ecModel))}var b=d.getTextColor()||r.get(["axisLine","lineStyle","color"]),w=d.getShallow("align",!0)||p.textAlign,S=at(d.getShallow("alignMinLabel",!0),w),M=at(d.getShallow("alignMaxLabel",!0),w),I=d.getShallow("verticalAlign",!0)||d.getShallow("baseline",!0)||p.textVerticalAlign,T=at(d.getShallow("verticalAlignMinLabel",!0),I),C=at(d.getShallow("verticalAlignMaxLabel",!0),I),D=10+((null===(n=i.time)||void 0===n?void 0:n.level)||0);y=Math.min(y,D),m=Math.max(m,D);var A=new du({x:0,y:0,rotation:0,silent:dD.isLabelSilent(r),z2:D,style:sp(d,{text:s,align:0===e?S:e===h.length-1?M:w,verticalAlign:0===e?T:e===h.length-1?C:I,fill:Z(b)?b("category"===a.type?u:"value"===a.type?x+"":x,e):b})});A.anid="label_"+x;var k=rD(A);if(k.labelInfo=t,k.layoutRotation=p.rotation,Gd({el:A,componentModel:r,itemName:s,formatterParamsExtra:{isTruncated:function(){return A.isTruncated},value:u,tickIndex:e}}),v){var L=dD.makeAxisEventDataBase(r);L.targetType="axisLabel",L.value=u,L.tickIndex=e;var P=t.tick.break;if(P){var O=P.parsedBreak;L.break={start:O.vmin,end:O.vmax}}"category"===a.type&&(L.dataIndex=x),Iu(A).eventData=L,P&&function(t,e,n,i){n.on("click",function(n){var r={type:ZC,breaks:[{start:i.parsedBreak.breakOption.start,end:i.parsedBreak.breakOption.end}]};r[t.axis.dim+"AxisIndex"]=t.componentIndex,e.dispatchAction(r)})}(r,o,A,P)}g.push(A),l.add(A)});var x=V(g,function(t){return{label:t,priority:rD(t).labelInfo.tick.break?t.z2+(m-y+1):t.z2,defaultAttr:{ignore:t.ignore}}});_D(e,x,l,u)}(t,e,r,s,i,a);var l=e.labelLayoutList;!function(t,e,n,i){var r=e.get(["axisLabel","margin"]);B(n,function(n,o){var a=JM(n);if(a){var s=a.label,l=rD(s);a.suggestIgnore=s.ignore,s.ignore=!1,Hr(bD,wD);var u=e.axis;bD.x=u.dataToCoord(tS(u.scale,l.labelInfo.tick)),bD.y=t.labelOffset+t.labelDirection*r,bD.rotation=l.layoutRotation,i.add(bD),bD.updateTransform(),i.remove(bD),bD.decomposeTransform(),Hr(s,bD),s.markRedraw(),KM(a,!0),JM(a)}})}(t,i,l,o),function(t,e,n){var i=Wp();if(!i)return;var r=i.retrieveAxisBreakPairs(n,function(t){return t&&rD(t.label).labelInfo.tick.break},!0),o=t.get(["breakLabelLayout","moveOverlap"],!0);!0!==o&&"auto"!==o||B(r,function(i){XC().adjustBreakLabelPair(t.axis.inverse,e,[JM(n[i[0]]),JM(n[i[1]])])})}(i,t.rotation,l);var u=t.optionHideOverlap;!function(t,e,n){var i=t.axis,r=t.get(["axisLabel","customValues"]);if(function(t){return"category"===t.type&&0===qw(t.getLabelModel())}(i))return;function o(t,o,a){var s=JM(e[o]),l=JM(e[a]),u=i.scale;if(s&&l){if(null==t){if(!n&&r)return;var c=rD(s.label).labelInfo.tick;if(fw(u)&&c.notNice||vw(u)&&c.offInterval)return void vD(s.label)}if(!1===t||s.suggestIgnore)vD(s.label);else if(l.suggestIgnore)vD(l.label);else{var h=.1;if(!n){var d=[0,0,0,0];s=nI({marginForce:d},s),l=nI({marginForce:d},l)}aI(s,l,null,{touchThreshold:h})&&vD(t?l.label:s.label)}}}var a=t.get(["axisLabel","showMinLabel"]),s=t.get(["axisLabel","showMaxLabel"]),l=e.length;o(a,0,1),o(s,l-1,l-2)}(i,l,u),u&&oI(F(l,function(t){return t&&!t.label.ignore})),function(t,e,n,i){var r,o=n.axis,a=e.ensureRecord(n),s=[],l=SD(t.axisName)&&Kw(t.nameLocation);B(i,function(t){var e=JM(t);if(e&&!e.label.ignore){s.push(e);var n=a.transGroup;l&&(n.transform?Ae(sD,n.transform):Se(sD),e.transform&&Ie(sD,sD,e.transform),Xe.copy(lD,e.localRect),lD.applyTransform(sD),r?r.union(lD):Xe.copy(r=new Xe(0,0,0,0),lD))}});var u=Math.abs(a.dirVec.x)>.1?"x":"y",c=a.transGroup[u];if(s.sort(function(t,e){return Math.abs(t.label[u]-c)-Math.abs(e.label[u]-c)}),l&&r){var h=o.getExtent(),d=Math.min(h[0],h[1]),p=Math.max(h[0],h[1])-d;r.union(new Xe(d,0,p,1))}a.stOccupiedRect=r,a.labelInfoList=s}(t,n,i,l)}function vD(t){t&&(t.ignore=!0)}function yD(t,e,n,i,r){for(var o=[],a=[],s=[],l=0;l=0&&n(i,t,e.getStore())})}var p=0;if(d(function(t,e,n){r.set(e.uid,1),o&&o.hasKey(e.uid)||(i=!0),p+=n.count()}),o&&o.keys().length===r.keys().length||(i=!0),i||null==a){uC(CD,p);var f=0;d(function(t,e,n){for(var i=0,r=n.count();i0&&m0?-2:-1,n.serUids=r}else e.liPosMinGap=a}var CD=uC({ctor:sC},50);function DD(t){return function(e,n){var i=bM(e,{fromStat:{key:t}});if(ca(i.w2))return[-i.w2/2,i.w2/2]}}function AD(t){return t+rS}function kD(t,e){return t+rS+e}function LD(t){return ID(),{liPosMinGap:!vw(t.scale)}}var PD="bar",OD="pictorialBar";function RD(t,e,n,i){xS(t,{key:e,seriesType:n,coordSysType:i,getMetrics:LD})}function ND(t){var e=t.scale.rawExtentInfo.makeRenderInfo().startValue;return e}var ED={left:0,right:0,top:0,bottom:0},zD=["25%","25%"],BD="cartesian2d",VD=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.mergeDefaultAndTheme=function(e,n){var i=og(e.outerBounds);t.prototype.mergeDefaultAndTheme.apply(this,arguments),i&&e.outerBounds&&rg(e.outerBounds,i)},e.prototype.mergeOption=function(e,n){t.prototype.mergeOption.apply(this,arguments),this.option.outerBounds&&e.outerBounds&&rg(this.option.outerBounds,e.outerBounds)},e.type="grid",e.dependencies=["xAxis","yAxis"],e.layoutMode="box",e.defaultOption={show:!1,z:0,left:"15%",top:65,right:"10%",bottom:80,containLabel:!1,outerBoundsMode:"auto",outerBounds:ED,outerBoundsContain:"all",outerBoundsClampWidth:zD[0],outerBoundsClampHeight:zD[1],backgroundColor:ug.color.transparent,borderWidth:1,borderColor:ug.color.neutral30},e}(lg),GD=Ja(),FD="__ec_stack_";function HD(t){return t.get("stack")||FD+t.seriesIndex}function WD(t,e){var n=function(t,e){var n=kD(e,BD),i=[],r=bM(t,{fromStat:{key:n},min:1});return hS(t,n,function(t){i.push({barWidth:Vo(t.get("barWidth"),r.w),barMaxWidth:Vo(t.get("barMaxWidth"),r.w),barMinWidth:Vo(t.get("barMinWidth")||(ZD(t)?.5:1),r.w),barGap:t.get("barGap"),barCategoryGap:t.get("barCategoryGap"),defaultBarGap:t.get("defaultBarGap"),stackId:HD(t)})}),{bandWidthResult:r,seriesInfo:i}}(t,e);return n.columnMap=UD(n),n}function UD(t){var e,n,i=t.bandWidthResult.w,r=i,o=0,a=[],s={};B(t.seriesInfo,function(t,i){i||(n=t.defaultBarGap||0);var l=t.stackId;wt(s,l)||o++;var u=s[l];u||(u=s[l]={width:0,maxWidth:0},a.push(l));var c=t.barWidth;c&&!u.width&&(u.width=c,c=Co(r,c),r-=c);var h=t.barMaxWidth;h&&(u.maxWidth=h);var d=t.barMinWidth;d&&(u.minWidth=d);var p=t.barGap;null!=p&&(n=p);var f=t.barCategoryGap;null!=f&&(e=f)}),null==e&&(e=Do(35-4*a.length,15)+"%");var l=Vo(e,i),u=Vo(n,1),c=(r-l)/(o+(o-1)*u);c=Do(c,0),B(a,function(t){var e=s[t],n=e.maxWidth,i=e.minWidth;if(e.width){a=e.width;n&&(a=Co(a,n)),i&&(a=Do(a,i)),e.width=a,r-=a+u*a,o--}else{var a=c;n&&na&&(a=i),a!==c&&(e.width=a,r-=a+u*a,o--)}}),c=Do(c=(r-l)/(o+(o-1)*u),0);var h,d=0;B(a,function(t){var e=s[t];e.width||(e.width=c),h=e,d+=e.width*(1+u)}),h&&(d-=h.width*u);var p={},f=-d/2;return B(a,function(t){var e=s[t];p[t]=p[t]||{bandWidth:i,offset:f,width:e.width},f+=e.width*(1+u)}),p}function YD(t){return{seriesType:t,overallReset:function(e){var n=kD(t,BD);pS(e,n,function(e){var i=WD(e,t);hS(e,n,function(t){var e=i.columnMap[HD(t)];t.getData().setLayout({bandWidth:e.bandWidth,offset:e.offset,size:e.width})})})}}}function XD(t){return{seriesType:t,plan:$y(),reset:function(t){if(function(t){return t.coordinateSystem&&"cartesian2d"===t.coordinateSystem.type}(t)){var e=t.getData(),n=t.coordinateSystem,i=n.getBaseAxis(),r=n.getOtherAxis(i),o=e.getDimensionIndex(e.mapDimension(r.dim)),a=e.getDimensionIndex(e.mapDimension(i.dim)),s=t.get("showBackground",!0),l=e.mapDimension(r.dim),u=e.getCalculationInfo("stackResultDimension"),c=qb(e,l)&&!!e.getCalculationInfo("stackedOnSeries"),h=r.isHorizontal(),d=r.toGlobalCoord(r.dataToCoord(ND(r))),p=ZD(t),f=t.get("barMinHeight")||0,g=u&&e.getDimensionIndex(u),v=e.getLayout("size"),y=e.getLayout("offset");return{progress:function(t,e){for(var i,r=t.count,l=p&&lC(3*r),u=p&&s&&lC(3*r),m=p&&lC(r),x=n.master.getRect(),_=h?x.width:x.height,b=e.getStore(),w=0;null!=(i=t.next());){var S=b.get(c?g:o,i),M=b.get(a,i),I=d,T=void 0;c&&(T=+S-b.get(o,i));var C=void 0,D=void 0,A=void 0,k=void 0;if(h){var L=n.dataToPoint([S,M]);c&&(I=n.dataToPoint([T,M])[0]),C=I,D=L[1]+y,A=L[0]-I,k=v,Ao(A)s){c=(p+u)/2;break}1===d&&(h=f-i[0].tickValue)}null==c&&(u?u&&(c=i[i.length-1].coord):c=i[0].coord),o[n]=t.toGlobalCoord(c)}});else{var a=this.getData(),s=a.getLayout("offset"),l=a.getLayout("size"),u=i.getBaseAxis().isHorizontal()?0:1;o[u]+=s+l/2}return o}return[NaN,NaN]},e.prototype.__requireStartValue=function(t){return this.getBaseAxis()!==t},e.type="series.__base_bar__",e.defaultOption={z:2,coordinateSystem:"cartesian2d",legendHoverLink:!0,barMinHeight:0,barMinAngle:0,large:!1,largeThreshold:400,progressive:3e3,progressiveChunkMode:"mod",defaultBarGap:"10%"},e}(Hy);Hy.registerClass(qD);var KD=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.getInitialData=function(){return $b(null,this,{useEncodeDefaulter:!0,createInvertedIndices:!!this.get("realtimeSort",!0)||null})},e.prototype.getProgressive=function(){return!!this.get("large")&&this.get("progressive")},e.prototype.__preparePipelineContext=function(t,e){var n=rs(this,t,e);return n.progressiveRender&&(n.large=!0),n},e.prototype.brushSelector=function(t,e,n){return n.rect(e.getItemLayout(t))},e.type="series."+PD,e.dependencies=["grid","polar"],e.defaultOption=Op(qD.defaultOption,{clip:!0,roundCap:!1,showBackground:!1,backgroundStyle:{color:"rgba(180, 180, 180, 0.2)",borderColor:null,borderWidth:0,borderType:"solid",borderRadius:0,shadowBlur:0,shadowColor:null,shadowOffsetX:0,shadowOffsetY:0,opacity:1},select:{itemStyle:{borderColor:ug.color.primary,borderWidth:2}},realtimeSort:!1}),e}(qD),$D=function(){this.cx=0,this.cy=0,this.r0=0,this.r=0,this.startAngle=0,this.endAngle=2*Math.PI,this.clockwise=!0},JD=function(t){function e(e){var n=t.call(this,e)||this;return n.type="sausage",n}return n(e,t),e.prototype.getDefaultShape=function(){return new $D},e.prototype.buildPath=function(t,e){var n=e.cx,i=e.cy,r=Math.max(e.r0||0,0),o=Math.max(e.r,0),a=.5*(o-r),s=r+a,l=e.startAngle,u=e.endAngle,c=e.clockwise,h=2*Math.PI,d=c?u-lo)return!0;o=u}return!1},e.prototype._isOrderDifferentInView=function(t,e){for(var n=e.scale,i=n.getExtent(),r=Math.max(0,i[0]),o=Math.min(i[1],n.getOrdinalMeta().categories.length-1);r<=o;++r)if(t.ordinalNumbers[r]!==n.getRawOrdinalNumber(r))return!0},e.prototype._updateSortWithinSameData=function(t,e,n,i){if(this._isOrderChangedWithinSameData(t,e,n)){var r=this._dataSort(t,n,e);this._isOrderDifferentInView(r,n)&&(this._removeOnRenderedListener(i),i.dispatchAction({type:"changeAxisOrder",componentType:n.dim+"Axis",axisId:n.index,sortInfo:r}))}},e.prototype._dispatchInitSort=function(t,e,n){var i=e.baseAxis,r=this._dataSort(t,i,function(n){return t.get(t.mapDimension(e.otherAxis.dim),n)});n.dispatchAction({type:"changeAxisOrder",componentType:i.dim+"Axis",isInitSort:!0,axisId:i.index,sortInfo:r})},e.prototype.remove=function(t,e){this._clear(this._model),this._removeOnRenderedListener(e)},e.prototype.dispose=function(t,e){this._removeOnRenderedListener(e)},e.prototype._removeOnRenderedListener=function(t){this._onRendered&&(t.getZr().off("rendered",this._onRendered),this._onRendered=null)},e.prototype._clear=function(t){var e=this.group,n=this._data;t&&t.isAnimationEnabled()&&n&&!this._isLargeDraw?(this._removeBackground(),this._backgroundEls=[],n.eachItemGraphicEl(function(e){ud(e,t,Iu(e).dataIndex)})):e.removeAll(),this._data=null,this._isFirstFrame=!0},e.prototype._removeBackground=function(){this.group.remove(this._backgroundGroup),this._backgroundGroup=null},e.type=PD,e}(tm),oA={cartesian2d:function(t,e){var n=e.width<0?-1:1,i=e.height<0?-1:1;n<0&&(e.x+=e.width,e.width=-e.width),i<0&&(e.y+=e.height,e.height=-e.height);var r=t.x+t.width,o=t.y+t.height,a=nA(e.x,t.x),s=iA(e.x+e.width,r),l=nA(e.y,t.y),u=iA(e.y+e.height,o),c=sr?s:a,e.y=h&&l>o?u:l,e.width=c?0:s-a,e.height=h?0:u-l,n<0&&(e.x+=e.width,e.width=-e.width),i<0&&(e.y+=e.height,e.height=-e.height),c||h},polar:function(t,e){var n=e.r0<=e.r?1:-1;if(n<0){var i=e.r;e.r=e.r0,e.r0=i}var r=iA(e.r,t.r),o=nA(e.r0,t.r0);e.r=r,e.r0=o;var a=r-o<0;if(n<0){i=e.r;e.r=e.r0,e.r0=i}return a}},aA={cartesian2d:function(t,e,n,i,r,o,a,s,l){var u=new lu({shape:k({},i),z2:1});(u.__dataIndex=n,u.name="item",o)&&(u.shape[r?"height":"width"]=0);return u},polar:function(t,e,n,i,r,o,a,s,l){var u=!r&&l?JD:Sh,c=new u({shape:i,z2:1});c.name="item";var h,d,p=pA(r);if(c.calculateTextPosition=(h=p,d=({isRoundCap:u===JD}||{}).isRoundCap,function(t,e,n){var i=e.position;if(!i||i instanceof Array)return to(t,e,n);var r=h(i),o=null!=e.distance?e.distance:5,a=this.shape,s=a.cx,l=a.cy,u=a.r,c=a.r0,p=(u+c)/2,f=a.startAngle,g=a.endAngle,v=(f+g)/2,y=d?Math.abs(u-c)/2:0,m=Math.cos,x=Math.sin,_=s+u*m(f),b=l+u*x(f),w="left",S="top";switch(r){case"startArc":_=s+(c-o)*m(v),b=l+(c-o)*x(v),w="center",S="top";break;case"insideStartArc":_=s+(c+o)*m(v),b=l+(c+o)*x(v),w="center",S="bottom";break;case"startAngle":_=s+p*m(f)+QD(f,o+y,!1),b=l+p*x(f)+tA(f,o+y,!1),w="right",S="middle";break;case"insideStartAngle":_=s+p*m(f)+QD(f,-o+y,!1),b=l+p*x(f)+tA(f,-o+y,!1),w="left",S="middle";break;case"middle":_=s+p*m(v),b=l+p*x(v),w="center",S="middle";break;case"endArc":_=s+(u+o)*m(v),b=l+(u+o)*x(v),w="center",S="bottom";break;case"insideEndArc":_=s+(u-o)*m(v),b=l+(u-o)*x(v),w="center",S="top";break;case"endAngle":_=s+p*m(g)+QD(g,o+y,!0),b=l+p*x(g)+tA(g,o+y,!0),w="left",S="middle";break;case"insideEndAngle":_=s+p*m(g)+QD(g,-o+y,!0),b=l+p*x(g)+tA(g,-o+y,!0),w="right",S="middle";break;default:return to(t,e,n)}return(t=t||{}).x=_,t.y=b,t.align=w,t.verticalAlign=S,t}),o){var f=r?"r":"endAngle",g={};c.shape[f]=r?i.r0:i.startAngle,g[f]=i[f],(s?rd:od)(c,{shape:g},o)}return c}};function sA(t,e,n,i,r,o,a,s){var l,u;o?(u={x:i.x,width:i.width},l={y:i.y,height:i.height}):(u={y:i.y,height:i.height},l={x:i.x,width:i.width}),s||(a?rd:od)(n,{shape:l},e,r,null),(a?rd:od)(n,{shape:u},e?t.baseAxis.model:null,r)}function lA(t,e){for(var n=0;n0?1:-1,a=i.height>0?1:-1;return{x:i.x+o*r/2,y:i.y+a*r/2,width:i.width-o*r,height:i.height-a*r}},polar:function(t,e,n){var i=t.getItemLayout(e);return{cx:i.cx,cy:i.cy,r0:i.r0,r:i.r,startAngle:i.startAngle,endAngle:i.endAngle,clockwise:i.clockwise}}};function pA(t){return function(t){var e=t?"Arc":"Angle";return function(t){switch(t){case"start":case"insideStart":case"end":case"insideEnd":return t+e;default:return t}}}(t)}function fA(t,e,n,i,r,o,a,s){var l=e.getItemVisual(n,"style");if(s){if(!o.get("roundCap")){var u=t.shape;k(u,eA(i.getModel("itemStyle"),u,!0)),t.setShape(u)}}else{var c=i.get(["itemStyle","borderRadius"])||0;t.setShape("r",c)}t.useStyle(l);var h=i.getShallow("cursor");h&&t.attr("cursor",h);var d=s?a?r.r>=r.r0?"endArc":"startArc":r.endAngle>=r.startAngle?"endAngle":"startAngle":a?function(t,e){if(0===t.height){return e.getOtherAxis(e.getBaseAxis()).inverse?"bottom":"top"}return t.height>0?"bottom":"top"}(r,o.coordinateSystem):function(t,e){if(0===t.width){return e.getOtherAxis(e.getBaseAxis()).inverse?"left":"right"}return t.width>=0?"right":"left"}(r,o.coordinateSystem),p=ap(i);op(t,p,{labelFetcher:o,labelDataIndex:n,defaultText:jT(o.getData(),n),inheritColor:l.fill,defaultOpacity:l.opacity,defaultOutsidePosition:d});var f=t.getTextContent();if(s&&f){var g=i.get(["label","position"]);t.textConfig.inside="middle"===g||null,function(t,e,n,i){if(K(i))t.setTextConfig({rotation:i});else if(X(e))t.setTextConfig({rotation:0});else{var r,o=t.shape,a=o.clockwise?o.startAngle:o.endAngle,s=o.clockwise?o.endAngle:o.startAngle,l=(a+s)/2,u=n(e);switch(u){case"startArc":case"insideStartArc":case"middle":case"insideEndArc":case"endArc":r=l;break;case"startAngle":case"insideStartAngle":r=a;break;case"endAngle":case"insideEndAngle":r=s;break;default:return void t.setTextConfig({rotation:0})}var c=1.5*Math.PI-r;"middle"===u&&c>Math.PI/2&&c<1.5*Math.PI&&(c-=Math.PI),t.setTextConfig({rotation:c})}}(t,"outside"===g?d:g,pA(a),i.get(["label","rotate"]))}gp(f,p,o.getRawValue(n),function(t){return qT(e,t)});var v=i.getModel(["emphasis"]);kc(t,v.get("focus"),v.get("blurScope"),v.get("disabled")),Rc(t,i),function(t){return null!=t.startAngle&&null!=t.endAngle&&t.startAngle===t.endAngle}(r)&&(t.style.fill="none",t.style.stroke="none",B(t.states,function(t){t.style&&(t.style.fill=t.style.stroke="none")}))}var gA=function(){},vA=function(t){function e(e){var n=t.call(this,e)||this;return n.type="largeBar",n}return n(e,t),e.prototype.getDefaultShape=function(){return new gA},e.prototype.buildPath=function(t,e){for(var n=e.points,i=this.baseDimIdx,r=1-this.baseDimIdx,o=[],a=[],s=this.barWidth,l=0;l=s[0]&&e<=s[0]+l[0]&&n>=s[1]&&n<=s[1]+l[1])return a[c]}return-1}(this,t.offsetX,t.offsetY);Iu(this).dataIndex=e>=0?e:null},30,!1);function xA(t,e,n){if(wC(n,"cartesian2d")){var i=e,r=n.getArea();return{x:t?i.x:r.x,y:t?r.y:i.y,width:t?i.width:r.width,height:t?r.height:i.height}}var o=e;return{cx:(r=n.getArea()).cx,cy:r.cy,r0:t?r.r0:o.r0,r:t?r.r:o.r,startAngle:t?o.startAngle:0,endAngle:t?o.endAngle:2*Math.PI}}function _A(t){return{seriesType:t,reset:function(t,e){var n=e.findComponents({mainType:"legend"});if(n&&n.length){var i=t.getData();i.filterSelf(function(t){for(var e=i.getName(t),r=0;r=0},t.prototype.indexOfName=function(t){return this._getDataWithEncodedVisual().indexOfName(t)},t.prototype.getItemVisual=function(t,e){return this._getDataWithEncodedVisual().getItemVisual(t,e)},t}(),MA="pie",IA=Pa(),TA=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(e){t.prototype.init.apply(this,arguments),this.legendVisualProvider=new SA(U(this.getData,this),U(this.getRawData,this)),this._defaultLabelLine(e)},e.prototype.mergeOption=function(){t.prototype.mergeOption.apply(this,arguments)},e.prototype.getInitialData=function(){return bA(this,{coordDimensions:["value"],encodeDefaulter:Y(Sg,this)})},e.prototype.getDataParams=function(e){var n=this.getData(),i=IA(n),r=i.seats;if(!r){var o=[];n.each(n.mapDimension("value"),function(t){o.push(t)}),r=i.seats=Zo(o,n.hostModel.get("percentPrecision"))}var a=t.prototype.getDataParams.call(this,e);return a.percent=r[e]||0,a.$vars.push("percent"),a},e.prototype._defaultLabelLine=function(t){ba(t,"labelLine",["show"]);var e=t.labelLine,n=t.emphasis.labelLine;e.show=e.show&&t.label.show,n.show=n.show&&t.emphasis.label.show},e.type="series."+MA,e.defaultOption={z:2,legendHoverLink:!0,colorBy:"data",center:["50%","50%"],radius:[0,"50%"],clockwise:!0,startAngle:90,endAngle:"auto",padAngle:0,minAngle:0,minShowLabelAngle:0,selectedOffset:10,percentPrecision:2,stillShowZeroSum:!0,coordinateSystemUsage:"box",left:0,top:0,right:0,bottom:0,width:null,height:null,label:{rotate:0,show:!0,overflow:"truncate",position:"outer",alignTo:"none",edgeDistance:"25%",distanceToLabelLine:5},labelLine:{show:!0,length:15,length2:30,smooth:!1,minTurnAngle:90,maxSurfaceAngle:90,lineStyle:{width:1,type:"solid"}},itemStyle:{borderWidth:1,borderJoin:"round"},showEmptyCircle:!0,emptyCircleStyle:{color:"lightgray",opacity:1},labelLayout:{hideOverlap:!0},emphasis:{scale:!0,scaleSize:5},avoidLabelOverlap:!0,animationType:"expansion",animationDuration:1e3,animationTypeUpdate:"transition",animationEasingUpdate:"cubicInOut",animationDurationUpdate:500,animationEasing:"cubicInOut"},e}(Hy);wA={fullType:TA.type,getCoord2:function(t){return t.getShallow("center")}},Gf.set(wA.fullType,{getCoord2:void 0}).getCoord2=wA.getCoord2;var CA=Math.PI/180;function DA(t,e,n,i,r,o,a,s,l,u){if(!(t.length<2)){for(var c=t.length,h=0;hn?a:o,c=Math.abs(l.label.y-n);if(c>=u.maxY){var h=l.label.x-e-l.len2*r,d=i+l.len,f=Math.abs(h)t.unconstrainedWidth?null:d:null;i.setStyle("width",p)}kA(o,i)}}}function kA(t,e){PA.rect=t,QM(PA,e,LA)}var LA={minMarginForce:[null,0,null,0],marginDefault:[1,0,1,0]},PA={};function OA(t){return"center"===t.position}function RA(t){var e,n,i=t.getData(),r=[],o=!1,a=(t.get("minShowLabelAngle")||0)*CA,s=i.getLayout("viewRect"),l=i.getLayout("r"),u=s.width,c=s.x,h=s.y,d=s.height;function p(t){t.ignore=!0}i.each(function(t){var s=i.getItemGraphicEl(t),h=s.shape,f=s.getTextContent(),g=s.getTextGuideLine(),v=i.getItemModel(t),y=v.getModel("label"),m=y.get("position")||v.get(["emphasis","label","position"]),x=y.get("distanceToLabelLine"),_=y.get("alignTo"),b=Vo(y.get("edgeDistance"),u),w=y.get("bleedMargin");null==w&&(w=Math.min(u,d)>200?10:2);var S=v.getModel("labelLine"),M=S.get("length");M=Vo(M,u);var I=S.get("length2");if(I=Vo(I,u),Math.abs(h.endAngle-h.startAngle)0?"right":"left":L>0?"left":"right"}var G=Math.PI,F=0,H=y.get("rotate");if(K(H))F=H*(G/180);else if("center"===m)F=0;else if("radial"===H||!0===H){F=L<0?-k+G:-k}else if("tangential"===H||"tangential-noflip"===H&&"outside"!==m&&"outer"!==m){var W=Math.atan2(L,P);W<0&&(W=2*G+W),P>0&&"tangential-noflip"!==H&&(W=G+W),F=W-G}if(o=!!F,f.x=T,f.y=C,f.rotation=F,f.setStyle({verticalAlign:"middle"}),O){f.setStyle({align:A});var U=f.states.select;U&&(U.x+=f.x,U.y+=f.y)}else{var Y=new Xe(0,0,0,0);kA(Y,f),r.push({label:f,labelLine:g,position:m,len:M,len2:I,minTurnAngle:S.get("minTurnAngle"),maxSurfaceAngle:S.get("maxSurfaceAngle"),surfaceNormal:new Pe(L,P),linePoints:D,textAlign:A,labelDistance:x,labelAlignTo:_,edgeDistance:b,bleedMargin:w,rect:Y,unconstrainedWidth:Y.width,labelStyleWidth:f.style.width})}s.setTextConfig({inside:O})}}),!o&&t.get("avoidLabelOverlap")&&function(t,e,n,i,r,o,a,s){for(var l=[],u=[],c=Number.MAX_VALUE,h=-Number.MAX_VALUE,d=0;di?c=u=D+b*i/2:(u=D+S,c=r-S),n.setItemLayout(e,{angle:i,startAngle:u,endAngle:c,clockwise:y,cx:o,cy:a,r0:l,r:m?Bo(t,_,[l,s]):s}),D=r}}),T0){for(var l=o.getItemLayout(0),u=1;isNaN(l&&l.startAngle)&&u=n.r0}},e.type=MA,e}(tm);var FA=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.hasSymbolVisual=!0,n}return n(e,t),e.prototype.getInitialData=function(t,e){return $b(null,this,{useEncodeDefaulter:!0})},e.prototype.getProgressive=function(){var t=this.option.progressive;return null==t?this.option.large?5e3:this.get("progressive"):t},e.prototype.getProgressiveThreshold=function(){var t=this.option.progressiveThreshold;return null==t?this.option.large?1e4:this.get("progressiveThreshold"):t},e.prototype.brushSelector=function(t,e,n){return n.point(e.getItemLayout(t))},e.prototype.getZLevelKey=function(){return this.getData().count()>this.getProgressiveThreshold()?this.id:""},e.type="series.scatter",e.dependencies=["grid","polar","geo","singleAxis","calendar","matrix"],e.defaultOption={coordinateSystem:"cartesian2d",z:2,legendHoverLink:!0,symbolSize:10,large:!1,largeThreshold:2e3,itemStyle:{opacity:.8},emphasis:{scale:!0},clip:!0,select:{itemStyle:{borderColor:ug.color.primary}},universalTransition:{divideShape:"clone"}},e}(Hy),HA=function(){},WA=function(t){function e(e){var n=t.call(this,e)||this;return n._off=0,n.hoverDataIdx=-1,n}return n(e,t),e.prototype.getDefaultShape=function(){return new HA},e.prototype.reset=function(){this.notClear=!1,this._off=0},e.prototype.beforeBrush=function(t){t&&!t.contentRetained&&this.reset()},e.prototype.buildPath=function(t,e){var n,i=e.points,r=e.size,o=this.symbolProxy,a=o.shape,s=t.getContext?t.getContext():t,l=s&&r[0]<4,u=this.softClipShape;if(l)this._ctx=s;else{for(this._ctx=null,n=this._off;n=0;s--){var l=2*s,u=i[l]-o/2,c=i[l+1]-a/2;if(t>=u&&e>=c&&t<=u+o&&e<=c+a)return s}return-1},e.prototype.contain=function(t,e){var n=this.transformCoordToLocal(t,e),i=this.getBoundingRect();return t=n[0],e=n[1],i.contain(t,e)?(this.hoverDataIdx=this.findDataIndex(t,e))>=0:(this.hoverDataIdx=-1,!1)},e.prototype.getBoundingRect=function(){var t=this._rect;if(!t){for(var e=this.shape,n=e.points,i=e.size,r=i[0],o=i[1],a=1/0,s=1/0,l=-1/0,u=-1/0,c=0;c=0&&(l.dataIndex=n+(t.startIndex||0))})},t.prototype.remove=function(){this._clear()},t.prototype._clear=function(){this._newAdded=[],this.group.removeAll()},t}(),YA=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n){var i=t.getData();this._updateSymbolDraw(i,t).updateData(i,XA(t)),this._finished=!0},e.prototype.incrementalPrepareRender=function(t,e,n){var i=t.getData();this._updateSymbolDraw(i,t).incrementalPrepareUpdate(i),this._finished=!1},e.prototype.incrementalRender=function(t,e,n){this._symbolDraw.incrementalUpdate(t,e.getData(),is(e),XA(e)),this._finished=t.end===e.getData().count()},e.prototype.updateTransform=function(t,e,n){var i=t.getData();if(this.group.dirty(),!this._finished)return{update:!0};var r=GC("").reset(t,e,n);r.progress&&r.progress({start:0,end:i.count(),count:i.count()},i),this._symbolDraw.updateLayout(XA(t))},e.prototype.eachRendered=function(t){this._symbolDraw&&this._symbolDraw.eachRendered(t)},e.prototype._updateSymbolDraw=function(t,e){var n=this._symbolDraw,i=e.pipelineContext.large;return n&&i===this._isLargeDraw||(n&&n.remove(),n=this._symbolDraw=i?new UA:new nC,this._isLargeDraw=i,this.group.removeAll()),this.group.add(n.group),n},e.prototype.remove=function(t,e){this._symbolDraw&&this._symbolDraw.remove(!0),this._symbolDraw=null},e.prototype.dispose=function(){},e.type="scatter",e}(tm);function XA(t){return{clipShape:bC(t)}}var ZA=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.getCoordSysModel=function(){return this.getReferringComponents("grid",Ea).models[0]},e.type="cartesian2dAxis",e}(lg);E(ZA,nS);var jA={show:!0,z:0,inverse:!1,name:"",nameLocation:"end",nameRotate:null,nameTruncate:{maxWidth:null,ellipsis:"...",placeholder:"."},nameTextStyle:{},nameGap:15,silent:!1,triggerEvent:!1,tooltip:{show:!1},axisPointer:{},axisLine:{show:!0,onZero:"auto",onZeroAxisIndex:null,lineStyle:{color:ug.color.axisLine,width:1,type:"solid"},symbol:["none","none"],symbolSize:[10,15],breakLine:!0},axisTick:{show:!0,inside:!1,length:5,lineStyle:{width:1}},axisLabel:{show:!0,inside:!1,rotate:0,showMinLabel:null,showMaxLabel:null,margin:8,fontSize:12,color:ug.color.axisLabel,textMargin:[0,3]},splitLine:{show:!0,showMinLine:!0,showMaxLine:!0,lineStyle:{color:ug.color.axisSplitLine,width:1,type:"solid"}},splitArea:{show:!1,areaStyle:{color:[ug.color.backgroundTint,ug.color.backgroundTransparent]}},breakArea:{show:!0,itemStyle:{color:ug.color.neutral00,borderColor:ug.color.border,borderWidth:1,borderType:[3,3],opacity:.6},zigzagAmplitude:4,zigzagMinSpan:4,zigzagMaxSpan:20,zigzagZ:100,expandOnClick:!0},breakLabelLayout:{moveOverlap:"auto"}},qA=D({boundaryGap:!0,deduplication:null,jitter:0,jitterOverlap:!0,jitterMargin:2,splitLine:{show:!1},axisTick:{alignWithLabel:!1,interval:"auto",show:"auto"},axisLabel:{interval:"auto"}},jA),KA=D({boundaryGap:[0,0],axisLine:{show:"auto"},axisTick:{show:"auto"},splitNumber:5,minorTick:{show:!1,splitNumber:5,length:3,lineStyle:{}},minorSplitLine:{show:!1,lineStyle:{color:ug.color.axisMinorSplitLine,width:1}}},jA),$A={category:qA,value:KA,time:D({splitNumber:6,axisLabel:{rich:{primary:{fontWeight:"bold"}}},splitLine:{show:!1}},KA),log:P({logBase:10},KA)};function JA(t,e,i,r){B(Gw,function(o,a){var s=D(D({},$A[a],!0),r,!0),l=function(t){function i(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e+"Axis."+a,n}return n(i,t),i.prototype.mergeDefaultAndTheme=function(t,e){var n=ig(this),i=n?og(t):{};D(t,e.getTheme().get(a+"Axis")),D(t,this.getDefaultOption()),t.type=QA(t),n&&rg(t,i,n)},i.prototype.optionUpdated=function(){"category"===this.option.type&&(this.__ordinalMeta=tw.createByAxisModel(this))},i.prototype.getCategories=function(t){var e=this.option;if("category"===e.type)return t?e.data:this.__ordinalMeta.categories},i.prototype.getOrdinalMeta=function(){return this.__ordinalMeta},i.prototype.updateAxisBreaks=function(t){var e=XC();return e?e.updateModelAxisBreak(this,t):{breaks:[]}},i.type=e+"Axis."+a,i.defaultOption=s,i}(i);t.registerComponentModel(l)}),t.registerSubTypeDefaulter(e+"Axis",QA)}function QA(t){return t.type||(t.data?"category":"value")}var tk=function(){function t(t){this.type="cartesian",this._dimList=[],this._axes={},this.name=t||""}return t.prototype.getAxis=function(t){return this._axes[t]},t.prototype.getAxes=function(){return V(this._dimList,function(t){return this._axes[t]},this)},t.prototype.getAxesByScale=function(t){return t=t.toLowerCase(),F(this.getAxes(),function(e){return e.scale.type===t})},t.prototype.addAxis=function(t){var e=t.dim;this._axes[e]=t,this._dimList.push(e)},t}(),ek=["x","y"];function nk(t){return("interval"===t.type||"time"===t.type)&&!Xp(t)}var ik=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type=BD,e.dimensions=ek,e}return n(e,t),e.prototype.calcAffineTransform=function(){this._transform=this._invTransform=null;var t=this.getAxis("x").scale,e=this.getAxis("y").scale;if(nk(t)&&nk(e)){var n=sw(t,null),i=sw(e,null),r=this.dataToPoint([n[0],i[0]]),o=this.dataToPoint([n[1],i[1]]),a=n[1]-n[0],s=i[1]-i[0];if(a&&s){var l=(o[0]-r[0])/a,u=(o[1]-r[1])/s,c=r[0]-n[0]*l,h=r[1]-i[0]*u,d=this._transform=[l,0,0,u,c,h];this._invTransform=Ae([],d)}}},e.prototype.getBaseAxis=function(){return this.getAxesByScale("ordinal")[0]||this.getAxesByScale("time")[0]||this.getAxis("x")},e.prototype.containPoint=function(t){var e=this.getAxis("x"),n=this.getAxis("y");return e.contain(e.toLocalCoord(t[0]))&&n.contain(n.toLocalCoord(t[1]))},e.prototype.containData=function(t){return this.getAxis("x").containData(t[0])&&this.getAxis("y").containData(t[1])},e.prototype.containZone=function(t,e){var n=this.dataToPoint(t),i=this.dataToPoint(e),r=this.getArea(),o=new Xe(n[0],n[1],i[0]-n[0],i[1]-n[1]);return r.intersect(o)},e.prototype.dataToPoint=function(t,e,n){n=n||[];var i=t[0],r=t[1];if(this._transform&&null!=i&&isFinite(i)&&null!=r&&isFinite(r))return Yt(n,t,this._transform);var o=this.getAxis("x"),a=this.getAxis("y");return n[0]=o.toGlobalCoord(o.dataToCoord(i,e)),n[1]=a.toGlobalCoord(a.dataToCoord(r,e)),n},e.prototype.clampData=function(t,e){var n=this.getAxis("x").scale,i=this.getAxis("y").scale,r=n.getExtent(),o=i.getExtent(),a=n.parse(t[0]),s=i.parse(t[1]);return(e=e||[])[0]=Math.min(Math.max(Math.min(r[0],r[1]),a),Math.max(r[0],r[1])),e[1]=Math.min(Math.max(Math.min(o[0],o[1]),s),Math.max(o[0],o[1])),e},e.prototype.pointToData=function(t,e,n){if(n=n||[],this._invTransform)return Yt(n,t,this._invTransform);var i=this.getAxis("x"),r=this.getAxis("y");return n[0]=i.coordToData(i.toLocalCoord(t[0]),e),n[1]=r.coordToData(r.toLocalCoord(t[1]),e),n},e.prototype.getOtherAxis=function(t){return this.getAxis("x"===t.dim?"y":"x")},e.prototype.getArea=function(t){t=t||0;var e=this.getAxis("x").getGlobalExtent(),n=this.getAxis("y").getGlobalExtent(),i=Math.min(e[0],e[1])-t,r=Math.min(n[0],n[1])-t,o=Math.max(e[0],e[1])-i+t,a=Math.max(n[0],n[1])-r+t;return new Xe(i,r,o,a)},e}(tk);function rk(t,e){var n=t.scale,i=t.model;var r,o,a,s=LS(n,i,i.ecModel,t,null),l=gw(n),u=gw(e)?e.intervalStub:e,c=l?n.intervalStub:n,h=n.base,d=u.getTicks(),p=u.getTicks({expandToNicedExtent:!0}),f=d.length-1;if(1===f)r=o=0,a=1;else if(2===f){var g=Ao(d[0].value-d[1].value),v=Ao(d[1].value-d[2].value);r=o=0,g===v?a=2:(a=1,g=A[1])return!0})):T[1]?(x=A[1],k(function(){if(R(),S=Ho(w-_*a,b),L(),m<=A[0])return!0})):k(function(){S=Ho(Po(A[0]/_)*_,b),w=Ho(Lo(A[1]/_)*_,b);var t=ko((w-S)/_);if(t<=a){var e=a-t,n=void 0,i=s.incl0||l;if(i&&0===A[0])n=[0,e];else if(i&&0===A[1])n=[e,0];else{var r=Lo(e/2);n=e%2==0?[r,r]:m+x=A[1])return!0}})}Qw(n,T,D,[m,x],C,{interval:_,intervalCount:a,intervalPrecision:b,niceExtent:[S,w]})}var ok,ak=[[3,1],[0,2]],sk=function(){function t(t,e,n){this.type="grid",this._coordsMap={},this._coordsList=[],this._axesMap={},this._axesList=[],this.axisPointerEnabled=!0,this.dimensions=ek,this._initCartesian(t,e,n),this.model=t}return t.prototype.getRect=function(){return this._rect},t.prototype.update=function(t,e){var n=this._axesMap;function i(t){for(var e=W(t),n=[],i=e.length-1;i>=0;i--){var r=t[+e[i]];r.__alignTo?n.push(r):RS(r)}B(n,function(t){var e,n;e=t,n=t.__alignTo,Xp(e.scale)||Xp(n.scale)||n.scale.getTicks().length<2?RS(t):rk(t,t.__alignTo.scale)})}B(this._axesList,function(t){CS(t,1);var e=t.scale;vw(e)&&e.setSortInfo(t.model.get("categorySortInfo"))}),i(n.x),i(n.y);var r={};B(n.x,function(t){lk(n,"y",t,r)}),B(n.y,function(t){lk(n,"x",t,r)}),this.resize(this.model,e)},t.prototype.resize=function(t,e,n){var i=eg(t,e),r=this._rect=Jf(t.getBoxLayoutParams(),i.refContainer),o=this._axesMap,a=this._coordsList,s=t.get("containLabel");if(hk(o,r),!n){var l=function(t,e,n,i,r){var o=new aD(gk);return B(n,function(n){return B(n,function(n){if($w(n.model)){var a=!i;n.axisBuilder=function(t,e,n,i,r,o){for(var a=MD(t,n),s=!1,l=!1,u=0;ue?Uw:Xw:Xw}(e.scale,0,!1),i=e&&"category"!==e.type&&"time"!==e.type&&n!==Xw;return i&&"auto"===t&&function(t){return Fw(t).noOnMyZero}(e)&&(i=!1),i}function ck(t){for(var e,n=W(t),i=[],r=n.length-1;r>=0;r--){var o=t[+n[r]];dw(o.scale)&&null==Jw(o.model,o.type,!0)&&(o.model.get("alignTicks")&&null==o.model.get("interval")?i.push(o):e=o)}e||(e=i.pop()),e&&B(i,function(t){t.__alignTo=e})}function hk(t,e){B(t.x,function(t){return dk(t,e.x,e.width)}),B(t.y,function(t){return dk(t,e.y,e.height)})}function dk(t,e,n){var i=[0,n],r=t.inverse?1:0;t.setExtent(i[r],i[1-r]),function(t,e){var n=t.getExtent(),i=n[0]+n[1];t.toGlobalCoord="x"===t.dim?function(t){return t+e}:function(t){return i-t+e},t.toLocalCoord="x"===t.dim?function(t){return t-e}:function(t){return i-t+e}}(t,e)}function pk(t,e,n,i,r,o,a){fk(i,r,aM,e,!1,a);var s=[0,0,0,0];u(0),u(1),c(i,0,NaN),c(i,1,NaN);var l=null==H(s,function(t){return t>0});return zd(i,s,!0,!0,n),hk(r,i),l;function u(t){B(r[dd[t]],function(e){if($w(e.model)){var n=o.ensureRecord(e.model),i=n.labelInfoList;if(i)for(var r=0;r0&&!rt(e)&&e>1e-4&&(t/=e),t}}function fk(t,e,n,i,r,o){var a=n===sM;B(e,function(e){return B(e,function(e){$w(e.model)&&(!function(t,e,n){var i=MD(e,n);t.updateCfg(i)}(e.axisBuilder,t,e.model),e.axisBuilder.build(a?{axisTickLabelDetermine:!0}:{axisTickLabelEstimate:!0},{noPxChange:r}))})});var s={x:0,y:0};function l(e){s[dd[1-e]]=t[pd[e]]<=.5*o.refContainer[pd[e]]?0:1-e==1?2:1}l(0),l(1),B(e,function(t,e){return B(t,function(t){$w(t.model)&&(("all"===i||a)&&t.axisBuilder.build({axisName:!0},{nameMarginLevel:s[e]}),a&&t.axisBuilder.build({axisLine:!0}))})})}var gk=function(t,e,n,i,r,o){var a="x"===n.axis.dim?"y":"x";uD(t,0,0,i,r,o),Kw(t.nameLocation)||B(e.recordMap[a],function(t){t&&t.labelInfoList&&t.dirVec&&hD(t.labelInfoList,t.dirVec,i,r)})};function vk(t,e){var n={axesInfo:{},seriesInvolved:!1,coordSysAxesInfo:{},coordSysMap:{}};return function(t,e,n){var i=e.getComponent("tooltip"),r=e.getComponent("axisPointer"),o=r.get("link",!0)||[],a=[];B(n.getCoordinateSystems(),function(n){if(n.axisPointerEnabled){var s=_k(n.model),l=t.coordSysAxesInfo[s]={};t.coordSysMap[s]=n;var u=n.model.getModel("tooltip",i);if(B(n.getAxes(),Y(p,!1,null)),n.getTooltipAxes&&i&&u.get("show")){var c="axis"===u.get("trigger"),h="cross"===u.get(["axisPointer","type"]),d=n.getTooltipAxes(u.get(["axisPointer","axis"]));(c||h)&&B(d.baseAxes,Y(p,!h||"cross",c)),h&&B(d.otherAxes,Y(p,"cross",!1))}}function p(i,s,c){var h=c.model.getModel("axisPointer",r),d=h.get("show");if(d&&("auto"!==d||i||xk(h))){null==s&&(s=h.get("triggerTooltip")),h=i?function(t,e,n,i,r,o){var a=e.getModel("axisPointer"),s={};B(["type","snap","lineStyle","shadowStyle","label","animation","animationDurationUpdate","animationEasingUpdate","z"],function(t){s[t]=C(a.get(t))}),s.snap="category"!==t.type&&!!o,"cross"===a.get("type")&&(s.type="line");var l=s.label||(s.label={});if(null==l.show&&(l.show=!1),"cross"===r){var u=a.get(["label","show"]);if(l.show=null==u||u,!o){var c=s.lineStyle=a.get("crossStyle");c&&P(l,c.textStyle)}}return t.model.getModel("axisPointer",new kp(s,n,i))}(c,u,r,e,i,s):h;var p=h.get("snap"),f=h.get("triggerEmphasis"),g=_k(c.model),v=s||p||"category"===c.type,y=t.axesInfo[g]={key:g,axis:c,coordSys:n,axisPointerModel:h,triggerTooltip:s,triggerEmphasis:f,involveSeries:v,snap:p,useHandle:xk(h),seriesModels:[],linkGroup:null};l[g]=y,t.seriesInvolved=t.seriesInvolved||v;var m=function(t,e){for(var n=e.model,i=e.dim,r=0;r=0||t===e}function mk(t){var e=(t.ecModel.getComponent("axisPointer")||{}).coordSysAxesInfo;return e&&e.axesInfo[_k(t)]}function xk(t){return!!t.get(["handle","show"])}function _k(t){return t.type+"||"+t.id}var bk={},wk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(e,n,i,r){this.axisPointerClass&&function(t){var e=mk(t);if(e){var n=e.axisPointerModel,i=e.axis.scale,r=n.option,o=n.get("status"),a=n.get("value");null!=a&&(a=i.parse(a));var s=xk(n);null==o&&(r.status=s?"show":"hide");var l=i.getExtent();(null==a||a>l[1])&&(a=l[1]),a0))return n;var a=r.get("jitterOverlap"),s=r.get("jitterMargin")||0,l=vw(t.scale)?bM(t).w:null;return a?Vk(n,o,l,i):function(t,e,n,i,r,o){var a=zk(t);a.items||(a.items=[]);var s=a.items,l=Gk(s,e,n,i,r,o,1),u=Gk(s,e,n,i,r,o,-1),c=Math.abs(l-n)r/2||h&&d>h/2-i)return Vk(n,r,h,i);return s.push({fixedCoord:e,floatCoord:c,r:i}),c}(t,e,n,i,o,s)}function Vk(t,e,n,i){if(null===n)return t+(Math.random()-.5)*e;var r=n-2*i,o=Math.min(Math.max(0,e),r);return t+(Math.random()-.5)*o}function Gk(t,e,n,i,r,o,a){for(var s=n,l=0;lr/2)return Number.MAX_VALUE;if(1===a&&f>s||-1===a&&f0;return a&&s}(t,n);if(i){var r=n.dim,o=n.orient,a="horizontal"===o&&"category"!==n.type||"vertical"===o&&"category"===n.type,s="y"===r||"single"===r&&a,l="x"===r||"single"===r&&!a;if(s||l)return{progress:function(t,e){var i=e.getLayout("points"),r=!!i;for(var o=t.start;o0&&!d.min?d.min=0:null!=d.min&&d.min<0&&!d.max&&(d.max=0);var p=s;null!=d.color&&(p=P({color:d.color},s));var f=D(C(d),{boundaryGap:t,splitNumber:e,clockwise:n,scale:i,axisLine:r,axisTick:o,axisLabel:a,name:d.text,showName:l,nameLocation:"end",nameGap:c,nameTextStyle:p,triggerEvent:h},!1);if(j(u)){var g=f.name;f.name=u.replace("{value}",null!=g?g:"")}else Z(u)&&(f.name=u(f.name,f));var v=new kp(f,null,this.ecModel);return E(v,nS.prototype),v.mainType="radar",v.componentIndex=this.componentIndex,v.uid=Pp("ec_radar"),v},this);this._indicatorModels=d},e.prototype.getIndicatorModels=function(){return this._indicatorModels},e.type=jk,e.defaultOption={z:0,center:["50%","50%"],radius:"50%",startAngle:90,clockwise:!1,axisName:{show:!0,color:ug.color.axisLabel},boundaryGap:[0,0],splitNumber:5,axisNameGap:15,scale:!1,shape:"polygon",axisLine:D({lineStyle:{color:ug.color.neutral20}},Xk.axisLine),axisLabel:Kk(Xk.axisLabel,!1),axisTick:Kk(Xk.axisTick,!1),splitLine:Kk(Xk.splitLine,!0),splitArea:Kk(Xk.splitArea,!0),indicator:[]},e}(lg),Jk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n){this.group.removeAll(),this._buildAxes(t,n),this._buildSplitLineAndArea(t)},e.prototype._buildAxes=function(t,e){var n=t.coordinateSystem;B(V(n.getIndicatorAxes(),function(t){var i=t.model.get("showName")?t.name:"";return new dD(t.model,e,{axisName:i,position:[n.cx,n.cy],rotation:t.angle,labelDirection:-1,tickDirection:-1,nameDirection:1})}),function(t){t.build(),this.group.add(t.group)},this)},e.prototype._buildSplitLineAndArea=function(t){var e=t.coordinateSystem,n=e.getIndicatorAxes();if(n.length){var i=t.get("shape"),r=t.getModel("splitLine"),o=t.getModel("splitArea"),a=r.getModel("lineStyle"),s=o.getModel("areaStyle"),l=r.get("show"),u=o.get("show"),c=a.get("color"),h=s.get("color"),d=X(c)?c:[c],p=X(h)?h:[h],f=[],g=[];if("circle"===i)for(var v=n[0].getTicksCoords(),y=e.cx,m=e.cy,x=0;x3?1.4:r>1?1.2:1.1,l=i>0?s:1/s;this._checkTriggerMoveZoom(this,"zoom","zoomOnMouseWheel",t,{scale:l,originX:o,originY:a,isAvailableBehavior:null})}if(n){var u=Math.abs(i),c=(i>0?1:-1)*(u>3?.4:u>1?.15:.05);this._checkTriggerMoveZoom(this,"scrollMove","moveOnMouseWheel",t,{scrollDelta:c,originX:o,originY:a,isAvailableBehavior:null})}}}},e.prototype._pinchHandler=function(t){if(!aL(this._zr,"globalPan")&&!cL(t)){var e=t.pinchScale>1?1.1:1/1.1;this._checkTriggerMoveZoom(this,"zoom",null,t,{scale:e,originX:t.pinchX,originY:t.pinchY,isAvailableBehavior:null})}},e.prototype._checkTriggerMoveZoom=function(t,e,n,i,r){t._checkPointer(i,r.originX,r.originY)&&(ye(i.event),i.__ecRoamConsumed=!0,yL(t,e,n,i,r))},e}($t);function cL(t){return t.__ecRoamConsumed}var hL,dL=Pa();function pL(t){var e=dL(t);return e.roam=e.roam||{},e.uniform=e.uniform||{},e}function fL(t,e,n,i){for(var r=pL(t).roam,o=r[e]=r[e]||[],a=0;a=4&&(l={x:parseFloat(h[0]||0),y:parseFloat(h[1]||0),width:parseFloat(h[2]),height:parseFloat(h[3])})}if(l&&null!=a&&null!=s&&(u=VL(l,{x:0,y:0,width:a,height:s}),!e.ignoreViewBox)){var d=i;(i=new vo).add(d),d.scaleX=d.scaleY=u.scale,d.x=u.x,d.y=u.y}return e.ignoreRootClip||null==a||null==s||i.setClipPath(new lu({shape:{x:0,y:0,width:a,height:s}})),{root:i,width:a,height:s,viewBoxRect:l,viewBoxTransform:u,named:r}},t.prototype._parseNode=function(t,e,n,i,r,o){var a,s=t.nodeName.toLowerCase(),l=i;if("defs"===s&&(r=!0),"text"===s&&(o=!0),"defs"===s||"switch"===s)a=e;else{if(!r){var u=hL[s];if(u&&wt(hL,s)){a=u.call(this,t,e);var c=t.getAttribute("name");if(c){var h={name:c,namedFrom:null,svgNodeTagLower:s,el:a};n.push(h),"g"===s&&(l=h)}else i&&n.push({name:i.name,namedFrom:i,svgNodeTagLower:s,el:a});e.add(a)}}var d=IL[s];if(d&&wt(IL,s)){var p=d.call(this,t),f=t.getAttribute("id");f&&(this._defs[f]=p)}}if(a&&a.isGroup)for(var g=t.firstChild;g;)1===g.nodeType?this._parseNode(g,a,n,l,r,o):3===g.nodeType&&o&&this._parseText(g,a),g=g.nextSibling},t.prototype._parseText=function(t,e){var n=new Jl({style:{text:t.textContent},silent:!0,x:this._textX||0,y:this._textY||0});DL(e,n),kL(t,n,this._defsUsePending,!1,!1),function(t,e){var n=e.__selfStyle;if(n){var i=n.textBaseline,r=i;i&&"auto"!==i?"baseline"===i?r="alphabetic":"before-edge"===i||"text-before-edge"===i?r="top":"after-edge"===i||"text-after-edge"===i?r="bottom":"central"!==i&&"mathematical"!==i||(r="middle"):r="alphabetic",t.style.textBaseline=r}var o=e.__inheritedStyle;if(o){var a=o.textAlign,s=a;a&&("middle"===a&&(s="center"),t.style.textAlign=s)}}(n,e);var i=n.style,r=i.fontSize;r&&r<9&&(i.fontSize=9,n.scaleX*=r/9,n.scaleY*=r/9);var o=(i.fontSize||i.fontFamily)&&[i.fontStyle,i.fontWeight,(i.fontSize||12)+"px",i.fontFamily||"sans-serif"].join(" ");i.font=o;var a=n.getBoundingRect();return this._textX+=a.width,e.add(n),n},t.internalField=void(hL={g:function(t,e){var n=new vo;return DL(e,n),kL(t,n,this._defsUsePending,!1,!1),n},rect:function(t,e){var n=new lu;return DL(e,n),kL(t,n,this._defsUsePending,!1,!1),n.setShape({x:parseFloat(t.getAttribute("x")||"0"),y:parseFloat(t.getAttribute("y")||"0"),width:parseFloat(t.getAttribute("width")||"0"),height:parseFloat(t.getAttribute("height")||"0")}),n.silent=!0,n},circle:function(t,e){var n=new ah;return DL(e,n),kL(t,n,this._defsUsePending,!1,!1),n.setShape({cx:parseFloat(t.getAttribute("cx")||"0"),cy:parseFloat(t.getAttribute("cy")||"0"),r:parseFloat(t.getAttribute("r")||"0")}),n.silent=!0,n},line:function(t,e){var n=new Oh;return DL(e,n),kL(t,n,this._defsUsePending,!1,!1),n.setShape({x1:parseFloat(t.getAttribute("x1")||"0"),y1:parseFloat(t.getAttribute("y1")||"0"),x2:parseFloat(t.getAttribute("x2")||"0"),y2:parseFloat(t.getAttribute("y2")||"0")}),n.silent=!0,n},ellipse:function(t,e){var n=new lh;return DL(e,n),kL(t,n,this._defsUsePending,!1,!1),n.setShape({cx:parseFloat(t.getAttribute("cx")||"0"),cy:parseFloat(t.getAttribute("cy")||"0"),rx:parseFloat(t.getAttribute("rx")||"0"),ry:parseFloat(t.getAttribute("ry")||"0")}),n.silent=!0,n},polygon:function(t,e){var n,i=t.getAttribute("points");i&&(n=AL(i));var r=new Dh({shape:{points:n||[]},silent:!0});return DL(e,r),kL(t,r,this._defsUsePending,!1,!1),r},polyline:function(t,e){var n,i=t.getAttribute("points");i&&(n=AL(i));var r=new kh({shape:{points:n||[]},silent:!0});return DL(e,r),kL(t,r,this._defsUsePending,!1,!1),r},image:function(t,e){var n=new eu;return DL(e,n),kL(t,n,this._defsUsePending,!1,!1),n.setStyle({image:t.getAttribute("xlink:href")||t.getAttribute("href"),x:+t.getAttribute("x"),y:+t.getAttribute("y"),width:+t.getAttribute("width"),height:+t.getAttribute("height")}),n.silent=!0,n},text:function(t,e){var n=t.getAttribute("x")||"0",i=t.getAttribute("y")||"0",r=t.getAttribute("dx")||"0",o=t.getAttribute("dy")||"0";this._textX=parseFloat(n)+parseFloat(r),this._textY=parseFloat(i)+parseFloat(o);var a=new vo;return DL(e,a),kL(t,a,this._defsUsePending,!1,!0),a},tspan:function(t,e){var n=t.getAttribute("x"),i=t.getAttribute("y");null!=n&&(this._textX=parseFloat(n)),null!=i&&(this._textY=parseFloat(i));var r=t.getAttribute("dx")||"0",o=t.getAttribute("dy")||"0",a=new vo;return DL(e,a),kL(t,a,this._defsUsePending,!1,!0),this._textX+=parseFloat(r),this._textY+=parseFloat(o),a},path:function(t,e){var n=ih(t.getAttribute("d")||"");return DL(e,n),kL(t,n,this._defsUsePending,!1,!1),n.silent=!0,n}}),t}(),IL={lineargradient:function(t){var e=parseInt(t.getAttribute("x1")||"0",10),n=parseInt(t.getAttribute("y1")||"0",10),i=parseInt(t.getAttribute("x2")||"10",10),r=parseInt(t.getAttribute("y2")||"0",10),o=new Hh(e,n,i,r);return TL(t,o),CL(t,o),o},radialgradient:function(t){var e=parseInt(t.getAttribute("cx")||"0",10),n=parseInt(t.getAttribute("cy")||"0",10),i=parseInt(t.getAttribute("r")||"0",10),r=new Wh(e,n,i);return TL(t,r),CL(t,r),r}};function TL(t,e){"userSpaceOnUse"===t.getAttribute("gradientUnits")&&(e.global=!0)}function CL(t,e){for(var n=t.firstChild;n;){if(1===n.nodeType&&"stop"===n.nodeName.toLocaleLowerCase()){var i=n.getAttribute("offset"),r=void 0;r=i&&i.indexOf("%")>0?parseInt(i,10)/100:i?parseFloat(i):0;var o={};BL(n,o,o);var a=o.stopColor||n.getAttribute("stop-color")||"#000000",s=o.stopOpacity||n.getAttribute("stop-opacity");if(s){var l=gi(a);l&&l[3]&&(l[3]*=si(s),a=Mi(l,"rgba"))}e.colorStops.push({offset:r,color:a})}n=n.nextSibling}}function DL(t,e){t&&t.__inheritedStyle&&(e.__inheritedStyle||(e.__inheritedStyle={}),P(e.__inheritedStyle,t.__inheritedStyle))}function AL(t){for(var e=RL(t),n=[],i=0;i0;o-=2){var a=i[o],s=i[o-1],l=RL(a);switch(r=r||we(),s){case"translate":Te(r,r,[parseFloat(l[0]),parseFloat(l[1]||"0")]);break;case"scale":De(r,r,[parseFloat(l[0]),parseFloat(l[1]||l[0])]);break;case"rotate":Ce(r,r,-parseFloat(l[0])*EL,[parseFloat(l[1]||"0"),parseFloat(l[2]||"0")]);break;case"skewX":Ie(r,[1,0,Math.tan(parseFloat(l[0])*EL),1,0,0],r);break;case"skewY":Ie(r,[1,Math.tan(parseFloat(l[0])*EL),0,1,0,0],r);break;case"matrix":r[0]=parseFloat(l[0]),r[1]=parseFloat(l[1]),r[2]=parseFloat(l[2]),r[3]=parseFloat(l[3]),r[4]=parseFloat(l[4]),r[5]=parseFloat(l[5])}}e.setLocalTransform(r)}}(t,e),BL(t,a,s),i||function(t,e,n){for(var i=0;i1e-6;zP[0]=a?(r[0]-i.x)/o:r[0],zP[1]=a?(r[1]-i.y)/o:r[1],Yt(zP,zP,t.mtRawInv);var s=function(t,e){var n=t.centerOption,i=t.dataRect;0;return!n||t.lgCt?e.slice():[NP(0,e,n,i),NP(1,e,n,i)]}(t,zP);BP(e,s,o),B(n,function(t){t!==e&&BP(t,s.slice(),o)})}(r,e,n,AP)}var AP=Vr(),kP=Vr();function LP(t,e,n){var i=PP(e);i&&(DP(i,e,n,t),dP(i,e))}function PP(t){return t.__ownRoamView?t.__ownRoamView():null}function OP(t,e,n,i){n.setUpdatePayload($d(t));var r=Fu(i,e);r&&r.__updateOnOwnRoam&&r.__updateOnOwnRoam(t,e,i)}function RP(t){return t.scaleX}function NP(t,e,n,i){return n&&i&&i[pd[t]]&&(j(r=n[t])&&Fo(r))?(e[t]-i[dd[t]])/i[pd[t]]*100+"%":e[t];var r}function EP(t,e){return e&&t&&t.getShallow("legacyViewCoordSysCenterBase")?{w:e.getWidth(),h:e.getHeight()}:null}var zP=[];function BP(t,e,n){var i=t.option;i.center=e,i.zoom=n}function VP(t,e){if(e){var n=e.min||0,i=e.max||1/0;t=Math.max(Math.min(i,t),n)}return t}function GP(t,e){var n=e.getShallow("nodeScaleRatio",!0)||1,i=t;return((i.zoom-1)*n+1)/(i.trans[2].scaleX||1)}function FP(t,e,n,i,r,o,a,s){function l(n){var i=t.mainType,r=$d(P({type:YP(i,t.subType,Bu)},n));s&&(r.componentType=i),r[i+"Id"]=t.id,e.dispatchAction(r)}PP(t)?(n.enable(at(t.get("roam"),a),{api:e,zInfo:{component:t},triggerInfo:{roamTrigger:t.get("roamTrigger"),isInSelf:i,isInClip:function(t,e,n){return!r||r.contain(e,n)}}}),n.off("pan").off("zoom").on("pan",function(t){o&&o("pan"),l({dx:t.dx,dy:t.dy})}).on("zoom",function(t){o&&o("zoom"),l({zoom:t.scale,originX:t.originX,originY:t.originY})})):n.disable()}function HP(t){return function(e,n,i){return WP.copy(t.getBoundingRect()),WP.applyTransform(t.getComputedTransform()),WP.contain(n,i)}}var WP=new Xe(0,0,0,0);function UP(t,e,n){var i=YP(e,n,Bu);t.registerAction({type:i,event:i,update:"none"},function(t,i,r){i.eachComponent(Va(t,e,n),function(e){LP(t,e),OP(t,e,i,r)})})}function YP(t,e,n){return(t!==Du?t:"map"===e?"geo":e)+n}function XP(t){return null!=t.zoom}function ZP(t,e,n,i,r,o,a){var s=new nP(null,EP(t.ecModel,e));return pP(s,n,i,r,o),a?fP(s,a.x,a.y,a.width,a.height):fP(s,n,i,r,o),dP(s,t),s}var jP=["rect","circle","line","ellipse","polygon","polyline","path"],qP=mt(jP),KP=mt(jP.concat(["g"])),$P=mt(jP.concat(["g"])),JP=Pa();function QP(t){var e=t.getItemStyle(),n=t.get("areaColor");return null!=n&&(e.fill=n),e}function tO(t){var e=t.style;e&&(e.stroke=e.stroke||e.fill,e.fill=null)}var eO=function(){function t(t){var e=this.group=new vo,n=this._transformGroup=new vo;e.add(n),this.uid=Pp("ec_map_draw"),this._controller=new uL(t.getZr()),n.add(this._regionsGroup=new vo),n.add(this._svgGroup=new vo)}return t.prototype.draw=function(t,e,n,i,r){var o=this,a=t.getData&&t.getData();lO(t)&&e.eachComponent({mainType:"series",subType:"map"},function(e){a||e.getHostGeoModel()!==t||(a=e.getData())});var s,l=t.coordinateSystem,u=l.view,c=this._regionsGroup,h=this._transformGroup,d=!c.childAt(0)||r;l.shouldClip()?(s=sP(null,u),this.group.setClipPath(new lu({shape:s.clone()}))):this.group.removeClipPath(),CP(h,1,u,d?null:t);var p=a&&a.getVisual("visualMeta")&&a.getVisual("visualMeta").length>0;"geoJSON"===l.resourceType?this._buildGeoJSON(u,n,l,t,a,p):"geoSVG"===l.resourceType&&this._buildSVG(u,n,l,t,a,p),FP(t,n,this._controller,function(e,n,i){return t.coordinateSystem.containPoint([n,i])},s,function(){o._mouseDownFlag=!1},!1,!0),this._updateMapSelectHandler(t,c,n,i)},t.prototype.__updateOnOwnRoam=function(t){CP(this._transformGroup,1,t.coordinateSystem.view,null)},t.prototype._buildGeoJSON=function(t,e,n,i,r,o){var a=this._regionsGroupByName=mt(),s=mt(),l=this._regionsGroup,u=n.projection,c=u&&u.stream,h=Br(lP(null,t,0));function d(t,e){return e&&(t=e(t)),t&&Yt([],t,h)}function p(t){for(var e=[],n=!c&&u&&u.project,i=0;i=0)&&(c=t);var h=a?{normal:{align:"center",verticalAlign:"middle"}}:null;op(n,ap(r),{labelFetcher:c,labelDataIndex:u,defaultText:i},h);var d=n.getTextContent();if(d&&(JP(d).ignore=d.ignore,n.textConfig&&a)){var p=n.getBoundingRect().clone();n.textConfig.layoutRect=p,n.textConfig.position=[(a[0]-p.x)/p.width*100+"%",(a[1]-p.y)/p.height*100+"%"]}n.disableLabelAnimation=!0}else n.removeTextContent(),n.removeTextConfig(),n.disableLabelAnimation=null}function rO(t,e,n,i,r,o){e?e.setItemGraphicEl(o,n):Iu(n).eventData={componentType:"geo",componentIndex:t.componentIndex,geoIndex:t.componentIndex,name:i,region:r&&r.option||{}}}function oO(t,e,n,i,r){e||Gd({el:n,componentModel:t,itemName:i,itemTooltipOption:r.get("tooltip")})}function aO(t,e,n,i){e.highDownSilentOnTouch=!!t.get("selectedMode");var r=i.getModel("emphasis"),o=r.get("focus");return kc(e,o,r.get("blurScope"),r.get("disabled")),lO(t)&&function(t,e,n){var i=Iu(t);i.componentMainType=e.mainType,i.componentIndex=e.componentIndex,i.componentHighDownName=n}(e,t,n),o}function sO(t,e,n){var i,r=[];function o(){i=[]}function a(){i.length&&(r.push(i),i=[])}var s=e({polygonStart:o,polygonEnd:a,lineStart:o,lineEnd:a,point:function(t,e){isFinite(t)&&isFinite(e)&&i.push([t,e])},sphere:function(){}});return!n&&s.polygonStart(),B(t,function(t){s.lineStart();for(var e=0;e-1&&(n.style.stroke=n.style.fill,n.style.fill=ug.color.neutral00,n.style.lineWidth=2),n},e.prototype.__ownRoamView=function(){return dO(this)?this.coordinateSystem.view:null},e.type="series."+uO,e.dependencies=["geo"],e.layoutMode="box",e.defaultOption={z:2,coordinateSystem:"geo",map:"",left:"center",top:"center",aspectScale:null,showLegendSymbol:!0,boundingCoords:null,center:null,zoom:1,scaleLimit:null,selectedMode:!0,label:{show:!1,color:ug.color.tertiary},itemStyle:{borderWidth:.5,borderColor:ug.color.border,areaColor:ug.color.background},emphasis:{label:{show:!0,color:ug.color.primary},itemStyle:{areaColor:ug.color.highlight}},select:{label:{show:!0,color:ug.color.primary},itemStyle:{color:ug.color.highlight}},nameProperty:"name"},e}(Hy);function hO(t){return 0===t.indexOf("i")}function dO(t){return pO(t.seriesGroup)===t&&!t.getHostGeoModel()}function pO(t){return t.f[0]}function fO(t,e){var n={};return t.eachRawSeriesByType(uO,function(i){var r=i.getHostGeoModel(),o=r?"o"+r.id:"i"+i.getMapType(),a=n[o]=n[o]||{f:[],r:[]};t.isSeriesFiltered(i)||e||a.f.push(i),a.r.push(i)}),n}var gO=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type=uO,e}return n(e,t),e.prototype.render=function(t,e,n,i){if(!i||"mapToggleSelect"!==i.type||i.from!==this.uid){var r=this.group;if(r.removeAll(),!t.getHostGeoModel()){var o=this._mapDraw;o&&i&&"geoRoam"===i.type&&o.resetForLabelLayout(),i&&"geoRoam"===i.type&&"series"===i.componentType&&i.seriesId===t.id?o&&r.add(o.group):dO(t)?(o=o||(this._mapDraw=new eO(n)),r.add(o.group),o.draw(t,e,n,this,i)):this._clearMapDraw(),t.get("showLegendSymbol")&&e.getComponent("legend")&&this._renderSymbols(t)}}},e.prototype.__updateOnOwnRoam=function(t,e,n){var i=this._mapDraw;dO(e)&&i&&i.__updateOnOwnRoam(e)},e.prototype.remove=function(){this._clearMapDraw(),this.group.removeAll()},e.prototype.dispose=function(){this._clearMapDraw()},e.prototype._clearMapDraw=function(){this._mapDraw&&this._mapDraw.remove(),this._mapDraw=null},e.prototype._renderSymbols=function(t){var e=t.originalData,n=this.group;e.each(e.mapDimension("value"),function(i,r){if(!isNaN(i)){var o=e.getItemLayout(r);if(o&&o.point){var a=o.point,s=o.offset,l=new ah({style:{fill:t.getData().getVisual("style").fill},shape:{cx:a[0]+9*s,cy:a[1],r:3},silent:!0,z2:8+(s?0:11)});if(!s){var u=pO(t.seriesGroup).getData(),c=e.getName(r),h=u.indexOfName(c),d=e.getItemModel(r),p=d.getModel("label"),f=u.getItemGraphicEl(h);op(l,ap(d),{labelFetcher:{getFormattedLabel:function(e,n){return t.getFormattedLabel(h,n)}},defaultText:c}),l.disableLabelAnimation=!0,p.get("position")||l.setTextConfig({position:"bottom"}),f.onHoverStateChange=function(t){hc(l,t)}}n.add(l)}}})},e.type=uO,e}(tm),vO={geoJSON:{aspectScale:.75,invertLongitute:!0},geoSVG:{aspectScale:1,invertLongitute:!1}},yO=["lng","lat"],mO=function(t){function e(e,n,i){var r=t.call(this)||this;r.dimensions=yO,r.type="geo",r._nameCoordMap=mt(),r.name=e;var o=i.projection,a=eP(n,i.nameMap,i.nameProperty),s=QL(n),l=(r.resourceType=s?s.type:null,r.regions=a.regions),u=vO[s.type];r._clip=i.clip;var c,h=!o&&u.invertLongitute;if(r.view=new nP(h,EP(i.ecModel,i.api),r),r.map=n,r._regionsMap=a.regionsMap,r.regions=a.regions,r.projection=o,o)for(var d=0;d1?(f.width=p,f.height=p/x):(f.height=p,f.width=p*x),f.y=d[1]-f.height/2,f.x=d[0]-f.width/2;else{var b=t.getBoxLayoutParams();b.aspect=x,f=Qf(t,f=Jf(b,m),x)}fP(n,f.x,f.y,f.width,f.height),dP(n,t)}var bO=function(){function t(){this.dimensions=yO}return t.prototype.create=function(t,e){var n=[];function i(t){return{nameProperty:t.get("nameProperty"),aspectScale:t.get("aspectScale"),projection:t.get("projection"),clip:t.getShallow("clip",!0)}}return t.eachComponent("geo",function(r,o){var a=r.get("map"),s=new mO(a+o,a,k({nameMap:r.get("nameMap"),api:e,ecModel:t},i(r)));n.push(s),r.coordinateSystem=s,s.model=r,s.resize=_O,s.resize(r,e)}),t.eachSeries(function(t){Wf({targetModel:t,coordSysType:"geo",coordSysProvider:function(){var e=t.subType===uO?t.getHostGeoModel():t.getReferringComponents("geo",Ea).models[0];return e&&e.coordinateSystem},allowNotFound:!0})}),B(fO(t,!0),function(r,o){if(hO(o)){var a=r.r[0],s=[];B(r.r,function(t){s.push(t.get("nameMap")),t.seriesGroup=null});var l,u=o.slice(1),c=new mO(u,u,k({nameMap:A(s),api:e,ecModel:t},i(a)));B(r.r,function(t){l=at(l,t.get("scaleLimit"))}),n.push(c),c.resize=_O,c.resize(a,e),B(r.r,function(t){t.coordinateSystem=c,function(t,e){B(e.get("geoCoord"),function(e,n){t.addGeoCoord(n,e)})}(c,t)})}}),n},t.prototype.getFilledRegions=function(t,e,n,i){for(var r=(t||[]).slice(),o=mt(),a=0;a=0;){var o=e[n];o.hierNode.prelim+=i,o.hierNode.modifier+=i,r+=o.hierNode.change,i+=o.hierNode.shift+r}}(t);var o=(n[0].hierNode.prelim+n[n.length-1].hierNode.prelim)/2;r?(t.hierNode.prelim=r.hierNode.prelim+e(t,r),t.hierNode.modifier=t.hierNode.prelim-o):t.hierNode.prelim=o}else r&&(t.hierNode.prelim=r.hierNode.prelim+e(t,r));t.parentNode.hierNode.defaultAncestor=function(t,e,n,i){if(e){for(var r=t,o=t,a=o.parentNode.children[0],s=e,l=r.hierNode.modifier,u=o.hierNode.modifier,c=a.hierNode.modifier,h=s.hierNode.modifier;s=OO(s),o=RO(o),s&&o;){r=OO(r),a=RO(a),r.hierNode.ancestor=t;var d=s.hierNode.prelim+h-o.hierNode.prelim-u+i(s,o);d>0&&(EO(NO(s,t,n),t,d),u+=d,l+=d),h+=s.hierNode.modifier,u+=o.hierNode.modifier,l+=r.hierNode.modifier,c+=a.hierNode.modifier}s&&!OO(r)&&(r.hierNode.thread=s,r.hierNode.modifier+=h-l),o&&!RO(a)&&(a.hierNode.thread=o,a.hierNode.modifier+=u-c,n=t)}return n}(t,r,t.parentNode.hierNode.defaultAncestor||i[0],e)}function kO(t){var e=t.hierNode.prelim+t.parentNode.hierNode.modifier;t.setLayout({x:e},!0),t.hierNode.modifier+=t.parentNode.hierNode.modifier}function LO(t){return arguments.length?t:zO}function PO(t,e){return t-=Math.PI/2,{x:e*Math.cos(t),y:e*Math.sin(t)}}function OO(t){var e=t.children;return e.length&&t.isExpand?e[e.length-1]:t.hierNode.thread}function RO(t){var e=t.children;return e.length&&t.isExpand?e[0]:t.hierNode.thread}function NO(t,e,n){return t.hierNode.ancestor.parentNode===e.parentNode?t.hierNode.ancestor:n}function EO(t,e,n){var i=n/(e.hierNode.i-t.hierNode.i);e.hierNode.change-=i,e.hierNode.shift+=n,e.hierNode.modifier+=n,e.hierNode.prelim+=n,t.hierNode.change+=i}function zO(t,e){return t.parentNode===e.parentNode?1:2}var BO=Pa();function VO(t){var e=t.mainData,n=t.datas;n||(n={main:e},t.datasAttr={main:"data"}),t.datas=t.mainData=null,YO(e,n,t),B(n,function(n){B(e.TRANSFERABLE_METHODS,function(e){n.wrapMethod(e,Y(GO,t))})}),e.wrapMethod("cloneShallow",Y(HO,t)),B(e.CHANGABLE_METHODS,function(n){e.wrapMethod(n,Y(FO,t))}),ct(n[e.dataType]===e)}function GO(t,e){if(BO(i=this).mainData===i){var n=k({},BO(this).datas);n[this.dataType]=e,YO(e,n,t)}else XO(e,this.dataType,BO(this).mainData,t);var i;return e}function FO(t,e){return t.struct&&t.struct.update(),e}function HO(t,e){return B(BO(e).datas,function(n,i){n!==e&&XO(n.cloneShallow(),i,e,t)}),e}function WO(t){var e=BO(this).mainData;return null==t||null==e?e:BO(e).datas[t]}function UO(){var t=BO(this).mainData;return null==t?[{data:t}]:V(W(BO(t).datas),function(e){return{type:e,data:BO(t).datas[e]}})}function YO(t,e,n){BO(t).datas={},B(e,function(e,i){XO(e,i,t,n)})}function XO(t,e,n,i){BO(n).datas[e]=t,BO(t).mainData=n,t.dataType=e,i.struct&&(t[i.structAttr]=i.struct,i.struct[i.datasAttr[e]]=t),t.getLinkedData=WO,t.getLinkedDataAll=UO}var ZO=function(){function t(t,e){this.depth=0,this.height=0,this.dataIndex=-1,this.children=[],this.viewChildren=[],this.isExpand=!1,this.name=t||"",this.hostTree=e}return t.prototype.isRemoved=function(){return this.dataIndex<0},t.prototype.eachNode=function(t,e,n){Z(t)&&(n=e,e=t,t=null),j(t=t||{})&&(t={order:t});var i,r=t.order||"preorder",o=this[t.attr||"children"];"preorder"===r&&(i=e.call(n,this));for(var a=0;!i&&ae&&(e=i.height)}this.height=e+1},t.prototype.getNodeById=function(t){if(this.getId()===t)return this;for(var e=0,n=this.children,i=n.length;e=0&&this.hostTree.data.setItemLayout(this.dataIndex,t,e)},t.prototype.getLayout=function(){return this.hostTree.data.getItemLayout(this.dataIndex)},t.prototype.getModel=function(t){if(!(this.dataIndex<0))return this.hostTree.data.getItemModel(this.dataIndex).getModel(t)},t.prototype.getLevelModel=function(){return(this.hostTree.levelModels||[])[this.depth]},t.prototype.setVisual=function(t,e){this.dataIndex>=0&&this.hostTree.data.setItemVisual(this.dataIndex,t,e)},t.prototype.getVisual=function(t){return this.hostTree.data.getItemVisual(this.dataIndex,t)},t.prototype.getRawIndex=function(){return this.hostTree.data.getRawIndex(this.dataIndex)},t.prototype.getId=function(){return this.hostTree.data.getId(this.dataIndex)},t.prototype.getChildIndex=function(){if(this.parentNode){for(var t=this.parentNode.children,e=0;e=0){var i=n.getData().tree.root,r=t.targetNode;if(j(r)&&(r=i.getNodeById(r)),r&&i.contains(r))return{node:r};var o=t.targetNodeId;if(null!=o&&(r=i.getNodeById(o)))return{node:r}}}function KO(t){for(var e=[];t;)(t=t.parentNode)&&e.push(t);return e.reverse()}function $O(t,e){return R(KO(t),e)>=0}function JO(t,e){for(var n=[];t;){var i=t.dataIndex;n.push({name:t.name,dataIndex:i,value:e.getRawValue(i)}),t=t.parentNode}return n.reverse(),n}var QO="tree",tR=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.hasSymbolVisual=!0,n.ignoreStyleOnData=!0,n}return n(e,t),e.prototype.getInitialData=function(t){var e={name:t.name,children:t.data},n=t.leaves||{},i=new kp(n,this,this.ecModel),r=jO.createTree(e,this,function(t){t.wrapMethod("getItemModel",function(t,e){var n=r.getNodeByDataIndex(e);return n&&n.children.length&&n.isExpand||(t.parentModel=i),t})});var o=0;r.eachNode("preorder",function(t){t.depth>o&&(o=t.depth)});var a=t.expandAndCollapse&&t.initialTreeDepth>=0?t.initialTreeDepth:o;return r.root.eachNode("preorder",function(t){var e=t.hostTree.data.getRawDataItem(t.dataIndex);t.isExpand=e&&null!=e.collapsed?!e.collapsed:t.depth<=a}),r.data},e.prototype.getOrient=function(){var t=this.get("orient");return"horizontal"===t?t="LR":"vertical"===t&&(t="TB"),t},e.prototype.formatTooltip=function(t,e,n){for(var i=this.getData().tree,r=i.root.children[0],o=i.getNodeByDataIndex(t),a=o.getValue(),s=o.name;o&&o!==r;)s=o.parentNode.name+"."+s,o=o.parentNode;return Ty("nameValue",{name:s,value:a,noValue:isNaN(a)||null==a})},e.prototype.getDataParams=function(e){var n=t.prototype.getDataParams.apply(this,arguments),i=this.getData().tree.getNodeByDataIndex(e);return n.treeAncestors=JO(i,this),n.collapsed=!i.isExpand,n},e.prototype.__ownRoamView=function(){return this.coordinateSystem},e.type="series."+QO,e.layoutMode="box",e.defaultOption={z:2,coordinateSystemUsage:"box",left:"12%",top:"12%",right:"12%",bottom:"12%",layout:"orthogonal",edgeShape:"curve",edgeForkPosition:"50%",roam:!1,roamTrigger:"global",nodeScaleRatio:.4,center:null,zoom:1,orient:"LR",symbol:"emptyCircle",symbolSize:7,expandAndCollapse:!0,initialTreeDepth:2,lineStyle:{color:ug.color.borderTint,width:1.5,curveness:.5},itemStyle:{color:"lightsteelblue",borderWidth:1.5},label:{show:!0},animationEasing:"linear",animationDuration:700,animationDurationUpdate:500},e}(Hy),eR=function(){this.parentPoint=[],this.childPoints=[]},nR=function(t){function e(e){return t.call(this,e)||this}return n(e,t),e.prototype.getDefaultStyle=function(){return{stroke:ug.color.neutral99,fill:null}},e.prototype.getDefaultShape=function(){return new eR},e.prototype.buildPath=function(t,e){var n=e.childPoints,i=n.length,r=e.parentPoint,o=n[0],a=n[i-1];if(1===i)return t.moveTo(r[0],r[1]),void t.lineTo(o[0],o[1]);var s=e.orient,l="TB"===s||"BT"===s?0:1,u=1-l,c=Vo(e.forkPosition,1),h=[];h[l]=r[l],h[u]=r[u]+(a[u]-r[u])*c,t.moveTo(r[0],r[1]),t.lineTo(h[0],h[1]),t.moveTo(o[0],o[1]),h[l]=o[l],t.lineTo(h[0],h[1]),h[l]=a[l],t.lineTo(h[0],h[1]),t.lineTo(a[0],a[1]);for(var d=1;dm.x)||(_-=Math.PI);var S=b?"left":"right",M=s.getModel("label"),I=M.get("rotate"),T=I*(Math.PI/180),C=v.getTextContent();C&&(v.setTextConfig({position:M.get("position")||S,rotation:null==I?-_:T,origin:"center"}),C.setStyle("verticalAlign","middle"))}var D=s.get(["emphasis","focus"]),A="relative"===D?xt(a.getAncestorsIndices(),a.getDescendantIndices()):"ancestor"===D?a.getAncestorsIndices():"descendant"===D?a.getDescendantIndices():null;A&&(Iu(n).focus=A),function(t,e,n,i,r,o,a,s){var l=e.getModel(),u=t.get("edgeShape"),c=t.get("layout"),h=t.getOrient(),d=t.get(["lineStyle","curveness"]),p=t.get("edgeForkPosition"),f=l.getModel("lineStyle").getLineStyle(),g=i.__edge;if("curve"===u)e.parentNode&&e.parentNode!==n&&(g||(g=i.__edge=new zh({shape:uR(c,h,d,r,r)})),rd(g,{shape:uR(c,h,d,o,a)},t));else if("polyline"===u)if("orthogonal"===c){if(e!==n&&e.children&&0!==e.children.length&&!0===e.isExpand){for(var v=e.children,y=[],m=0;m=0;o--)i.push(r[o])}}function hR(t,e){t.eachSeriesByType("tree",function(t){!function(t,e){var n=eg(t,e).refContainer,i=Jf(t.getBoxLayoutParams(),n);t.layoutInfo=i;var r=t.get("layout"),o=0,a=0,s=null;"radial"===r?(o=2*Math.PI,a=Math.min(i.height,i.width)/2,s=LO(function(t,e){return(t.parentNode===e.parentNode?1:2)/t.depth})):(o=i.width,a=i.height,s=LO());var l=t.getData().tree.root,u=l.children[0];if(u){!function(t){var e=t;e.hierNode={defaultAncestor:null,ancestor:e,prelim:0,modifier:0,change:0,shift:0,i:0,thread:null};for(var n,i,r=[e];n=r.pop();)if(i=n.children,n.isExpand&&i.length)for(var o=i.length-1;o>=0;o--){var a=i[o];a.hierNode={defaultAncestor:null,ancestor:a,prelim:0,modifier:0,change:0,shift:0,i:o,thread:null},r.push(a)}}(l),function(t,e,n){for(var i,r=[t],o=[];i=r.pop();)if(o.push(i),i.isExpand){var a=i.children;if(a.length)for(var s=0;sh.getLayout().x&&(h=t),t.depth>d.depth&&(d=t)});var p=c===h?1:s(c,h)/2,f=p-c.getLayout().x,g=0,v=0,y=0,m=0;if("radial"===r)g=o/(h.getLayout().x+p+f),v=a/(d.depth-1||1),cR(u,function(t){y=(t.getLayout().x+f)*g,m=(t.depth-1)*v;var e=PO(y,m);t.setLayout({x:e.x,y:e.y,rawX:y,rawY:m},!0)});else{var x=t.getOrient();"RL"===x||"LR"===x?(v=a/(h.getLayout().x+p+f),g=o/(d.depth-1||1),cR(u,function(t){m=(t.getLayout().x+f)*v,y="LR"===x?(t.depth-1)*g:o-(t.depth-1)*g,t.setLayout({x:y,y:m},!0)})):"TB"!==x&&"BT"!==x||(g=o/(h.getLayout().x+p+f),v=a/(d.depth-1||1),cR(u,function(t){y=(t.getLayout().x+f)*g,m="TB"===x?(t.depth-1)*v:a-(t.depth-1)*v,t.setLayout({x:y,y:m},!0)}))}}}(t,e)})}var dR=os(QO,function(t){t.eachSeriesByType(QO,function(t){var e=t.getData();e.tree.eachNode(function(t){var n=t.getModel().getModel("itemStyle").getItemStyle();k(e.ensureUniqueItemVisual(t.dataIndex,"style"),n)})})});var pR=["treemapZoomToNode","treemapRender","treemapMove"];function fR(t){var e=t.getData().tree,n={};e.eachNode(function(e){for(var i=e;i&&i.depth>1;)i=i.parentNode;var r=Rg(t.ecModel,i.name||i.dataIndex+"",n);e.setVisual("decal",r)})}var gR=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.preventUsingHoverLayer=!0,n}return n(e,t),e.prototype.getInitialData=function(t,e){var n={name:t.name,children:t.data};vR(n);var i=t.levels||[],r=this.designatedVisualItemStyle={},o=new kp({itemStyle:r},this,e);i=t.levels=function(t,e){var n,i,r=_a(e.get("color")),o=_a(e.get(["aria","decal","decals"]));if(!r)return;t=t||[],B(t,function(t){var e=new kp(t),r=e.get("color"),o=e.get("decal");(e.get(["itemStyle","color"])||r&&"none"!==r)&&(n=!0),(e.get(["itemStyle","decal"])||o&&"none"!==o)&&(i=!0)});var a=t[0]||(t[0]={});n||(a.color=r.slice());!i&&o&&(a.decal=o.slice());return t}(i,e);var a=V(i||[],function(t){return new kp(t,o,e)},this),s=jO.createTree(n,this,function(t){t.wrapMethod("getItemModel",function(t,e){var n=s.getNodeByDataIndex(e),i=n?a[n.depth]:null;return t.parentModel=i||o,t})});return s.data},e.prototype.optionUpdated=function(){this.resetViewRoot()},e.prototype.formatTooltip=function(t,e,n){var i=this.getData(),r=this.getRawValue(t);return Ty("nameValue",{name:i.getName(t),value:r})},e.prototype.getDataParams=function(e){var n=t.prototype.getDataParams.apply(this,arguments),i=this.getData().tree.getNodeByDataIndex(e);return n.treeAncestors=JO(i,this),n.treePathInfo=n.treeAncestors,n},e.prototype.setLayoutInfo=function(t){this.layoutInfo=this.layoutInfo||{},k(this.layoutInfo,t)},e.prototype.mapIdToIndex=function(t){var e=this._idIndexMap;e||(e=this._idIndexMap=mt(),this._idIndexMapCount=0);var n=e.get(t);return null==n&&e.set(t,n=this._idIndexMapCount++),n},e.prototype.getViewRoot=function(){return this._viewRoot},e.prototype.resetViewRoot=function(t){t?this._viewRoot=t:t=this._viewRoot;var e=this.getRawData().tree.root;t&&(t===e||e.contains(t))||(this._viewRoot=e)},e.prototype.enableAriaDecal=function(){fR(this)},e.type="series.treemap",e.layoutMode="box",e.defaultOption={progressive:0,coordinateSystemUsage:"box",left:ug.size.l,top:ug.size.xxxl,right:ug.size.l,bottom:ug.size.xxxl,sort:!0,clipWindow:"origin",squareRatio:.5*(1+Math.sqrt(5)),leafDepth:null,drillDownIcon:"▶",zoomToNodeRatio:.1024,scaleLimit:{max:5,min:.2},roam:!0,roamTrigger:"global",nodeClick:"zoomToNode",animation:!0,animationDurationUpdate:900,animationEasing:"quinticInOut",breadcrumb:{show:!0,height:22,left:"center",bottom:ug.size.m,emptyItemWidth:25,itemStyle:{color:ug.color.backgroundShade,textStyle:{color:ug.color.secondary}},emphasis:{itemStyle:{color:ug.color.background}}},label:{show:!0,distance:0,padding:5,position:"inside",color:ug.color.neutral00,overflow:"truncate"},upperLabel:{show:!1,position:[0,"50%"],height:20,overflow:"truncate",verticalAlign:"middle"},itemStyle:{color:null,colorAlpha:null,colorSaturation:null,borderWidth:0,gapWidth:0,borderColor:ug.color.neutral00,borderColorSaturation:null},emphasis:{upperLabel:{show:!0,position:[0,"50%"],overflow:"truncate",verticalAlign:"middle"}},visualDimension:0,visualMin:null,visualMax:null,color:[],colorAlpha:null,colorSaturation:null,colorMappingBy:"index",visibleMin:10,childrenVisibleMin:null,levels:[]},e}(Hy);function vR(t){var e=0;B(t.children,function(t){vR(t);var n=t.value;X(n)&&(n=n[0]),e+=n});var n=t.value;X(n)&&(n=n[0]),(null==n||isNaN(n))&&(n=e),n<0&&(n=0),X(t.value)?t.value[0]=n:t.value=n}var yR=function(){function t(t){this.group=new vo,t.add(this.group)}return t.prototype.render=function(t,e,n,i){var r=t.getModel("breadcrumb"),o=this.group;if(o.removeAll(),r.get("show")&&n){var a=r.getModel("itemStyle"),s=r.getModel("emphasis"),l=a.getModel("textStyle"),u=s.getModel(["itemStyle","textStyle"]),c=eg(t,e).refContainer,h={left:r.get("left"),right:r.get("right"),top:r.get("top"),bottom:r.get("bottom")},d={emptyItemWidth:r.get("emptyItemWidth"),totalWidth:0,renderList:[]},p=Jf(h,c);this._prepare(n,d,l),this._renderContent(t,d,p,a,s,l,u,i),ng(o,h,c)}},t.prototype._prepare=function(t,e,n){for(var i=t;i;i=i.parentNode){var r=Da(i.getModel().get("name"),""),o=n.getTextRect(r),a=Math.max(o.width+16,e.emptyItemWidth);e.totalWidth+=a+8,e.renderList.push({node:i,text:r,width:a})}},t.prototype._renderContent=function(t,e,n,i,r,o,a,s){for(var l=0,u=e.emptyItemWidth,c=t.get(["breadcrumb","height"]),h=e.totalWidth,d=e.renderList,p=r.getModel("itemStyle").getItemStyle(),f=d.length-1;f>=0;f--){var g=d[f],v=g.node,y=g.width,m=g.text;h>n.width&&(h-=y-u,y=u,m=null);var x=new Dh({shape:{points:mR(l,0,y,c,f===d.length-1,0===f)},style:P(i.getItemStyle(),{lineJoin:"bevel"}),textContent:new du({style:sp(o,{text:m})}),textConfig:{position:"inside"},z2:1e5,onclick:Y(s,v)});x.disableLabelAnimation=!0,x.getTextContent().ensureState("emphasis").style=sp(a,{text:m}),x.ensureState("emphasis").style=p,kc(x,r.get("focus"),r.get("blurScope"),r.get("disabled")),this.group.add(x),xR(x,t,v),l+=y+8}},t.prototype.remove=function(){this.group.removeAll()},t}();function mR(t,e,n,i,r,o){var a=[[r?t:t-5,e],[t+n,e],[t+n,e+i],[r?t:t-5,e+i]];return!o&&a.splice(2,0,[t+n+5,e+i/2]),!r&&a.push([t,e+i/2]),a}function xR(t,e,n){Iu(t).eventData={componentType:"series",componentSubType:"treemap",componentIndex:e.componentIndex,seriesIndex:e.seriesIndex,seriesName:e.name,seriesType:"treemap",selfType:"breadcrumb",nodeData:{dataIndex:n&&n.dataIndex,name:n&&n.name},treePathInfo:n&&JO(n,e)}}var _R=function(){function t(){this._storage=[],this._elExistsMap={}}return t.prototype.add=function(t,e,n,i,r){return!this._elExistsMap[t.id]&&(this._elExistsMap[t.id]=!0,this._storage.push({el:t,target:e,duration:n,delay:i,easing:r}),!0)},t.prototype.finished=function(t){return this._finishedCallback=t,this},t.prototype.start=function(){for(var t=this,e=this._storage.length,n=function(){--e<=0&&(t._storage.length=0,t._elExistsMap={},t._finishedCallback&&t._finishedCallback())},i=0,r=this._storage.length;iqo&&(c=qo),o=r}ca[1]&&(a[1]=e)})):a=[NaN,NaN];return{sum:i,dataExtent:a}}(e,a,s);if(0===u.sum)return t.viewChildren=[];if(u.sum=function(t,e,n,i,r){if(!i)return n;for(var o=t.get("visibleMin"),a=r.length,s=a,l=a-1;l>=0;l--){var u=r["asc"===i?a-l-1:l].getValue();u/n*ei&&(i=a));var l=t.area*t.area,u=e*e*n;return l?bR(u*i/l,l/(u*r)):1/0}function LR(t,e,n,i,r){var o=e===n.width?0:1,a=1-o,s=["x","y"],l=["width","height"],u=n[s[o]],c=e?t.area/e:0;(r||c>n[l[a]])&&(c=n[l[a]]);for(var h=0,d=t.length;h3||Math.abs(t.dy)>3)){var e=this.seriesModel.getData().tree.root;if(!e)return;var n=e.getLayout();if(!n)return;this.api.dispatchAction({type:"treemapMove",from:this.uid,seriesId:this.seriesModel.id,rootRect:{x:n.x+t.dx,y:n.y+t.dy,width:n.width,height:n.height}})}},e.prototype._onZoom=function(t){var e=t.originX,n=t.originY,i=t.scale,r=this.seriesModel;if("animating"!==this._state){var o=r.getData().tree.root;if(!o)return;var a=o.getLayout();if(!a)return;var s=new Xe(a.x,a.y,a.width,a.height),l=r.layoutInfo,u=RR(l,a),c=u*i,h=(c=NR(c,r))/u,d=[1,0,0,1,0,0];Te(d,d,[-(e-=l.x),-(n-=l.y)]),De(d,d,[h,h]),Te(d,d,[e,n]),s.applyTransform(d),this.api.dispatchAction({type:"treemapRender",from:this.uid,seriesId:this.seriesModel.id,rootRect:{x:s.x,y:s.y,width:s.width,height:s.height}})}},e.prototype._initEvents=function(t){var e=this;t.on("click",function(t){if("ready"===e._state){var n=e.seriesModel.get("nodeClick",!0);if(n){var i=e.findTarget(t.offsetX,t.offsetY);if(i){var r=i.node;if(r.getLayout().isLeafRoot)e._rootToNode(i);else if("zoomToNode"===n)e._zoomToNode(i);else if("link"===n){var o=r.hostTree.data.getItemModel(r.dataIndex),a=o.get("link",!0),s=o.get("target",!0)||"blank";a&&Ef(a,s)}}}}},this)},e.prototype._renderBreadcrumb=function(t,e,n){var i=this;n||(n=null!=t.get("leafDepth",!0)?{node:t.getViewRoot()}:this.findTarget(e.getWidth()/2,e.getHeight()/2))||(n={node:t.getData().tree.root}),(this._breadcrumb||(this._breadcrumb=new yR(this.group))).render(t,e,n.node,function(e){"animating"!==i._state&&($O(t.getViewRoot(),e)?i._rootToNode({node:e}):i._zoomToNode({node:e}))})},e.prototype.remove=function(){this._clearController(),this._containerGroup&&this._containerGroup.removeAll(),this._storage={nodeGroup:[],background:[],content:[]},this._state="ready",this._breadcrumb&&this._breadcrumb.remove()},e.prototype.dispose=function(){this._clearController()},e.prototype._zoomToNode=function(t){this.api.dispatchAction({type:"treemapZoomToNode",from:this.uid,seriesId:this.seriesModel.id,targetNode:t.node})},e.prototype._rootToNode=function(t){this.api.dispatchAction({type:"treemapRootToNode",from:this.uid,seriesId:this.seriesModel.id,targetNode:t.node})},e.prototype.findTarget=function(t,e){var n;return this.seriesModel.getViewRoot().eachNode({attr:"viewChildren",order:"preorder"},function(i){var r=this._storage.background[i.getRawIndex()];if(r){var o=r.transformCoordToLocal(t,e),a=r.shape;if(!(a.x<=o[0]&&o[0]<=a.x+a.width&&a.y<=o[1]&&o[1]<=a.y+a.height))return!1;n={node:i,offsetX:o[0],offsetY:o[1]}}},this),n},e.type="treemap",e}(tm);var UR=B,YR=$,XR=-1,ZR=function(){function t(e){var n=e.mappingMethod,i=e.type,r=this.option=C(e);this.type=i,this.mappingMethod=n,this._normalizeData=iN[n];var o=t.visualHandlers[i];this.applyVisual=o.applyVisual,this.getColorMapper=o.getColorMapper,this._normalizedToVisual=o._normalizedToVisual[n],"piecewise"===n?(jR(r),function(t){var e=t.pieceList;t.hasSpecialVisual=!1,B(e,function(e,n){e.originIndex=n,null!=e.visual&&(t.hasSpecialVisual=!0)})}(r)):"category"===n?r.categories?function(t){var e=t.categories,n=t.categoryMap={},i=t.visual;if(UR(e,function(t,e){n[t]=e}),!X(i)){var r=[];$(i)?UR(i,function(t,e){var i=n[e];r[null!=i?i:XR]=t}):r[-1]=i,i=nN(t,r)}for(var o=e.length-1;o>=0;o--)null==i[o]&&(delete n[e[o]],e.pop())}(r):jR(r,!0):(ct("linear"!==n||r.dataExtent),jR(r))}return t.prototype.mapValueToVisual=function(t){var e=this._normalizeData(t);return this._normalizedToVisual(e,t)},t.prototype.getNormalizer=function(){return U(this._normalizeData,this)},t.listVisualTypes=function(){return W(t.visualHandlers)},t.isValidType=function(e){return t.visualHandlers.hasOwnProperty(e)},t.eachVisual=function(t,e,n){$(t)?B(t,e,n):e.call(n,t)},t.mapVisual=function(e,n,i){var r,o=X(e)?[]:$(e)?{}:(r=!0,null);return t.eachVisual(e,function(t,e){var a=n.call(i,t,e);r?o=a:o[e]=a}),o},t.retrieveVisuals=function(e){var n,i={};return e&&UR(t.visualHandlers,function(t,r){e.hasOwnProperty(r)&&(i[r]=e[r],n=!0)}),n?i:null},t.prepareVisualTypes=function(t){if(X(t))t=t.slice();else{if(!YR(t))return[];var e=[];UR(t,function(t,n){e.push(n)}),t=e}return t.sort(function(t,e){return"color"===e&&"color"!==t&&0===t.indexOf("color")?1:-1}),t},t.dependsOn=function(t,e){return"color"===e?!(!t||0!==t.indexOf(e)):t===e},t.findPieceIndex=function(t,e,n){for(var i,r=1/0,o=0,a=e.length;ou[1]&&(u[1]=l);var c=e.get("colorMappingBy"),h={type:a.name,dataExtent:u,visual:a.range};"color"!==h.type||"index"!==c&&"id"!==c?h.mappingMethod="linear":(h.mappingMethod="category",h.loop=!0);var d=new ZR(h);return oN(d).drColorMappingBy=c,d}(0,r,o,0,u,p);B(p,function(t,e){if(t.depth>=n.length||t===n[t.depth]){var o=function(t,e,n,i,r,o){var a=k({},e);if(r){var s=r.type,l="color"===s&&oN(r).drColorMappingBy,u="index"===l?i:"id"===l?o.mapIdToIndex(n.getId()):n.getValue(t.get("visualDimension"));a[s]=r.mapValueToVisual(u)}return a}(r,u,t,e,f,i);sN(t,o,n,i)}})}else s=lN(u),c.fill=s}}function lN(t){var e=uN(t,"color");if(e){var n=uN(t,"colorAlpha"),i=uN(t,"colorSaturation");return i&&(e=wi(e,null,null,i)),n&&(e=Si(e,n)),e}}function uN(t,e){var n=t[e];if(null!=n&&"none"!==n)return n}function cN(t,e){var n=t.get(e);return X(n)&&n.length?{name:e,range:n}:null}function hN(t){return"_EC_"+t}var dN=function(){function t(t){this.type="graph",this.nodes=[],this.edges=[],this._nodesMap={},this._edgesMap={},this._directed=t||!1}return t.prototype.isDirected=function(){return this._directed},t.prototype.addNode=function(t,e){t=null==t?""+e:""+t;var n=this._nodesMap;if(!n[hN(t)]){var i=new pN(t,e);return i.hostGraph=this,this.nodes.push(i),n[hN(t)]=i,i}},t.prototype.getNodeByIndex=function(t){var e=this.data.getRawIndex(t);return this.nodes[e]},t.prototype.getNodeById=function(t){return this._nodesMap[hN(t)]},t.prototype.addEdge=function(t,e,n){var i=this._nodesMap,r=this._edgesMap;if(K(t)&&(t=this.nodes[t]),K(e)&&(e=this.nodes[e]),t instanceof pN||(t=i[hN(t)]),e instanceof pN||(e=i[hN(e)]),t&&e){var o=t.id+"-"+e.id,a=new fN(t,e,n);return a.hostGraph=this,this._directed&&(t.outEdges.push(a),e.inEdges.push(a)),t.edges.push(a),t!==e&&e.edges.push(a),this.edges.push(a),r[o]=a,a}},t.prototype.getEdgeByIndex=function(t){var e=this.edgeData.getRawIndex(t);return this.edges[e]},t.prototype.getEdge=function(t,e){t instanceof pN&&(t=t.id),e instanceof pN&&(e=e.id);var n=this._edgesMap;return this._directed?n[t+"-"+e]:n[t+"-"+e]||n[e+"-"+t]},t.prototype.eachNode=function(t,e){for(var n=this.nodes,i=n.length,r=0;r=0&&t.call(e,n[r],r)},t.prototype.eachEdge=function(t,e){for(var n=this.edges,i=n.length,r=0;r=0&&n[r].node1.dataIndex>=0&&n[r].node2.dataIndex>=0&&t.call(e,n[r],r)},t.prototype.breadthFirstTraverse=function(t,e,n,i){if(e instanceof pN||(e=this._nodesMap[hN(e)]),e){for(var r="out"===n?"outEdges":"in"===n?"inEdges":"edges",o=0;o=0&&n.node2.dataIndex>=0});for(r=0,o=i.length;r=0&&!t.hasKey(p)&&(t.set(p,!0),o.push(d.node1))}for(s=0;s=0&&!t.hasKey(m)&&(t.set(m,!0),a.push(y.node2))}}}return{edge:t.keys(),node:e.keys()}},t}(),fN=function(){function t(t,e,n){this.dataIndex=-1,this.node1=t,this.node2=e,this.dataIndex=null==n?-1:n}return t.prototype.getModel=function(t){if(!(this.dataIndex<0))return this.hostGraph.edgeData.getItemModel(this.dataIndex).getModel(t)},t.prototype.getAdjacentDataIndices=function(){return{edge:[this.dataIndex],node:[this.node1.dataIndex,this.node2.dataIndex]}},t.prototype.getTrajectoryDataIndices=function(){var t=mt(),e=mt();t.set(this.dataIndex,!0);for(var n=[this.node1],i=[this.node2],r=0;r=0&&!t.hasKey(u)&&(t.set(u,!0),n.push(l.node1))}for(r=0;r=0&&!t.hasKey(d)&&(t.set(d,!0),i.push(h.node2))}return{edge:t.keys(),node:e.keys()}},t}();function gN(t,e){return{getValue:function(n){var i=this[t][e];return i.getStore().get(i.getDimensionIndex(n||"value"),this.dataIndex)},setVisual:function(n,i){this.dataIndex>=0&&this[t][e].setItemVisual(this.dataIndex,n,i)},getVisual:function(n){return this[t][e].getItemVisual(this.dataIndex,n)},setLayout:function(n,i){this.dataIndex>=0&&this[t][e].setItemLayout(this.dataIndex,n,i)},getLayout:function(){return this[t][e].getItemLayout(this.dataIndex)},getGraphicEl:function(){return this[t][e].getItemGraphicEl(this.dataIndex)},getRawIndex:function(){return this[t][e].getRawIndex(this.dataIndex)}}}function vN(t,e,n,i,r){for(var o=new dN(i),a=0;a "+d)),u++)}var p,f=n.get("coordinateSystem");if("cartesian2d"===f||"polar"===f||"matrix"===f)p=$b(t,n);else{var g=Vf.get(f),v=g&&g.dimensions||[];R(v,"value")<0&&v.concat(["value"]);var y=Wb(t,{coordDimensions:v,encodeDefine:n.getEncode()}).dimensions;(p=new Hb(y,n)).initData(t)}var m=new Hb(["value"],n);return m.initData(l,s),r&&r(p,m),VO({mainData:p,struct:o,structAttr:"graph",datas:{node:p,edge:m},datasAttr:{node:"data",edge:"edgeData"}}),o.update(),o}E(pN,gN("hostGraph","data")),E(fN,gN("hostGraph","edgeData"));var yN="--\x3e",mN=function(t){return t.get("autoCurveness")||null},xN=function(t,e){var n=mN(t),i=20,r=[];if(K(n))i=n;else if(X(n))return void(t.__curvenessList=n);e>i&&(i=e);var o=i%2?i+2:i+3;r=[];for(var a=0;a "),value:r.value,noValue:null==r.value})}return By({series:this,dataIndex:t,multipleSeries:e})},e.prototype._updateCategoriesData=function(){var t=V(this.option.categories||[],function(t){return null!=t.value?t:k({value:0},t)}),e=new Hb(["value"],this);e.initData(t),this._categoriesData=e,this._categoriesModels=e.mapArray(function(t){return e.getItemModel(t)})},e.prototype.isAnimationEnabled=function(){return t.prototype.isAnimationEnabled.call(this)&&!("force"===this.get("layout")&&this.get(["force","layoutAnimation"]))},e.prototype.__ownRoamView=function(){var t=this.coordinateSystem;return TP(t)&&t},e.type="series."+MN,e.dependencies=["grid","polar","geo","singleAxis","calendar"],e.defaultOption={z:2,coordinateSystem:"view",legendHoverLink:!0,layout:null,circular:{rotateLabel:!1},force:{initLayout:null,repulsion:[0,50],gravity:.1,friction:.6,edgeLength:30,layoutAnimation:!0},left:"center",top:"center",symbol:"circle",symbolSize:10,edgeSymbol:["none","none"],edgeSymbolSize:10,edgeLabel:{position:"middle",distance:5},draggable:!1,roam:!1,center:null,zoom:1,nodeScaleRatio:.6,label:{show:!1,formatter:"{b}"},itemStyle:{},lineStyle:{color:ug.color.neutral50,width:1,opacity:.5},emphasis:{scale:!0,label:{show:!0}},select:{itemStyle:{borderColor:ug.color.primary}}},e}(Hy);function TN(t){return t instanceof Array||(t=[t,t]),t}var CN=os(MN,function(t){t.eachSeriesByType(MN,function(t){var e=t.getGraph(),n=t.getEdgeData(),i=TN(t.get("edgeSymbol")),r=TN(t.get("edgeSymbolSize"));n.setVisual("fromSymbol",i&&i[0]),n.setVisual("toSymbol",i&&i[1]),n.setVisual("fromSymbolSize",r&&r[0]),n.setVisual("toSymbolSize",r&&r[1]),n.setVisual("style",t.getModel("lineStyle").getLineStyle()),n.each(function(t){var i=n.getItemModel(t),r=e.getEdgeByIndex(t),o=TN(i.getShallow("symbol",!0)),a=TN(i.getShallow("symbolSize",!0)),s=i.getModel("lineStyle").getLineStyle(),l=n.ensureUniqueItemVisual(t,"style");switch(k(l,s),l.stroke){case"source":var u=r.node1.getVisual("style");l.stroke=u&&u.fill;break;case"target":u=r.node2.getVisual("style");l.stroke=u&&u.fill}o[0]&&r.setVisual("fromSymbol",o[0]),o[1]&&r.setVisual("toSymbol",o[1]),a[0]&&r.setVisual("fromSymbolSize",a[0]),a[1]&&r.setVisual("toSymbolSize",a[1])})})});function DN(t){var e=t.coordinateSystem;if(!e||"view"===e.type){var n=t.getGraph();n.eachNode(function(t){var e=t.getModel();t.setLayout([+e.get("x"),+e.get("y")])}),AN(n,t)}}function AN(t,e){t.eachEdge(function(t,n){var i=st(t.getModel().get(["lineStyle","curveness"]),-SN(t,e,n,!0),0),r=At(t.node1.getLayout()),o=At(t.node2.getLayout()),a=[r,o];+i&&a.push([(r[0]+o[0])/2-(r[1]-o[1])*i,(r[1]+o[1])/2-(o[0]-r[0])*i]),t.setLayout(a)})}var kN=os(MN,function(t,e){t.eachSeriesByType(MN,function(t){var e=t.get("layout"),n=t.coordinateSystem;if(n&&"view"!==n.type){var i=t.getData(),r=[];B(n.dimensions,function(t){r=r.concat(i.mapDimensionsAll(t))});for(var o=0;o0&&(m[0]=-m[0],m[1]=-m[1]);var _=y[0]<0?-1:1;if("start"!==i.__position&&"end"!==i.__position){var b=-Math.atan2(y[1],y[0]);u[0].8?"left":c[0]<-.8?"right":"center",d=c[1]>.8?"top":c[1]<-.8?"bottom":"middle";break;case"start":i.x=-c[0]*f+l[0],i.y=-c[1]*g+l[1],h=c[0]>.8?"right":c[0]<-.8?"left":"center",d=c[1]>.8?"bottom":c[1]<-.8?"top":"middle";break;case"insideStartTop":case"insideStart":case"insideStartBottom":i.x=f*_+l[0],i.y=l[1]+w,h=y[0]<0?"right":"left",i.originX=-f*_,i.originY=-w;break;case"insideMiddleTop":case"insideMiddle":case"insideMiddleBottom":case"middle":i.x=x[0],i.y=x[1]+w,h="center",i.originY=-w;break;case"insideEndTop":case"insideEnd":case"insideEndBottom":i.x=-f*_+u[0],i.y=u[1]+w,h=y[0]>=0?"right":"left",i.originX=f*_,i.originY=-w}i.scaleX=i.scaleY=r,i.setStyle({verticalAlign:i.__verticalAlign||d,align:i.__align||h})}}}function S(t,e){var n=t.__specifiedRotation;if(null==n){var i=a.tangentAt(e);t.attr("rotation",(1===e?-1:1)*Math.PI/2-Math.atan2(i[1],i[0]))}else t.attr("rotation",n)}},e}(vo),QN=function(){function t(t){this.group=new vo,this._LineCtor=t||JN}return t.prototype.updateData=function(t){var e=this;this._progressiveEls=null;var n=this,i=n.group,r=n._lineData;n._lineData=t,r||i.removeAll();var o=tE(t);t.diff(r).add(function(n){e._doAdd(t,n,o)}).update(function(n,i){e._doUpdate(r,t,i,n,o)}).remove(function(t){i.remove(r.getItemGraphicEl(t))}).execute()},t.prototype.updateLayout=function(){var t=this._lineData;t&&t.eachItemGraphicEl(function(e,n){e.updateLayout(t,n)},this)},t.prototype.incrementalPrepareUpdate=function(t){this._seriesScope=tE(t),this._lineData=null,this.group.removeAll()},t.prototype.incrementalUpdate=function(t,e,n){function i(t){t.isGroup||function(t){return t.animators&&t.animators.length>0}(t)||(t.incremental=n,t.ensureState("emphasis").hoverLayer=2)}this._progressiveEls=[];for(var r=t.start;r=0?i+=u:i-=u:f>=0?i-=u:i+=u}return i}function cE(t,e){var n=[],i=jn,r=[[],[],[]],o=[[],[]],a=[];e/=2,t.eachEdge(function(t,s){var l=t.getLayout(),u=t.getVisual("fromSymbol"),c=t.getVisual("toSymbol");l.__original||(l.__original=[At(l[0]),At(l[1])],l[2]&&l.__original.push(At(l[2])));var h=l.__original;if(null!=l[2]){if(Dt(r[0],h[0]),Dt(r[1],h[2]),Dt(r[2],h[1]),u&&"none"!==u){var d=PN(t.node1),p=uE(r,h[0],d*e);i(r[0][0],r[1][0],r[2][0],p,n),r[0][0]=n[3],r[1][0]=n[4],i(r[0][1],r[1][1],r[2][1],p,n),r[0][1]=n[3],r[1][1]=n[4]}if(c&&"none"!==c){d=PN(t.node2),p=uE(r,h[1],d*e);i(r[0][0],r[1][0],r[2][0],p,n),r[1][0]=n[1],r[2][0]=n[2],i(r[0][1],r[1][1],r[2][1],p,n),r[1][1]=n[1],r[2][1]=n[2]}Dt(l[0],r[0]),Dt(l[1],r[2]),Dt(l[2],r[1])}else{if(Dt(o[0],h[0]),Dt(o[1],h[1]),Ot(a,o[1],o[0]),Vt(a,a),u&&"none"!==u){d=PN(t.node1);Pt(o[0],o[0],a,d*e)}if(c&&"none"!==c){d=PN(t.node2);Pt(o[1],o[1],a,-d*e)}Dt(l[0],o[0]),Dt(l[1],o[1])}})}var hE=Pa();function dE(t,e){t&&(hE(t).bridge=e)}var pE=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type=MN,e}return n(e,t),e.prototype.init=function(t,e){var n=new nC,i=new QN,r=this.group,o=new vo;this._controller=new uL(e.getZr()),o.add(n.group),o.add(i.group),r.add(o),this._symbolDraw=n,this._lineDraw=i,this._mainGroup=o,this._firstRender=!0},e.prototype.render=function(t,e,n){var i=this,r=PP(t),o=!1;this._model=t,this._api=n,this._active=!0;var a=this._mainGroup,s=this._getThumbnailInfo();s&&s.bridge.reset(n);var l=this._symbolDraw,u=this._lineDraw;r&&CP(a,2,r,this._firstRender?null:t),cE(t.getGraph(),LN(t));var c=t.getData();l.updateData(c);var h=t.getEdgeData();u.updateData(h),this._updateNodeAndLinkScale(),r&&FP(t,n,this._controller,function(e,n,i){return t.coordinateSystem.containPoint([n,i])},null),clearTimeout(this._layoutTimeout);var d=t.forceLayout,p=t.get(["force","layoutAnimation"]);d&&(o=!0,this._startForceLayoutIteration(d,n,p));var f=t.get("layout");c.graph.eachNode(function(e){var r=e.dataIndex,o=e.getGraphicEl(),a=e.getModel();if(o){o.off("drag").off("dragend");var s=a.get("draggable");s&&o.on("drag",function(a){switch(f){case"force":d.warmUp(),!i._layouting&&i._startForceLayoutIteration(d,n,p),d.setFixed(r),c.setItemLayout(r,[o.x,o.y]);break;case"circular":c.setItemLayout(r,[o.x,o.y]),e.setLayout({fixed:!0},!0),NN(t,"symbolSize",e,[a.offsetX,a.offsetY]),i.updateLayout(t);break;default:c.setItemLayout(r,[o.x,o.y]),AN(t.getGraph(),t),i.updateLayout(t)}}).on("dragend",function(){d&&d.setUnfixed(r)}),o.setDraggable(s,!!a.get("cursor")),"adjacency"===a.get(["emphasis","focus"])&&(Iu(o).focus=e.getAdjacentDataIndices())}}),c.graph.eachEdge(function(t){var e=t.getGraphicEl(),n=t.getModel().get(["emphasis","focus"]);e&&"adjacency"===n&&(Iu(e).focus={edge:[t.dataIndex],node:[t.node1.dataIndex,t.node2.dataIndex]})});var g="circular"===t.get("layout")&&t.get(["circular","rotateLabel"]),v=c.getLayout("cx"),y=c.getLayout("cy");c.graph.eachNode(function(t){zN(t,g,v,y)}),this._firstRender=!1,o||this._renderThumbnail(t,n,this._symbolDraw,this._lineDraw)},e.prototype.dispose=function(){this.remove(),this._controller&&this._controller.dispose()},e.prototype._startForceLayoutIteration=function(t,e,n){var i=this,r=!1;!function o(){t.step(function(t){i.updateLayout(i._model),!t&&r||(r=!0,i._renderThumbnail(i._model,e,i._symbolDraw,i._lineDraw)),(i._layouting=!t)&&(n?i._layoutTimeout=setTimeout(o,16):o())})}()},e.prototype.__updateOnOwnRoam=function(t,e,n){var i=PP(e);this._active&&i&&(CP(this._mainGroup,2,i,null),XP(t)&&(this._updateNodeAndLinkScale(),cE(e.getGraph(),LN(e)),this._lineDraw.updateLayout(),n.updateLabelLayout()),this._updateThumbnailWindow())},e.prototype._updateNodeAndLinkScale=function(){var t=this._model,e=t.getData(),n=LN(t);e.eachItemGraphicEl(function(t,e){t&&t.setSymbolScale(n)})},e.prototype.updateLayout=function(t){this._active&&(cE(t.getGraph(),LN(t)),this._symbolDraw.updateLayout(),this._lineDraw.updateLayout())},e.prototype.remove=function(){this._active=!1,clearTimeout(this._layoutTimeout),this._layouting=!1,this._layoutTimeout=null,this._symbolDraw&&this._symbolDraw.remove(),this._lineDraw&&this._lineDraw.remove(),this._controller&&this._controller.disable()},e.prototype._getThumbnailInfo=function(){var t=this._model,e=t.coordinateSystem;if("view"===e.type){var n=function(t){if(t)return hE(t).bridge}(t);if(n)return{bridge:n,coordSys:e}}},e.prototype._updateThumbnailWindow=function(){var t=this._getThumbnailInfo();t&&t.bridge.updateWindow(rP(null,t.coordSys),this._api)},e.prototype._renderThumbnail=function(t,e,n,i){var r=this._getThumbnailInfo();if(r){var o=new vo,a=n.group.children(),s=i.group.children(),l=new vo,u=new vo;o.add(u),o.add(l);for(var c=0;c "),value:i.value,noValue:null==i.value})}return Ty("nameValue",{name:i.name,value:i.value,noValue:null==i.value})},e.prototype.getDataParams=function(e,n){var i=t.prototype.getDataParams.call(this,e,n);if("node"===n){var r=this.getData(),o=this.getGraph().getNodeByIndex(e);if(null==i.name&&(i.name=r.getName(e)),null==i.value){var a=o.getLayout().value;i.value=a}}return i},e.type="series."+vE,e.defaultOption={z:2,coordinateSystem:"none",legendHoverLink:!0,colorBy:"data",left:0,top:0,right:0,bottom:0,width:null,height:null,center:["50%","50%"],radius:["70%","80%"],clockwise:!0,startAngle:90,endAngle:"auto",minAngle:0,padAngle:3,itemStyle:{borderRadius:[0,0,5,5]},lineStyle:{width:0,color:"source",opacity:.2},label:{show:!0,position:"outside",distance:5},emphasis:{focus:"adjacency",lineStyle:{opacity:.5}}},e}(Hy),mE=function(t){function e(e,n,i){var r=t.call(this)||this;Iu(r).dataType="node",r.z2=2;var o=new du;return r.setTextContent(o),r.updateData(e,n,i,!0),r}return n(e,t),e.prototype.updateData=function(t,e,n,i){var r=this,o=t.graph.getNodeByIndex(e),a=t.hostModel,s=o.getModel(),l=s.getModel("emphasis"),u=t.getItemLayout(e),c=k(eA(s.getModel("itemStyle"),u,!0),u),h=this;if(isNaN(c.startAngle))h.setShape(c);else{i?h.setShape(c):rd(h,{shape:c},a,e);var d=k(eA(s.getModel("itemStyle"),u,!0),u);r.setShape(d),r.useStyle(t.getItemVisual(e,"style")),Rc(r,s),this._updateLabel(a,s,o),t.setItemGraphicEl(e,h),Rc(h,s,"itemStyle");var p=l.get("focus");kc(this,"adjacency"===p?o.getAdjacentDataIndices():p,l.get("blurScope"),l.get("disabled"))}},e.prototype._updateLabel=function(t,e,n){var i=this.getTextContent(),r=n.getLayout(),o=(r.startAngle+r.endAngle)/2,a=Math.cos(o),s=Math.sin(o),l=e.getModel("label");i.ignore=!l.get("show");var u=ap(e),c=n.getVisual("style");op(i,u,{labelFetcher:{getFormattedLabel:function(n,i,r,o,a,s){return t.getFormattedLabel(n,i,"node",o,st(a,u.normal&&u.normal.get("formatter"),e.get("name")),s)}},labelDataIndex:n.dataIndex,defaultText:n.dataIndex+"",inheritColor:c.fill,defaultOpacity:c.opacity,defaultOutsidePosition:"startArc"});var h,d=l.get("position")||"outside",p=l.get("distance")||0;h="outside"===d?r.r+p:(r.r+r.r0)/2,this.textConfig={inside:"outside"!==d};var f="outside"!==d?l.get("align")||"center":a>0?"left":"right",g="outside"!==d?l.get("verticalAlign")||"middle":s>0?"top":"bottom";i.attr({x:a*h+r.cx,y:s*h+r.cy,rotation:0,style:{align:f,verticalAlign:g}})},e}(Sh),xE=function(t){function e(e,n,i,r){var o=t.call(this)||this;return Iu(o).dataType="edge",o.updateData(e,n,i,r,!0),o}return n(e,t),e.prototype.buildPath=function(t,e){t.moveTo(e.s1[0],e.s1[1]);var n=.7,i=e.clockwise;t.arc(e.cx,e.cy,e.r,e.sStartAngle,e.sEndAngle,!i),t.bezierCurveTo((e.cx-e.s2[0])*n+e.s2[0],(e.cy-e.s2[1])*n+e.s2[1],(e.cx-e.t1[0])*n+e.t1[0],(e.cy-e.t1[1])*n+e.t1[1],e.t1[0],e.t1[1]),t.arc(e.cx,e.cy,e.r,e.tStartAngle,e.tEndAngle,!i),t.bezierCurveTo((e.cx-e.t2[0])*n+e.t2[0],(e.cy-e.t2[1])*n+e.t2[1],(e.cx-e.s1[0])*n+e.s1[0],(e.cy-e.s1[1])*n+e.s1[1],e.s1[0],e.s1[1]),t.closePath()},e.prototype.updateData=function(t,e,n,i,r){var o=t.hostModel,a=e.graph.getEdgeByIndex(n),s=a.getLayout(),l=a.node1.getModel(),u=e.getItemModel(a.dataIndex),c=u.getModel("lineStyle"),h=u.getModel("emphasis"),d=h.get("focus"),p=k(eA(l.getModel("itemStyle"),s,!0),s),f=this;isNaN(p.sStartAngle)||isNaN(p.tStartAngle)?f.setShape(p):(r?(f.setShape(p),_E(f,a,t,c)):(cd(f),_E(f,a,t,c),rd(f,{shape:p},o,n)),kc(this,"adjacency"===d?a.getAdjacentDataIndices():d,h.get("blurScope"),h.get("disabled")),Rc(f,u,"lineStyle"),e.setItemGraphicEl(a.dataIndex,f))},e}(Kl);function _E(t,e,n,i){var r=e.node1,o=e.node2,a=t.style;switch(t.setStyle(i.getLineStyle()),i.get("color")){case"source":a.fill=n.getItemVisual(r.dataIndex,"style").fill,a.decal=r.getVisual("style").decal;break;case"target":a.fill=n.getItemVisual(o.dataIndex,"style").fill,a.decal=o.getVisual("style").decal;break;case"gradient":var s=n.getItemVisual(r.dataIndex,"style").fill,l=n.getItemVisual(o.dataIndex,"style").fill;if(j(s)&&j(l)){var u=t.shape,c=(u.s1[0]+u.s2[0])/2,h=(u.s1[1]+u.s2[1])/2,d=(u.t1[0]+u.t2[0])/2,p=(u.t1[1]+u.t2[1])/2;a.fill=new Hh(c,h,d,p,[{offset:0,color:s},{offset:1,color:l}],!0)}}}var bE=Math.PI/180,wE=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type=vE,e}return n(e,t),e.prototype.init=function(t,e){},e.prototype.render=function(t,e,n){var i=t.getData(),r=this._data,o=this.group,a=-t.get("startAngle")*bE;if(i.diff(r).add(function(t){if(i.getItemLayout(t)){var e=new mE(i,t,a);Iu(e).dataIndex=t,o.add(e)}}).update(function(e,n){var s=r.getItemGraphicEl(n);i.getItemLayout(e)?(s?s.updateData(i,e,a):s=new mE(i,e,a),o.add(s)):s&&ud(s,t,n)}).remove(function(e){var n=r.getItemGraphicEl(e);n&&ud(n,t,e)}).execute(),!r){var s=t.get("center");this.group.scaleX=.01,this.group.scaleY=.01,this.group.originX=Vo(s[0],n.getWidth()),this.group.originY=Vo(s[1],n.getHeight()),od(this.group,{scaleX:1,scaleY:1},t)}this._data=i,this.renderEdges(t,a)},e.prototype.renderEdges=function(t,e){var n=t.getData(),i=t.getEdgeData(),r=this._edgeData,o=this.group;i.diff(r).add(function(t){var r=new xE(n,i,t,e);Iu(r).dataIndex=t,o.add(r)}).update(function(t,a){var s=r.getItemGraphicEl(a);s.updateData(n,i,t,e),o.add(s)}).remove(function(e){var n=r.getItemGraphicEl(e);n&&ud(n,t,e)}).execute(),this._edgeData=i},e.prototype.dispose=function(){},e.type=vE,e}(tm),SE=Math.PI/180,ME=os(vE,function(t,e){t.eachSeriesByType(vE,function(t){!function(t,e){var n=t.getData(),i=n.graph,r=t.getEdgeData();if(!r.count())return;var o=$f(t,e),a=o.cx,s=o.cy,l=o.r,u=o.r0,c=Math.max((t.get("padAngle")||0)*SE,0),h=Math.max((t.get("minAngle")||0)*SE,0),d=-t.get("startAngle")*SE,p=d+2*Math.PI,f=t.get("clockwise"),g=f?1:-1,v=[d,p];Dl(v,!f);var y=v[0],m=v[1]-y,x=0===n.getSum("value")&&0===r.getSum("value"),_=[],b=0;i.eachEdge(function(t){var e=x?1:t.getValue("value");x&&(e>0||h)&&(b+=2);var n=t.node1.dataIndex,i=t.node2.dataIndex;_[n]=(_[n]||0)+e,_[i]=(_[i]||0)+e});var w=0;if(i.eachNode(function(t){var e=t.getValue("value");isNaN(e)||(_[t.dataIndex]=Math.max(e,_[t.dataIndex]||0)),!x&&(_[t.dataIndex]>0||h)&&b++,w+=_[t.dataIndex]||0}),0===b||0===w)return;c*b>=Math.abs(m)&&(c=Math.max(0,(Math.abs(m)-h*b)/b));(c+h)*b>=Math.abs(m)&&(h=(Math.abs(m)-c*b)/b);var S=(m-c*b*g)/w,M=0,I=0,T=0;i.eachNode(function(t){var e=_[t.dataIndex]||0,n=S*(w?e:1)*g;Math.abs(n)I){var D=M/I;i.eachNode(function(t){var e=t.getLayout().angle;Math.abs(e)>=h?t.setLayout({angle:e*D,ratio:D},!0):t.setLayout({angle:h,ratio:0===h?1:e/h},!0)})}else i.eachNode(function(t){if(!C){var e=t.getLayout().angle;e-Math.min(e/T,1)*Mh&&h>0){var n=C?1:Math.min(e/T,1),i=e-h,r=Math.min(i,Math.min(A,M*n));A-=r,t.setLayout({angle:e-r,ratio:(e-r)/e},!0)}else h>0&&t.setLayout({angle:h,ratio:0===e?1:h/e},!0)}});var k=y,L=[];i.eachNode(function(t){var e=Math.max(t.getLayout().angle,h);t.setLayout({cx:a,cy:s,r0:u,r:l,startAngle:k,endAngle:k+e*g,clockwise:f},!0),L[t.dataIndex]=k,k+=(e+c)*g}),i.eachEdge(function(t){var e=x?1:t.getValue("value"),n=S*(w?e:1)*g,i=t.node1.dataIndex,r=L[i]||0,o=r+Math.abs((t.node1.getLayout().ratio||1)*n)*g,l=[a+u*Math.cos(r),s+u*Math.sin(r)],c=[a+u*Math.cos(o),s+u*Math.sin(o)],h=t.node2.dataIndex,d=L[h]||0,p=d+Math.abs((t.node2.getLayout().ratio||1)*n)*g,v=[a+u*Math.cos(d),s+u*Math.sin(d)],y=[a+u*Math.cos(p),s+u*Math.sin(p)];t.setLayout({s1:l,s2:c,sStartAngle:r,sEndAngle:o,t1:v,t2:y,tStartAngle:d,tEndAngle:p,cx:a,cy:s,r:u,value:e,clockwise:f}),L[i]=o,L[h]=p})}(t,e)})});var IE=function(){this.angle=0,this.width=10,this.r=10,this.x=0,this.y=0},TE=function(t){function e(e){var n=t.call(this,e)||this;return n.type="pointer",n}return n(e,t),e.prototype.getDefaultShape=function(){return new IE},e.prototype.buildPath=function(t,e){var n=Math.cos,i=Math.sin,r=e.r,o=e.width,a=e.angle,s=e.x-n(a)*o*(o>=r/3?1:2),l=e.y-i(a)*o*(o>=r/3?1:2);a=e.angle-Math.PI/2,t.moveTo(s,l),t.lineTo(e.x+n(a)*o,e.y+i(a)*o),t.lineTo(e.x+n(e.angle)*r,e.y+i(e.angle)*r),t.lineTo(e.x-n(a)*o,e.y-i(a)*o),t.lineTo(s,l)},e}(Kl);function CE(t,e){var n=null==t?"":t+"";return e&&(j(e)?n=e.replace("{value}",n):Z(e)&&(n=e(t))),n}var DE=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n){this.group.removeAll();var i=t.get(["axisLine","lineStyle","color"]),r=function(t,e){var n=t.get("center"),i=e.getWidth(),r=e.getHeight(),o=Math.min(i,r);return{cx:Vo(n[0],e.getWidth()),cy:Vo(n[1],e.getHeight()),r:Vo(t.get("radius"),o/2)}}(t,n);this._renderMain(t,e,n,i,r),this._data=t.getData()},e.prototype.dispose=function(){},e.prototype._renderMain=function(t,e,n,i,r){var o=this.group,a=t.get("clockwise"),s=-t.get("startAngle")/180*Math.PI,l=-t.get("endAngle")/180*Math.PI,u=t.getModel("axisLine"),c=u.get("roundCap")?JD:Sh,h=u.get("show"),d=u.getModel("lineStyle"),p=d.get("width"),f=[s,l];Dl(f,!a);for(var g=(l=f[1])-(s=f[0]),v=s,y=[],m=0;h&&m=t&&(0===e?0:i[e-1][0])Math.PI/2&&(B+=Math.PI):"tangential"===z?B=-M-Math.PI/2:K(z)&&(B=z*Math.PI/180),0===B?h.add(new du({style:sp(x,{text:O,x:N,y:E,verticalAlign:c<-.8?"top":c>.8?"bottom":"middle",align:u<-.4?"left":u>.4?"right":"center"},{inheritColor:R}),silent:!0})):h.add(new du({style:sp(x,{text:O,x:N,y:E,verticalAlign:"middle",align:"center"},{inheritColor:R}),silent:!0,originX:N,originY:E,rotation:B}))}if(m.get("show")&&k!==_){P=(P=m.get("distance"))?P+l:l;for(var V=0;V<=b;V++){u=Math.cos(M),c=Math.sin(M);var G=new Oh({shape:{x1:u*(f-P)+d,y1:c*(f-P)+p,x2:u*(f-S-P)+d,y2:c*(f-S-P)+p},silent:!0,style:D});"auto"===D.stroke&&G.setStyle({stroke:i((k+V/b)/_)}),h.add(G),M+=T}M-=T}else M+=I}},e.prototype._renderPointer=function(t,e,n,i,r,o,a,s,l){var u=this.group,c=this._data,h=this._progressEls,d=[],p=t.get(["pointer","show"]),f=t.getModel("progress"),g=f.get("show"),v=t.getData(),y=v.mapDimension("value"),m=+t.get("min"),x=+t.get("max"),_=[m,x],b=[o,a];function w(e,n){var i,o=v.getItemModel(e).getModel("pointer"),a=Vo(o.get("width"),r.r),s=Vo(o.get("length"),r.r),l=t.get(["pointer","icon"]),u=o.get("offsetCenter"),c=Vo(u[0],r.r),h=Vo(u[1],r.r),d=o.get("keepAspect");return(i=l?xx(l,c-a/2,h-s,a,s,null,d):new TE({shape:{angle:-Math.PI/2,width:a,r:s,x:c,y:h}})).rotation=-(n+Math.PI/2),i.x=r.cx,i.y=r.cy,i}function S(t,e){var n=f.get("roundCap")?JD:Sh,i=f.get("overlap"),a=i?f.get("width"):l/v.count(),u=i?r.r-a:r.r-(t+1)*a,c=i?r.r:r.r-t*a,h=new n({shape:{startAngle:o,endAngle:e,cx:r.cx,cy:r.cy,clockwise:s,r0:u,r:c}});return i&&(h.z2=Bo(v.get(y,t),[m,x],[100,0],!0)),h}(g||p)&&(v.diff(c).add(function(e){var n=v.get(y,e);if(p){var i=w(e,o);od(i,{rotation:-((isNaN(+n)?b[0]:Bo(n,_,b,!0))+Math.PI/2)},t),u.add(i),v.setItemGraphicEl(e,i)}if(g){var r=S(e,o),a=f.get("clip");od(r,{shape:{endAngle:Bo(n,_,b,a)}},t),u.add(r),Tu(t.seriesIndex,v.dataType,e,r),d[e]=r}}).update(function(e,n){var i=v.get(y,e);if(p){var r=c.getItemGraphicEl(n),a=r?r.rotation:o,s=w(e,a);s.rotation=a,rd(s,{rotation:-((isNaN(+i)?b[0]:Bo(i,_,b,!0))+Math.PI/2)},t),u.add(s),v.setItemGraphicEl(e,s)}if(g){var l=h[n],m=S(e,l?l.shape.endAngle:o),x=f.get("clip");rd(m,{shape:{endAngle:Bo(i,_,b,x)}},t),u.add(m),Tu(t.seriesIndex,v.dataType,e,m),d[e]=m}}).execute(),v.each(function(t){var e=v.getItemModel(t),n=e.getModel("emphasis"),r=n.get("focus"),o=n.get("blurScope"),a=n.get("disabled"),s=i(Bo(v.get(y,t),_,[0,1],!0));if(p){var l=v.getItemGraphicEl(t),u=v.getItemVisual(t,"style"),c=u.fill;if(l instanceof eu){var h=l.style;l.useStyle(k({image:h.image,x:h.x,y:h.y,width:h.width,height:h.height},u))}else l.useStyle(u),"pointer"!==l.type&&l.setColor(c);l.setStyle(e.getModel(["pointer","itemStyle"]).getItemStyle()),"auto"===l.style.fill&&l.setStyle("fill",s),l.z2EmphasisLift=0,Rc(l,e),kc(l,r,o,a)}if(g){var f=d[t];f.useStyle(v.getItemVisual(t,"style")),f.setStyle(e.getModel(["progress","itemStyle"]).getItemStyle()),"auto"===f.style.fill&&f.setStyle("fill",s),f.z2EmphasisLift=0,Rc(f,e),kc(f,r,o,a)}}),this._progressEls=d)},e.prototype._renderAnchor=function(t,e){var n=t.getModel("anchor");if(n.get("show")){var i=n.get("size"),r=n.get("icon"),o=n.get("offsetCenter"),a=n.get("keepAspect"),s=xx(r,e.cx-i/2+Vo(o[0],e.r),e.cy-i/2+Vo(o[1],e.r),i,i,null,a);s.z2=n.get("showAbove")?1:0,s.setStyle(n.getModel("itemStyle").getItemStyle()),this.group.add(s)}},e.prototype._renderTitleAndDetail=function(t,e,n,i,r){var o=this,a=t.getData(),s=a.mapDimension("value"),l=+t.get("min"),u=+t.get("max"),c=new vo,h=[],d=[],p=t.isAnimationEnabled(),f=t.get(["pointer","showAbove"]);a.diff(this._data).add(function(t){h[t]=new du({silent:!0}),d[t]=new du({silent:!0})}).update(function(t,e){h[t]=o._titleEls[e],d[t]=o._detailEls[e]}).execute(),a.each(function(e){var n=a.getItemModel(e),o=a.get(s,e),g=new vo,v=i(Bo(o,[l,u],[0,1],!0)),y=n.getModel("title");if(y.get("show")){var m=y.get("offsetCenter"),x=r.cx+Vo(m[0],r.r),_=r.cy+Vo(m[1],r.r);(D=h[e]).attr({z2:f?0:2,style:sp(y,{x:x,y:_,text:a.getName(e),align:"center",verticalAlign:"middle"},{inheritColor:v})}),g.add(D)}var b=n.getModel("detail");if(b.get("show")){var w=b.get("offsetCenter"),S=r.cx+Vo(w[0],r.r),M=r.cy+Vo(w[1],r.r),I=Vo(b.get("width"),r.r),T=Vo(b.get("height"),r.r),C=t.get(["progress","show"])?a.getItemVisual(e,"style").fill:v,D=d[e],A=b.get("formatter");D.attr({z2:f?0:2,style:sp(b,{x:S,y:M,text:CE(o,A),width:isNaN(I)?null:I,height:isNaN(T)?null:T,align:"center",verticalAlign:"middle"},{inheritColor:C})}),gp(D,{normal:b},o,function(t){return CE(t,A)}),p&&vp(D,e,a,t,{getFormattedLabel:function(t,e,n,i,r,a){return CE(a?a.interpolatedValue:o,A)}}),g.add(D)}c.add(g)}),this.group.add(c),this._titleEls=h,this._detailEls=d},e.type="gauge",e}(tm),AE=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.visualStyleAccessPath="itemStyle",n}return n(e,t),e.prototype.getInitialData=function(t,e){return bA(this,["value"])},e.type="series.gauge",e.defaultOption={z:2,colorBy:"data",center:["50%","50%"],legendHoverLink:!0,radius:"75%",startAngle:225,endAngle:-45,clockwise:!0,min:0,max:100,splitNumber:10,axisLine:{show:!0,roundCap:!1,lineStyle:{color:[[1,ug.color.neutral10]],width:10}},progress:{show:!1,overlap:!0,width:10,roundCap:!1,clip:!0},splitLine:{show:!0,length:10,distance:10,lineStyle:{color:ug.color.axisTick,width:3,type:"solid"}},axisTick:{show:!0,splitNumber:5,length:6,distance:10,lineStyle:{color:ug.color.axisTickMinor,width:1,type:"solid"}},axisLabel:{show:!0,distance:15,color:ug.color.axisLabel,fontSize:12,rotate:0},pointer:{icon:null,offsetCenter:[0,0],show:!0,showAbove:!0,length:"60%",width:6,keepAspect:!1},anchor:{show:!1,showAbove:!1,size:6,icon:"circle",offsetCenter:[0,0],keepAspect:!1,itemStyle:{color:ug.color.neutral00,borderWidth:0,borderColor:ug.color.theme[0]}},title:{show:!0,offsetCenter:[0,"20%"],color:ug.color.secondary,fontSize:16,valueAnimation:!1},detail:{show:!0,backgroundColor:ug.color.transparent,borderWidth:0,borderColor:ug.color.neutral40,width:100,height:null,padding:[5,10],offsetCenter:[0,"40%"],color:ug.color.primary,fontSize:30,fontWeight:"bold",lineHeight:30,valueAnimation:!1}},e}(Hy);var kE="funnel",LE=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(e){t.prototype.init.apply(this,arguments),this.legendVisualProvider=new SA(U(this.getData,this),U(this.getRawData,this)),this._defaultLabelLine(e)},e.prototype.getInitialData=function(t,e){return bA(this,{coordDimensions:["value"],encodeDefaulter:Y(Sg,this)})},e.prototype._defaultLabelLine=function(t){ba(t,"labelLine",["show"]);var e=t.labelLine,n=t.emphasis.labelLine;e.show=e.show&&t.label.show,n.show=n.show&&t.emphasis.label.show},e.prototype.getDataParams=function(e){var n=this.getData(),i=t.prototype.getDataParams.call(this,e),r=n.mapDimension("value"),o=n.getSum(r);return i.percent=o?+(n.get(r,e)/o*100).toFixed(2):0,i.$vars.push("percent"),i},e.type="series."+kE,e.defaultOption={coordinateSystemUsage:"box",z:2,legendHoverLink:!0,colorBy:"data",left:80,top:60,right:80,bottom:65,minSize:"0%",maxSize:"100%",sort:"descending",orient:"vertical",gap:0,funnelAlign:"center",label:{show:!0,position:"outer"},labelLine:{show:!0,length:20,lineStyle:{width:1}},itemStyle:{borderColor:ug.color.neutral00,borderWidth:1},emphasis:{label:{show:!0}},select:{itemStyle:{borderColor:ug.color.primary}}},e}(Hy),PE=["itemStyle","opacity"],OE=function(t){function e(e,n){var i=t.call(this)||this,r=i,o=new kh,a=new du;return r.setTextContent(a),i.setTextGuideLine(o),i.updateData(e,n,!0),i}return n(e,t),e.prototype.updateData=function(t,e,n){var i=this,r=t.hostModel,o=t.getItemModel(e),a=t.getItemLayout(e),s=o.getModel("emphasis"),l=o.get(PE);l=null==l?1:l,n||cd(i),i.useStyle(t.getItemVisual(e,"style")),i.style.lineJoin="round",n?(i.setShape({points:a.points}),i.style.opacity=0,od(i,{style:{opacity:l}},r,e)):rd(i,{style:{opacity:l},shape:{points:a.points}},r,e),Rc(i,o),this._updateLabel(t,e),kc(this,s.get("focus"),s.get("blurScope"),s.get("disabled"))},e.prototype._updateLabel=function(t,e){var n=this,i=this.getTextGuideLine(),r=n.getTextContent(),o=t.hostModel,a=t.getItemModel(e),s=t.getItemLayout(e).label,l=t.getItemVisual(e,"style"),u=l.fill;op(r,ap(a),{labelFetcher:t.hostModel,labelDataIndex:e,defaultOpacity:l.opacity,defaultText:t.getName(e)},{normal:{align:s.textAlign,verticalAlign:s.verticalAlign}});var c="inherit"===a.getModel("label").get("color")?u:null;n.setTextConfig({local:!0,inside:!!s.inside,insideStroke:c,outsideFill:c});var h=s.linePoints;i.setShape({points:h}),n.textGuideLineConfig={anchor:h?new Pe(h[0][0],h[0][1]):null},rd(r,{style:{x:s.x,y:s.y}},o,e),r.attr({rotation:s.rotation,originX:s.x,originY:s.y,z2:10}),ZM(n,jM(a),{stroke:u})},e}(Dh),RE=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type=kE,e.ignoreLabelLineUpdate=!0,e}return n(e,t),e.prototype.render=function(t,e,n){var i=t.getData(),r=this._data,o=this.group;i.diff(r).add(function(t){var e=new OE(i,t);i.setItemGraphicEl(t,e),o.add(e)}).update(function(t,e){var n=r.getItemGraphicEl(e);n.updateData(i,t),o.add(n),i.setItemGraphicEl(t,n)}).remove(function(e){ud(r.getItemGraphicEl(e),t,e)}).execute(),this._data=i},e.prototype.remove=function(){this.group.removeAll(),this._data=null},e.prototype.dispose=function(){},e.type=kE,e}(tm);var NE=os(kE,function(t,e){t.eachSeriesByType(kE,function(t){var n=t.getData(),i=n.mapDimension("value"),r=t.get("sort"),o=eg(t,e),a=Jf(t.getBoxLayoutParams(),o.refContainer),s=EE(t),l=a.width,u=a.height,c=function(t,e){for(var n=t.mapDimension("value"),i=t.mapArray(n,function(t){return t}),r=[],o="ascending"===e,a=0,s=t.count();a-1&&(l="left"),e&&R(["left","right"],l)>-1&&(l="bottom")),"left"===l?(p=(h[3][0]+h[0][0])/2,f=(h[3][1]+h[0][1])/2,r=(g=p-y)-5,i="right"):"right"===l?(p=(h[1][0]+h[2][0])/2,f=(h[1][1]+h[2][1])/2,r=(g=p+y)+5,i="left"):"top"===l?(p=(h[3][0]+h[0][0])/2,o=(v=(f=(h[3][1]+h[0][1])/2)-y)-5,i="center"):"bottom"===l?(p=(h[1][0]+h[2][0])/2,o=(v=(f=(h[1][1]+h[2][1])/2)+y)+5,i="center"):"rightTop"===l?(p=e?h[3][0]:h[1][0],f=e?h[3][1]:h[1][1],e?(o=(v=f-y)-5,i="center"):(r=(g=p+y)+5,i="top")):"rightBottom"===l?(p=h[2][0],f=h[2][1],e?(o=(v=f+y)+5,i="center"):(r=(g=p+y)+5,i="bottom")):"leftTop"===l?(p=h[0][0],f=e?h[0][1]:h[1][1],e?(o=(v=f-y)-5,i="center"):(r=(g=p-y)-5,i="right")):"leftBottom"===l?(p=e?h[1][0]:h[3][0],f=e?h[1][1]:h[2][1],e?(o=(v=f+y)+5,i="center"):(r=(g=p-y)-5,i="right")):(p=(h[1][0]+h[2][0])/2,f=(h[1][1]+h[2][1])/2,e?(o=(v=f+y)+5,i="center"):(r=(g=p+y)+5,i="left")),e?r=g=p:o=v=f,a=[[p,f],[g,v]]}c.label={linePoints:a,x:r,y:o,verticalAlign:"middle",textAlign:i,inside:d}})}(n)})});function EE(t){return"horizontal"===t.get("orient")}var zE=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n._dataGroup=new vo,n._initialized=!1,n}return n(e,t),e.prototype.init=function(){this.group.add(this._dataGroup)},e.prototype.render=function(t,e,n,i){var r=this._dataGroup;this._progressiveEls=[],r.removeAll();var o=t.getData(),a=this._data,s=t.coordinateSystem,l=s.dimensions,u=GE(t);if(o.diff(a).add(function(t){FE(VE(o,r,t,l,s),o,t,u)}).update(function(e,n){var i=a.getItemGraphicEl(n);r.add(i);var c=BE(o,e,l,s);o.setItemGraphicEl(e,i),rd(i,{shape:{points:c}},t,e),cd(i),FE(i,o,e,u)}).remove(function(t){var e=a.getItemGraphicEl(t);r.remove(e)}).execute(),!this._initialized){this._initialized=!0;var c=function(t,e,n){var i=t.model,r=t.getRect(),o=new lu({shape:{x:r.x,y:r.y,width:r.width,height:r.height}}),a="horizontal"===i.get("layout")?"width":"height";return o.setShape(a,0),od(o,{shape:{width:r.width,height:r.height}},e,n),o}(s,t,function(){setTimeout(function(){r.removeClipPath()})});r.setClipPath(c)}this._data=o},e.prototype.incrementalPrepareRender=function(t,e,n){this._initialized=!0,this._data=null,this._dataGroup.removeAll()},e.prototype.incrementalRender=function(t,e,n){for(var i=e.getData(),r=e.coordinateSystem,o=this._progressiveEls=[],a=t.start;a5)return;var i=this._model.coordinateSystem.getSlidedAxisExpandWindow([t.offsetX,t.offsetY]);"none"!==i.behavior&&this._dispatchExpand({axisExpandWindow:i.axisExpandWindow})}this._mouseDownPoint=null},mousemove:function(t){if(!this._mouseDownPoint&&KE(this,"mousemove")){var e=this._model,n=e.coordinateSystem.getSlidedAxisExpandWindow([t.offsetX,t.offsetY]),i=n.behavior;"jump"===i&&this._throttledDispatchExpand.debounceNextCall(e.get("axisExpandDebounce")),this._throttledDispatchExpand("none"===i?null:{axisExpandWindow:n.axisExpandWindow,animation:"jump"===i?null:{duration:0}})}}};function KE(t,e){var n=t._model;return n.get("axisExpandable")&&n.get("axisExpandTriggerOn")===e}var $E="parallel",JE=$E,QE=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(){t.prototype.init.apply(this,arguments),this.mergeOption({})},e.prototype.mergeOption=function(t){var e=this.option;t&&D(e,t,!0),this._initDimensions()},e.prototype.contains=function(t,e){var n=t.get("parallelIndex");return null!=n&&e.getComponent("parallel",n)===this},e.prototype.setAxisExpand=function(t){B(["axisExpandable","axisExpandCenter","axisExpandCount","axisExpandWidth","axisExpandWindow"],function(e){t.hasOwnProperty(e)&&(this.option[e]=t[e])},this)},e.prototype._initDimensions=function(){var t=this.dimensions=[],e=this.parallelAxisIndex=[];B(F(this.ecModel.queryComponents({mainType:"parallelAxis"}),function(t){return(t.get("parallelIndex")||0)===this.componentIndex},this),function(n){t.push("dim"+n.get("dim")),e.push(n.componentIndex)})},e.type=JE,e.dependencies=["parallelAxis"],e.layoutMode="box",e.defaultOption={z:0,left:80,top:60,right:80,bottom:60,layout:"horizontal",axisExpandable:!1,axisExpandCenter:null,axisExpandCount:0,axisExpandWidth:50,axisExpandRate:17,axisExpandDebounce:50,axisExpandSlideTriggerArea:[-.15,.05,.4],axisExpandTriggerOn:"click",parallelAxisDefault:null},e}(lg),tz=function(t){function e(e,n,i,r,o){var a=t.call(this,e,n,i)||this;return a.type=r||"value",a.axisIndex=o,a}return n(e,t),e.prototype.isHorizontal=function(){return"horizontal"!==this.coordinateSystem.getModel().get("layout")},e}(SM);function ez(t,e,n,i,r,o){t=t||0;var a=jo(n[1],-n[0]);if(null!=r&&(r=iz(r,[0,a])),null!=o&&(o=Math.max(o,null!=r?r:0)),"all"===i){var s=Math.abs(jo(e[1],-e[0]));s=iz(s,[0,a]),r=o=iz(s,[r,o]),i=0}e[0]=iz(e[0],n),e[1]=iz(e[1],n);var l=nz(e,i);e[i]+=t;var u,c=r||0,h=n.slice();return l.sign<0?h[0]=jo(h[0],c):h[1]=jo(h[1],-c),e[i]=iz(e[i],h),u=nz(e,i),null!=r&&(u.sign!==l.sign||u.spano&&(e[1-i]=jo(e[i],u.sign*o)),e}function nz(t,e){var n=t[e]-t[1-e];return{span:Math.abs(n),sign:n>0?-1:n<0?1:e?-1:1}}function iz(t,e){return Math.min(null!=e[1]?e[1]:1/0,Math.max(null!=e[0]?e[0]:-1/0,t))}var rz=function(){function t(t,e,n){this.type=$E,this._axesMap=mt(),this._axesLayout={},this.dimensions=t.dimensions,this._model=t,this._init(t,e,n)}return t.prototype._init=function(t,e,n){var i=t.dimensions,r=t.parallelAxisIndex;B(i,function(t,n){var i=r[n],o=e.getComponent("parallelAxis",i),a=Hw(o),s=this._axesMap.set(t,new tz(t,Ww(o,a,!1),[0,0],a,i));s.onBand=eS(s.scale,o),s.inverse=o.get("inverse"),o.axis=s,s.model=o,s.coordinateSystem=o.coordinateSystem=this},this)},t.prototype.update=function(t,e){B(this.dimensions,function(t){var e=this._axesMap.get(t);CS(e,1),RS(e)},this)},t.prototype.containPoint=function(t){var e=this._makeLayoutInfo(),n=e.axisBase,i=e.layoutBase,r=e.pixelDimIndex,o=t[1-r],a=t[r];return o>=n&&o<=n+e.axisLength&&a>=i&&a<=i+e.layoutLength},t.prototype.getModel=function(){return this._model},t.prototype.resize=function(t,e){var n=eg(t,e).refContainer;this._rect=Jf(t.getBoxLayoutParams(),n),this._layoutAxes()},t.prototype.getRect=function(){return this._rect},t.prototype._makeLayoutInfo=function(){var t,e=this._model,n=this._rect,i=["x","y"],r=["width","height"],o=e.get("layout"),a="horizontal"===o?0:1,s=n[r[a]],l=[0,s],u=this.dimensions.length,c=oz(e.get("axisExpandWidth"),l),h=oz(e.get("axisExpandCount")||0,[0,u]),d=e.get("axisExpandable")&&u>3&&u>h&&h>1&&c>0&&s>0,p=e.get("axisExpandWindow");p?(t=oz(p[1]-p[0],l),p[1]=p[0]+t):(t=oz(c*(h-1),l),(p=[c*(e.get("axisExpandCenter")||Lo(u/2))-t/2])[1]=p[0]+t);var f=(s-t)/(u-h);f<3&&(f=0);var g=[Lo(Ho(p[0]/c,1))+1,Po(Ho(p[1]/c,1))-1],v=f/c*p[0];return{layout:o,pixelDimIndex:a,layoutBase:n[i[a]],layoutLength:s,axisBase:n[i[1-a]],axisLength:n[r[1-a]],axisExpandable:d,axisExpandWidth:c,axisCollapseWidth:f,axisExpandWindow:p,axisCount:u,winInnerIndices:g,axisExpandWindow0Pos:v}},t.prototype._layoutAxes=function(){var t=this._rect,e=this._axesMap,n=this.dimensions,i=this._makeLayoutInfo(),r=i.layout;e.each(function(t){var e=[0,i.axisLength],n=t.inverse?1:0;t.setExtent(e[n],e[1-n])}),B(n,function(e,n){var o=(i.axisExpandable?sz:az)(n,i),a={horizontal:{x:o.position,y:i.axisLength},vertical:{x:0,y:o.position}},s={horizontal:Eo/2,vertical:0},l=[a[r].x+t.x,a[r].y+t.y],u=s[r],c=[1,0,0,1,0,0];Ce(c,c,u),Te(c,c,l),this._axesLayout[e]={position:l,rotation:u,transform:c,axisNameAvailableWidth:o.axisNameAvailableWidth,axisLabelShow:o.axisLabelShow,nameTruncateMaxWidth:o.nameTruncateMaxWidth,tickDirection:1,labelDirection:1}},this)},t.prototype.getAxis=function(t){return this._axesMap.get(t)},t.prototype.dataToPoint=function(t,e){return this.axisCoordToPoint(this._axesMap.get(e).dataToCoord(t),e)},t.prototype.eachActiveState=function(t,e,n,i){null==n&&(n=0),null==i&&(i=t.count());var r=this._axesMap,o=this.dimensions,a=[],s=[];B(o,function(e){a.push(t.mapDimension(e)),s.push(r.get(e).model)});for(var l=this.hasAxisBrushed(),u=n;ur*(1-c[0])?(l="jump",a=s-r*(1-c[2])):(a=s-r*c[1])>=0&&(a=s-r*(1-c[1]))<=0&&(a=0),(a*=e.axisExpandWidth/u)?ez(a,i,o,"all"):l="none";else{var d=i[1]-i[0];(i=[Do(0,o[1]*s/d-d/2)])[1]=Co(o[1],i[0]+d),i[0]=i[1]-d}return{axisExpandWindow:i,behavior:l}},t}();function oz(t,e){return Co(Do(t,e[0]),e[1])}function az(t,e){var n=e.layoutLength/(e.axisCount-1);return{position:n*t,axisNameAvailableWidth:n,axisLabelShow:!0}}function sz(t,e){var n,i,r=e.layoutLength,o=e.axisExpandWidth,a=e.axisCount,s=e.axisCollapseWidth,l=e.winInnerIndices,u=s,c=!1;return t=0;n--)Wo(e[n])},e.prototype.getActiveState=function(t){var e=this.activeIntervals;if(!e.length)return"normal";if(null==t||isNaN(+t))return"inactive";if(1===e.length){var n=e[0];if(n[0]<=t&&t<=n[1])return"active"}else for(var i=0,r=e.length;i6}(t)||o){if(a&&!o){"single"===s.brushMode&&Dz(t);var l=C(s);l.brushType=Yz(l.brushType,a),l.panelId=a===cz?null:a.panelId,o=t._creatingCover=_z(t,l),t._covers.push(o)}if(o){var u=jz[Yz(t._brushType,a)];o.__brushOption.range=u.getCreatingRange(Fz(t,o,t._track)),i&&(bz(t,o),u.updateCommon(t,o)),wz(t,o),r={isEnd:i}}}else i&&"single"===s.brushMode&&s.removeOnClick&&Tz(t,e,n)&&Dz(t)&&(r={isEnd:i,removeOnClick:!0});return r}function Yz(t,e){return"auto"===t?e.defaultBrushType:t}var Xz={mousedown:function(t){if(this._dragging)Zz(this,t);else if(!t.target||!t.target.draggable){Hz(t);var e=this.group.transformCoordToLocal(t.offsetX,t.offsetY);this._creatingCover=null,(this._creatingPanel=Tz(this,t,e))&&(this._dragging=!0,this._track=[e.slice()])}},mousemove:function(t){var e=t.offsetX,n=t.offsetY,i=this.group.transformCoordToLocal(e,n);if(function(t,e,n){if(t._brushType&&!function(t,e,n){var i=t._zr;return e<0||e>i.getWidth()||n<0||n>i.getHeight()}(t,e.offsetX,e.offsetY)){var i=t._zr,r=t._covers,o=Tz(t,e,n);if(!t._dragging)for(var a=0;a=0&&(o[r[a].depth]=new kp(r[a],this,e));var s=vN(i,n,this,!0,function(t,e){t.wrapMethod("getItemModel",function(t,e){var n=t.parentModel,i=n.getData().getItemLayout(e);if(i){var r=i.depth,o=n.levelModels[r];o&&(t.parentModel=o)}return t}),e.wrapMethod("getItemModel",function(t,e){var n=t.parentModel,i=n.getGraph().getEdgeByIndex(e).node1.getLayout();if(i){var r=i.depth,o=n.levelModels[r];o&&(t.parentModel=o)}return t})});return s.data},e.prototype.setNodePosition=function(t,e){var n=(this.option.data||this.option.nodes)[t];n.localX=e[0],n.localY=e[1]},e.prototype.getGraph=function(){return this.getData().graph},e.prototype.getEdgeData=function(){return this.getGraph().edgeData},e.prototype.formatTooltip=function(t,e,n){function i(t){return isNaN(t)||null==t}if("edge"===n){var r=this.getDataParams(t,n),o=r.data,a=r.value;return Ty("nameValue",{name:o.source+" -- "+o.target,value:a,noValue:i(a)})}var s=this.getGraph().getNodeByIndex(t).getLayout().value,l=this.getDataParams(t,n).data.name;return Ty("nameValue",{name:null!=l?l+"":null,value:s,noValue:i(s)})},e.prototype.optionUpdated=function(){},e.prototype.getDataParams=function(e,n){var i=t.prototype.getDataParams.call(this,e,n);if(null==i.value&&"node"===n){var r=this.getGraph().getNodeByIndex(e).getLayout().value;i.value=r}return i},e.prototype.__ownRoamView=function(){return this.coordinateSystem},e.type="series."+rB,e.layoutMode="box",e.defaultOption={z:2,coordinateSystemUsage:"box",left:"5%",top:"5%",right:"20%",bottom:"5%",orient:"horizontal",nodeWidth:20,nodeGap:8,draggable:!0,layoutIterations:32,roam:!1,roamTrigger:"global",center:null,zoom:1,label:{show:!0,position:"right",fontSize:12},edgeLabel:{show:!1,fontSize:12},levels:[],nodeAlign:"justify",lineStyle:{color:ug.color.neutral50,opacity:.2,curveness:.5},emphasis:{label:{show:!0},lineStyle:{opacity:.5}},select:{itemStyle:{borderColor:ug.color.primary}},animationEasing:"linear",animationDuration:1e3},e}(Hy),aB=function(){this.x1=0,this.y1=0,this.x2=0,this.y2=0,this.cpx1=0,this.cpy1=0,this.cpx2=0,this.cpy2=0,this.extent=0},sB=function(t){function e(e){return t.call(this,e)||this}return n(e,t),e.prototype.getDefaultShape=function(){return new aB},e.prototype.buildPath=function(t,e){var n=e.extent;t.moveTo(e.x1,e.y1),t.bezierCurveTo(e.cpx1,e.cpy1,e.cpx2,e.cpy2,e.x2,e.y2),"vertical"===e.orient?(t.lineTo(e.x2+n,e.y2),t.bezierCurveTo(e.cpx2+n,e.cpy2,e.cpx1+n,e.cpy1,e.x1+n,e.y1)):(t.lineTo(e.x2,e.y2+n),t.bezierCurveTo(e.cpx2,e.cpy2+n,e.cpx1,e.cpy1+n,e.x1,e.y1+n)),t.closePath()},e.prototype.highlight=function(){vc(this)},e.prototype.downplay=function(){yc(this)},e}(Kl),lB=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type=rB,e._mainGroup=new vo,e}return n(e,t),e.prototype.init=function(t,e){this._controller=new uL(e.getZr()),this.group.add(this._mainGroup),this._firstRender=!0},e.prototype.render=function(t,e,n){var i=t.getGraph(),r=this._mainGroup,o=t.layoutInfo,a=o.width,s=o.height,l=t.getData(),u=t.getData("edge"),c=t.get("orient");r.removeAll(),r.x=o.x,r.y=o.y,this._updateViewCoordSys(t,n),FP(t,n,this._controller,HP(r),null),i.eachEdge(function(e){var n=new sB,i=Iu(n);i.dataIndex=e.dataIndex,i.seriesIndex=t.seriesIndex,i.dataType="edge";var o,l,h,d,p,f,g,v,y=e.getModel(),m=y.getModel("lineStyle"),x=m.get("curveness"),_=e.node1.getLayout(),b=e.node1.getModel(),w=b.get("localX"),S=b.get("localY"),M=e.node2.getLayout(),I=e.node2.getModel(),T=I.get("localX"),C=I.get("localY"),D=e.getLayout();n.shape.extent=Math.max(1,D.dy),n.shape.orient=c,"vertical"===c?(o=(null!=w?w*a:_.x)+D.sy,l=(null!=S?S*s:_.y)+_.dy,h=(null!=T?T*a:M.x)+D.ty,p=o,f=l*(1-x)+(d=null!=C?C*s:M.y)*x,g=h,v=l*x+d*(1-x)):(o=(null!=w?w*a:_.x)+_.dx,l=(null!=S?S*s:_.y)+D.sy,p=o*(1-x)+(h=null!=T?T*a:M.x)*x,f=l,g=o*x+h*(1-x),v=d=(null!=C?C*s:M.y)+D.ty),n.setShape({x1:o,y1:l,x2:h,y2:d,cpx1:p,cpy1:f,cpx2:g,cpy2:v}),n.useStyle(m.getItemStyle()),uB(n.style,c,e);var A=""+y.get("value"),k=ap(y,"edgeLabel");op(n,k,{labelFetcher:{getFormattedLabel:function(e,n,i,r,o,a){return t.getFormattedLabel(e,n,"edge",r,st(o,k.normal&&k.normal.get("formatter"),A),a)}},labelDataIndex:e.dataIndex,defaultText:A}),n.setTextConfig({position:"inside"});var L=y.getModel("emphasis");Rc(n,y,"lineStyle",function(t){var n=t.getItemStyle();return uB(n,c,e),n}),r.add(n),u.setItemGraphicEl(e.dataIndex,n);var P=L.get("focus");kc(n,"adjacency"===P?e.getAdjacentDataIndices():"trajectory"===P?e.getTrajectoryDataIndices():P,L.get("blurScope"),L.get("disabled"))}),i.eachNode(function(e){var n=e.getLayout(),i=e.getModel(),o=i.get("localX"),u=i.get("localY"),c=i.getModel("emphasis"),h=i.get(["itemStyle","borderRadius"])||0,d=new lu({shape:{x:null!=o?o*a:n.x,y:null!=u?u*s:n.y,width:n.dx,height:n.dy,r:h},style:i.getModel("itemStyle").getItemStyle(),z2:10});op(d,ap(i),{labelFetcher:{getFormattedLabel:function(e,n){return t.getFormattedLabel(e,n,"node")}},labelDataIndex:e.dataIndex,defaultText:e.id}),d.disableLabelAnimation=!0,d.setStyle("fill",e.getVisual("color")),d.setStyle("decal",e.getVisual("style").decal),Rc(d,i),r.add(d),l.setItemGraphicEl(e.dataIndex,d),Iu(d).dataType="node";var p=c.get("focus");kc(d,"adjacency"===p?e.getAdjacentDataIndices():"trajectory"===p?e.getTrajectoryDataIndices():p,c.get("blurScope"),c.get("disabled"))}),l.eachItemGraphicEl(function(e,i){l.getItemModel(i).get("draggable")&&(e.drift=function(e,r){this.shape.x+=e,this.shape.y+=r,this.dirty(),n.dispatchAction({type:"dragNode",seriesId:t.id,dataIndex:l.getRawIndex(i),localX:this.shape.x/a,localY:this.shape.y/s})},e.draggable=!0,e.cursor="move")}),!this._data&&t.isAnimationEnabled()&&r.setClipPath(function(t,e,n){var i=new lu({shape:{x:t.x-10,y:t.y-10,width:0,height:t.height+20}});return od(i,{shape:{width:t.width+20}},e,n),i}(r.getBoundingRect(),t,function(){r.removeClipPath()})),this._data=t.getData(),this._firstRender=!1},e.prototype.__updateOnOwnRoam=function(t,e,n){CP(this.group,2,e.coordinateSystem,null)},e.prototype.dispose=function(){this._controller&&this._controller.dispose()},e.prototype._updateViewCoordSys=function(t,e){var n=t.layoutInfo,i=t.coordinateSystem=ZP(t,e,n.x,n.y,n.width,n.height);CP(this.group,2,i,this._firstRender?null:t)},e.type=rB,e}(tm);function uB(t,e,n){switch(t.fill){case"source":t.fill=n.node1.getVisual("color"),t.decal=n.node1.getVisual("style").decal;break;case"target":t.fill=n.node2.getVisual("color"),t.decal=n.node2.getVisual("style").decal;break;case"gradient":var i=n.node1.getVisual("color"),r=n.node2.getVisual("color");j(i)&&j(r)&&(t.fill=new Hh(0,0,+("horizontal"===e),+("vertical"===e),[{color:i,offset:0},{color:r,offset:1}]))}}var cB=os(rB,function(t,e){t.eachSeriesByType(rB,function(t){var n=t.get("nodeWidth"),i=t.get("nodeGap"),r=eg(t,e).refContainer,o=Jf(t.getBoxLayoutParams(),r);t.layoutInfo=o;var a=o.width,s=o.height,l=t.getGraph(),u=l.nodes,c=l.edges;!function(t){B(t,function(t){var e=_B(t.outEdges,xB),n=_B(t.inEdges,xB),i=t.getValue()||0,r=Math.max(e,n,i);t.setLayout({value:r},!0)})}(u),function(t,e,n,i,r,o,a,s,l){(function(t,e,n,i,r,o,a){for(var s=[],l=[],u=[],c=[],h=0,d=0;d=0;y&&v.depth>p&&(p=v.depth),g.setLayout({depth:y?v.depth:h},!0),"vertical"===o?g.setLayout({dy:n},!0):g.setLayout({dx:n},!0);for(var m=0;mh-1?p:h-1;a&&"left"!==a&&function(t,e,n,i){if("right"===e){for(var r=[],o=t,a=0;o.length;){for(var s=0;s0;o--)pB(s,l*=.99,a),dB(s,r,n,i,a),bB(s,l,a),dB(s,r,n,i,a)}(t,e,o,r,i,a,s),function(t,e){var n="vertical"===e?"x":"y";B(t,function(t){t.outEdges.sort(function(t,e){return t.node2.getLayout()[n]-e.node2.getLayout()[n]}),t.inEdges.sort(function(t,e){return t.node1.getLayout()[n]-e.node1.getLayout()[n]})}),B(t,function(t){var e=0,n=0;B(t.outEdges,function(t){t.setLayout({sy:e},!0),e+=t.getLayout().dy}),B(t.inEdges,function(t){t.setLayout({ty:n},!0),n+=t.getLayout().dy})})}(t,s)}(u,c,n,i,a,s,0!==F(u,function(t){return 0===t.getLayout().value}).length?0:t.get("layoutIterations"),t.get("orient"),t.get("nodeAlign"))})});function hB(t){var e=t.hostGraph.data.getRawDataItem(t.dataIndex);return null!=e.depth&&e.depth>=0}function dB(t,e,n,i,r){var o="vertical"===r?"x":"y";B(t,function(t){var a,s,l;t.sort(function(t,e){return t.getLayout()[o]-e.getLayout()[o]});for(var u=0,c=t.length,h="vertical"===r?"dx":"dy",d=0;d0&&(a=s.getLayout()[o]+l,"vertical"===r?s.setLayout({x:a},!0):s.setLayout({y:a},!0)),u=s.getLayout()[o]+s.getLayout()[h]+e;if((l=u-e-("vertical"===r?i:n))>0){a=s.getLayout()[o]-l,"vertical"===r?s.setLayout({x:a},!0):s.setLayout({y:a},!0),u=a;for(d=c-2;d>=0;--d)(l=(s=t[d]).getLayout()[o]+s.getLayout()[h]+e-u)>0&&(a=s.getLayout()[o]-l,"vertical"===r?s.setLayout({x:a},!0):s.setLayout({y:a},!0)),u=s.getLayout()[o]}})}function pB(t,e,n){B(t.slice().reverse(),function(t){B(t,function(t){if(t.outEdges.length){var i=_B(t.outEdges,fB,n)/_B(t.outEdges,xB);if(isNaN(i)){var r=t.outEdges.length;i=r?_B(t.outEdges,gB,n)/r:0}if("vertical"===n){var o=t.getLayout().x+(i-mB(t,n))*e;t.setLayout({x:o},!0)}else{var a=t.getLayout().y+(i-mB(t,n))*e;t.setLayout({y:a},!0)}}})})}function fB(t,e){return mB(t.node2,e)*t.getValue()}function gB(t,e){return mB(t.node2,e)}function vB(t,e){return mB(t.node1,e)*t.getValue()}function yB(t,e){return mB(t.node1,e)}function mB(t,e){return"vertical"===e?t.getLayout().x+t.getLayout().dx/2:t.getLayout().y+t.getLayout().dy/2}function xB(t){return t.getValue()}function _B(t,e,n){for(var i=0,r=t.length,o=-1;++oo&&(o=e)}),B(n,function(e){var n=new ZR({type:"color",mappingMethod:"linear",dataExtent:[r,o],visual:t.get("color")}).mapValueToVisual(e.getLayout().value),i=e.getModel().get(["itemStyle","color"]);null!=i?(e.setVisual("color",i),e.setVisual("style",{fill:i})):(e.setVisual("color",n),e.setVisual("style",{fill:n}))})}i.length&&B(i,function(t){var e=t.getModel().get("lineStyle");t.setVisual("style",e)})})});var SB=function(){function t(){}return t.prototype._hasEncodeRule=function(t){var e=this.getEncode();return e&&null!=e.get(t)},t.prototype.getInitialData=function(t,e){var n,i,r=e.getComponent("xAxis",this.get("xAxisIndex")),o=e.getComponent("yAxis",this.get("yAxisIndex")),a=r.get("type"),s=o.get("type"),l=t.layout;"category"===a?(l="horizontal",n=r.getOrdinalMeta(),i=!this._hasEncodeRule("x")):"category"===s&&(l="vertical",n=o.getOrdinalMeta(),i=!this._hasEncodeRule("y")),l||(l="time"===s?"vertical":"horizontal"),this._layout=l;var u=["x","y"],c="horizontal"===l?0:1,h=this._baseAxisDim=u[c],d=u[1-c],p=[r,o],f=p[c].get("type"),g=p[1-c].get("type"),v=t.data;if(v&&i){var y=[];B(v,function(t,e){var n;X(t)?(n=t.slice(),t.unshift(e)):X(t.value)?((n=k({},t)).value=n.value.slice(),t.value.unshift(e)):n=t,y.push(n)}),t.data=y}var m=this.defaultValueDimensions,x=[{name:h,type:bb(f),ordinalMeta:n,otherDims:{tooltip:!1,itemName:0},dimsDef:["base"]},{name:d,type:bb(g),dimsDef:m.slice()}];return bA(this,{coordDimensions:x,dimensionsCount:m.length+1,encodeDefaulter:Y(wg,x,this)})},t.prototype.getBaseAxis=function(){var t=this._baseAxisDim;return this.ecModel.getComponent(t+"Axis",this.get(t+"AxisIndex")).axis},t.prototype.getWhiskerBoxesLayout=function(){return this._layout},t}();function MB(t,e){for(var n=e.ends.length,i=0,r=0;rg){var _=[y,x];i.push(_)}}}return{boxData:n,outliers:i}}(e.getRawData(),t.config);return[{dimensions:["ItemName","Low","Q1","Q2","Q3","High"],data:i.boxData},{data:i.outliers}]}};var EB="candlestick",zB=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.defaultValueDimensions=[{name:"open",defaultTooltip:!0},{name:"close",defaultTooltip:!0},{name:"lowest",defaultTooltip:!0},{name:"highest",defaultTooltip:!0}],n}return n(e,t),e.prototype.getShadowDim=function(){return"open"},e.prototype.brushSelector=function(t,e,n){var i=e.getItemLayout(t);return i&&n.rect(i.brushRect)},e.type="series."+EB,e.dependencies=["xAxis","yAxis","grid"],e.defaultOption={z:2,coordinateSystem:"cartesian2d",legendHoverLink:!0,layout:null,clip:!0,itemStyle:{color:"#eb5454",color0:"#47b262",borderColor:"#eb5454",borderColor0:"#47b262",borderColorDoji:null,borderWidth:1},emphasis:{itemStyle:{borderWidth:2}},barMaxWidth:null,barMinWidth:null,barWidth:null,large:!0,largeThreshold:600,progressive:3e3,progressiveThreshold:1e4,progressiveChunkMode:"mod",animationEasing:"linear",animationDuration:300},e}(Hy);E(zB,SB,!0);var BB=["itemStyle","borderColor"],VB=["itemStyle","borderColor0"],GB=["itemStyle","borderColorDoji"],FB=["itemStyle","color"],HB=["itemStyle","color0"];function WB(t,e){return e.get(t>0?FB:HB)}function UB(t,e){return e.get(0===t?GB:t>0?BB:VB)}var YB={seriesType:EB,plan:$y(),performRawSeries:!0,reset:function(t,e){if(!e.isSeriesFiltered(t))return!t.pipelineContext.large&&{progress:function(t,e){for(var n;null!=(n=t.next());){var i=e.getItemModel(n),r=e.getItemLayout(n).sign,o=i.getItemStyle();o.fill=WB(r,i),o.stroke=UB(r,i)||o.fill,k(e.ensureUniqueItemVisual(n,"style"),o)}}}}},XB=["color","borderColor"],ZB=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n){this.group.removeClipPath(),this._progressiveEls=null,this._updateDrawMode(t),this._isLargeDraw?this._renderLarge(t):this._renderNormal(t)},e.prototype.incrementalPrepareRender=function(t,e,n){this._clear(),this._updateDrawMode(t)},e.prototype.incrementalRender=function(t,e,n,i){this._progressiveEls=[],this._isLargeDraw?this._incrementalRenderLarge(t,e):this._incrementalRenderNormal(t,e)},e.prototype.eachRendered=function(t){Hd(this._progressiveEls||this.group,t)},e.prototype._updateDrawMode=function(t){var e=t.pipelineContext.large;null!=this._isLargeDraw&&e===this._isLargeDraw||(this._isLargeDraw=e,this._clear())},e.prototype._renderNormal=function(t){var e=t.getData(),n=this._data,i=this.group,r=e.getLayout("isSimpleBox"),o=t.get("clip",!0),a=t.coordinateSystem,s=a.getArea&&a.getArea(),l=o&&xC(a,!1,t);this._data||i.removeAll();var u=QB(t);e.diff(n).add(function(n){if(e.hasValue(n)){var a=e.getItemLayout(n),c=o?MB(s,a):0;if(2===c)return;var h=KB(a,n,u,!0);od(h,{shape:{points:a.ends}},t,n),_C(1===c,h,l),$B(h,e,n,r),i.add(h),e.setItemGraphicEl(n,h)}}).update(function(a,c){var h=n.getItemGraphicEl(c);if(e.hasValue(a)){var d=e.getItemLayout(a),p=o?MB(s,d):0;2!==p?(h?(rd(h,{shape:{points:d.ends}},t,a),cd(h)):h=KB(d,a,u),$B(h,e,a,r),_C(1===p,h,l),i.add(h),e.setItemGraphicEl(a,h)):i.remove(h)}else i.remove(h)}).remove(function(t){var e=n.getItemGraphicEl(t);e&&i.remove(e)}).execute(),this._data=e},e.prototype._renderLarge=function(t){this._clear(),nV(t,this.group);var e=t.get("clip",!0)?xC(t.coordinateSystem,!1,t):null;_C(!!e,this.group,e)},e.prototype._incrementalRenderNormal=function(t,e){for(var n,i=e.getData(),r=i.getLayout("isSimpleBox"),o=QB(e);null!=(n=t.next());){var a=KB(i.getItemLayout(n),n,o);$B(a,i,n,r),a.incremental=is(e),this.group.add(a),this._progressiveEls.push(a)}},e.prototype._incrementalRenderLarge=function(t,e){nV(e,this.group,this._progressiveEls,!0)},e.prototype.remove=function(t){this._clear()},e.prototype._clear=function(){this.group.removeAll(),_C(!1,this.group,null),this._data=null},e.type=EB,e}(tm),jB=function(){},qB=function(t){function e(e){var n=t.call(this,e)||this;return n.type="normalCandlestickBox",n}return n(e,t),e.prototype.getDefaultShape=function(){return new jB},e.prototype.buildPath=function(t,e){var n=e.points;this.__simpleBox?(t.moveTo(n[4][0],n[4][1]),t.lineTo(n[6][0],n[6][1])):(t.moveTo(n[0][0],n[0][1]),t.lineTo(n[1][0],n[1][1]),t.lineTo(n[2][0],n[2][1]),t.lineTo(n[3][0],n[3][1]),t.closePath(),t.moveTo(n[4][0],n[4][1]),t.lineTo(n[5][0],n[5][1]),t.moveTo(n[6][0],n[6][1]),t.lineTo(n[7][0],n[7][1]))},e}(Kl);function KB(t,e,n,i){var r=t.ends;return new qB({shape:{points:i?JB(r,n,t):r},z2:100})}function $B(t,e,n,i){var r=e.getItemModel(n);t.useStyle(e.getItemVisual(n,"style")),t.style.strokeNoScale=!0;var o=r.getShallow("cursor");o&&t.attr("cursor",o),t.__simpleBox=i,Rc(t,r);var a=e.getItemLayout(n).sign;B(t.states,function(t,e){var n=r.getModel(e),i=WB(a,n),o=UB(a,n)||i,s=t.style||(t.style={});i&&(s.fill=i),o&&(s.stroke=o)});var s=r.getModel("emphasis");kc(t,s.get("focus"),s.get("blurScope"),s.get("disabled"))}function JB(t,e,n){return V(t,function(t){return(t=t.slice())[e]=n.initBaseline,t})}function QB(t){return"horizontal"===t.getWhiskerBoxesLayout()?1:0}var tV=function(){},eV=function(t){function e(e){var n=t.call(this,e)||this;return n.type="largeCandlestickBox",n}return n(e,t),e.prototype.getDefaultShape=function(){return new tV},e.prototype.buildPath=function(t,e){for(var n=e.points,i=0;ig?b[o]:_[o],ends:M,brushRect:D(v,y,p)})}function T(t,n){var i=[];return i[r]=n,i[o]=t,isNaN(n)||isNaN(t)?[NaN,NaN]:e.dataToPoint(i)}function C(t,e,n){var o=e.slice(),a=e.slice();o[r]=Id(o[r]+i/2,1,!1),a[r]=Id(a[r]-i/2,1,!0),n?t.push(o,a):t.push(a,o)}function D(t,e,n){var a=T(t,n),s=T(e,n);return a[r]-=i/2,s[r]-=i/2,{x:a[0],y:a[1],width:o?i:s[0]-a[0],height:o?s[1]-a[1]:i}}function A(t){return t[r]=Id(t[r],1),t}}}}};function sV(t,e,n,i,r,o){return n>i?-1:n0?t.get(r,e-1)<=i?1:-1:1}function lV(t,e){var n=e.rippleEffectColor||e.color;t.eachChild(function(t){t.attr({z:e.z,zlevel:e.zlevel,style:{stroke:"stroke"===e.brushType?n:null,fill:"fill"===e.brushType?n:null}})})}var uV=function(t){function e(e,n){var i=t.call(this)||this,r=new KT(e,n),o=new vo;return i.add(r),i.add(o),i.updateData(e,n),i}return n(e,t),e.prototype.stopEffectAnimation=function(){this.childAt(1).removeAll()},e.prototype.startEffectAnimation=function(t){for(var e=t.symbolType,n=t.color,i=t.rippleNumber,r=this.childAt(1),o=0;o0&&(o=this._getLineLength(i)/l*1e3),o!==this._period||a!==this._loop||s!==this._roundTrip){i.stopAnimation();var c=void 0;c=Z(u)?u(n):u,i.__t>0&&(c=-o*i.__t),this._animateSymbol(i,o,c,a,s)}this._period=o,this._loop=a,this._roundTrip=s}},e.prototype._animateSymbol=function(t,e,n,i,r){if(e>0){t.__t=0;var o=this,a=t.animate("",i).when(r?2*e:e,{__t:r?2:1}).delay(n).during(function(){o._updateSymbolPosition(t)});i||a.done(function(){o.remove(t)}),a.start()}},e.prototype._getLineLength=function(t){return Ft(t.__p1,t.__cp1)+Ft(t.__cp1,t.__p2)},e.prototype._updateAnimationPoints=function(t,e){t.__p1=e[0],t.__p2=e[1],t.__cp1=e[2]||[(e[0][0]+e[1][0])/2,(e[0][1]+e[1][1])/2]},e.prototype.updateData=function(t,e,n){this.childAt(0).updateData(t,e,n),this._updateEffectSymbol(t,e)},e.prototype._updateSymbolPosition=function(t){var e=t.__p1,n=t.__p2,i=t.__cp1,r=t.__t<=1?t.__t:2-t.__t,o=[t.x,t.y],a=o.slice(),s=Yn,l=Xn;o[0]=s(e[0],i[0],n[0],r),o[1]=s(e[1],i[1],n[1],r);var u=t.__t<=1?l(e[0],i[0],n[0],r):l(n[0],i[0],e[0],1-r),c=t.__t<=1?l(e[1],i[1],n[1],r):l(n[1],i[1],e[1],1-r);t.rotation=-Math.atan2(c,u)-Math.PI/2,"line"!==this._symbolType&&"rect"!==this._symbolType&&"roundRect"!==this._symbolType||(void 0!==t.__lastT&&t.__lastT=0&&!(i[o]<=e);o--);o=Math.min(o,r-2)}else{for(o=a;oe);o++);o=Math.min(o-1,r-2)}var s=(e-i[o])/(i[o+1]-i[o]),l=n[o],u=n[o+1];t.x=l[0]*(1-s)+s*u[0],t.y=l[1]*(1-s)+s*u[1];var c=t.__t<=1?u[0]-l[0]:l[0]-u[0],h=t.__t<=1?u[1]-l[1]:l[1]-u[1];t.rotation=-Math.atan2(h,c)-Math.PI/2,this._lastFrame=o,this._lastFramePercent=e,t.ignore=!1}},e}(pV),vV=function(){this.polyline=!1,this.curveness=0,this.segs=[]},yV=function(t){function e(e){var n=t.call(this,e)||this;return n._off=0,n.hoverDataIdx=-1,n}return n(e,t),e.prototype.reset=function(){this.notClear=!1,this._off=0},e.prototype.beforeBrush=function(t){t&&!t.contentRetained&&this.reset()},e.prototype.getDefaultStyle=function(){return{stroke:ug.color.neutral99,fill:null}},e.prototype.getDefaultShape=function(){return new vV},e.prototype.buildPath=function(t,e){var n,i=e.segs,r=e.curveness;if(e.polyline)for(n=this._off;n0){t.moveTo(i[n++],i[n++]);for(var a=1;a0){var h=(s+u)/2-(l-c)*r,d=(l+c)/2-(u-s)*r;t.quadraticCurveTo(h,d,u,c)}else t.lineTo(u,c)}this.incremental&&(this._off=n,this.notClear=!0)},e.prototype.findDataIndex=function(t,e){var n=this.shape,i=n.segs,r=n.curveness,o=this.style.lineWidth;if(n.polyline)for(var a=0,s=0;s0)for(var u=i[s++],c=i[s++],h=1;h0){if(Pl(u,c,(u+d)/2-(c-p)*r,(c+p)/2-(d-u)*r,d,p,o,t,e))return a}else if(kl(u,c,d,p,o,t,e))return a;a++}return-1},e.prototype.contain=function(t,e){var n=this.transformCoordToLocal(t,e),i=this.getBoundingRect();return t=n[0],e=n[1],i.contain(t,e)?(this.hoverDataIdx=this.findDataIndex(t,e))>=0:(this.hoverDataIdx=-1,!1)},e.prototype.getBoundingRect=function(){var t=this._rect;if(!t){for(var e=this.shape.segs,n=1/0,i=1/0,r=-1/0,o=-1/0,a=0;a0&&(o.dataIndex=n+t.__startIndex)})},t.prototype._clear=function(){this._newAdded=[],this.group.removeAll()},t}(),xV={seriesType:"lines",plan:$y(),reset:function(t){var e=t.coordinateSystem;if(e){var n=t.get("polyline"),i=t.pipelineContext.large;return{progress:function(r,o){var a=[];if(i){var s=void 0,l=r.end-r.start;if(n){for(var u=0,c=r.start;c0&&l&&s.configLayer(o,{motionBlur:!0,lastFrameAlpha:Math.max(Math.min(a/10+.9,1),0)}),r.updateData(i);var u=t.get("clip",!0)&&xC(t.coordinateSystem,!1,t);u?this.group.setClipPath(u):this.group.removeClipPath(),this._lastZlevel=o,this._finished=!0},e.prototype.incrementalPrepareRender=function(t,e,n){var i=t.getData();this._updateLineDraw(i,t).incrementalPrepareUpdate(i),this._clearLayer(n),this._finished=!1},e.prototype.incrementalRender=function(t,e,n){this._lineDraw.incrementalUpdate(t,e.getData(),is(e)),this._finished=t.end===e.getData().count()},e.prototype.eachRendered=function(t){this._lineDraw&&this._lineDraw.eachRendered(t)},e.prototype.updateTransform=function(t,e,n){var i=t.getData(),r=this._lineDraw;if(!(this._finished&&r&&r.updateLayout))return{update:!0};var o=xV.reset(t,e,n);o.progress&&o.progress({start:0,end:i.count(),count:i.count()},i),r.updateLayout(),this._clearLayer(n)},e.prototype._updateLineDraw=function(t,e){var n=this._lineDraw,i=this._showEffect(e),r=!!e.get("polyline"),o=e.pipelineContext.large;return n&&i===this._hasEffet&&r===this._isPolyline&&o===this._isLargeDraw||(n&&n.remove(),n=this._lineDraw=o?new mV:new QN(r?i?gV:fV:i?pV:JN),this._hasEffet=i,this._isPolyline=r,this._isLargeDraw=o),this.group.add(n.group),n},e.prototype._showEffect=function(t){return!!t.get(["effect","show"])},e.prototype._clearLayer=function(t){var e=tp(t);e&&null!=this._lastZlevel&&e.getLayer(this._lastZlevel).clear(!0)},e.prototype.remove=function(t,e){this._lineDraw&&this._lineDraw.remove(),this._lineDraw=null,this._clearLayer(e)},e.prototype.dispose=function(t,e){this.remove(t,e)},e.type="lines",e}(tm),bV="undefined"==typeof Uint32Array?Array:Uint32Array,wV="undefined"==typeof Float64Array?Array:Float64Array;function SV(t){var e=t.data;e&&e[0]&&e[0][0]&&e[0][0].coord&&(t.data=V(e,function(t){var e={coords:[t[0].coord,t[1].coord]};return t[0].name&&(e.fromName=t[0].name),t[1].name&&(e.toName=t[1].name),A([e,t[0],t[1]])}))}var MV=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.visualStyleAccessPath="lineStyle",n.visualDrawType="stroke",n}return n(e,t),e.prototype.init=function(e){e.data=e.data||[],SV(e);var n=this._processFlatCoordsArray(e.data);this._flatCoords=n.flatCoords,this._flatCoordsOffset=n.flatCoordsOffset,n.flatCoords&&(e.data=new Float32Array(n.count)),t.prototype.init.apply(this,arguments)},e.prototype.mergeOption=function(e){if(SV(e),e.data){var n=this._processFlatCoordsArray(e.data);this._flatCoords=n.flatCoords,this._flatCoordsOffset=n.flatCoordsOffset,n.flatCoords&&(e.data=new Float32Array(n.count))}t.prototype.mergeOption.apply(this,arguments)},e.prototype.appendData=function(t){var e=this._processFlatCoordsArray(t.data);e.flatCoords&&(this._flatCoords?(this._flatCoords=xt(this._flatCoords,e.flatCoords),this._flatCoordsOffset=xt(this._flatCoordsOffset,e.flatCoordsOffset)):(this._flatCoords=e.flatCoords,this._flatCoordsOffset=e.flatCoordsOffset),t.data=new Float32Array(e.count)),this.getRawData().appendData(t.data)},e.prototype._getCoordsFromItemModel=function(t){var e=this.getData().getItemModel(t),n=e.option instanceof Array?e.option:e.getShallow("coords");return n},e.prototype.getLineCoordsCount=function(t){return this._flatCoordsOffset?this._flatCoordsOffset[2*t+1]:this._getCoordsFromItemModel(t).length},e.prototype.getLineCoords=function(t,e){if(this._flatCoordsOffset){for(var n=this._flatCoordsOffset[2*t],i=this._flatCoordsOffset[2*t+1],r=0;r ")}return Ty("nameValue",{name:a,value:r,noValue:null==r||isNaN(r)})},e.prototype.preventIncremental=function(){return!!this.get(["effect","show"])},e.prototype.getProgressive=function(){var t=this.option.progressive;return null==t?this.option.large?1e4:this.get("progressive"):t},e.prototype.getProgressiveThreshold=function(){var t=this.option.progressiveThreshold;return null==t?this.option.large?2e4:this.get("progressiveThreshold"):t},e.prototype.getZLevelKey=function(){var t=this.getModel("effect"),e=t.get("trailLength");return this.getData().count()>this.getProgressiveThreshold()?this.id:t.get("show")&&e>0?e+"":""},e.type="series.lines",e.dependencies=["grid","polar","geo","calendar"],e.defaultOption={coordinateSystem:"geo",z:2,legendHoverLink:!0,xAxisIndex:0,yAxisIndex:0,symbol:["none","none"],symbolSize:[10,10],geoIndex:0,effect:{show:!1,period:4,constantSpeed:0,symbol:"circle",symbolSize:3,loop:!0,trailLength:.2},large:!1,largeThreshold:2e3,polyline:!1,clip:!0,label:{show:!1,position:"end"},lineStyle:{opacity:.5}},e}(Hy);function IV(t){return t instanceof Array||(t=[t,t]),t}var TV={seriesType:"lines",reset:function(t){var e=IV(t.get("symbol")),n=IV(t.get("symbolSize")),i=t.getData();return i.setVisual("fromSymbol",e&&e[0]),i.setVisual("toSymbol",e&&e[1]),i.setVisual("fromSymbolSize",n&&n[0]),i.setVisual("toSymbolSize",n&&n[1]),{dataEach:i.hasItemOption?function(t,e){var n=t.getItemModel(e),i=IV(n.getShallow("symbol",!0)),r=IV(n.getShallow("symbolSize",!0));i[0]&&t.setItemVisual(e,"fromSymbol",i[0]),i[1]&&t.setItemVisual(e,"toSymbol",i[1]),r[0]&&t.setItemVisual(e,"fromSymbolSize",r[0]),r[1]&&t.setItemVisual(e,"toSymbolSize",r[1])}:null}}};var CV=function(){function t(){this.blurSize=30,this.pointSize=20,this.maxOpacity=1,this.minOpacity=0,this._gradientPixels={inRange:null,outOfRange:null};var t=c.createCanvas();this.canvas=t}return t.prototype.update=function(t,e,n,i,r,o){var a=this._getBrush(),s=this._getGradient(r,"inRange"),l=this._getGradient(r,"outOfRange"),u=this.pointSize+this.blurSize,c=this.canvas,h=c.getContext("2d"),d=t.length;c.width=e,c.height=n;for(var p=0;p0){var I=o(y)?s:l;y>0&&(y=y*S+w),x[_++]=I[M],x[_++]=I[M+1],x[_++]=I[M+2],x[_++]=I[M+3]*y*256}else _+=4}return h.putImageData(m,0,0),c},t.prototype._getBrush=function(){var t=this._brushCanvas||(this._brushCanvas=c.createCanvas()),e=this.pointSize+this.blurSize,n=2*e;t.width=n,t.height=n;var i=t.getContext("2d");return i.clearRect(0,0,n,n),i.shadowOffsetX=n,i.shadowBlur=this.blurSize,i.shadowColor=ug.color.neutral99,i.beginPath(),i.arc(-e,e,this.pointSize,0,2*Math.PI,!0),i.closePath(),i.fill(),t},t.prototype._getGradient=function(t,e){for(var n=this._gradientPixels,i=n[e]||(n[e]=new Uint8ClampedArray(1024)),r=[0,0,0,0],o=0,a=0;a<256;a++)t[e](a/255,!0,r),i[o++]=r[0],i[o++]=r[1],i[o++]=r[2],i[o++]=r[3];return i},t}();var DV=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n){var i;e.eachComponent("visualMap",function(e){e.eachTargetSeries(function(n){n===t&&(i=e)})}),this._progressiveEls=null,this.group.removeAll();var r=t.coordinateSystem;"cartesian2d"===r.type||"calendar"===r.type||"matrix"===r.type?this._renderOnGridLike(t,n,0,t.getData().count()):SC(r)&&this._renderOnGeo(r,t,i,n)},e.prototype.incrementalPrepareRender=function(t,e,n){this.group.removeAll()},e.prototype.incrementalRender=function(t,e,n,i){var r=e.coordinateSystem;r&&(SC(r)?this.render(e,n,i):(this._progressiveEls=[],this._renderOnGridLike(e,i,t.start,t.end,!0)))},e.prototype.eachRendered=function(t){Hd(this._progressiveEls||this.group,t)},e.prototype._renderOnGridLike=function(t,e,n,i,r){var o,a,s,l,u=t.coordinateSystem,c=wC(u,"cartesian2d"),h=wC(u,"matrix");if(c){var d=u.getAxis("x"),p=u.getAxis("y");0,o=bM(d).w+.5,a=bM(p).w+.5,s=d.scale.getExtent(),l=p.scale.getExtent()}for(var f=this.group,g=t.getData(),v=t.getModel(["emphasis","itemStyle"]).getItemStyle(),y=t.getModel(["blur","itemStyle"]).getItemStyle(),m=t.getModel(["select","itemStyle"]).getItemStyle(),x=t.get(["itemStyle","borderRadius"]),_=ap(t),b=t.getModel("emphasis"),w=b.get("focus"),S=b.get("blurScope"),M=b.get("disabled"),I=c||h?[g.mapDimension("x"),g.mapDimension("y"),g.mapDimension("value")]:[g.mapDimension("time"),g.mapDimension("value")],T=n;Ts[1]||kl[1])continue;var L=u.dataToPoint([A,k]);C=new lu({shape:{x:L[0]-o/2,y:L[1]-a/2,width:o,height:a},style:D})}else if(h){if(rt((P=u.dataToLayout([g.get(I[0],T),g.get(I[1],T)]).rect).x))continue;C=new lu({z2:1,shape:P,style:D})}else{if(isNaN(g.get(I[1],T)))continue;var P,O=u.dataToLayout([g.get(I[0],T)]);if(rt((P=O.contentRect||O.rect).x)||rt(P.y))continue;C=new lu({z2:1,shape:P,style:D})}if(g.hasItemOption){var R=g.getItemModel(T),N=R.getModel("emphasis");v=N.getModel("itemStyle").getItemStyle(),y=R.getModel(["blur","itemStyle"]).getItemStyle(),m=R.getModel(["select","itemStyle"]).getItemStyle(),x=R.get(["itemStyle","borderRadius"]),w=N.get("focus"),S=N.get("blurScope"),M=N.get("disabled"),_=ap(R)}C.shape.r=x;var E=t.getRawValue(T),z="-";E&&null!=E[2]&&(z=E[2]+""),op(C,_,{labelFetcher:t,labelDataIndex:T,defaultOpacity:D.opacity,defaultText:z}),C.ensureState("emphasis").style=v,C.ensureState("blur").style=y,C.ensureState("select").style=m,kc(C,w,S,M),C.incremental=is(t,r),r&&(C.states.emphasis.hoverLayer=2),f.add(C),g.setItemGraphicEl(T,C),this._progressiveEls&&this._progressiveEls.push(C)}},e.prototype._renderOnGeo=function(t,e,n,i){var r=n.targetVisuals.inRange,o=n.targetVisuals.outOfRange,a=e.getData(),s=this._hmLayer||this._hmLayer||new CV;s.blurSize=e.get("blurSize"),s.pointSize=e.get("pointSize"),s.minOpacity=e.get("minOpacity"),s.maxOpacity=e.get("maxOpacity");var l=t.getViewRect().clone(),u=t.getRoamTransform();l.applyTransform(u);var c=Math.max(l.x,0),h=Math.max(l.y,0),d=Math.min(l.width+l.x,i.getWidth()),p=Math.min(l.height+l.y,i.getHeight()),f=d-c,g=p-h,v=[a.mapDimension("lng"),a.mapDimension("lat"),a.mapDimension("value")],y=a.mapArray(v,function(e,n,i){var r=t.dataToPoint([e,n]);return r[0]-=c,r[1]-=h,r.push(i),r}),m=n.getExtent(),x="visualMap.continuous"===n.type?function(t,e){var n=t[1]-t[0];return e=[(e[0]-t[0])/n,(e[1]-t[0])/n],function(t){return t>=e[0]&&t<=e[1]}}(m,n.option.range):function(t,e,n){var i=t[1]-t[0],r=(e=V(e,function(e){return{interval:[(e.interval[0]-t[0])/i,(e.interval[1]-t[0])/i]}})).length,o=0;return function(t){var i;for(i=o;i=0;i--){var a;if((a=e[i].interval)[0]<=t&&t<=a[1]){o=i;break}}return i>=0&&i=0?1:-1:o>0?1:-1}(n,o,r,i,h),function(t,e,n,i,r,o,a,s,l,u){var c,h=l.valueDim,d=l.categoryDim,p=Math.abs(n[d.wh]),f=t.getItemVisual(e,"symbolSize");c=X(f)?f.slice():null==f?["100%","100%"]:[f,f];c[d.index]=Vo(c[d.index],p),c[h.index]=Vo(c[h.index],i?p:Math.abs(o)),u.symbolSize=c;var g=u.symbolScale=[c[0]/s,c[1]/s];g[h.index]*=(l.isHorizontal?-1:1)*a}(t,e,r,o,0,h.boundingLength,h.pxSign,u,i,h),function(t,e,n,i,r){var o=t.get(kV)||0;o&&(PV.attr({scaleX:e[0],scaleY:e[1],rotation:n}),PV.updateTransform(),o/=PV.getLineScale(),o*=e[i.valueDim.index]);r.valueLineWidth=o||0}(n,h.symbolScale,l,i,h);var d=h.symbolSize,p=bx(n.get("symbolOffset"),d);return function(t,e,n,i,r,o,a,s,l,u,c,h){var d=c.categoryDim,p=c.valueDim,f=h.pxSign,g=Math.max(e[p.index]+s,0),v=g;if(i){var y=Math.abs(l),m=ot(t.get("symbolMargin"),"15%")+"",x=!1;m.lastIndexOf("!")===m.length-1&&(x=!0,m=m.slice(0,m.length-1));var _=Vo(m,e[p.index]),b=Math.max(g+2*_,0),w=x?0:2*_,S=aa(i),M=S?i:KV((y+w)/b);b=g+2*(_=(y-M*g)/2/(x?M:Math.max(M-1,1))),w=x?0:2*_,S||"fixed"===i||(M=u?KV((Math.abs(u)+w)/b):0),v=M*b-w,h.repeatTimes=M,h.symbolMargin=_}var I=f*(v/2),T=h.pathPosition=[];T[d.index]=n[d.wh]/2,T[p.index]="start"===a?I:"end"===a?l-I:l/2,o&&(T[0]+=o[0],T[1]+=o[1]);var C=h.bundlePosition=[];C[d.index]=n[d.xy],C[p.index]=n[p.xy];var D=h.barRectShape=k({},n);D[p.wh]=f*Math.max(Math.abs(n[p.wh]),Math.abs(T[p.index]+I)),D[d.wh]=n[d.wh];var A=h.clipShape={};A[d.xy]=-n[d.xy],A[d.wh]=c.ecSize[d.wh],A[p.xy]=0,A[p.wh]=n[p.wh]}(n,d,r,o,0,p,s,h.valueLineWidth,h.boundingLength,h.repeatCutLength,i,h),h}function NV(t,e){return t.toGlobalCoord(t.dataToCoord(t.scale.parse(e)))}function EV(t){var e=t.symbolPatternSize,n=xx(t.symbolType,-e/2,-e/2,e,e);return n.attr({culling:!0}),"image"!==n.type&&n.setStyle({strokeNoScale:!0}),n}function zV(t,e,n,i){var r=t.__pictorialBundle,o=n.symbolSize,a=n.valueLineWidth,s=n.pathPosition,l=e.valueDim,u=n.repeatTimes||0,c=0,h=o[e.valueDim.index]+a+2*n.symbolMargin;for(ZV(t,function(t){t.__pictorialAnimationIndex=c,t.__pictorialRepeatTimes=u,c0:i<0)&&(r=u-1-t),e[l.index]=h*(r-u/2+.5)+s[l.index],{x:e[0],y:e[1],scaleX:n.symbolScale[0],scaleY:n.symbolScale[1],rotation:n.rotation}}}function BV(t,e,n,i){var r=t.__pictorialBundle,o=t.__pictorialMainPath;o?jV(o,null,{x:n.pathPosition[0],y:n.pathPosition[1],scaleX:n.symbolScale[0],scaleY:n.symbolScale[1],rotation:n.rotation},n,i):(o=t.__pictorialMainPath=EV(n),r.add(o),jV(o,{x:n.pathPosition[0],y:n.pathPosition[1],scaleX:0,scaleY:0,rotation:n.rotation},{scaleX:n.symbolScale[0],scaleY:n.symbolScale[1]},n,i))}function VV(t,e,n){var i=k({},e.barRectShape),r=t.__pictorialBarRect;r?jV(r,null,{shape:i},e,n):((r=t.__pictorialBarRect=new lu({z2:2,shape:i,silent:!0,style:{stroke:"transparent",fill:"transparent",lineWidth:0}})).disableMorphing=!0,t.add(r))}function GV(t,e,n,i){if(n.symbolClip){var r=t.__pictorialClipPath,o=k({},n.clipShape),a=e.valueDim,s=n.animationModel,l=n.dataIndex;if(r)rd(r,{shape:o},s,l);else{o[a.wh]=0,r=new lu({shape:o}),t.__pictorialBundle.setClipPath(r),t.__pictorialClipPath=r;var u={};u[a.wh]=n.clipShape[a.wh],ep[i?"updateProps":"initProps"](r,{shape:u},s,l)}}}function FV(t,e){var n=t.getItemModel(e);return n.getAnimationDelayParams=HV,n.isAnimationEnabled=WV,n}function HV(t){return{index:t.__pictorialAnimationIndex,count:t.__pictorialRepeatTimes}}function WV(){return this.parentModel.isAnimationEnabled()&&!!this.getShallow("animation")}function UV(t,e,n,i){var r=new vo,o=new vo;return r.add(o),r.__pictorialBundle=o,o.x=n.bundlePosition[0],o.y=n.bundlePosition[1],n.symbolRepeat?zV(r,e,n):BV(r,0,n),VV(r,n,i),GV(r,e,n,i),r.__pictorialShapeStr=XV(t,n),r.__pictorialSymbolMeta=n,r}function YV(t,e,n,i){var r=i.__pictorialBarRect;r&&r.removeTextContent();var o=[];ZV(i,function(t){o.push(t)}),i.__pictorialMainPath&&o.push(i.__pictorialMainPath),i.__pictorialClipPath&&(n=null),B(o,function(t){sd(t,{scaleX:0,scaleY:0},n,e,function(){i.parent&&i.parent.remove(i)})}),t.setItemGraphicEl(e,null)}function XV(t,e){return[t.getItemVisual(e.dataIndex,"symbol")||"none",!!e.symbolRepeat,!!e.symbolClip].join(":")}function ZV(t,e,n){B(t.__pictorialBundle.children(),function(i){i!==t.__pictorialBarRect&&e.call(n,i)})}function jV(t,e,n,i,r,o){e&&t.attr(e),i.symbolClip&&!r?n&&t.attr(n):n&&ep[r?"updateProps":"initProps"](t,n,i.animationModel,i.dataIndex,o)}function qV(t,e,n){var i=n.dataIndex,r=n.itemModel,o=r.getModel("emphasis"),a=o.getModel("itemStyle").getItemStyle(),s=r.getModel(["blur","itemStyle"]).getItemStyle(),l=r.getModel(["select","itemStyle"]).getItemStyle(),u=r.getShallow("cursor"),c=o.get("focus"),h=o.get("blurScope"),d=o.get("scale");ZV(t,function(t){if(t instanceof eu){var e=t.style;t.useStyle(k({image:e.image,x:e.x,y:e.y,width:e.width,height:e.height},n.style))}else t.useStyle(n.style);var i=t.ensureState("emphasis");i.style=a,d&&(i.scaleX=1.1*t.scaleX,i.scaleY=1.1*t.scaleY),t.ensureState("blur").style=s,t.ensureState("select").style=l,u&&(t.cursor=u),t.z2=n.z2});var p=e.valueDim.posDesc[+(n.boundingLength>0)],f=t.__pictorialBarRect;f.ignoreClip=!0,op(f,ap(r),{labelFetcher:e.seriesModel,labelDataIndex:i,defaultText:jT(e.seriesModel.getData(),i),inheritColor:n.style.fill,defaultOpacity:n.style.opacity,defaultOutsidePosition:p}),kc(t,c,h,o.get("disabled"))}function KV(t){var e=Math.round(t);return Math.abs(t-e)<1e-4?e:Math.ceil(t)}var $V=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.hasSymbolVisual=!0,n.defaultSymbol="roundRect",n}return n(e,t),e.prototype.getInitialData=function(e){return e.stack=null,t.prototype.getInitialData.apply(this,arguments)},e.type="series."+OD,e.dependencies=["grid"],e.defaultOption=Op(qD.defaultOption,{symbol:"circle",symbolSize:null,symbolRotate:null,symbolPosition:null,symbolOffset:null,symbolMargin:null,symbolRepeat:!1,symbolRepeatDirection:"end",symbolClip:!1,symbolBoundingData:null,symbolPatternSize:400,barGap:"-100%",clip:!1,progressive:0,emphasis:{scale:!1},select:{itemStyle:{borderColor:ug.color.primary}}}),e}(qD);var JV="themeRiver",QV=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(e){t.prototype.init.apply(this,arguments),this.legendVisualProvider=new SA(U(this.getData,this),U(this.getRawData,this))},e.prototype.fixData=function(t){var e=t.length,n={},i=Fa(t,function(t){return n.hasOwnProperty(t[0]+"")||(n[t[0]+""]=-1),t[2]}),r=[];i.buckets.each(function(t,e){r.push({name:e,dataList:t})});for(var o=r.length,a=0;ao&&(o=s),i.push(s)}for(var u=0;uo&&(o=h)}return{y0:r,max:o}}(l),c=u.y0,h=n/u.max,d=o.length,p=o[0].indices.length,f=0;fI&&!$o(C-I)&&C0?(r.virtualPiece?r.virtualPiece.updateData(!1,i,t,e,n):(r.virtualPiece=new iG(i,t,e,n),l.add(r.virtualPiece)),o.piece.off("click"),r.virtualPiece.on("click",function(t){r._rootToNode(o.parentNode)})):r.virtualPiece&&(l.remove(r.virtualPiece),r.virtualPiece=null)}(a,s),this._initEvents(),this._oldChildren=c},e.prototype._initEvents=function(){var t=this;this.group.off("click"),this.group.on("click",function(e){var n=!1;t.seriesModel.getViewRoot().eachNode(function(i){if(!n&&i.piece&&i.piece===e.target){var r=i.getModel().get("nodeClick");if("rootToNode"===r)t._rootToNode(i);else if("link"===r){var o=i.getModel(),a=o.get("link");if(a)Ef(a,o.get("target",!0)||"_blank")}n=!0}})})},e.prototype._rootToNode=function(t){t!==this.seriesModel.getViewRoot()&&this.api.dispatchAction({type:sG,from:this.uid,seriesId:this.seriesModel.id,targetNode:t})},e.prototype.containPoint=function(t,e){var n=e.getData().getItemLayout(0);if(n){var i=t[0]-n.cx,r=t[1]-n.cy,o=Math.sqrt(i*i+r*r);return o<=n.r&&o>=n.r0}},e.type=rG,e}(tm),cG=os(rG,function(t){var e={};t.eachSeriesByType(rG,function(t){var n=t.getData(),i=n.tree;i.eachNode(function(r){var o=r.getModel().getModel("itemStyle").getItemStyle();o.fill||(o.fill=function(t,n,i){if(0===t.depth)return ug.color.neutral50;for(var r=t;r&&r.depth>1;)r=r.parentNode;var o=n.getColorFromPalette(r.name||r.dataIndex+"",e);return t.depth>1&&j(o)&&(o=yi(o,(t.depth-1)/(i-1)*.5)),o}(r,t,i.root.height)),k(n.ensureUniqueItemVisual(r.dataIndex,"style"),o)})})});var hG=Math.PI/180,dG=os(rG,function(t,e){t.eachSeriesByType(rG,function(t){var n=t.get("center"),i=t.get("radius");X(i)||(i=[0,i]),X(n)||(n=[n,n]);var r=e.getWidth(),o=e.getHeight(),a=Math.min(r,o),s=Vo(n[0],r),l=Vo(n[1],o),u=Vo(i[0],a/2),c=Vo(i[1],a/2),h=-t.get("startAngle")*hG,d=t.get("minAngle")*hG,p=t.getData().tree.root,f=t.getViewRoot(),g=f.depth,v=t.get("sort");null!=v&&pG(f,v);var y=0;B(f.children,function(t){!isNaN(t.getValue())&&y++});var m=f.getValue(),x=Math.PI/(m||y)*2,_=f.depth>0,b=f.height-(_?-1:1),w=(c-u)/(b||1),S=t.get("clockwise"),M=t.get("stillShowZeroSum"),I=S?1:-1,T=function(e,n){if(e){var i=n;if(e!==p){var r=e.getValue(),o=0===m&&M?x:r*x;o0&&(a.during=s?U(OG,{el:e,userDuring:s}):null,a.setToFinal=!0,a.scope=t),k(a,n[o]),a}function CG(t,e,n,i){var r=(i=i||{}).dataIndex,o=i.isInit,a=i.clearStyle,s=n.isAnimationEnabled(),l=IG(t),u=e.style;l.userDuring=e.during;var c={},h={};if(function(t,e,n){for(var i=0;i=0)){var h=t.getAnimationStyleProps(),d=h?h.style:null;if(d){!r&&(r=i.style={});var p=W(n);for(u=0;u0&&t.animateFrom(g,v)}else!function(t,e,n,i,r){if(r){var o=TG("update",t,e,i,n);o.duration>0&&t.animateFrom(r,o)}}(t,e,r||0,n,c);DG(t,e),u?t.dirty():t.markRedraw()}function DG(t,e){for(var n=IG(t).leaveToProps,i=0;i=0){!o&&(o=i[t]={});var d=W(a);for(c=0;ci[1]&&i.reverse(),{coordSys:{type:"polar",cx:t.cx,cy:t.cy,r:i[1],r0:i[0]},api:{coord:function(i){var r=e.dataToRadius(i[0]),o=n.dataToAngle(i[1]),a=t.coordToPoint([r,o]);return a.push(r,o*Math.PI/180),a},size:U(bG,t)}}},calendar:function(t){var e=t.getRect(),n=t.getRangeInfo();return{coordSys:{type:"calendar",x:e.x,y:e.y,width:e.width,height:e.height,cellWidth:t.getCellWidth(),cellHeight:t.getCellHeight(),rangeInfo:{start:n.start,end:n.end,weeks:n.weeks,dayCount:n.allDay}},api:{coord:function(e,n){return t.dataToPoint(e,n)},layout:function(e,n){return t.dataToLayout(e,n)}}}},matrix:function(t){var e=t.getRect();return{coordSys:{type:"matrix",x:e.x,y:e.y,width:e.width,height:e.height},api:{coord:function(e,n){return t.dataToPoint(e,n)},layout:function(e,n){return t.dataToLayout(e,n)}}}}};function $G(t){return t instanceof Kl}function JG(t){return t instanceof Zs}var QG=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n,i){this._progressiveEls=null;var r=this._data,o=t.getData(),a=this.group,s=rF(t,o,e,n);r||a.removeAll(),o.diff(r).add(function(e){aF(n,null,e,s(e,i),t,a,o)}).remove(function(e){var n=r.getItemGraphicEl(e);n&&AG(n,vG(n).option,t)}).update(function(e,l){var u=r.getItemGraphicEl(l);aF(n,u,e,s(e,i),t,a,o)}).execute();var l=t.get("clip",!0)?xC(t.coordinateSystem,!1,t):null;l?a.setClipPath(l):a.removeClipPath(),this._data=o},e.prototype.incrementalPrepareRender=function(t,e,n){this.group.removeAll(),this._data=null},e.prototype.incrementalRender=function(t,e,n,i,r){var o=e.getData(),a=rF(e,o,n,i),s=this._progressiveEls=[];function l(t){t.isGroup||(t.incremental=is(e),t.ensureState("emphasis").hoverLayer=2)}for(var u=t.start;u=0?e.getStore().get(r,n):void 0}var o=e.get(i.name,n),a=i&&i.ordinalMeta;return a?a.categories[o]:o},styleEmphasis:function(n,i){0;null==i&&(i=l);var r=x(i,FG).getItemStyle(),o=_(i,FG),a=sp(o,null,null,!0,!0);a.text=o.getShallow("show")?st(t.getFormattedLabel(i,FG),t.getFormattedLabel(i,HG),jT(e,i)):null;var s=lp(o,null,!0);return w(n,r),r=CC(r,a,s),n&&b(r,n),r.legacy=!0,r},visual:function(t,n){if(null==n&&(n=l),wt(fG,t)){var i=e.getItemVisual(n,"style");return i?i[fG[t]]:null}if(wt(gG,t))return e.getItemVisual(n,t)},barLayout:function(t){if("cartesian2d"===a.type){return function(t){if(vw(t.axis.scale)){for(var e=bM(t.axis),n=[],i=0;i=h;f--){var g=e.childAt(f);dF(e,g,r)}}(t,h,n,i,r),a>=0?o.replaceAt(h,a):o.add(h),h}function lF(t,e,n){var i,r=vG(t),o=e.type,a=e.shape,s=e.style;return n.isUniversalTransitionEnabled()||null!=o&&o!==r.customGraphicType||"path"===o&&((i=a)&&(wt(i,"pathData")||wt(i,"d")))&&vF(a)!==r.customPathData||"image"===o&&wt(s,"image")&&s.image!==r.customImagePath}function uF(t,e,n){var i=e?cF(t,e):t,r=e?hF(t,i,FG):t.style,o=t.type,a=i?i.textConfig:null,s=t.textContent,l=s?e?cF(s,e):s:null;if(r&&(n.isLegacy||MC(r,o,!!a,!!l))){n.isLegacy=!0;var u=IC(r,o,!e);!a&&u.textConfig&&(a=u.textConfig),!l&&u.textContent&&(l=u.textContent)}if(!e&&l){var c=l;!c.type&&(c.type="text")}var h=e?n[e]:n.normal;h.cfg=a,h.conOpt=l}function cF(t,e){return e?t?t[e]:null:t}function hF(t,e,n){var i=e&&e.style;return null==i&&n===FG&&t&&(i=t.styleEmphasis),i}function dF(t,e,n){e&&AG(e,vG(t).option,n)}function pF(t,e){var n=t&&t.name;return null!=n?n:"e\0\0"+e}function fF(t,e){var n=this.context,i=null!=t?n.newChildren[t]:null,r=null!=e?n.oldChildren[e]:null;sF(n.api,r,n.dataIndex,i,n.seriesModel,n.group)}function gF(t){var e=this.context,n=e.oldChildren[t];n&&AG(n,vG(n).option,e.seriesModel)}function vF(t){return t&&(t.pathData||t.d)}var yF=Pa(),mF=C,xF=U,_F=function(){function t(){this._dragging=!1,this.animationThreshold=15}return t.prototype.render=function(t,e,n,i){var r=e.get("value"),o=e.get("status");if(this._axisModel=t,this._axisPointerModel=e,this._api=n,i||this._lastValue!==r||this._lastStatus!==o){this._lastValue=r,this._lastStatus=o;var a=this._group,s=this._handle;if(!o||"hide"===o)return a&&a.hide(),void(s&&s.hide());a&&a.show(),s&&s.show();var l={};this.makeElOption(l,r,t,e,n);var u=l.graphicKey;u!==this._lastGraphicKey&&this.clear(n),this._lastGraphicKey=u;var c=this._moveAnimation=this.determineAnimation(t,e);if(a){var h=Y(bF,e,c);this.updatePointerEl(a,l,h),this.updateLabelEl(a,l,h,e)}else a=this._group=new vo,this.createPointerEl(a,l,t,e),this.createLabelEl(a,l,t,e),n.getZr().add(a);IF(a,e,!0),this._renderHandle(r)}},t.prototype.remove=function(t){this.clear(t)},t.prototype.dispose=function(t){this.clear(t)},t.prototype.determineAnimation=function(t,e){var n=e.get("animation"),i=t.axis,r="category"===i.type,o=e.get("snap");if(!o&&!r)return!1;if("auto"===n||null==n){var a=this.animationThreshold;if(r&&bM(i).w>a)return!0;if(o){var s=mk(t).seriesDataCount,l=i.getExtent();return Math.abs(l[0]-l[1])/s>a}return!1}return!0===n},t.prototype.makeElOption=function(t,e,n,i,r){},t.prototype.createPointerEl=function(t,e,n,i){var r=e.pointer;if(r){var o=yF(t).pointerEl=new ep[r.type](mF(e.pointer));t.add(o)}},t.prototype.createLabelEl=function(t,e,n,i){if(e.label){var r=yF(t).labelEl=new du(mF(e.label));t.add(r),SF(r,i)}},t.prototype.updatePointerEl=function(t,e,n){var i=yF(t).pointerEl;i&&e.pointer&&(i.setStyle(e.pointer.style),n(i,{shape:e.pointer.shape}))},t.prototype.updateLabelEl=function(t,e,n,i){var r=yF(t).labelEl;r&&(r.setStyle(e.label.style),n(r,{x:e.label.x,y:e.label.y}),SF(r,i))},t.prototype._renderHandle=function(t){if(!this._dragging&&this.updateHandleTransform){var e,n=this._axisPointerModel,i=this._api.getZr(),r=this._handle,o=n.getModel("handle"),a=n.get("status");if(!o.get("show")||!a||"hide"===a)return r&&i.remove(r),void(this._handle=null);this._handle||(e=!0,r=this._handle=Od(o.get("icon"),{cursor:"move",draggable:!0,onmousemove:function(t){ye(t.event)},onmousedown:xF(this._onHandleDragMove,this,0,0),drift:xF(this._onHandleDragMove,this),ondragend:xF(this._onHandleDragEnd,this)}),i.add(r)),IF(r,n,!1),r.setStyle(o.getItemStyle(null,["color","borderColor","borderWidth","opacity","shadowColor","shadowBlur","shadowOffsetX","shadowOffsetY"]));var s=o.get("size");X(s)||(s=[s,s]),r.scaleX=s[0]/2,r.scaleY=s[1]/2,cm(this,"_doDispatchAxisPointer",o.get("throttle")||0,"fixRate"),this._moveHandleToValue(t,e)}},t.prototype._moveHandleToValue=function(t,e){bF(this._axisPointerModel,!e&&this._moveAnimation,this._handle,MF(this.getHandleTransform(t,this._axisModel,this._axisPointerModel)))},t.prototype._onHandleDragMove=function(t,e){var n=this._handle;if(n){this._dragging=!0;var i=this.updateHandleTransform(MF(n),[t,e],this._axisModel,this._axisPointerModel);this._payloadInfo=i,n.stopAnimation(),n.attr(MF(i)),yF(n).lastProp=null,this._doDispatchAxisPointer()}},t.prototype._doDispatchAxisPointer=function(){if(this._handle){var t=this._payloadInfo,e=this._axisModel;this._api.dispatchAction({type:"updateAxisPointer",x:t.cursorPoint[0],y:t.cursorPoint[1],tooltipOption:t.tooltipOption,axesInfo:[{axisDim:e.axis.dim,axisIndex:e.componentIndex}]})}},t.prototype._onHandleDragEnd=function(){if(this._dragging=!1,this._handle){var t=this._axisPointerModel.get("value");this._moveHandleToValue(t),this._api.dispatchAction({type:"hideTip"})}},t.prototype.clear=function(t){this._lastValue=null,this._lastStatus=null;var e=t.getZr(),n=this._group,i=this._handle;e&&n&&(this._lastGraphicKey=null,n&&e.remove(n),i&&e.remove(i),this._group=null,this._handle=null,this._payloadInfo=null),hm(this,"_doDispatchAxisPointer")},t.prototype.doClear=function(){},t.prototype.buildLabel=function(t,e,n){return{x:t[n=n||0],y:t[1-n],width:e[n],height:e[1-n]}},t}();function bF(t,e,n,i){wF(yF(n).lastProp,i)||(yF(n).lastProp=i,e?rd(n,i,t):(n.stopAnimation(),n.attr(i)))}function wF(t,e){if($(t)&&$(e)){var n=!0;return B(e,function(e,i){n=n&&wF(t[i],e)}),!!n}return t===e}function SF(t,e){t[e.get(["label","show"])?"show":"hide"]()}function MF(t){return{x:t.x||0,y:t.y||0,rotation:t.rotation||0}}function IF(t,e,n){var i=e.get("z"),r=e.get("zlevel");t&&t.traverse(function(t){"group"!==t.type&&(null!=i&&(t.z=i),null!=r&&(t.zlevel=r),t.silent=n)})}function TF(t){var e,n=t.get("type"),i=t.getModel(n+"Style");return"line"===n?(e=i.getLineStyle()).fill=null:"shadow"===n&&((e=i.getAreaStyle()).stroke=null),e}function CF(t,e,n,i,r){var o=DF(n.get("value"),e.axis,e.ecModel,n.get("seriesDataIndices"),{precision:n.get(["label","precision"]),formatter:n.get(["label","formatter"])}),a=n.getModel("label"),s=Df(a.get("padding")||0),l=a.getFont(),u=qr(o,l),c=r.position,h=u.width+s[1]+s[3],d=u.height+s[0]+s[2],p=r.align;"right"===p&&(c[0]-=h),"center"===p&&(c[0]-=h/2);var f=r.verticalAlign;"bottom"===f&&(c[1]-=d),"middle"===f&&(c[1]-=d/2),function(t,e,n,i){var r=i.getWidth(),o=i.getHeight();t[0]=Math.min(t[0]+e,r)-e,t[1]=Math.min(t[1]+n,o)-n,t[0]=Math.max(t[0],0),t[1]=Math.max(t[1],0)}(c,h,d,i);var g=a.get("backgroundColor");g&&"auto"!==g||(g=e.get(["axisLine","lineStyle","color"])),t.label={x:c[0],y:c[1],style:sp(a,{text:o,font:l,fill:a.getTextColor(),padding:s,backgroundColor:g}),z2:10}}function DF(t,e,n,i,r){t=e.scale.parse(t);var o=e.scale.getLabel({value:t},{precision:r.precision}),a=r.formatter;if(a){var s={value:jw(e,{value:t}),axisDimension:e.dim,axisIndex:e.index,seriesData:[]};B(i,function(t){var e=n.getSeriesByIndex(t.seriesIndex),i=t.dataIndexInside,r=e&&e.getDataParams(i);r&&s.seriesData.push(r)}),j(a)?o=a.replace("{value}",o):Z(a)&&(o=a(s))}return o}function AF(t,e,n){var i=[1,0,0,1,0,0];return Ce(i,i,n.rotation),Te(i,i,n.position),Cd([t.dataToCoord(e),(n.labelOffset||0)+(n.labelDirection||1)*(n.labelMargin||0)],i)}function kF(t,e,n,i,r,o){var a=dD.innerTextLayout(n.rotation,0,n.labelDirection);n.labelMargin=r.get(["label","margin"]),CF(e,i,r,o,{position:AF(i.axis,t,n),align:a.textAlign,verticalAlign:a.textVerticalAlign})}function LF(t,e,n){return{x1:t[n=n||0],y1:t[1-n],x2:e[n],y2:e[1-n]}}function PF(t,e,n){return{x:t[n=n||0],y:t[1-n],width:e[n],height:e[1-n]}}function OF(t,e,n,i,r,o){return{cx:t,cy:e,r0:n,r:i,startAngle:r,endAngle:o,clockwise:!0}}function RF(t,e,n){return bM(t,{fromStat:{sers:V(e,function(t){return n.getSeriesByIndex(t.seriesIndex)})},min:1}).w}function NF(t,e,n){return[Do(Co(e[0],e[1]),t-n/2),Co(t+n/2,Do(e[0],e[1]))]}var EF=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.makeElOption=function(t,e,n,i,r){var o=n.axis,a=o.grid,s=i.get("type"),l=o.getGlobalExtent(),u=zF(a,o).getOtherAxis(o).getGlobalExtent(),c=o.toGlobalCoord(o.dataToCoord(e,!0));if(s&&"none"!==s){var h=TF(i),d=BF[s](o,c,l,u,i.get("seriesDataIndices"),i.ecModel);d.style=h,t.graphicKey=d.type,t.pointer=d}kF(e,t,MD(a.getRect(),n),n,i,r)},e.prototype.getHandleTransform=function(t,e,n){var i=MD(e.axis.grid.getRect(),e,{labelInside:!1});i.labelMargin=n.get(["handle","margin"]);var r=AF(e.axis,t,i);return{x:r[0],y:r[1],rotation:i.rotation+(i.labelDirection<0?Math.PI:0)}},e.prototype.updateHandleTransform=function(t,e,n,i){var r=n.axis,o=r.grid,a=r.getGlobalExtent(!0),s=zF(o,r).getOtherAxis(r).getGlobalExtent(),l="x"===r.dim?0:1,u=[t.x,t.y];u[l]+=e[l],u[l]=Co(a[1],u[l]),u[l]=Do(a[0],u[l]);var c=(s[1]+s[0])/2,h=[c,c];h[l]=u[l];return{x:u[0],y:u[1],rotation:t.rotation,cursorPoint:h,tooltipOption:[{verticalAlign:"middle"},{align:"center"}][l]}},e}(_F);function zF(t,e){var n={};return n[e.dim+"AxisIndex"]=e.index,t.getCartesian(n)}var BF={line:function(t,e,n,i){return{type:"Line",subPixelOptimize:!0,shape:LF([e,i[0]],[e,i[1]],VF(t))}},shadow:function(t,e,n,i,r,o){var a=RF(t,r,o),s=i[1]-i[0],l=NF(e,n,a),u=l[0],c=l[1];return{type:"Rect",shape:PF([u,i[0]],[c-u,s],VF(t))}}};function VF(t){return"x"===t.dim?0:1}var GF=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="axisPointer",e.defaultOption={show:"auto",z:50,type:"line",snap:!1,triggerTooltip:!0,triggerEmphasis:!0,value:null,status:null,link:[],animation:null,animationDurationUpdate:200,lineStyle:{color:ug.color.border,width:1,type:"dashed"},shadowStyle:{color:ug.color.shadowTint},label:{show:!0,formatter:null,precision:"auto",margin:3,color:ug.color.neutral00,padding:[5,7,5,7],backgroundColor:ug.color.accent60,borderColor:null,borderWidth:0,borderRadius:3},handle:{show:!1,icon:"M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4h1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7v-1.2h6.6z M13.3,22H6.7v-1.2h6.6z M13.3,19.6H6.7v-1.2h6.6z",size:45,margin:50,color:ug.color.accent40,throttle:40}},e}(lg),FF=Pa(),HF=B;function WF(t,e,n){if(!r.node){var i=e.getZr();FF(i).records||(FF(i).records={}),function(t,e){if(FF(t).initialized)return;function n(n,i){t.on(n,function(n){var r=function(t){var e={showTip:[],hideTip:[]},n=function(i){var r=e[i.type];r?r.push(i):(i.dispatchAction=n,t.dispatchAction(i))};return{dispatchAction:n,pendings:e}}(e);HF(FF(t).records,function(t){t&&i(t,n,r.dispatchAction)}),function(t,e){var n,i=t.showTip.length,r=t.hideTip.length;i?n=t.showTip[i-1]:r&&(n=t.hideTip[r-1]);n&&(n.dispatchAction=null,e.dispatchAction(n))}(r.pendings,e)})}FF(t).initialized=!0,n("click",Y(YF,"click")),n("mousemove",Y(YF,"mousemove")),n("mousewheel",Y(YF,"mousewheel")),n("globalout",UF)}(i,e),(FF(i).records[t]||(FF(i).records[t]={})).handler=n}}function UF(t,e,n){t.handler("leave",null,n)}function YF(t,e,n,i){e.handler(t,n,i)}function XF(t,e){if(!r.node){var n=e.getZr();(FF(n).records||{})[t]&&(FF(n).records[t]=null)}}var ZF=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n){var i=e.getComponent("tooltip"),r=t.get("triggerOn")||i&&i.get("triggerOn")||"mousemove|click|mousewheel";WF("axisPointer",n,function(t,e,n){"none"!==r&&("leave"===t||r.indexOf(t)>=0)&&n({type:"updateAxisPointer",currTrigger:t,x:e&&e.offsetX,y:e&&e.offsetY})})},e.prototype.remove=function(t,e){XF("axisPointer",e)},e.prototype.dispose=function(t,e){XF("axisPointer",e)},e.type="axisPointer",e}(Ky);function jF(t,e){var n,i=[],r=t.seriesIndex;if(null==r||!(n=e.getSeriesByIndex(r)))return{point:[]};var o=n.getData(),a=La(o,t);if(null==a||a<0||X(a))return{point:[]};var s=o.getItemGraphicEl(a),l=n.coordinateSystem;if(n.getTooltipPosition)i=n.getTooltipPosition(a)||[];else if(l&&l.dataToPoint)if(t.isStacked){var u=l.getBaseAxis(),c=l.getOtherAxis(u).dim,h=u.dim,d="x"===c||"radius"===c?1:0,p=o.mapDimension(h),f=[];f[d]=o.get(p,a),f[1-d]=o.get(o.getCalculationInfo("stackResultDimension"),a),i=l.dataToPoint(f)||[]}else i=l.dataToPoint(o.getValues(V(l.dimensions,function(t){return o.mapDimension(t)}),a))||[];else if(s){var g=s.getBoundingRect().clone();g.applyTransform(s.transform),i=[g.x+g.width/2,g.y+g.height/2]}return{point:i,el:s}}var qF=Pa();function KF(t,e,n){var i=t.currTrigger,r=[t.x,t.y],o=t,a=t.dispatchAction||U(n.dispatchAction,n),s=e.getComponent("axisPointer").coordSysAxesInfo;if(s){eH(r)&&(r=jF({seriesIndex:o.seriesIndex,dataIndex:o.dataIndex},e).point);var l=eH(r),u=o.axesInfo,c=s.axesInfo,h="leave"===i||eH(r),d={},p={},f={list:[],map:{}},g={showPointer:Y(JF,p),showTooltip:Y(QF,f)};B(s.coordSysMap,function(t,e){var n=l||t.containPoint(r);B(s.coordSysAxesInfo[e],function(t,e){var i=t.axis,o=function(t,e){for(var n=0;n<(t||[]).length;n++){var i=t[n];if(e.axis.dim===i.axisDim&&e.axis.model.componentIndex===i.axisIndex)return i}}(u,t);if(!h&&n&&(!u||o)){var a=o&&o.value;null!=a||l||(a=i.pointToData(r)),null!=a&&$F(t,a,g,!1,d)}})});var v={};return B(c,function(t,e){var n=t.linkGroup;n&&!p[e]&&B(n.axesInfo,function(e,i){var r=p[i];if(e!==t&&r){var o=r.value;n.mapper&&(o=t.axis.scale.parse(n.mapper(o,tH(e),tH(t)))),v[t.key]=o}})}),B(v,function(t,e){$F(c[e],t,g,!0,d)}),function(t,e,n){var i=n.axesInfo=[];B(e,function(e,n){var r=e.axisPointerModel.option,o=t[n];o?(!e.useHandle&&(r.status="show"),r.value=o.value,r.seriesDataIndices=(o.payloadBatch||[]).slice()):!e.useHandle&&(r.status="hide"),"show"===r.status&&i.push({axisDim:e.axis.dim,axisIndex:e.axis.model.componentIndex,value:r.value})})}(p,c,d),function(t,e,n,i){if(eH(e)||!t.list.length)return void i({type:"hideTip"});var r=((t.list[0].dataByAxis[0]||{}).seriesDataIndices||[])[0]||{};i({type:"showTip",escapeConnect:!0,x:e[0],y:e[1],tooltipOption:n.tooltipOption,position:n.position,dataIndexInside:r.dataIndexInside,dataIndex:r.dataIndex,seriesIndex:r.seriesIndex,dataByCoordSys:t.list})}(f,r,t,a),function(t,e,n){var i=n.getZr(),r="axisPointerLastHighlights",o=qF(i)[r]||{},a=qF(i)[r]={};B(t,function(t,e){var n=t.axisPointerModel.option;"show"===n.status&&t.triggerEmphasis&&B(n.seriesDataIndices,function(t){a[t.seriesIndex+"|"+t.dataIndex]=t})});var s=[],l=[];function u(t){return{seriesIndex:t.seriesIndex,dataIndex:t.dataIndex}}B(o,function(t,e){!a[e]&&l.push(u(t))}),B(a,function(t,e){!o[e]&&s.push(u(t))}),l.length&&n.dispatchAction({type:"downplay",escapeConnect:!0,notBlur:!0,batch:l}),s.length&&n.dispatchAction({type:"highlight",escapeConnect:!0,notBlur:!0,batch:s})}(c,0,n),d}}function $F(t,e,n,i,r){var o=t.axis;if(!o.scale.isBlank()&&o.containData(e))if(t.involveSeries){var a=function(t,e){var n=e.axis,i=n.dim,r=t,o=[],a=Number.MAX_VALUE,s=-1;return B(e.seriesModels,function(e,l){var u,c,h=e.getData().mapDimensionsAll(i);if(e.getAxisTooltipData){var d=e.getAxisTooltipData(h,t,n);c=d.dataIndices,u=d.nestestValue}else{if(!(c=e.indicesOfNearest(i,h[0],t,"category"===n.type?.5:null)).length)return;u=e.getData().get(h[0],c[0])}if(ca(u)){var p=t-u,f=Math.abs(p);f<=a&&((f=0&&s<0)&&(a=f,s=p,r=u,o.length=0),B(c,function(t){o.push({seriesIndex:e.seriesIndex,dataIndexInside:t,dataIndex:e.getData().getRawIndex(t)})}))}}),{payloadBatch:o,snapToValue:r}}(e,t),s=a.payloadBatch,l=a.snapToValue;s[0]&&null==r.seriesIndex&&k(r,s[0]),!i&&t.snap&&o.containData(l)&&null!=l&&(e=l),n.showPointer(t,e,s),n.showTooltip(t,a,l)}else n.showPointer(t,e)}function JF(t,e,n,i){t[e.key]={value:n,payloadBatch:i}}function QF(t,e,n,i){var r=n.payloadBatch,o=e.axis,a=o.model,s=e.axisPointerModel;if(e.triggerTooltip&&r.length){var l=e.coordSys.model,u=_k(l),c=t.map[u];c||(c=t.map[u]={coordSysId:l.id,coordSysIndex:l.componentIndex,coordSysType:l.type,coordSysMainType:l.mainType,dataByAxis:[]},t.list.push(c)),c.dataByAxis.push({axisDim:o.dim,axisIndex:a.componentIndex,axisType:a.type,axisId:a.id,value:i,valueLabelOpt:{precision:s.get(["label","precision"]),formatter:s.get(["label","formatter"])},seriesDataIndices:r.slice()})}}function tH(t){var e=t.axis.model,n={},i=n.axisDim=t.axis.dim;return n.axisIndex=n[i+"AxisIndex"]=e.componentIndex,n.axisName=n[i+"AxisName"]=e.name,n.axisId=n[i+"AxisId"]=e.id,n}function eH(t){return!t||null==t[0]||isNaN(t[0])||null==t[1]||isNaN(t[1])}function nH(t){wk.registerAxisPointerClass("CartesianAxisPointer",EF),t.registerComponentModel(GF),t.registerComponentView(ZF),t.registerPreprocessor(function(t){if(t){(!t.axisPointer||0===t.axisPointer.length)&&(t.axisPointer={});var e=t.axisPointer.link;e&&!X(e)&&(t.axisPointer.link=[e])}}),t.registerProcessor(t.PRIORITY.PROCESSOR.STATISTIC,{overallReset:function(t,e){t.getComponent("axisPointer").coordSysAxesInfo=vk(t,e)}}),t.registerAction({type:"updateAxisPointer",event:"updateAxisPointer",update:":updateAxisPointer"},KF)}var iH=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.makeElOption=function(t,e,n,i,r){var o=n.axis;"angle"===o.dim&&(this.animationThreshold=Math.PI/18);var a=o.polar,s=o.getExtent(),l=a.getOtherAxis(o).getExtent(),u=o.dataToCoord(e),c=i.get("type");if(c&&"none"!==c){var h=TF(i),d=rH[c](o,a,u,s,l,i.get("seriesDataIndices"),i.ecModel);d.style=h,t.graphicKey=d.type,t.pointer=d}var p=function(t,e,n,i,r){var o=e.axis,a=o.dataToCoord(t),s=i.getAngleAxis().getExtent()[0];s=s/180*Math.PI;var l,u,c,h=i.getRadiusAxis().getExtent();if("radius"===o.dim){var d=[1,0,0,1,0,0];Ce(d,d,s),Te(d,d,[i.cx,i.cy]),l=Cd([a,-r],d);var p=e.getModel("axisLabel").get("rotate")||0,f=dD.innerTextLayout(s,p*Math.PI/180,-1);u=f.textAlign,c=f.textVerticalAlign}else{var g=h[1];l=i.coordToPoint([g+r,a]);var v=i.cx,y=i.cy;u=Math.abs(l[0]-v)/g<.3?"center":l[0]>v?"left":"right",c=Math.abs(l[1]-y)/g<.3?"middle":l[1]>y?"top":"bottom"}return{position:l,align:u,verticalAlign:c}}(e,n,0,a,i.get(["label","margin"]));CF(t,n,i,r,p)},e}(_F);var rH={line:function(t,e,n,i,r){return"angle"===t.dim?{type:"Line",shape:LF(e.coordToPoint([r[0],n]),e.coordToPoint([r[1],n]))}:{type:"Circle",shape:{cx:e.cx,cy:e.cy,r:n}}},shadow:function(t,e,n,i,r,o,a){var s,l=Math.PI/180,u=RF(t,o,a);if("angle"===t.dim)s=OF(e.cx,e.cy,r[0],r[1],(-n-u/2)*l,(u/2-n)*l);else{var c=NF(n,i,u),h=c[0],d=c[1];s=OF(e.cx,e.cy,h,d,0,2*Math.PI)}return{type:"Sector",shape:s}}},oH="polar",aH=oH,sH=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.findAxisModel=function(t){var e;return this.ecModel.eachComponent(t,function(t){t.getCoordSysModel()===this&&(e=t)},this),e},e.type=oH,e.dependencies=["radiusAxis","angleAxis"],e.defaultOption={z:0,center:["50%","50%"],radius:"80%"},e}(lg),lH=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.getCoordSysModel=function(){return this.getReferringComponents("polar",Ea).models[0]},e.type="polarAxis",e}(lg);E(lH,nS);var uH=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="angleAxis",e}(lH),cH=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="radiusAxis",e}(lH),hH=function(t){function e(e,n){return t.call(this,"radius",e,n)||this}return n(e,t),e.prototype.pointToData=function(t,e){return this.polar.pointToData(t,e)["radius"===this.dim?0:1]},e}(SM);hH.prototype.dataToRadius=SM.prototype.dataToCoord,hH.prototype.radiusToData=SM.prototype.coordToData;var dH=Pa(),pH=function(t){function e(e,n){return t.call(this,"angle",e,n||[0,360])||this}return n(e,t),e.prototype.pointToData=function(t,e){return this.polar.pointToData(t,e)["radius"===this.dim?0:1]},e.prototype.calculateCategoryInterval=function(){var t=this,e=t.getLabelModel(),n=t.scale,i=n.getExtent(),r=n.count();if(i[1]-i[0]<1)return 0;var o=i[0],a=t.dataToCoord(o+1)-t.dataToCoord(o),s=Math.abs(a),l=qr(null==o?"":o+"",e.getFont(),"center","top"),u=Math.max(l.height,7)/s;isNaN(u)&&(u=1/0);var c=Math.max(0,Math.floor(u)),h=dH(t.model),d=h.lastAutoInterval,p=h.lastTickCount;return null!=d&&null!=p&&Math.abs(d-c)<=1&&Math.abs(p-r)<=1&&d>c?c=d:(h.lastTickCount=r,h.lastAutoInterval=c),c},e}(SM);pH.prototype.dataToAngle=SM.prototype.dataToCoord,pH.prototype.angleToData=SM.prototype.coordToData;var fH=["radius","angle"],gH=function(){function t(t){this.dimensions=fH,this.type=oH,this.cx=0,this.cy=0,this._radiusAxis=new hH,this._angleAxis=new pH,this.axisPointerEnabled=!0,this.name=t||"",this._radiusAxis.polar=this._angleAxis.polar=this}return t.prototype.containPoint=function(t){var e=this.pointToCoord(t);return this._radiusAxis.contain(e[0])&&this._angleAxis.contain(e[1])},t.prototype.containData=function(t){return this._radiusAxis.containData(t[0])&&this._angleAxis.containData(t[1])},t.prototype.getAxis=function(t){return this["_"+t+"Axis"]},t.prototype.getAxes=function(){return[this._radiusAxis,this._angleAxis]},t.prototype.getAxesByScale=function(t){var e=[],n=this._angleAxis,i=this._radiusAxis;return n.scale.type===t&&e.push(n),i.scale.type===t&&e.push(i),e},t.prototype.getAngleAxis=function(){return this._angleAxis},t.prototype.getRadiusAxis=function(){return this._radiusAxis},t.prototype.getOtherAxis=function(t){var e=this._angleAxis;return t===e?this._radiusAxis:e},t.prototype.getBaseAxis=function(){return this.getAxesByScale("ordinal")[0]||this.getAxesByScale("time")[0]||this.getAngleAxis()},t.prototype.getTooltipAxes=function(t){var e=null!=t&&"auto"!==t?this.getAxis(t):this.getBaseAxis();return{baseAxes:[e],otherAxes:[this.getOtherAxis(e)]}},t.prototype.dataToPoint=function(t,e,n){return this.coordToPoint([this._radiusAxis.dataToRadius(t[0],e),this._angleAxis.dataToAngle(t[1],e)],n)},t.prototype.pointToData=function(t,e,n){n=n||[];var i=this.pointToCoord(t);return n[0]=this._radiusAxis.radiusToData(i[0],e),n[1]=this._angleAxis.angleToData(i[1],e),n},t.prototype.pointToCoord=function(t){var e=t[0]-this.cx,n=t[1]-this.cy,i=this.getAngleAxis(),r=i.getExtent(),o=Math.min(r[0],r[1]),a=Math.max(r[0],r[1]);i.inverse?o=a-360:a=o+360;var s=Math.sqrt(e*e+n*n);e/=s,n/=s;for(var l=Math.atan2(-n,e)/Math.PI*180,u=la;)l+=360*u;return[s,l]},t.prototype.coordToPoint=function(t,e){e=e||[];var n=t[0],i=t[1]/180*Math.PI;return e[0]=Math.cos(i)*n+this.cx,e[1]=-Math.sin(i)*n+this.cy,e},t.prototype.getArea=function(){var t=this.getAngleAxis(),e=this.getRadiusAxis().getExtent().slice();e[0]>e[1]&&e.reverse();var n=t.getExtent(),i=Math.PI/180,r=1e-4;return{cx:this.cx,cy:this.cy,r0:e[0],r:e[1],startAngle:-n[0]*i,endAngle:-n[1]*i,clockwise:t.inverse,contain:function(t,e){var n=t-this.cx,i=e-this.cy,o=n*n+i*i,a=this.r,s=this.r0;return a!==s&&o-r<=a*a&&o+r>=s*s},x:this.cx-e[1],y:this.cy-e[1],width:2*e[1],height:2*e[1]}},t.prototype.convertToPixel=function(t,e,n){return vH(e)===this?this.dataToPoint(n):null},t.prototype.convertFromPixel=function(t,e,n){return vH(e)===this?this.pointToData(n):null},t}();function vH(t){var e=t.seriesModel,n=t.polarModel;return n&&n.coordinateSystem||e&&e.coordinateSystem}function yH(t,e){var n=this.getAngleAxis(),i=this.getRadiusAxis();if(CS(n,1),CS(i,1),RS(n),RS(i),"category"===n.type&&!n.onBand){var r=n.getExtent(),o=360/n.scale.count();n.inverse?r[1]+=o:r[1]-=o,n.setExtent(r[0],r[1])}}function mH(t,e){var n;if(t.type=Hw(e),t.scale=Ww(e,t.type,!1),t.onBand=eS(t.scale,e),t.inverse=e.get("inverse"),function(t){return"angleAxis"===t.mainType}(e)){t.inverse=t.inverse!==e.get("clockwise");var i=e.get("startAngle"),r=null!==(n=e.get("endAngle"))&&void 0!==n?n:i+(t.inverse?-360:360);t.setExtent(i,r)}e.axis=t,t.model=e}var xH={dimensions:fH,create:function(t,e){var n=[];return t.eachComponent(aH,function(t,i){var r=new gH(i+"");r.update=yH;var o=r.getRadiusAxis(),a=r.getAngleAxis(),s=t.findAxisModel("radiusAxis"),l=t.findAxisModel("angleAxis");mH(o,s),mH(a,l),function(t,e,n){var i=e.get("center"),r=eg(e,n).refContainer;t.cx=Vo(i[0],r.width)+r.x,t.cy=Vo(i[1],r.height)+r.y;var o=t.getRadiusAxis(),a=Math.min(r.width,r.height)/2,s=e.get("radius");null==s?s=[0,"100%"]:X(s)||(s=[0,s]);var l=[Vo(s[0],a),Vo(s[1],a)];o.inverse?o.setExtent(l[1],l[0]):o.setExtent(l[0],l[1])}(r,t,e),n.push(r),t.coordinateSystem=r,r.model=t}),t.eachSeries(function(t){if(t.get("coordinateSystem")===oH){var e=t.getReferringComponents(aH,Ea).models[0];0;var n=t.coordinateSystem=e.coordinateSystem;n&&(yS(n.getRadiusAxis(),t,oH),yS(n.getAngleAxis(),t,oH))}}),n}},_H=["axisLine","axisLabel","axisTick","minorTick","splitLine","minorSplitLine","splitArea"];function bH(t,e,n){e[1]>e[0]&&(e=e.slice().reverse());var i=t.coordToPoint([e[0],n]),r=t.coordToPoint([e[1],n]);return{x1:i[0],y1:i[1],x2:r[0],y2:r[1]}}function wH(t){return t.getRadiusAxis().inverse?0:1}function SH(t){var e=t[0],n=t[t.length-1];e&&n&&Math.abs(Math.abs(e.coord-n.coord)-360)<1e-4&&t.pop()}var MH=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.axisPointerClass="PolarAxisPointer",n}return n(e,t),e.prototype.render=function(t,e){if(this.group.removeAll(),t.get("show")){var n=t.axis,i=n.polar,r=i.getRadiusAxis().getExtent(),o=n.getTicksCoords({breakTicks:"none"}),a=n.getMinorTicksCoords(),s=[];B(n.getViewLabels(),function(t){if(!t.tick.offInterval){t=C(t);var e=n.scale;t.coord=n.dataToCoord(tS(e,t.tick)),s.push(t)}}),SH(s),SH(o),B(_H,function(e){!t.get([e,"show"])||n.scale.isBlank()&&"axisLine"!==e||IH[e](this.group,t,i,o,a,r,s)},this)}},e.type="angleAxis",e}(wk),IH={axisLine:function(t,e,n,i,r,o){var a,s=e.getModel(["axisLine","lineStyle"]),l=n.getAngleAxis(),u=Math.PI/180,c=l.getExtent(),h=wH(n),d=h?0:1,p=360===Math.abs(c[1]-c[0])?"Circle":"Arc";(a=0===o[d]?new ep[p]({shape:{cx:n.cx,cy:n.cy,r:o[h],startAngle:-c[0]*u,endAngle:-c[1]*u,clockwise:l.inverse},style:s.getLineStyle(),z2:1,silent:!0}):new Ih({shape:{cx:n.cx,cy:n.cy,r:o[h],r0:o[d]},style:s.getLineStyle(),z2:1,silent:!0})).style.fill=null,t.add(a)},axisTick:function(t,e,n,i,r,o){var a=e.getModel("axisTick"),s=(a.get("inside")?-1:1)*a.get("length"),l=o[wH(n)],u=V(i,function(t){return new Oh({shape:bH(n,[l,l+s],t.coord)})});t.add(wd(u,{style:P(a.getModel("lineStyle").getLineStyle(),{stroke:e.get(["axisLine","lineStyle","color"])})}))},minorTick:function(t,e,n,i,r,o){if(r.length){for(var a=e.getModel("axisTick"),s=e.getModel("minorTick"),l=(a.get("inside")?-1:1)*s.get("length"),u=o[wH(n)],c=[],h=0;hf?"left":"right",y=Math.abs(p[1]-g)/d<.3?"middle":p[1]>g?"top":"bottom";if(s&&s[h]){var m=s[h];$(m)&&m.textStyle&&(a=new kp(m.textStyle,l,l.ecModel))}var x=new du({silent:dD.isLabelSilent(e),style:sp(a,{x:p[0],y:p[1],fill:a.getTextColor()||e.get(["axisLine","lineStyle","color"]),text:i.formattedLabel,align:v,verticalAlign:y})});if(t.add(x),Gd({el:x,componentModel:e,itemName:i.formattedLabel,formatterParamsExtra:{isTruncated:function(){return x.isTruncated},value:i.rawLabel,tickIndex:r}}),c){var _=dD.makeAxisEventDataBase(e);_.targetType="axisLabel",_.value=i.rawLabel,Iu(x).eventData=_}},this)},splitLine:function(t,e,n,i,r,o){var a=e.getModel("splitLine").getModel("lineStyle"),s=a.get("color"),l=0;s=s instanceof Array?s:[s];for(var u=[],c=0;c=0?"p":"n",I=x;y&&(i[o][S]||(i[o][S]={p:x,n:x}),I=i[o][S][M]);var T=void 0,C=void 0,D=void 0,A=void 0;if("radius"===c.dim){var k=c.dataToCoord(w)-x,L=t.dataToCoord(S);Ao(k)=A})}}(t,e,n,i)})})});var PH={startAngle:90,clockwise:!0,splitNumber:12,containShape:!1,axisLabel:{rotate:0}},OH={splitNumber:5},RH=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="polar",e}(Ky);function NH(t,e){e=e||{};var n=t.coordinateSystem,i=t.axis,r={},o=i.position,a=i.orient,s=n.getRect(),l=[s.x,s.x+s.width,s.y,s.y+s.height],u={horizontal:{top:l[2],bottom:l[3]},vertical:{left:l[0],right:l[1]}};r.position=["vertical"===a?u.vertical[o]:l[0],"horizontal"===a?u.horizontal[o]:l[3]];r.rotation=Math.PI/2*{horizontal:0,vertical:1}[a];r.labelDirection=r.tickDirection=r.nameDirection={top:-1,bottom:1,right:1,left:-1}[o],t.get(["axisTick","inside"])&&(r.tickDirection=-r.tickDirection),ot(e.labelInside,t.get(["axisLabel","inside"]))&&(r.labelDirection=-r.labelDirection);var c=t.get(["axisLabel","rotate"]);return r.labelRotate="top"===o?-c:c,r.z2=1,r}var EH=["splitArea","splitLine","breakArea"],zH=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.axisPointerClass="SingleAxisPointer",n}return n(e,t),e.prototype.render=function(e,n,i,r){var o=this.group;o.removeAll();var a=this._axisGroup;this._axisGroup=new vo;var s=NH(e),l=new dD(e,i,s);l.build(),o.add(this._axisGroup),o.add(l.group),B(EH,function(t){e.get([t,"show"])&&BH[t](this,this.group,this._axisGroup,e,i)},this),kd(a,this._axisGroup,e),t.prototype.render.call(this,e,n,i,r)},e.prototype.remove=function(){Ik(this)},e.type="singleAxis",e}(wk),BH={splitLine:function(t,e,n,i,r){var o=i.axis;if(!o.scale.isBlank()){var a=i.getModel("splitLine"),s=a.getModel("lineStyle"),l=s.get("color");l=l instanceof Array?l:[l];for(var u=s.get("width"),c=i.coordinateSystem.getRect(),h=o.isHorizontal(),d=[],p=0,f=o.getTicksCoords({tickModel:a,breakTicks:"none",pruneByBreak:"preserve_extent_bound"}),g=[],v=[],y=0;y=e.y&&t[1]<=e.y+e.height:n.contain(n.toLocalCoord(t[1]))&&t[0]>=e.y&&t[0]<=e.y+e.height},t.prototype.pointToData=function(t,e,n){n=n||[];var i=this.getAxis();return n[0]=i.coordToData(i.toLocalCoord(t["horizontal"===i.orient?0:1])),n},t.prototype.dataToPoint=function(t,e,n){var i=this.getAxis(),r=this.getRect();n=n||[];var o="horizontal"===i.orient?0:1;return t instanceof Array&&(t=t[0]),n[o]=i.toGlobalCoord(i.dataToCoord(+t)),n[1-o]=0===o?r.y+r.height/2:r.x+r.width/2,n},t.prototype.convertToPixel=function(t,e,n){return HH(e)===this?this.dataToPoint(n):null},t.prototype.convertFromPixel=function(t,e,n){return HH(e)===this?this.pointToData(n):null},t}();function HH(t){var e=t.seriesModel,n=t.singleAxisModel;return n&&n.coordinateSystem||e&&e.coordinateSystem}var WH={create:function(t,e){var n=[];return t.eachComponent(Nk,function(i,r){var o=new FH(i,t,e);o.name="single_"+r,o.resize(i,e),i.coordinateSystem=o,n.push(o)}),t.eachSeries(function(t){if("singleAxis"===t.get("coordinateSystem")){var e=t.getReferringComponents(Nk,Ea).models[0],n=t.coordinateSystem=e&&e.coordinateSystem;n&&yS(n.getAxis(),t,Rk)}}),n},dimensions:GH},UH=["x","y"],YH=["width","height"],XH=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.makeElOption=function(t,e,n,i,r){var o=n.axis,a=o.coordinateSystem,s=jH(o),l=qH(a,s),u=qH(a,1-s),c=a.dataToPoint(e)[0],h=i.get("type");if(h&&"none"!==h){var d=TF(i),p=ZH[h](o,c,l,u,i.get("seriesDataIndices"),i.ecModel);p.style=d,t.graphicKey=p.type,t.pointer=p}kF(e,t,NH(n),n,i,r)},e.prototype.getHandleTransform=function(t,e,n){var i=NH(e,{labelInside:!1});i.labelMargin=n.get(["handle","margin"]);var r=AF(e.axis,t,i);return{x:r[0],y:r[1],rotation:i.rotation+(i.labelDirection<0?Math.PI:0)}},e.prototype.updateHandleTransform=function(t,e,n,i){var r=n.axis,o=r.coordinateSystem,a=jH(r),s=qH(o,a),l=[t.x,t.y];l[a]+=e[a],l[a]=Math.min(s[1],l[a]),l[a]=Math.max(s[0],l[a]);var u=qH(o,1-a),c=(u[1]+u[0])/2,h=[c,c];return h[a]=l[a],{x:l[0],y:l[1],rotation:t.rotation,cursorPoint:h,tooltipOption:{verticalAlign:"middle"}}},e}(_F),ZH={line:function(t,e,n,i){return{type:"Line",subPixelOptimize:!0,shape:LF([e,i[0]],[e,i[1]],jH(t))}},shadow:function(t,e,n,i,r,o){var a=RF(t,r,o),s=i[1]-i[0],l=NF(e,n,a),u=l[0],c=l[1];return{type:"Rect",shape:PF([u,i[0]],[c-u,s],jH(t))}}};function jH(t){return t.isHorizontal()?0:1}function qH(t,e){var n=t.getRect();return[n[UH[e]],n[UH[e]]+n[YH[e]]]}var KH=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="single",e}(Ky);var $H=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(e,n,i){var r=og(e);t.prototype.init.apply(this,arguments),JH(e,r)},e.prototype.mergeOption=function(e){t.prototype.mergeOption.apply(this,arguments),JH(this.option,e)},e.prototype.getCellSize=function(){return this.option.cellSize},e.type="calendar",e.layoutMode="box",e.defaultOption={z:2,left:80,top:60,cellSize:20,orient:"horizontal",splitLine:{show:!0,lineStyle:{color:ug.color.axisLine,width:1,type:"solid"}},itemStyle:{color:ug.color.neutral00,borderWidth:1,borderColor:ug.color.neutral10},dayLabel:{show:!0,firstDay:0,position:"start",margin:ug.size.s,color:ug.color.secondary},monthLabel:{show:!0,position:"start",margin:ug.size.s,align:"center",formatter:null,color:ug.color.secondary},yearLabel:{show:!0,position:null,margin:ug.size.xl,formatter:null,color:ug.color.quaternary,fontFamily:"sans-serif",fontWeight:"bolder",fontSize:20}},e}(lg);function JH(t,e){var n,i=t.cellSize;1===(n=X(i)?i:t.cellSize=[i,i]).length&&(n[1]=n[0]);var r=V([0,1],function(t){return function(t,e){return null!=t[Zf[e][0]]||null!=t[Zf[e][1]]&&null!=t[Zf[e][2]]}(e,t)&&(n[t]="auto"),null!=n[t]&&"auto"!==n[t]});rg(t,e,{type:"box",ignoreSize:r})}var QH=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n){var i=this.group;i.removeAll();var r=t.coordinateSystem,o=r.getRangeInfo(),a=r.getOrient(),s=e.getLocaleModel();this._renderDayRect(t,o,i),this._renderLines(t,o,a,i),this._renderYearText(t,o,a,i),this._renderMonthText(t,s,a,i),this._renderWeekText(t,s,o,a,i)},e.prototype._renderDayRect=function(t,e,n){for(var i=t.coordinateSystem,r=t.getModel("itemStyle").getItemStyle(),o=i.getCellWidth(),a=i.getCellHeight(),s=e.start.time;s<=e.end.time;s=i.getNextNDay(s,1).time){var l=i.dataToCalendarLayout([s],!1).tl,u=new lu({shape:{x:l[0],y:l[1],width:o,height:a},cursor:"default",style:r});n.add(u)}},e.prototype._renderLines=function(t,e,n,i){var r=this,o=t.coordinateSystem,a=t.getModel(["splitLine","lineStyle"]).getLineStyle(),s=t.get(["splitLine","show"]),l=a.lineWidth;this._tlpoints=[],this._blpoints=[],this._firstDayOfMonth=[],this._firstDayPoints=[];for(var u=e.start,c=0;u.time<=e.end.time;c++){d(u.formatedDate),0===c&&(u=o.getDateInfo(e.start.y+"-"+e.start.m));var h=u.date;h.setMonth(h.getMonth()+1),u=o.getDateInfo(h)}function d(e){r._firstDayOfMonth.push(o.getDateInfo(e)),r._firstDayPoints.push(o.dataToCalendarLayout([e],!1).tl);var l=r._getLinePointsOfOneWeek(t,e,n);r._tlpoints.push(l[0]),r._blpoints.push(l[l.length-1]),s&&r._drawSplitline(l,a,i)}d(o.getNextNDay(e.end.time,1).formatedDate),s&&this._drawSplitline(r._getEdgesPoints(r._tlpoints,l,n),a,i),s&&this._drawSplitline(r._getEdgesPoints(r._blpoints,l,n),a,i)},e.prototype._getEdgesPoints=function(t,e,n){var i=[t[0].slice(),t[t.length-1].slice()],r="horizontal"===n?0:1;return i[0][r]=i[0][r]-e/2,i[1][r]=i[1][r]+e/2,i},e.prototype._drawSplitline=function(t,e,n){var i=new kh({z2:20,shape:{points:t},style:e});n.add(i)},e.prototype._getLinePointsOfOneWeek=function(t,e,n){for(var i=t.coordinateSystem,r=i.getDateInfo(e),o=[],a=0;a<7;a++){var s=i.getNextNDay(r.time,a),l=i.dataToCalendarLayout([s.time],!1);o[2*s.day]=l.tl,o[2*s.day+1]=l["horizontal"===n?"bl":"tr"]}return o},e.prototype._formatterLabel=function(t,e){return j(t)&&t?Of(t,e):Z(t)?t(e):e.nameMap},e.prototype._yearTextPositionControl=function(t,e,n,i,r){var o=e[0],a=e[1],s=["center","bottom"];"bottom"===i?(a+=r,s=["center","top"]):"left"===i?o-=r:"right"===i?(o+=r,s=["center","top"]):a-=r;var l=0;return"left"!==i&&"right"!==i||(l=Math.PI/2),{rotation:l,x:o,y:a,style:{align:s[0],verticalAlign:s[1]}}},e.prototype._renderYearText=function(t,e,n,i){var r=t.getModel("yearLabel");if(r.get("show")){var o=r.get("margin"),a=r.get("position");a||(a="horizontal"!==n?"top":"left");var s=[this._tlpoints[this._tlpoints.length-1],this._blpoints[0]],l=(s[0][0]+s[1][0])/2,u=(s[0][1]+s[1][1])/2,c="horizontal"===n?0:1,h={top:[l,s[c][1]],bottom:[l,s[1-c][1]],left:[s[1-c][0],u],right:[s[c][0],u]},d=e.start.y;+e.end.y>+e.start.y&&(d=d+"-"+e.end.y);var p=r.get("formatter"),f={start:e.start.y,end:e.end.y,nameMap:d},g=this._formatterLabel(p,f),v=new du({z2:30,style:sp(r,{text:g}),silent:r.get("silent")});v.attr(this._yearTextPositionControl(v,h[a],n,a,o)),i.add(v)}},e.prototype._monthTextPositionControl=function(t,e,n,i,r){var o="left",a="top",s=t[0],l=t[1];return"horizontal"===n?(l+=r,e&&(o="center"),"start"===i&&(a="bottom")):(s+=r,e&&(a="middle"),"start"===i&&(o="right")),{x:s,y:l,align:o,verticalAlign:a}},e.prototype._renderMonthText=function(t,e,n,i){var r=t.getModel("monthLabel");if(r.get("show")){var o=r.get("nameMap"),a=r.get("margin"),s=r.get("position"),l=r.get("align"),u=[this._tlpoints,this._blpoints];o&&!j(o)||(o&&(e=Fp(o)||e),o=e.get(["time","monthAbbr"])||[]);var c="start"===s?0:1,h="horizontal"===n?0:1;a="start"===s?-a:a;for(var d="center"===l,p=r.get("silent"),f=0;f=r.start.time&&i.timea.end.time&&t.reverse(),t},t.prototype._getRangeInfo=function(t){var e,n=[this.getDateInfo(t[0]),this.getDateInfo(t[1])];n[0].time>n[1].time&&(e=!0,n.reverse());var i=Math.floor(n[1].time/tW)-Math.floor(n[0].time/tW)+1,r=new Date(n[0].time),o=r.getDate(),a=n[1].date.getDate();r.setDate(o+i-1);var s=r.getDate();if(s!==a)for(var l=r.getTime()-n[1].time>0?1:-1;(s=r.getDate())!==a&&(r.getTime()-n[1].time)*l>0;)i-=l,r.setDate(s-l);var u=Math.floor((i+n[0].day+6)/7),c=e?1-u:u-1;return e&&n.reverse(),{range:[n[0].formatedDate,n[1].formatedDate],start:n[0],end:n[1],allDay:i,weeks:u,nthWeek:c,fweek:n[0].day,lweek:n[1].day}},t.prototype._getDateByWeeksAndDay=function(t,e,n){var i=this._getRangeInfo(n);if(t>i.weeks||0===t&&ei.lweek)return null;var r=7*(t-1)-i.fweek+e,o=new Date(i.start.time);return o.setDate(+i.start.d+r),this.getDateInfo(o)},t.create=function(e,n){var i=[];return e.eachComponent("calendar",function(r){var o=new t(r,e,n);i.push(o),r.coordinateSystem=o}),e.eachComponent(function(t,e){Wf({targetModel:e,coordSysType:"calendar",coordSysProvider:Uf})}),i},t.dimensions=["time","value"],t}();function nW(t){var e=t.calendarModel,n=t.seriesModel;return e?e.coordinateSystem:n?n.coordinateSystem:null}var iW=1,rW=2,oW=3,aW={none:0,all:1,body:2,corner:3};function sW(t,e,n){var i=e[dd[n]].getCell(t);return!i&&K(t)&&t<0&&(i=e[dd[1-n]].getUnitLayoutInfo(n,Math.round(t))),i}function lW(t){var e=t||[];return e[0]=e[0]||[],e[1]=e[1]||[],e[0][0]=e[0][1]=e[1][0]=e[1][1]=NaN,e}function uW(t,e,n,i,r){cW(t[0],e,r,n,i,0),cW(t[1],e,r,n,i,1)}function cW(t,e,n,i,r,o){t[0]=1/0,t[1]=-1/0;var a=i[o],s=X(a)?a:[a],l=s.length,u=!!n;if(l>=1?(hW(t,e,s,u,r,o,0),l>1&&hW(t,e,s,u,r,o,l-1)):t[0]=t[1]=NaN,u){var c=-r[dd[1-o]].getLocatorCount(o),h=r[dd[o]].getLocatorCount(o)-1;n===aW.body?c=Do(0,c):n===aW.corner&&(h=Co(-1,h)),h=e[0]&&t[0]<=e[1]}function yW(t,e){t.id.set(e[0][0],e[1][0]),t.span.set(e[0][1]-t.id.x+1,e[1][1]-t.id.y+1)}function mW(t,e,n,i){var r=sW(e[i][0],n,i),o=sW(e[i][1],n,i);t[dd[i]]=t[pd[i]]=NaN,r&&o&&(t[dd[i]]=r.xy,t[pd[i]]=o.xy+o.wh-r.xy)}function xW(t,e,n,i){return t[dd[e]]=n,t[dd[1-e]]=i,t}var _W=function(){function t(t,e){this._cells=[],this._levels=[],this.dim=t,this.dimIdx="x"===t?0:1,this._model=e,this._uniqueValueGen=function(t){var e=t.toUpperCase(),n=new RegExp("^"+e+"([0-9]+)$"),i=0;function r(t){var e;null!=t&&(e=t.match(n))&&(i=Do(i,+e[1]+1))}function o(){return""+e+i++}function a(t,e){for(var n=mt(),i=0;i=1,x=n[dd[i]],_=o.getLocatorCount(i)-1,b=new Wa;for(a.resetLayoutIterator(b,i);b.next();)w(b.item);for(o.resetLayoutIterator(b,i);b.next();)w(b.item);function w(t){rt(t.wh)&&(t.wh=y),t.xy=x,t.id[dd[i]]!==_||m||(t.wh=n[dd[i]]+n[pd[i]]-t.xy),x+=t.wh}}function XW(t,e){for(var n=e[dd[t]].resetCellIterator();n.next();){var i=n.item;jW(i.rect,t,i.id,i.span,e),jW(i.rect,1-t,i.id,i.span,e),i.type===oW&&(i.xy=i.rect[dd[t]],i.wh=i.rect[pd[t]])}}function ZW(t,e){t.travelExistingCells(function(t){var n=t.span;if(n){var i=t.spanRect,r=t.id;jW(i,0,r,n,e),jW(i,1,r,n,e)}})}function jW(t,e,n,i,r){t[pd[e]]=0;var o=n[dd[e]]<0?r[dd[1-e]]:r[dd[e]],a=o.getUnitLayoutInfo(e,n[dd[e]]);if(t[dd[e]]=a.xy,t[pd[e]]=a.wh,i[dd[e]]>1){var s=o.getUnitLayoutInfo(e,n[dd[e]]+i[dd[e]]-1);t[pd[e]]=s.xy+s.wh-a.xy}}function qW(t,e){return Math.max(Math.min(t,at(e,1/0)),0)}function KW(t){var e=t.matrixModel,n=t.seriesModel;return e?e.coordinateSystem:n?n.coordinateSystem:null}var $W={inBody:1,inCorner:2,outside:3},JW={x:null,y:null,point:[]};function QW(t,e,n,i,r){var o=n[dd[e]],a=n[dd[1-e]],s=o.getUnitLayoutInfo(e,o.getLocatorCount(e)-1),l=o.getUnitLayoutInfo(e,0),u=a.getUnitLayoutInfo(e,-a.getLocatorCount(e)),c=a.shouldShow()?a.getUnitLayoutInfo(e,-1):null,h=t.point[e]=i[e];if(l||c)if(r!==aW.body)if(r!==aW.corner){var d=l?l.xy:c?c.xy+c.wh:NaN,p=u?u.xy:d,f=s?s.xy+s.wh:d;if(hf){if(!r)return void(t[dd[e]]=$W.outside);h=f}t.point[e]=h,t[dd[e]]=d<=h&&h<=f?$W.inBody:p<=h&&h<=d?$W.inCorner:$W.outside}else c?(t[dd[e]]=$W.inCorner,h=Co(c.xy+c.wh,Do(u.xy,h)),t.point[e]=h):t[dd[e]]=$W.outside;else l?(t[dd[e]]=$W.inBody,h=Co(s.xy+s.wh,Do(l.xy,h)),t.point[e]=h):t[dd[e]]=$W.outside;else t[dd[e]]=$W.outside}function tU(t,e,n,i){var r=1-n;if(t[dd[n]]!==$W.outside)for(i[dd[n]].resetCellIterator(UW);UW.next();){var o=UW.item;if(iU(t.point[n],o.rect,n)&&iU(t.point[r],o.rect,r))return e[n]=o.ordinal,void(e[r]=o.id[dd[r]])}}function eU(t,e,n,i){if(t[dd[n]]!==$W.outside)for((t[dd[n]]===$W.inCorner?i[dd[1-n]]:i[dd[n]]).resetLayoutIterator(WW,n);WW.next();)if(nU(t.point[n],WW.item))return void(e[n]=WW.item.id[dd[n]])}function nU(t,e){return e.xy<=t&&t<=e.xy+e.wh}function iU(t,e,n){return e[dd[n]]<=t&&t<=e[dd[n]]+e[pd[n]]}function rU(t,e){var n;return B(e,function(e){null!=t[e]&&"auto"!==t[e]&&(n=!0)}),n}var oU=["transition","enterFrom","leaveTo"],aU=oU.concat(["enterAnimation","updateAnimation","leaveAnimation"]);function sU(t,e,n){if(n&&(!t[n]&&e[n]&&(t[n]={}),t=t[n],e=e[n]),t&&e)for(var i=n?oU:aU,r=0;r=0;l--){var d,p,f;if(f=null!=(p=Da((d=n[l]).id,null))?r.get(p):null){var g=f.parent,v=(h=cU(g),{}),y=ng(f,d,g===i?{width:o,height:a}:{width:h.width,height:h.height},null,{hv:d.hv,boundingMode:d.bounding},v);if(!cU(f).isNew&&y){for(var m=d.transition,x={},_=0;_=0)?x[b]=w:f[b]=w}rd(f,x,t,0)}else f.attr(v)}}},e.prototype._clear=function(){var t=this,e=this._elMap;e.each(function(n){fU(n,cU(n).option,e,t._lastGraphicModel)}),this._elMap=mt()},e.prototype.dispose=function(){this._clear()},e.type="graphic",e}(Ky);function dU(t){var e=wt(uU,t)?uU[t]:md(t);var n=new e({});return cU(n).type=t,n}function pU(t,e,n,i){var r=dU(n);return e.add(r),i.set(t,r),cU(r).id=t,cU(r).isNew=!0,r}function fU(t,e,n,i){t&&t.parent&&("group"===t.type&&t.traverse(function(t){fU(t,e,n,i)}),AG(t,e,i),n.removeKey(cU(t).id))}function gU(t,e,n,i){t.isGroup||B([["cursor",Zs.prototype.cursor],["zlevel",i||0],["z",n||0],["z2",0]],function(n){var i=n[0];wt(e,i)?t[i]=at(e[i],n[1]):null==t[i]&&(t[i]=n[1])}),B(W(e),function(n){if(0===n.indexOf("on")){var i=e[n];t[n]=Z(i)?i:null}}),wt(e,"draggable")&&(t.draggable=e.draggable),null!=e.name&&(t.name=e.name),null!=e.id&&(t.id=e.id)}var vU=["x","y","radius","angle","single"],yU=Pa(),mU=["cartesian2d","polar","singleAxis"];function xU(t){return t+"Axis"}function _U(t,e){var n,i=mt(),r=[],o=mt();t.eachComponent({mainType:"dataZoom",query:e},function(t){o.get(t.uid)||s(t)});do{n=!1,t.eachComponent("dataZoom",a)}while(n);function a(t){!o.get(t.uid)&&function(t){var e=!1;return t.eachTargetAxis(function(t,n){var r=i.get(t);r&&r[n]&&(e=!0)}),e}(t)&&(s(t),n=!0)}function s(t){o.set(t.uid,!0),r.push(t),t.eachTargetAxis(function(t,e){(i.get(t)||i.set(t,[]))[e]=!0})}return r}function bU(t){var e=t.ecModel,n={infoList:[],infoMap:mt()};return t.eachTargetAxis(function(t,i){var r=e.getComponent(xU(t),i);if(r){var o=r.getCoordSysModel();if(o){var a=o.uid,s=n.infoMap.get(a);s||(s={model:o,axisModels:[]},n.infoList.push(s),n.infoMap.set(a,s)),s.axisModels.push(r)}}}),n}function wU(t){var e=yU(ax(t));return e.axisProxyMap||(e.axisProxyMap=mt())}function SU(t){if(t)return wU(t.ecModel).get(t.uid)}function MU(t,e){var n=e.getAxisModel().axis.__alignTo;return n&&t.getAxisProxy(n.dim,n.model.componentIndex)?SU(n.model):null}var IU=function(){function t(){this.indexList=[],this.indexMap=[]}return t.prototype.add=function(t){this.indexMap[t]||(this.indexList.push(t),this.indexMap[t]=!0)},t}(),TU=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n._autoThrottle=!0,n._noTarget=!0,n._rangePropMode=["percent","percent"],n}return n(e,t),e.prototype.init=function(t,e,n){var i=CU(t);this.settledOption=i,this.mergeDefaultAndTheme(t,n),this._doInit(i)},e.prototype.mergeOption=function(t){var e=CU(t);D(this.option,t,!0),D(this.settledOption,e,!0),this._doInit(e)},e.prototype._doInit=function(t){var e=this.option;this._setDefaultThrottle(t),this._updateRangeUse(t);var n=this.settledOption;B([["start","startValue"],["end","endValue"]],function(t,i){"value"===this._rangePropMode[i]&&(e[t[0]]=n[t[0]]=null)},this),this._resetTarget()},e.prototype._resetTarget=function(){var t=this.get("orient",!0),e=this._targetAxisInfoMap=mt();this._fillSpecifiedTargetAxis(e)?this._orient=t||this._makeAutoOrientByTargetAxis():(this._orient=t||"horizontal",this._fillAutoTargetAxisByOrient(e,this._orient)),this._noTarget=!0,e.each(function(t){t.indexList.length&&(this._noTarget=!1)},this)},e.prototype._fillSpecifiedTargetAxis=function(t){var e=!1;return B(vU,function(n){var i=this.getReferringComponents(xU(n),za);if(i.specified){e=!0;var r=new IU;B(i.models,function(t){r.add(t.componentIndex)}),t.set(n,r)}},this),e},e.prototype._fillAutoTargetAxisByOrient=function(t,e){var n=this.ecModel,i=!0;if(i){var r="vertical"===e?"y":"x";o(n.findComponents({mainType:r+"Axis"}),r)}i&&o(n.findComponents({mainType:"singleAxis",filter:function(t){return t.get("orient",!0)===e}}),"single");function o(e,n){var r=e[0];if(r){var o=new IU;if(o.add(r.componentIndex),t.set(n,o),i=!1,"x"===n||"y"===n){var a=r.getReferringComponents("grid",Ea).models[0];a&&B(e,function(t){r.componentIndex!==t.componentIndex&&a===t.getReferringComponents("grid",Ea).models[0]&&o.add(t.componentIndex)})}}}i&&B(vU,function(e){if(i){var r=n.findComponents({mainType:xU(e),filter:function(t){return"category"===t.get("type",!0)}});if(r[0]){var o=new IU;o.add(r[0].componentIndex),t.set(e,o),i=!1}}},this)},e.prototype._makeAutoOrientByTargetAxis=function(){var t;return this.eachTargetAxis(function(e){!t&&(t=e)},this),"y"===t?"vertical":"horizontal"},e.prototype._setDefaultThrottle=function(t){if(t.hasOwnProperty("throttle")&&(this._autoThrottle=!1),this._autoThrottle){var e=this.ecModel.option;this.option.throttle=e.animation&&e.animationDurationUpdate>0?100:20}},e.prototype._updateRangeUse=function(t){var e=this._rangePropMode,n=this.get("rangeMode");B([["start","startValue"],["end","endValue"]],function(i,r){var o=null!=t[i[0]],a=null!=t[i[1]];o&&!a?e[r]="percent":!o&&a?e[r]="value":n?e[r]=n[r]:o&&(e[r]="percent")})},e.prototype.noTarget=function(){return this._noTarget},e.prototype.getFirstTargetAxisModel=function(){var t;return this.eachTargetAxis(function(e,n){null==t&&(t=this.ecModel.getComponent(xU(e),n))},this),t},e.prototype.eachTargetAxis=function(t,e){this._targetAxisInfoMap.each(function(n,i){B(n.indexList,function(n){t.call(e,i,n)})})},e.prototype.getAxisProxy=function(t,e){return SU(this.getAxisModel(t,e))},e.prototype.getAxisModel=function(t,e){var n=this._targetAxisInfoMap.get(t);if(n&&n.indexMap[e])return this.ecModel.getComponent(xU(t),e)},e.prototype.setRawRange=function(t){var e=this.option,n=this.settledOption;B([["start","startValue"],["end","endValue"]],function(i){null==t[i[0]]&&null==t[i[1]]||(e[i[0]]=n[i[0]]=t[i[0]],e[i[1]]=n[i[1]]=t[i[1]])},this),this._updateRangeUse(t)},e.prototype.setCalculatedRange=function(t){var e=this.option;B(["start","startValue","end","endValue"],function(n){e[n]=t[n]})},e.prototype.getPercentRange=function(){var t=this.findRepresentativeAxisProxy();if(t)return t.getWindow().percent},e.prototype.getValueRange=function(t,e){if(null!=t||null!=e)return this.getAxisProxy(t,e).getWindow().value;var n=this.findRepresentativeAxisProxy();return n?n.getWindow().value:void 0},e.prototype.findRepresentativeAxisProxy=function(t){if(t)return SU(t);for(var e,n=this._targetAxisInfoMap.keys(),i=0;i=0}(e)){var n=xU(this._dimName),i=e.getReferringComponents(n,Ea).models[0];i&&this._axisIndex===i.componentIndex&&t.push(e)}},this),t},t.prototype.getAxisModel=function(){return this.ecModel.getComponent(this._dimName+"Axis",this._axisIndex)},t.prototype.getMinMaxSpan=function(){return C(this._minMaxSpan)},t.prototype.calculateDataWindow=function(t){var e,n=this._extent,i=this.getAxisModel().axis,r=i.scale,o=this._dataZoomModel.getRangePropMode(),a=[0,100],s=[],l=[],u=[!1,!1];B(["start","end"],function(i,c){var h=t[i],d=t[i+"Value"];"percent"===o[c]?(null==h&&(h=a[c]),d=Bo(h,a,n),u[c]=!0):(e=!0,null==d?d=n[c]:(d=r.parse(d),r.sanitize&&(d=r.sanitize(d,n))),h=Bo(d,n,a)),l[c]=null==d||isNaN(d)?n[c]:d,s[c]=null==h||isNaN(h)?a[c]:h}),Wo(l),Wo(s);var c=this._minMaxSpan;function h(t,e,n,i,r){var o=r?"Span":"ValueSpan";ez(0,t,n,"all",c["min"+o],c["max"+o]);for(var a=0;a<2;a++)e[a]=Bo(t[a],n,i,!0),r&&(e[a]=e[a],u[a]=!0);$a(e)}e?h(l,s,n,a,!1):h(s,l,a,n,!0);var d=vw(r)||fw(r),p=i.getExtent(),f=Ao(p[1]-p[0]),g=d?0:Xo(l,f,.5);B([[0,Po],[1,Lo]],function(t){var e=t[0],i=t[1];u[e]&&isFinite(g)&&(l[e]=Ho(l[e],g),l[e]=Co(n[1],Do(n[0],l[e])),s[e]===a[e]&&(l[e]=n[e],d&&(l[e]=i(l[e]))))}),$a(l);var v=[Bo(l[0],n,a,!0),Bo(l[1],n,a,!0)];return $a(v),{value:l,percent:s,percentInverted:v,valuePrecision:g}},t.prototype.reset=function(t,e){if(this.hostedBy(t)){var n=this.getAxisModel().axis;CS(n,2);var i=n.scale.rawExtentInfo;this._extent=i.makeNoZoom(),this._updateMinMaxSpan();var r=t.settledOption;e&&(r=P({start:e[0],end:e[1]},r));var o=this._window=this.calculateDataWindow(r),a=o.percent,s=o.value;0!==a[0]&&i.setZoomMM(0,s[0]),100!==a[1]&&i.setZoomMM(1,s[1])}},t.prototype.filterData=function(t,e){if(this.hostedBy(t)){var n=this._dimName,i=this.getTargetSeriesModels(),r=t.get("filterMode"),o=this._window.value;"none"!==r&&B(i,function(t){var e=t.getData(),i=e.mapDimensionsAll(n);if(i.length){if("weakFilter"===r){var a=e.getStore(),s=V(i,function(t){return e.getDimensionIndex(t)},e);e.filterSelf(function(t){for(var e,n,r,l=0;lo[1];if(c&&!h&&!d)return!0;c&&(r=!0),h&&(e=!0),d&&(n=!0)}return r&&e&&n})}else B(i,function(n){if("empty"===r)t.setData(e=e.map(n,function(t){return function(t){return t>=o[0]&&t<=o[1]}(t)?t:NaN}));else{var i={};i[n]=o,e.selectRange(i)}});B(i,function(t){e.setApproximateExtent(o,t)})}})}},t.prototype._updateMinMaxSpan=function(){var t=this._minMaxSpan={},e=this._dataZoomModel,n=this._extent;B(["min","max"],function(i){var r=e.get(i+"Span"),o=e.get(i+"ValueSpan");null!=o&&(o=this.getAxisModel().axis.scale.parse(o)),null!=o?r=Bo(n[0]+o,n,[0,100],!0):null!=r&&(o=Bo(r,[0,100],n,!0)-n[0]),t[i+"Span"]=r,t[i+"ValueSpan"]=o},this)},t}(),PU={dirtyOnOverallProgress:!0,getTargetSeries:function(t){var e,n=[];e=function(e,i,r,o){if(!SU(r)){var a=new LU(e,i,o,t);n.push(a),function(t,e){wU(t.ecModel).set(t.uid,e)}(r,a)}},t.eachComponent("dataZoom",function(n){n.eachTargetAxis(function(i,r){var o=t.getComponent(xU(i),r);e(i,r,o,n)})});var i=mt();return B(n,function(t){B(t.getTargetSeriesModels(),function(t){i.set(t.uid,t)})}),i},overallReset:function(t,e){t.eachComponent("dataZoom",function(t){var n=[];t.eachTargetAxis(function(e,i){var r=t.getAxisProxy(e,i),o=MU(t,r);o?n.push([r,o]):r.reset(t,null)}),B(n,function(e){e[0].reset(t,e[1].getWindow().percentInverted)}),t.eachTargetAxis(function(n,i){t.getAxisProxy(n,i).filterData(t,e)})}),t.eachComponent("dataZoom",function(t){var e=t.findRepresentativeAxisProxy();if(e){var n=e.getWindow(),i=n.percent,r=n.value;t.setCalculatedRange({start:i[0],end:i[1],startValue:r[0],endValue:r[1]})}})}};var OU=Ja();function RU(t){OU(t,function(){t.registerProcessor(t.PRIORITY.PROCESSOR.FILTER,PU),function(t){t.registerAction("dataZoom",function(t,e){B(_U(e,t),function(e){e.setRawRange({start:t.start,end:t.end,startValue:t.startValue,endValue:t.endValue})})})}(t),t.registerSubTypeDefaulter("dataZoom",function(){return"slider"})})}function NU(t){t.registerComponentModel(DU),t.registerComponentView(kU),RU(t)}var EU=function(){},zU={};function BU(t,e){zU[t]=e}function VU(t){return zU[t]}var GU=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(e,n,i){var r=i.getTheme().get("toolbox"),o=r?r.feature:null;o&&(this._themeFeatureOption=k({},o),r.feature={}),t.prototype.init.call(this,e,n,i),o&&(r.feature=o)},e.prototype.optionUpdated=function(){B(this.option.feature,function(t,e){var n=this._themeFeatureOption,i=VU(e);i&&(i.getDefaultOption&&(i.defaultOption=i.getDefaultOption(this.ecModel)),n&&n[e]&&(D(t,n[e]),n[e]=null),D(t,i.defaultOption))},this)},e.type="toolbox",e.layoutMode={type:"box",ignoreSize:!0},e.defaultOption={show:!0,z:6,orient:"horizontal",left:"right",top:"top",backgroundColor:"transparent",borderColor:ug.color.border,borderRadius:0,borderWidth:0,padding:ug.size.m,itemSize:15,itemGap:ug.size.s,showTitle:!0,iconStyle:{borderColor:ug.color.accent50,color:"none"},emphasis:{iconStyle:{borderColor:ug.color.accent70}},tooltip:{show:!1,position:"bottom"}},e}(lg);function FU(t,e){var n=Df(e.get("padding")),i=e.getItemStyle(["color","opacity"]);return i.fill=e.get("backgroundColor"),new lu({shape:{x:t.x-n[3],y:t.y-n[0],width:t.width+n[1]+n[3],height:t.height+n[0]+n[2],r:e.get("borderRadius")},style:i,silent:!0,z2:-1})}var HU=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.render=function(t,e,n,i){var r=this.group;if(r.removeAll(),t.get("show")){var o=+t.get("itemSize"),a="vertical"===t.get("orient"),s=t.get("feature")||{},l=this._features||(this._features=mt()),u=[];B(s,function(t,e){u.push(e)}),new mb(this._featureNames||[],u).add(f).update(f).remove(Y(f,null)).execute(),this._featureNames=F(u,function(t){return l.hasKey(t)});var c=eg(t,n).refContainer,h=t.getBoxLayoutParams(),d=t.get("padding"),p=Jf(h,c,d);qf(t.get("orient"),r,t.get("itemGap"),p.width,p.height),ng(r,h,c,d),r.add(FU(r.getBoundingRect(),t)),a||r.eachChild(function(t){var e=t.__title,i=t.ensureState("emphasis"),a=i.textConfig||(i.textConfig={}),s=t.getTextContent(),l=s&&s.ensureState("emphasis");if(l&&!Z(l)&&e){var u=l.style||(l.style={}),c=qr(e,du.makeFont(u)),h=t.x+r.x,d=!1;t.y+r.y+o+c.height>n.getHeight()&&(a.position="top",d=!0);var p=d?-5-c.height:o+10;h+c.width/2>n.getWidth()?(a.position=["100%",p],u.align="right"):h-c.width/2<0&&(a.position=[0,p],u.align="left")}})}function f(c,h){var d,p=null!=c&&null==h,f=null!=c&&null!=h,g=null==c,v=p||f?u[c]:u[h],y=s[v],m=p||f?new kp(y,t,e):null,x=m&&m.get("show");if(p){if(!x)return;if(function(t){return 0===t.indexOf("my")}(v))d={onclick:m.option.onclick,featureName:v};else{var _=VU(v);if(!_)return;d=new _}l.set(v,d)}else d=l.get(v);if(g||!x)return WU(d)&&d.dispose&&d.dispose(e,n),void l.removeKey(v);i&&null!=i.newTitle&&i.featureName===v&&(y.title=i.newTitle),p&&(d.uid=Pp("toolbox-feature")),d.model=m,d.ecModel=e,d.api=n,function(i,s,l){var u,c,h=i.getModel("iconStyle"),d=i.getModel(["emphasis","iconStyle"]),p=s instanceof EU&&s.getIcons?s.getIcons():i.get("icon"),f=i.get("title")||{};j(p)?(u={})[l]=p:u=p;j(f)?(c={})[l]=f:c=f;var g=i.iconPaths={};B(u,function(l,u){var p=Od(l,{},{x:-o/2,y:-o/2,width:o,height:o});p.setStyle(h.getItemStyle()),p.ensureState("emphasis").style=d.getItemStyle();var f=new du({style:{text:c[u],align:d.get("textAlign"),borderRadius:d.get("textBorderRadius"),padding:d.get("textPadding"),fill:null,font:pp({fontStyle:d.get("textFontStyle"),fontFamily:d.get("textFontFamily"),fontSize:d.get("textFontSize"),fontWeight:d.get("textFontWeight")},e)},ignore:!0});p.setTextContent(f),Gd({el:p,componentModel:t,itemName:u,formatterParamsExtra:{title:c[u]}}),p.__title=c[u],p.on("mouseover",function(){var e=d.getItemStyle(),i=a?null==t.get("right")&&"right"!==t.get("left")?"right":"left":null==t.get("bottom")&&"bottom"!==t.get("top")?"bottom":"top";f.setStyle({fill:d.get("textFill")||e.fill||e.stroke||ug.color.neutral99,backgroundColor:d.get("textBackgroundColor")}),p.setTextConfig({position:d.get("textPosition")||i}),f.ignore=!t.get("showTitle"),n.enterEmphasis(this)}).on("mouseout",function(){"emphasis"!==i.get(["iconStatus",u])&&n.leaveEmphasis(this),f.hide()}),("emphasis"===i.get(["iconStatus",u])?vc:yc)(p),r.add(p),p.on("click",U(s.onclick,s,e,n,u)),g[u]=p})}(m,d,v),m.setIconStatus=function(t,e){var n=this.option,i=this.iconPaths;n.iconStatus=n.iconStatus||{},n.iconStatus[t]=e,i[t]&&("emphasis"===e?vc:yc)(i[t])},WU(d)&&d.render&&d.render(m,e,n,i)}},e.prototype.updateView=function(t,e,n,i){B(this._features,function(t){t&&t instanceof EU&&t.updateView&&t.updateView(t.model,e,n,i)})},e.prototype.dispose=function(t,e){B(this._features,function(n){n&&n instanceof EU&&n.dispose&&n.dispose(t,e)})},e.type="toolbox",e}(Ky);function WU(t){return t instanceof EU}var UU=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.onclick=function(t,e){var n=this.model,i=n.get("name")||t.get("title.0.text")||"echarts",o="svg"===e.getZr().painter.getType(),a=o?"svg":n.get("type",!0)||"png",s=e.getConnectedDataURL({type:a,backgroundColor:n.get("backgroundColor",!0)||t.get("backgroundColor")||ug.color.neutral00,connectedBackgroundColor:n.get("connectedBackgroundColor"),excludeComponents:n.get("excludeComponents"),pixelRatio:n.get("pixelRatio")}),l=r.browser;if("function"!=typeof MouseEvent||!l.newEdge&&(l.ie||l.edge))if(window.navigator.msSaveOrOpenBlob||o){var u=s.split(","),c=u[0].indexOf("base64")>-1,h=o?decodeURIComponent(u[1]):u[1];c&&(h=window.atob(h));var d=i+"."+a;if(window.navigator.msSaveOrOpenBlob){for(var p=h.length,f=new Uint8Array(p);p--;)f[p]=h.charCodeAt(p);var g=new Blob([f]);window.navigator.msSaveOrOpenBlob(g,d)}else{var v=document.createElement("iframe");document.body.appendChild(v);var y=v.contentWindow,m=y.document;m.open("image/svg+xml","replace"),m.write(h),m.close(),y.focus(),m.execCommand("SaveAs",!0,d),document.body.removeChild(v)}}else{var x=n.get("lang"),_='',b=window.open();b.document.write(_),b.document.title=i}else{var w=document.createElement("a");w.download=i+"."+a,w.target="_blank",w.href=s;var S=new MouseEvent("click",{view:document.defaultView,bubbles:!0,cancelable:!1});w.dispatchEvent(S)}},e.getDefaultOption=function(t){return{show:!0,icon:"M4.7,22.9L29.3,45.5L54.7,23.4M4.6,43.6L4.6,58L53.8,58L53.8,43.6M29.2,45.1L29.2,0",title:t.getLocaleModel().get(["toolbox","saveAsImage","title"]),type:"png",connectedBackgroundColor:ug.color.neutral00,name:"",excludeComponents:["toolbox"],lang:t.getLocaleModel().get(["toolbox","saveAsImage","lang"])}},e}(EU),YU="__ec_magicType_stack__",XU=[["line","bar"],["stack"]],ZU=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.getIcons=function(){var t=this.model,e=t.get("icon"),n={};return B(t.get("type"),function(t){e[t]&&(n[t]=e[t])}),n},e.getDefaultOption=function(t){return{show:!0,type:[],icon:{line:"M4.1,28.9h7.1l9.3-22l7.4,38l9.7-19.7l3,12.8h14.9M4.1,58h51.4",bar:"M6.7,22.9h10V48h-10V22.9zM24.9,13h10v35h-10V13zM43.2,2h10v46h-10V2zM3.1,58h53.7",stack:"M8.2,38.4l-8.4,4.1l30.6,15.3L60,42.5l-8.1-4.1l-21.5,11L8.2,38.4z M51.9,30l-8.1,4.2l-13.4,6.9l-13.9-6.9L8.2,30l-8.4,4.2l8.4,4.2l22.2,11l21.5-11l8.1-4.2L51.9,30z M51.9,21.7l-8.1,4.2L35.7,30l-5.3,2.8L24.9,30l-8.4-4.1l-8.3-4.2l-8.4,4.2L8.2,30l8.3,4.2l13.9,6.9l13.4-6.9l8.1-4.2l8.1-4.1L51.9,21.7zM30.4,2.2L-0.2,17.5l8.4,4.1l8.3,4.2l8.4,4.2l5.5,2.7l5.3-2.7l8.1-4.2l8.1-4.2l8.1-4.1L30.4,2.2z"},title:t.getLocaleModel().get(["toolbox","magicType","title"]),option:{},seriesIndex:{}}},e.prototype.onclick=function(t,e,n){var i=this.model,r=i.get(["seriesIndex",n]);if(jU[n]){var o,a={series:[]};B(XU,function(t){R(t,n)>=0&&B(t,function(t){i.setIconStatus(t,"normal")})}),i.setIconStatus(n,"emphasis"),t.eachComponent({mainType:"series",query:null==r?null:{seriesIndex:r}},function(t){var e=t.subType,r=t.id,o=jU[n](e,r,t,i);o&&(P(o,t.option),a.series.push(o));var s=t.coordinateSystem;if(s&&"cartesian2d"===s.type&&("line"===n||"bar"===n)){var l=s.getAxesByScale("ordinal")[0];if(l){var u=l.dim+"Axis",c=t.getReferringComponents(u,Ea).models[0].componentIndex;a[u]=a[u]||[];for(var h=0;h<=c;h++)a[u][c]=a[u][c]||{};a[u][c].boundaryGap="bar"===n}}});var s=n;"stack"===n&&(o=D({stack:i.option.title.tiled,tiled:i.option.title.stack},i.option.title),"emphasis"!==i.get(["iconStatus",n])&&(s="tiled")),e.dispatchAction({type:"changeMagicType",currentType:s,newOption:a,newTitle:o,featureName:"magicType"})}},e}(EU),jU={line:function(t,e,n,i){if("bar"===t)return D({id:e,type:"line",data:n.get("data"),stack:n.get("stack"),markPoint:n.get("markPoint"),markLine:n.get("markLine")},i.get(["option","line"])||{},!0)},bar:function(t,e,n,i){if("line"===t)return D({id:e,type:"bar",data:n.get("data"),stack:n.get("stack"),markPoint:n.get("markPoint"),markLine:n.get("markLine")},i.get(["option","bar"])||{},!0)},stack:function(t,e,n,i){var r=n.get("stack")===YU;if("line"===t||"bar"===t)return i.setIconStatus("stack",r?"normal":"emphasis"),D({id:e,stack:r?"":YU},i.get(["option","stack"])||{},!0)}};ib({type:"changeMagicType",event:"magicTypeChanged",update:"prepareAndUpdate"},function(t,e){e.mergeOption(t.newOption)});var qU=new Array(60).join("-"),KU="\t";function $U(t){return t.replace(/^\s\s*/,"").replace(/\s\s*$/,"")}var JU=new RegExp("[\t]+","g");function QU(t,e){var n=t.split(new RegExp("\n*"+qU+"\n*","g")),i={series:[]};return B(n,function(t,n){if(function(t){if(t.slice(0,t.indexOf("\n")).indexOf(KU)>=0)return!0}(t)){var r=function(t){for(var e=t.split(/\n+/g),n=[],i=V($U(e.shift()).split(JU),function(t){return{name:t,data:[]}}),r=0;r=0)&&t(r,i._targetInfoList)})}return t.prototype.setOutputRanges=function(t,e){return this.matchOutputRanges(t,e,function(t,e,n){if((t.coordRanges||(t.coordRanges=[])).push(e),!t.coordRange){t.coordRange=e;var i=pY[t.brushType](0,n,e);t.__rangeOffset={offset:gY[t.brushType](i.values,t.range,[1,1]),xyMinMax:i.xyMinMax}}}),t},t.prototype.matchOutputRanges=function(t,e,n){B(t,function(t){var i=this.findTargetInfo(t,e);i&&!0!==i&&B(i.coordSyses,function(i){var r=pY[t.brushType](1,i,t.range,!0);n(t,r.values,i,e)})},this)},t.prototype.setInputRanges=function(t,e){B(t,function(t){var n,i,r,o,a,s=this.findTargetInfo(t,e);if(t.range=t.range||[],s&&!0!==s){t.panelId=s.panelId;var l=pY[t.brushType](0,s.coordSys,t.coordRange),u=t.__rangeOffset;t.range=u?gY[t.brushType](l.values,u.offset,(n=l.xyMinMax,i=u.xyMinMax,r=yY(n),o=yY(i),a=[r[0]/o[0],r[1]/o[1]],isNaN(a[0])&&(a[0]=1),isNaN(a[1])&&(a[1]=1),a)):l.values}},this)},t.prototype.makePanelOpts=function(t,e){return V(this._targetInfoList,function(n){var i=n.getPanelRect();return{panelId:n.panelId,defaultBrushType:e?e(n):null,clipPath:Kz(i),isTargetByCursor:Jz(i,t,n.coordSysModel),getLinearBrushOtherExtent:$z(i)}})},t.prototype.controlSeries=function(t,e,n){var i=this.findTargetInfo(t,n);return!0===i||i&&R(i.coordSyses,e.coordinateSystem)>=0},t.prototype.findTargetInfo=function(t,e){for(var n=this._targetInfoList,i=uY(e,t),r=0;rt[1]&&t.reverse(),t}function uY(t,e){return Ra(t,e,{includeMainTypes:aY})}var cY={grid:function(t,e){var n=t.xAxisModels,i=t.yAxisModels,r=t.gridModels,o=mt(),a={},s={};(n||i||r)&&(B(n,function(t){var e=t.axis.grid.model;o.set(e.id,e),a[e.id]=!0}),B(i,function(t){var e=t.axis.grid.model;o.set(e.id,e),s[e.id]=!0}),B(r,function(t){o.set(t.id,t),a[t.id]=!0,s[t.id]=!0}),o.each(function(t){var r=t.coordinateSystem,o=[];B(r.getCartesians(),function(t,e){(R(n,t.getAxis("x").model)>=0||R(i,t.getAxis("y").model)>=0)&&o.push(t)}),e.push({panelId:"grid--"+t.id,gridModel:t,coordSysModel:t,coordSys:o[0],coordSyses:o,getPanelRect:dY.grid,xAxisDeclared:a[t.id],yAxisDeclared:s[t.id]})}))},geo:function(t,e){B(t.geoModels,function(t){var n=t.coordinateSystem;e.push({panelId:"geo--"+t.id,geoModel:t,coordSysModel:t,coordSys:n,coordSyses:[n],getPanelRect:dY.geo})})}},hY=[function(t,e){var n=t.xAxisModel,i=t.yAxisModel,r=t.gridModel;return!r&&n&&(r=n.axis.grid.model),!r&&i&&(r=i.axis.grid.model),r&&r===e.gridModel},function(t,e){var n=t.geoModel;return n&&n===e.geoModel}],dY={grid:function(){return this.coordSys.master.getRect().clone()},geo:function(){var t=this.coordSys.view,e=aP(null,t);return $e(e,e,rP(null,t)),e}},pY={lineX:Y(fY,0),lineY:Y(fY,1),rect:function(t,e,n,i){var r=t?e.pointToData([n[0][0],n[1][0]],i):e.dataToPoint([n[0][0],n[1][0]],i),o=t?e.pointToData([n[0][1],n[1][1]],i):e.dataToPoint([n[0][1],n[1][1]],i),a=[lY([r[0],o[0]]),lY([r[1],o[1]])];return{values:a,xyMinMax:a}},polygon:function(t,e,n,i){var r=[[1/0,-1/0],[1/0,-1/0]];return{values:V(n,function(n){var o=t?e.pointToData(n,i):e.dataToPoint(n,i);return r[0][0]=Math.min(r[0][0],o[0]),r[1][0]=Math.min(r[1][0],o[1]),r[0][1]=Math.max(r[0][1],o[0]),r[1][1]=Math.max(r[1][1],o[1]),o}),xyMinMax:r}}};function fY(t,e,n,i){var r=n.getAxis(["x","y"][t]),o=lY(V([0,1],function(t){return e?r.coordToData(r.toLocalCoord(i[t]),!0):r.toGlobalCoord(r.dataToCoord(i[t]))})),a=[];return a[t]=o,a[1-t]=[NaN,NaN],{values:o,xyMinMax:a}}var gY={lineX:Y(vY,0),lineY:Y(vY,1),rect:function(t,e,n){return[[t[0][0]-n[0]*e[0][0],t[0][1]-n[0]*e[0][1]],[t[1][0]-n[1]*e[1][0],t[1][1]-n[1]*e[1][1]]]},polygon:function(t,e,n){return V(t,function(t,i){return[t[0]-n[0]*e[i][0],t[1]-n[1]*e[i][1]]})}};function vY(t,e,n,i){return[e[0]-i[t]*n[0],e[1]-i[t]*n[1]]}function yY(t){return t?[t[0][1]-t[0][0],t[1][1]-t[1][0]]:[NaN,NaN]}var mY,xY,_Y=B,bY=xa+"toolbox-dataZoom_",wY={x:"width",y:"height"},SY=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.render=function(t,e,n,i){this._brushController||(this._brushController=new xz(n.getZr()),this._brushController.on("brush",U(this._onBrush,this)).mount()),function(t,e,n,i,r){var o=n._isZoomActive;i&&"takeGlobalCursor"===i.type&&(o="dataZoomSelect"===i.key&&i.dataZoomSelectActive);n._isZoomActive=o,t.setIconStatus("zoom",o?"emphasis":"normal");var a=new sY(IY(t),e,{include:["grid"]}),s=a.makePanelOpts(r,function(t){return t.xAxisDeclared&&!t.yAxisDeclared?"lineX":!t.xAxisDeclared&&t.yAxisDeclared?"lineY":"rect"});n._brushController.setPanels(s).enableBrush(!(!o||!s.length)&&{brushType:"auto",brushStyle:t.getModel("brushStyle").getItemStyle()})}(t,e,this,i,n),function(t,e){t.setIconStatus("back",function(t){return rY(t).length}(e)>1?"emphasis":"normal")}(t,e)},e.prototype.onclick=function(t,e,n){MY[n].call(this)},e.prototype.dispose=function(t,e){this._brushController&&this._brushController.dispose()},e.prototype._onBrush=function(t){var e=t.areas;if(t.isEnd&&e.length){var n={},i=this.ecModel;this._brushController.updateCovers([]),new sY(IY(this.model),i,{include:["grid"]}).matchOutputRanges(e,i,function(t,e,n){if("cartesian2d"===n.type){var i=n.master.getRect().clone(),o=t.brushType;"rect"===o?(r("x",n,i,e[0]),r("y",n,i,e[1])):r({lineX:"x",lineY:"y"}[o],n,i,e)}}),function(t,e){var n=rY(t);nY(e,function(e,i){for(var r=n.length-1;r>=0&&!n[r][i];r--);if(r<0){var o=t.queryComponents({mainType:"dataZoom",subType:"select",id:i})[0];if(o){var a=o.getPercentRange();n[0][i]={dataZoomId:i,start:a[0],end:a[1]}}}}),n.push(e)}(i,n),this._dispatchZoomAction(n)}function r(t,e,r,o){var a=e.getAxis(t),s=a.model,l=function(t,e,n){var i;return n.eachComponent({mainType:"dataZoom",subType:"select"},function(n){n.getAxisModel(t,e.componentIndex)&&(i=n)}),i}(t,s,i),u=l.findRepresentativeAxisProxy(s).getMinMaxSpan(),c=a.scale.getExtent();null==u.minValueSpan&&null==u.maxValueSpan||(o=ez(0,o.slice(),c,0,u.minValueSpan,u.maxValueSpan));var h=Xo(c,r[wY[t]],.5);l&&(n[l.id]={dataZoomId:l.id,startValue:isFinite(h)?Ho(o[0],h):o[0],endValue:isFinite(h)?Ho(o[1],h):o[1]})}},e.prototype._dispatchZoomAction=function(t){var e=[];_Y(t,function(t,n){e.push(C(t))}),e.length&&this.api.dispatchAction({type:"dataZoom",from:this.uid,batch:e})},e.getDefaultOption=function(t){return{show:!0,filterMode:"filter",icon:{zoom:"M0,13.5h26.9 M13.5,26.9V0 M32.1,13.5H58V58H13.5 V32.1",back:"M22,1.4L9.9,13.5l12.3,12.3 M10.3,13.5H54.9v44.6 H10.3v-26"},title:t.getLocaleModel().get(["toolbox","dataZoom","title"]),brushStyle:{borderWidth:0,color:ug.color.backgroundTint}}},e}(EU),MY={zoom:function(){var t=!this._isZoomActive;this.api.dispatchAction({type:"takeGlobalCursor",key:"dataZoomSelect",dataZoomSelectActive:t})},back:function(){this._dispatchZoomAction(function(t){var e=rY(t),n=e[e.length-1];e.length>1&&e.pop();var i={};return nY(n,function(t,n){for(var r=e.length-1;r>=0;r--)if(t=e[r][n]){i[n]=t;break}}),i}(this.ecModel))}};function IY(t){var e={xAxisIndex:t.get("xAxisIndex",!0),yAxisIndex:t.get("yAxisIndex",!0),xAxisId:t.get("xAxisId",!0),yAxisId:t.get("yAxisId",!0)};return null==e.xAxisIndex&&null==e.xAxisId&&(e.xAxisIndex="all"),null==e.yAxisIndex&&null==e.yAxisId&&(e.yAxisIndex="all"),e}mY="dataZoom",xY=function(t){var e=t.getComponent("toolbox",0),n=["feature","dataZoom"];if(e&&null!=e.get(n)){var i=e.getModel(n),r=[],o=Ra(t,IY(i));return _Y(o.xAxisModels,function(t){return a(t,"xAxis","xAxisIndex")}),_Y(o.yAxisModels,function(t){return a(t,"yAxis","yAxisIndex")}),r}function a(t,e,n){var o=t.componentIndex,a={type:"select",$fromToolbox:!0,filterMode:i.get("filterMode",!0)||"filter",id:bY+e+o};a[n]=o,r.push(a)}},ct(null==Cg.get(mY)&&xY),Cg.set(mY,xY);var TY=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="tooltip",e.dependencies=["axisPointer"],e.defaultOption={z:60,show:!0,showContent:!0,trigger:"item",triggerOn:"mousemove|click|mousewheel",alwaysShowContent:!1,renderMode:"auto",confine:null,showDelay:0,hideDelay:100,transitionDuration:.4,displayTransition:!0,enterable:!1,backgroundColor:ug.color.neutral00,shadowBlur:10,shadowColor:"rgba(0, 0, 0, .2)",shadowOffsetX:1,shadowOffsetY:2,borderRadius:4,borderWidth:1,defaultBorderColor:ug.color.border,padding:null,extraCssText:"",axisPointer:{type:"line",axis:"auto",animation:"auto",animationDurationUpdate:200,animationEasingUpdate:"exponentialOut",crossStyle:{color:ug.color.borderShade,width:1,type:"dashed",textStyle:{}}},textStyle:{color:ug.color.tertiary,fontSize:14}},e}(lg);function CY(t){var e=t.get("confine");return null!=e?!!e:"richText"===t.get("renderMode")}function DY(t){if(r.domSupported)for(var e=document.documentElement.style,n=0,i=t.length;n0&&o.push(function(t,e,n){var i="cubic-bezier(0.23,1,0.32,1)",o="",a="";return n&&(a="opacity"+(o=" "+t/2+"s "+i)+",visibility"+o),e||(o=" "+t+"s "+i,a+=(a.length?",":"")+(r.transformSupported?""+PY+o:",left"+o+",top"+o)),LY+":"+a}(a,n,i)),s&&o.push("background-color:"+s),B(["width","color","radius"],function(e){var n="border-"+e,i=Cf(n),r=t.get(i);null!=r&&o.push(n+":"+r+("color"===e?"":"px"))}),o.push(function(t){var e=[],n=t.get("fontSize"),i=t.getTextColor();i&&e.push("color:"+i),e.push("font:"+t.getFont());var r=at(t.get("lineHeight"),Math.round(3*n/2));n&&e.push("line-height:"+r+"px");var o=t.get("textShadowColor"),a=t.get("textShadowBlur")||0,s=t.get("textShadowOffsetX")||0,l=t.get("textShadowOffsetY")||0;return o&&a&&e.push("text-shadow:"+s+"px "+l+"px "+a+"px "+o),B(["decoration","align"],function(n){var i=t.get(n);i&&e.push("text-"+n+":"+i)}),e.join(";")}(d)),null!=p&&o.push("padding:"+Df(p).join("px ")+"px"),o.join(";")+";"}function EY(t,e,n,i,r){var o=e&&e.painter;if(n){var a=o&&o.getViewportRoot();a&&function(t,e,n,i,r){ie(ne,e,i,r,!0)&&ie(t,n,ne[0],ne[1])}(t,a,n,i,r)}else{t[0]=i,t[1]=r;var s=o&&o.getViewportRootOffset();s&&(t[0]+=s.offsetLeft,t[1]+=s.offsetTop)}t[2]=t[0]/e.getWidth(),t[3]=t[1]/e.getHeight()}var zY=function(){function t(t,e){if(this._show=!1,this._styleCoord=[0,0,0,0],this._enterable=!0,this._alwaysShowContent=!1,this._firstShow=!0,this._longHide=!0,r.wxa)return null;var n=document.createElement("div");n.domBelongToZr=!0,this.el=n;var i=this._zr=t.getZr(),o=e.appendTo,a=o&&(j(o)?document.querySelector(o):tt(o)?o:Z(o)&&o(t.getDom()));EY(this._styleCoord,i,a,t.getWidth()/2,t.getHeight()/2),(a||t.getDom()).appendChild(n),this._api=t,this._container=a;var s=this;n.onmouseenter=function(){s._enterable&&(clearTimeout(s._hideTimeout),s._show=!0),s._inContent=!0},n.onmousemove=function(t){if(t=t||window.event,!s._enterable){var e=i.handler;fe(i.painter.getViewportRoot(),t,!0),e.dispatch("mousemove",t)}},n.onmouseleave=function(){s._inContent=!1,s._enterable&&s._show&&s.hideLater(s._hideDelay)}}return t.prototype.update=function(t){if(!this._container){var e=this._api.getDom(),n=(o="position",(a=(r=e).currentStyle||document.defaultView&&document.defaultView.getComputedStyle(r))?o?a[o]:a:null),i=e.style;"absolute"!==i.position&&"absolute"!==n&&(i.position="relative")}var r,o,a,s=t.get("alwaysShowContent");s&&this._moveIfResized(),this._alwaysShowContent=s,this._enableDisplayTransition=t.get("displayTransition")&&t.get("transitionDuration")>0,this.el.className=t.get("className")||""},t.prototype.show=function(t,e){clearTimeout(this._hideTimeout),clearTimeout(this._longHideTimeout);var n=this.el,i=n.style,r=this._styleCoord;n.innerHTML?i.cssText=OY+NY(t,!this._firstShow,this._longHide,this._enableDisplayTransition)+RY(r[0],r[1],!0)+"border-color:"+Nf(e)+";"+(t.get("extraCssText")||"")+";pointer-events:"+(this._enterable?"auto":"none"):i.display="none",this._show=!0,this._firstShow=!1,this._longHide=!1},t.prototype.setContent=function(t,e,n,i,r){var o=this.el;if(null!=t){var a="";if(j(r)&&"item"===n.get("trigger")&&!CY(n)&&(a=function(t,e,n){if(!j(n)||"inside"===n)return"";var i=t.get("backgroundColor"),r=t.get("borderWidth");e=Nf(e);var o,a,s="left"===(o=n)?"right":"right"===o?"left":"top"===o?"bottom":"top",l=Math.max(1.5*Math.round(r),6),u="",c=PY+":";R(["left","right"],s)>-1?(u+="top:50%",c+="translateY(-50%) rotate("+(a="left"===s?-225:-45)+"deg)"):(u+="left:50%",c+="translateX(-50%) rotate("+(a="top"===s?225:45)+"deg)");var h=a*Math.PI/180,d=l+r,p=d*Math.abs(Math.cos(h))+d*Math.abs(Math.sin(h)),f=e+" solid "+r+"px;";return'
'}(n,i,r)),j(t))o.innerHTML=t+a;else if(t){o.innerHTML="",X(t)||(t=[t]);for(var s=0;s=0?this._tryShow(n,i):"leave"===e&&this._hide(i))},this))},e.prototype._keepShow=function(){var t=this._tooltipModel,e=this._ecModel,n=this._api,i=t.get("triggerOn");if("axis"!==t.get("trigger")&&(this._lastDataByCoordSys=null,this._cbParamsList=null),null!=this._lastX&&null!=this._lastY&&"none"!==i&&"click"!==i){var r=this;clearTimeout(this._refreshUpdateTimeout),this._refreshUpdateTimeout=setTimeout(function(){!n.isDisposed()&&r.manuallyShowTip(t,e,n,{x:r._lastX,y:r._lastY,dataByCoordSys:r._lastDataByCoordSys})})}},e.prototype.manuallyShowTip=function(t,e,n,i){if(i.from!==this.uid&&!r.node&&n.getDom()){var o=YY(i,n);this._ticket="";var a=i.dataByCoordSys,s=function(t,e,n){var i=Na(t).queryOptionMap,r=i.keys()[0];if(!r||"series"===r)return;var o=Ba(e,r,i.get(r),{useDefault:!1,enableAll:!1,enableNone:!1}),a=o.models[0];if(!a)return;var s,l=n.getViewOfComponentModel(a);if(l.group.traverse(function(e){var n=Iu(e).tooltipConfig;if(n&&n.name===t.name)return s=e,!0}),s)return{componentMainType:r,componentIndex:a.componentIndex,el:s}}(i,e,n);if(s){var l=s.el.getBoundingRect().clone();l.applyTransform(s.el.transform),this._tryShow({offsetX:l.x+l.width/2,offsetY:l.y+l.height/2,target:s.el,position:i.position,positionDefault:"bottom"},o)}else if(i.tooltip&&null!=i.x&&null!=i.y){var u=HY;u.x=i.x,u.y=i.y,u.update(),Iu(u).tooltipConfig={name:null,option:i.tooltip},this._tryShow({offsetX:i.x,offsetY:i.y,target:u},o)}else if(a)this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,dataByCoordSys:a,tooltipOption:i.tooltipOption},o);else if(null!=i.seriesIndex){if(this._manuallyAxisShowTip(t,e,n,i))return;var c=jF(i,e),h=c.point[0],d=c.point[1];null!=h&&null!=d&&this._tryShow({offsetX:h,offsetY:d,target:c.el,position:i.position,positionDefault:"bottom"},o)}else null!=i.x&&null!=i.y&&(n.dispatchAction({type:"updateAxisPointer",x:i.x,y:i.y}),this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,target:n.getZr().findHover(i.x,i.y).target},o))}},e.prototype.manuallyHideTip=function(t,e,n,i){var r=this._tooltipContent;this._tooltipModel&&r.hideLater(this._tooltipModel.get("hideDelay")),this._lastX=this._lastY=this._lastDataByCoordSys=null,this._cbParamsList=null,i.from!==this.uid&&this._hide(YY(i,n))},e.prototype._manuallyAxisShowTip=function(t,e,n,i){var r=i.seriesIndex,o=i.dataIndex,a=e.getComponent("axisPointer").coordSysAxesInfo;if(null!=r&&null!=o&&null!=a){var s=e.getSeriesByIndex(r);if(s)if("axis"===UY([s.getData().getItemModel(o),s,(s.coordinateSystem||{}).model],this._tooltipModel).get("trigger"))return n.dispatchAction({type:"updateAxisPointer",seriesIndex:r,dataIndex:o,position:i.position}),!0}},e.prototype._tryShow=function(t,e){var n=t.target;if(this._tooltipModel){this._lastX=t.offsetX,this._lastY=t.offsetY;var i=t.dataByCoordSys;if(i&&i.length)this._showAxisTooltip(i,t);else if(n){var r,o;if("legend"===Iu(n).ssrType)return;this._lastDataByCoordSys=null,this._cbParamsList=null,Qm(n,function(t){if(t.tooltipDisabled)return r=o=null,!0;r||o||(null!=Iu(t).dataIndex?r=t:null!=Iu(t).tooltipConfig&&(o=t))},!0),r?this._showSeriesItemTooltip(t,r,e):o?this._showComponentItemTooltip(t,o,e):this._hide(e)}else this._lastDataByCoordSys=null,this._cbParamsList=null,this._hide(e)}},e.prototype._showOrMove=function(t,e){var n=t.get("showDelay");e=U(e,this),clearTimeout(this._showTimout),n>0?this._showTimout=setTimeout(e,n):e()},e.prototype._showAxisTooltip=function(t,e){var n=this._ecModel,i=this._tooltipModel,r=[e.offsetX,e.offsetY],o=UY([e.tooltipOption],i),a=this._renderMode,s=[],l=Ty("section",{blocks:[],noHeader:!0}),u=[],c=new zy;B(t,function(t){B(t.dataByAxis,function(t){var e=n.getComponent(t.axisDim+"Axis",t.axisIndex),r=t.value,o=e.axis,h=o.scale.parse(r);if(e&&null!=r){var d=DF(r,o,n,t.seriesDataIndices,t.valueLabelOpt),p=Ty("section",{header:d,noHeader:!ht(d),sortBlocks:!0,blocks:[]});l.blocks.push(p),B(t.seriesDataIndices,function(r){var o=n.getSeriesByIndex(r.seriesIndex),l=r.dataIndexInside,f=o.getDataParams(l);if(!(f.dataIndex<0)){f.axisDim=t.axisDim,f.axisIndex=t.axisIndex,f.axisType=t.axisType,f.axisId=t.axisId,f.axisValue=jw(e.axis,{value:h}),f.axisValueLabel=d,f.marker=c.makeTooltipMarker("item",Nf(f.color),a);var g=Gv(o.formatTooltip(l,!0,null)),v=g.frag;if(v){var y=UY([o],i).get("valueFormatter");p.blocks.push(y?k({valueFormatter:y},v):v)}g.text&&u.push(g.text),s.push(f)}})}})}),l.blocks.reverse(),u.reverse();var h=e.position,d=o.get("order"),p=Py(l,c,a,d,n.get("useUTC"),o.get("textStyle"));p&&u.unshift(p);var f="richText"===a?"\n\n":"
",g=u.join(f);this._showOrMove(o,function(){this._updateContentNotChangedOnAxis(t,s)?this._updatePosition(o,h,r[0],r[1],this._tooltipContent,s):this._showTooltipContent(o,g,s,Math.random()+"",r[0],r[1],h,null,c)})},e.prototype._showSeriesItemTooltip=function(t,e,n){var i=this._ecModel,r=Iu(e),o=r.seriesIndex,a=i.getSeriesByIndex(o),s=r.dataModel||a,l=r.dataIndex,u=r.dataType,c=s.getData(u),h=this._renderMode,d=t.positionDefault,p=UY([c.getItemModel(l),s,a&&(a.coordinateSystem||{}).model],this._tooltipModel,d?{position:d}:null),f=p.get("trigger");if(null==f||"item"===f){var g=s.getDataParams(l,u),v=new zy;g.marker=v.makeTooltipMarker("item",Nf(g.color),h);var y=Gv(s.formatTooltip(l,!1,u)),m=p.get("order"),x=p.get("valueFormatter"),_=y.frag,b=_?Py(x?k({valueFormatter:x},_):_,v,h,m,i.get("useUTC"),p.get("textStyle")):y.text,w="item_"+s.name+"_"+l;this._showOrMove(p,function(){this._showTooltipContent(p,b,g,w,t.offsetX,t.offsetY,t.position,t.target,v)}),n({type:"showTip",dataIndexInside:l,dataIndex:c.getRawIndex(l),seriesIndex:o,from:this.uid})}},e.prototype._showComponentItemTooltip=function(t,e,n){var i="html"===this._renderMode,r=Iu(e),o=r.tooltipConfig.option||{},a=o.encodeHTMLContent;if(j(o)){o={content:o,formatter:o},a=!0}a&&i&&o.content&&((o=C(o)).content=se(o.content));var s=[o],l=this._ecModel.getComponent(r.componentMainType,r.componentIndex);l&&s.push(l),s.push({formatter:o.content});var u=t.positionDefault,c=UY(s,this._tooltipModel,u?{position:u}:null),h=c.get("content"),d=Math.random()+"",p=new zy;this._showOrMove(c,function(){var n=C(c.get("formatterParams")||{});this._showTooltipContent(c,h,n,d,t.offsetX,t.offsetY,t.position,e,p)}),n({type:"showTip",from:this.uid})},e.prototype._showTooltipContent=function(t,e,n,i,r,o,a,s,l){if(this._ticket="",t.get("showContent")&&t.get("show")){var u=this._tooltipContent;u.setEnterable(t.get("enterable"));var c=t.get("formatter");a=a||t.get("position");var h=e,d=this._getNearestPoint([r,o],n,t.get("trigger"),t.get("borderColor"),t.get("defaultBorderColor",!0)).color;if(c)if(j(c)){var p=t.ecModel.get("useUTC"),f=X(n)?n[0]:n;h=c,f&&f.axisType&&f.axisType.indexOf("time")>=0&&(h=uf(f.axisValue,h,p)),h=Pf(h,n,!0)}else if(Z(c)){var g=U(function(e,i){e===this._ticket&&(u.setContent(i,l,t,d,a),this._updatePosition(t,a,r,o,u,n,s))},this);this._ticket=i,h=c(n,i,g)}else h=c;u.setContent(h,l,t,d,a),u.show(t,d),this._updatePosition(t,a,r,o,u,n,s)}},e.prototype._getNearestPoint=function(t,e,n,i,r){return"axis"===n||X(e)?{color:i||r}:X(e)?void 0:{color:i||e.color||e.borderColor}},e.prototype._updatePosition=function(t,e,n,i,r,o,a){var s=this._api.getWidth(),l=this._api.getHeight();e=e||t.get("position");var u=r.getSize(),c=t.get("align"),h=t.get("verticalAlign"),d=a&&a.getBoundingRect().clone();if(a&&d.applyTransform(a.transform),Z(e)&&(e=e([n,i],o,r.el,d,{viewSize:[s,l],contentSize:u.slice()})),X(e))n=Vo(e[0],s),i=Vo(e[1],l);else if($(e)){var p=e;p.width=u[0],p.height=u[1];var f=Jf(p,{width:s,height:l});n=f.x,i=f.y,c=null,h=null}else if(j(e)&&a){var g=function(t,e,n,i){var r=n[0],o=n[1],a=Math.ceil(Math.SQRT2*i)+8,s=0,l=0,u=e.width,c=e.height;switch(t){case"inside":s=e.x+u/2-r/2,l=e.y+c/2-o/2;break;case"top":s=e.x+u/2-r/2,l=e.y-o-a;break;case"bottom":s=e.x+u/2-r/2,l=e.y+c+a;break;case"left":s=e.x-r-a,l=e.y+c/2-o/2;break;case"right":s=e.x+u+a,l=e.y+c/2-o/2}return[s,l]}(e,d,u,t.get("borderWidth"));n=g[0],i=g[1]}else{g=function(t,e,n,i,r,o,a){var s=n.getSize(),l=s[0],u=s[1];null!=o&&(t+l+o+2>i?t-=l+o:t+=o);null!=a&&(e+u+a>r?e-=u+a:e+=a);return[t,e]}(n,i,r,s,l,c?null:20,h?null:20);n=g[0],i=g[1]}if(c&&(n-=XY(c)?u[0]/2:"right"===c?u[0]:0),h&&(i-=XY(h)?u[1]/2:"bottom"===h?u[1]:0),CY(t)){g=function(t,e,n,i,r){var o=n.getSize(),a=o[0],s=o[1];return t=Math.min(t+a,i)-a,e=Math.min(e+s,r)-s,t=Math.max(t,0),e=Math.max(e,0),[t,e]}(n,i,r,s,l);n=g[0],i=g[1]}r.moveTo(n,i)},e.prototype._updateContentNotChangedOnAxis=function(t,e){var n=this._lastDataByCoordSys,i=this._cbParamsList,r=!!n&&n.length===t.length;return r&&B(n,function(n,o){var a=n.dataByAxis||[],s=(t[o]||{}).dataByAxis||[];(r=r&&a.length===s.length)&&B(a,function(t,n){var o=s[n]||{},a=t.seriesDataIndices||[],l=o.seriesDataIndices||[];(r=r&&t.value===o.value&&t.axisType===o.axisType&&t.axisId===o.axisId&&a.length===l.length)&&B(a,function(t,e){var n=l[e];r=r&&t.seriesIndex===n.seriesIndex&&t.dataIndex===n.dataIndex}),i&&B(t.seriesDataIndices,function(t){var n=t.seriesIndex,o=e[n],a=i[n];o&&a&&a.data!==o.data&&(r=!1)})})}),this._lastDataByCoordSys=t,this._cbParamsList=e,!!r},e.prototype._hide=function(t){this._lastDataByCoordSys=null,this._cbParamsList=null,t({type:"hideTip",from:this.uid})},e.prototype.dispose=function(t,e){!r.node&&e.getDom()&&(hm(this,"_updatePosition"),this._tooltipContent.dispose(),XF("itemTooltip",e),this._tooltipContent=null,this._tooltipModel=null,this._lastDataByCoordSys=null,this._cbParamsList=null)},e.type="tooltip",e}(Ky);function UY(t,e,n){var i,r=e.ecModel;n?(i=new kp(n,r,r),i=new kp(e.option,i,r)):i=e;for(var o=t.length-1;o>=0;o--){var a=t[o];a&&(a instanceof kp&&(a=a.get("tooltip",!0)),j(a)&&(a={formatter:a}),a&&(i=new kp(a,i,r)))}return i}function YY(t,e){return t.dispatchAction||U(e.dispatchAction,e)}function XY(t){return"center"===t||"middle"===t}var ZY=["rect","polygon","keep","clear"];function jY(t,e){var n=_a(t?t.brush:[]);if(n.length){var i=[];B(n,function(t){var e=t.hasOwnProperty("toolbox")?t.toolbox:[];e instanceof Array&&(i=i.concat(e))});var r=t&&t.toolbox;X(r)&&(r=r[0]),r||(r={feature:{}},t.toolbox=[r]);var o=r.feature||(r.feature={}),a=o.brush||(o.brush={}),s=a.type||(a.type=[]);s.push.apply(s,i),ts(s,function(t){return t+""},null),e&&!s.length&&s.push.apply(s,ZY)}}var qY=B;function KY(t){if(t)for(var e in t)if(t.hasOwnProperty(e))return!0}function $Y(t,e,n){var i={};return qY(e,function(e){var r,o=i[e]=((r=function(){}).prototype.__hidden=r.prototype,new r);qY(t[e],function(t,i){if(ZR.isValidType(i)){var r={type:i,visual:t};n&&n(r,e),o[i]=new ZR(r),"opacity"===i&&((r=C(r)).type="colorAlpha",o.__hidden.__alphaForOpacity=new ZR(r))}})}),i}function JY(t,e,n){var i;B(n,function(t){e.hasOwnProperty(t)&&KY(e[t])&&(i=!0)}),i&&B(n,function(n){e.hasOwnProperty(n)&&KY(e[n])?t[n]=C(e[n]):delete t[n]})}var QY={lineX:tX(0),lineY:tX(1),rect:{point:function(t,e,n){return t&&n.boundingRect.contain(t[0],t[1])},rect:function(t,e,n){return t&&n.boundingRect.intersect(t)}},polygon:{point:function(t,e,n){return t&&n.boundingRect.contain(t[0],t[1])&&FS(n.range,t[0],t[1])},rect:function(t,e,n){var i=n.range;if(!t||i.length<=1)return!1;var r=t.x,o=t.y,a=t.width,s=t.height,l=i[0];return!!(FS(i,r,o)||FS(i,r+a,o)||FS(i,r,o+s)||FS(i,r+a,o+s)||Xe.create(t).contain(l[0],l[1])||Rd(r,o,r+a,o,i)||Rd(r,o,r,o+s,i)||Rd(r+a,o,r+a,o+s,i)||Rd(r,o+s,r+a,o+s,i))||void 0}}};function tX(t){var e=["x","y"],n=["width","height"];return{point:function(e,n,i){if(e){var r=i.range;return eX(e[t],r)}},rect:function(i,r,o){if(i){var a=o.range,s=[i[e[t]],i[e[t]]+i[n[t]]];return s[1]e[0][1]&&(e[0][1]=o[0]),o[1]e[1][1]&&(e[1][1]=o[1])}return e&&cX(e)}};function cX(t){return new Xe(t[0][0],t[1][0],t[0][1]-t[0][0],t[1][1]-t[1][0])}var hX=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(t,e){this.ecModel=t,this.api=e,this.model,(this._brushController=new xz(e.getZr())).on("brush",U(this._onBrush,this)).mount()},e.prototype.render=function(t,e,n,i){this.model=t,this._updateController(t,e,n,i)},e.prototype.updateTransform=function(t,e,n,i){oX(e),this._updateController(t,e,n,i)},e.prototype.updateVisual=function(t,e,n,i){this.updateTransform(t,e,n,i)},e.prototype.updateView=function(t,e,n,i){this._updateController(t,e,n,i)},e.prototype._updateController=function(t,e,n,i){(!i||i.$from!==t.id)&&this._brushController.setPanels(t.brushTargetManager.makePanelOpts(n)).enableBrush(t.brushOption).updateCovers(t.areas.slice())},e.prototype.dispose=function(){this._brushController.dispose()},e.prototype._onBrush=function(t){var e=this.model.id,n=this.model.brushTargetManager.setOutputRanges(t.areas,this.ecModel);(!t.isEnd||t.removeOnClick)&&this.api.dispatchAction({type:"brush",brushId:e,areas:C(n),$from:e}),t.isEnd&&this.api.dispatchAction({type:"brushEnd",brushId:e,areas:C(n),$from:e})},e.type="brush",e}(Ky),dX=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.areas=[],n.brushOption={},n}return n(e,t),e.prototype.optionUpdated=function(t,e){var n=this.option;!e&&JY(n,t,["inBrush","outOfBrush"]);var i=n.inBrush=n.inBrush||{};n.outOfBrush=n.outOfBrush||{color:this.option.defaultOutOfBrushColor},i.hasOwnProperty("liftZ")||(i.liftZ=5)},e.prototype.setAreas=function(t){t&&(this.areas=V(t,function(t){return pX(this.option,t)},this))},e.prototype.setBrushOption=function(t){this.brushOption=pX(this.option,t),this.brushType=this.brushOption.brushType},e.type="brush",e.dependencies=["geo","grid","xAxis","yAxis","parallel","series"],e.defaultOption={seriesIndex:"all",brushType:"rect",brushMode:"single",transformable:!0,brushStyle:{borderWidth:1,color:ug.color.backgroundTint,borderColor:ug.color.borderTint},throttleType:"fixRate",throttleDelay:0,removeOnClick:!0,z:1e4,defaultOutOfBrushColor:ug.color.disabled},e}(lg);function pX(t,e){return D({brushType:t.brushType,brushMode:t.brushMode,transformable:t.transformable,brushStyle:new kp(t.brushStyle).getItemStyle(),removeOnClick:t.removeOnClick,z:t.z},e,!0)}var fX=["rect","polygon","lineX","lineY","keep","clear"],gX=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.render=function(t,e,n){var i,r,o;e.eachComponent({mainType:"brush"},function(t){i=t.brushType,r=t.brushOption.brushMode||"single",o=o||!!t.areas.length}),this._brushType=i,this._brushMode=r,B(t.get("type",!0),function(e){t.setIconStatus(e,("keep"===e?"multiple"===r:"clear"===e?o:e===i)?"emphasis":"normal")})},e.prototype.updateView=function(t,e,n){this.render(t,e,n)},e.prototype.getIcons=function(){var t=this.model,e=t.get("icon",!0),n={};return B(t.get("type",!0),function(t){e[t]&&(n[t]=e[t])}),n},e.prototype.onclick=function(t,e,n){var i=this._brushType,r=this._brushMode;"clear"===n?(e.dispatchAction({type:"axisAreaSelect",intervals:[]}),e.dispatchAction({type:"brush",command:"clear",areas:[]})):e.dispatchAction({type:"takeGlobalCursor",key:"brush",brushOption:{brushType:"keep"===n?i:i!==n&&n,brushMode:"keep"===n?"multiple"===r?"single":"multiple":r}})},e.getDefaultOption=function(t){return{show:!0,type:fX.slice(),icon:{rect:"M7.3,34.7 M0.4,10V-0.2h9.8 M89.6,10V-0.2h-9.8 M0.4,60v10.2h9.8 M89.6,60v10.2h-9.8 M12.3,22.4V10.5h13.1 M33.6,10.5h7.8 M49.1,10.5h7.8 M77.5,22.4V10.5h-13 M12.3,31.1v8.2 M77.7,31.1v8.2 M12.3,47.6v11.9h13.1 M33.6,59.5h7.6 M49.1,59.5 h7.7 M77.5,47.6v11.9h-13",polygon:"M55.2,34.9c1.7,0,3.1,1.4,3.1,3.1s-1.4,3.1-3.1,3.1 s-3.1-1.4-3.1-3.1S53.5,34.9,55.2,34.9z M50.4,51c1.7,0,3.1,1.4,3.1,3.1c0,1.7-1.4,3.1-3.1,3.1c-1.7,0-3.1-1.4-3.1-3.1 C47.3,52.4,48.7,51,50.4,51z M55.6,37.1l1.5-7.8 M60.1,13.5l1.6-8.7l-7.8,4 M59,19l-1,5.3 M24,16.1l6.4,4.9l6.4-3.3 M48.5,11.6 l-5.9,3.1 M19.1,12.8L9.7,5.1l1.1,7.7 M13.4,29.8l1,7.3l6.6,1.6 M11.6,18.4l1,6.1 M32.8,41.9 M26.6,40.4 M27.3,40.2l6.1,1.6 M49.9,52.1l-5.6-7.6l-4.9-1.2",lineX:"M15.2,30 M19.7,15.6V1.9H29 M34.8,1.9H40.4 M55.3,15.6V1.9H45.9 M19.7,44.4V58.1H29 M34.8,58.1H40.4 M55.3,44.4 V58.1H45.9 M12.5,20.3l-9.4,9.6l9.6,9.8 M3.1,29.9h16.5 M62.5,20.3l9.4,9.6L62.3,39.7 M71.9,29.9H55.4",lineY:"M38.8,7.7 M52.7,12h13.2v9 M65.9,26.6V32 M52.7,46.3h13.2v-9 M24.9,12H11.8v9 M11.8,26.6V32 M24.9,46.3H11.8v-9 M48.2,5.1l-9.3-9l-9.4,9.2 M38.9-3.9V12 M48.2,53.3l-9.3,9l-9.4-9.2 M38.9,62.3V46.4",keep:"M4,10.5V1h10.3 M20.7,1h6.1 M33,1h6.1 M55.4,10.5V1H45.2 M4,17.3v6.6 M55.6,17.3v6.6 M4,30.5V40h10.3 M20.7,40 h6.1 M33,40h6.1 M55.4,30.5V40H45.2 M21,18.9h62.9v48.6H21V18.9z",clear:"M22,14.7l30.9,31 M52.9,14.7L22,45.7 M4.7,16.8V4.2h13.1 M26,4.2h7.8 M41.6,4.2h7.8 M70.3,16.8V4.2H57.2 M4.7,25.9v8.6 M70.3,25.9v8.6 M4.7,43.2v12.6h13.1 M26,55.8h7.8 M41.6,55.8h7.8 M70.3,43.2v12.6H57.2"},title:t.getLocaleModel().get(["toolbox","brush","title"])}},e}(EU);var vX=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.layoutMode={type:"box",ignoreSize:!0},n}return n(e,t),e.type="title",e.defaultOption={z:6,show:!0,text:"",target:"blank",subtext:"",subtarget:"blank",left:"center",top:ug.size.m,backgroundColor:ug.color.transparent,borderColor:ug.color.primary,borderWidth:0,padding:5,itemGap:10,textStyle:{fontSize:18,fontWeight:"bold",color:ug.color.primary},subtextStyle:{fontSize:12,color:ug.color.quaternary}},e}(lg),yX=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n){if(this.group.removeAll(),t.get("show")){var i=this.group,r=t.getModel("textStyle"),o=t.getModel("subtextStyle"),a=t.get("textAlign"),s=at(t.get("textBaseline"),t.get("textVerticalAlign")),l=new du({style:sp(r,{text:t.get("text"),fill:r.getTextColor()},{disableBox:!0}),z2:10}),u=l.getBoundingRect(),c=t.get("subtext"),h=new du({style:sp(o,{text:c,fill:o.getTextColor(),y:u.height+t.get("itemGap"),verticalAlign:"top"},{disableBox:!0}),z2:10}),d=t.get("link"),p=t.get("sublink"),f=t.get("triggerEvent",!0);l.silent=!d&&!f,h.silent=!p&&!f,d&&l.on("click",function(){Ef(d,"_"+t.get("target"))}),p&&h.on("click",function(){Ef(p,"_"+t.get("subtarget"))}),Iu(l).eventData=Iu(h).eventData=f?{componentType:"title",componentIndex:t.componentIndex}:null,i.add(l),c&&i.add(h);var g=i.getBoundingRect(),v=t.getBoxLayoutParams();v.width=g.width,v.height=g.height;var y=Jf(v,eg(t,n).refContainer,t.get("padding"));a||("middle"===(a=t.get("left")||t.get("right"))&&(a="center"),"right"===a?y.x+=y.width:"center"===a&&(y.x+=y.width/2)),s||("center"===(s=t.get("top")||t.get("bottom"))&&(s="middle"),"bottom"===s?y.y+=y.height:"middle"===s&&(y.y+=y.height/2),s=s||"top"),i.x=y.x,i.y=y.y,i.markRedraw();var m={align:a,verticalAlign:s};l.setStyle(m),h.setStyle(m),g=i.getBoundingRect();var x=y.margin,_=t.getItemStyle(["color","opacity"]);_.fill=t.get("backgroundColor");var b=new lu({shape:{x:g.x-x[3],y:g.y-x[0],width:g.width+x[1]+x[3],height:g.height+x[0]+x[2],r:t.get("borderRadius")},style:_,subPixelOptimize:!0,silent:!0});i.add(b)}},e.type="title",e}(Ky);var mX=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.layoutMode="box",n}return n(e,t),e.prototype.init=function(t,e,n){this.mergeDefaultAndTheme(t,n),this._initData()},e.prototype.mergeOption=function(e){t.prototype.mergeOption.apply(this,arguments),this._initData()},e.prototype.setCurrentIndex=function(t){null==t&&(t=this.option.currentIndex);var e=this._data.count();this.option.loop?t=(t%e+e)%e:(t>=e&&(t=e-1),t<0&&(t=0)),this.option.currentIndex=t},e.prototype.getCurrentIndex=function(){return this.option.currentIndex},e.prototype.isIndexMax=function(){return this.getCurrentIndex()>=this._data.count()-1},e.prototype.setPlayState=function(t){this.option.autoPlay=!!t},e.prototype.getPlayState=function(){return!!this.option.autoPlay},e.prototype._initData=function(){var t,e=this.option,n=e.data||[],i=e.axisType,r=this._names=[];"category"===i?(t=[],B(n,function(e,n){var i,o=Da(Sa(e),"");$(e)?(i=C(e)).value=n:i=n,t.push(i),r.push(o)})):t=n;var o={category:"ordinal",time:"time",value:"number"}[i]||"number";(this._data=new Hb([{name:"value",type:o}],this)).initData(t,r)},e.prototype.getData=function(){return this._data},e.prototype.getCategories=function(){if("category"===this.get("axisType"))return this._names.slice()},e.type="timeline",e.defaultOption={z:4,show:!0,axisType:"time",realtime:!0,left:"20%",top:null,right:"20%",bottom:0,width:null,height:40,padding:ug.size.m,controlPosition:"left",autoPlay:!1,rewind:!1,loop:!0,playInterval:2e3,currentIndex:0,itemStyle:{},label:{color:ug.color.secondary},data:[]},e}(lg),xX=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="timeline.slider",e.defaultOption=Op(mX.defaultOption,{backgroundColor:"rgba(0,0,0,0)",borderColor:ug.color.border,borderWidth:0,orient:"horizontal",inverse:!1,tooltip:{trigger:"item"},symbol:"circle",symbolSize:12,lineStyle:{show:!0,width:2,color:ug.color.accent10},label:{position:"auto",show:!0,interval:"auto",rotate:0,color:ug.color.tertiary},itemStyle:{color:ug.color.accent20,borderWidth:0},checkpointStyle:{symbol:"circle",symbolSize:15,color:ug.color.accent50,borderColor:ug.color.accent50,borderWidth:0,shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,shadowColor:"rgba(0, 0, 0, 0)",animation:!0,animationDuration:300,animationEasing:"quinticInOut"},controlStyle:{show:!0,showPlayBtn:!0,showPrevBtn:!0,showNextBtn:!0,itemSize:24,itemGap:12,position:"left",playIcon:"path://M15 0C23.2843 0 30 6.71573 30 15C30 23.2843 23.2843 30 15 30C6.71573 30 0 23.2843 0 15C0 6.71573 6.71573 0 15 0ZM15 3C8.37258 3 3 8.37258 3 15C3 21.6274 8.37258 27 15 27C21.6274 27 27 21.6274 27 15C27 8.37258 21.6274 3 15 3ZM11.5 10.6699C11.5 9.90014 12.3333 9.41887 13 9.80371L20.5 14.1338C21.1667 14.5187 21.1667 15.4813 20.5 15.8662L13 20.1963C12.3333 20.5811 11.5 20.0999 11.5 19.3301V10.6699Z",stopIcon:"path://M15 0C23.2843 0 30 6.71573 30 15C30 23.2843 23.2843 30 15 30C6.71573 30 0 23.2843 0 15C0 6.71573 6.71573 0 15 0ZM15 3C8.37258 3 3 8.37258 3 15C3 21.6274 8.37258 27 15 27C21.6274 27 27 21.6274 27 15C27 8.37258 21.6274 3 15 3ZM11.5 10C12.3284 10 13 10.6716 13 11.5V18.5C13 19.3284 12.3284 20 11.5 20C10.6716 20 10 19.3284 10 18.5V11.5C10 10.6716 10.6716 10 11.5 10ZM18.5 10C19.3284 10 20 10.6716 20 11.5V18.5C20 19.3284 19.3284 20 18.5 20C17.6716 20 17 19.3284 17 18.5V11.5C17 10.6716 17.6716 10 18.5 10Z",nextIcon:"path://M0.838834 18.7383C0.253048 18.1525 0.253048 17.2028 0.838834 16.617L7.55635 9.89949L0.838834 3.18198C0.253048 2.59619 0.253048 1.64645 0.838834 1.06066C1.42462 0.474874 2.37437 0.474874 2.96015 1.06066L10.7383 8.83883L10.8412 8.95277C11.2897 9.50267 11.2897 10.2963 10.8412 10.8462L10.7383 10.9602L2.96015 18.7383C2.37437 19.3241 1.42462 19.3241 0.838834 18.7383Z",prevIcon:"path://M10.9602 1.06066C11.5459 1.64645 11.5459 2.59619 10.9602 3.18198L4.24264 9.89949L10.9602 16.617C11.5459 17.2028 11.5459 18.1525 10.9602 18.7383C10.3744 19.3241 9.42462 19.3241 8.83883 18.7383L1.06066 10.9602L0.957771 10.8462C0.509245 10.2963 0.509245 9.50267 0.957771 8.95277L1.06066 8.83883L8.83883 1.06066C9.42462 0.474874 10.3744 0.474874 10.9602 1.06066Z",prevBtnSize:18,nextBtnSize:18,color:ug.color.accent50,borderColor:ug.color.accent50,borderWidth:0},emphasis:{label:{show:!0,color:ug.color.accent60},itemStyle:{color:ug.color.accent60,borderColor:ug.color.accent60},controlStyle:{color:ug.color.accent70,borderColor:ug.color.accent70}},progress:{lineStyle:{color:ug.color.accent30},itemStyle:{color:ug.color.accent40}},data:[]}),e}(mX);E(xX,Vv.prototype);var _X=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="timeline",e}(Ky),bX=function(t){function e(e,n,i,r){var o=t.call(this,e,n,i)||this;return o.type=r||"value",o}return n(e,t),e.prototype.getLabelModel=function(){return this.model.getModel("label")},e.prototype.isHorizontal=function(){return"horizontal"===this.model.get("orient")},e}(SM),wX=Math.PI,SX=Pa(),MX=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(t,e){this.api=e},e.prototype.render=function(t,e,n){if(this.model=t,this.api=n,this.ecModel=e,this.group.removeAll(),t.get("show",!0)){var i=this._layout(t,n),r=this._createGroup("_mainGroup"),o=this._createGroup("_labelGroup"),a=this._axis=this._createAxis(i,t);t.formatTooltip=function(t){return Ty("nameValue",{noName:!0,value:a.scale.getLabel({value:t})})},B(["AxisLine","AxisTick","Control","CurrentPointer"],function(e){this["_render"+e](i,r,a,t)},this),this._renderAxisLabel(i,o,a,t),this._position(i,t)}this._doPlayStop(),this._updateTicksStatus()},e.prototype.remove=function(){this._clearTimer(),this.group.removeAll()},e.prototype.dispose=function(){this._clearTimer()},e.prototype._layout=function(t,e){var n,i,r,o,a=t.get(["label","position"]),s=t.get("orient"),l=function(t,e){return Jf(t.getBoxLayoutParams(),eg(t,e).refContainer,t.get("padding"))}(t,e),u={horizontal:"center",vertical:(n=null==a||"auto"===a?"horizontal"===s?l.y+l.height/2=0||"+"===n?"left":"right"},c={horizontal:n>=0||"+"===n?"top":"bottom",vertical:"middle"},h={horizontal:0,vertical:wX/2},d="vertical"===s?l.height:l.width,p=t.getModel("controlStyle"),f=p.get("show",!0),g=f?p.get("itemSize"):0,v=f?p.get("itemGap"):0,y=g+v,m=t.get(["label","rotate"])||0;m=m*wX/180;var x=p.get("position",!0),_=f&&p.get("showPlayBtn",!0),b=f&&p.get("showPrevBtn",!0),w=f&&p.get("showNextBtn",!0),S=0,M=d;"left"===x||"bottom"===x?(_&&(i=[0,0],S+=y),b&&(r=[S,0],S+=y),w&&(o=[M-g,0],M-=y)):(_&&(i=[M-g,0],M-=y),b&&(r=[0,0],S+=y),w&&(o=[M-g,0],M-=y));var I=[S,M];return t.get("inverse")&&I.reverse(),{viewRect:l,mainLength:d,orient:s,rotation:h[s],labelRotation:m,labelPosOpt:n,labelAlign:t.get(["label","align"])||u[s],labelBaseline:t.get(["label","verticalAlign"])||t.get(["label","baseline"])||c[s],playPosition:i,prevBtnPosition:r,nextBtnPosition:o,axisExtent:I,controlSize:g,controlGap:v}},e.prototype._position=function(t,e){var n=this._mainGroup,i=this._labelGroup,r=t.viewRect;if("vertical"===t.orient){var o=[1,0,0,1,0,0],a=r.x,s=r.y+r.height;Te(o,o,[-a,-s]),Ce(o,o,-wX/2),Te(o,o,[a,s]),(r=r.clone()).applyTransform(o)}var l=v(r),u=v(n.getBoundingRect()),c=v(i.getBoundingRect()),h=[n.x,n.y],d=[i.x,i.y];d[0]=h[0]=l[0][0];var p,f=t.labelPosOpt;null==f||j(f)?(y(h,u,l,1,p="+"===f?0:1),y(d,c,l,1,1-p)):(y(h,u,l,1,p=f>=0?0:1),d[1]=h[1]+f);function g(t){t.originX=l[0][0]-t.x,t.originY=l[1][0]-t.y}function v(t){return[[t.x,t.x+t.width],[t.y,t.y+t.height]]}function y(t,e,n,i,r){t[i]+=n[i][r]-e[i][r]}n.setPosition(h),i.setPosition(d),n.rotation=i.rotation=t.rotation,g(n),g(i)},e.prototype._createAxis=function(t,e){var n=e.getData(),i=e.get("axisType")||e.get("type");"category"!==i&&"time"!==i&&(i="value");var r=Ww(e,i,!1);r.getTicks=function(){return n.mapArray(["value"],function(t){return{value:t}})};var o=n.getDataExtent("value");r.setExtent(o[0],o[1]),ES(r,{fixMinMax:[!0,!0]});var a=new bX("value",r,t.axisExtent,i);return a.model=e,a},e.prototype._createGroup=function(t){var e=this[t]=new vo;return this.group.add(e),e},e.prototype._renderAxisLine=function(t,e,n,i){var r=n.getExtent();if(i.get(["lineStyle","show"])){var o=new Oh({shape:{x1:r[0],y1:0,x2:r[1],y2:0},style:k({lineCap:"round"},i.getModel("lineStyle").getLineStyle()),silent:!0,z2:1});e.add(o);var a=this._progressLine=new Oh({shape:{x1:r[0],x2:this._currentPointer?this._currentPointer.x:r[0],y1:0,y2:0},style:P({lineCap:"round",lineWidth:o.style.lineWidth},i.getModel(["progress","lineStyle"]).getLineStyle()),silent:!0,z2:1});e.add(a)}},e.prototype._renderAxisTick=function(t,e,n,i){var r=this,o=i.getData(),a=n.scale.getTicks();this._tickSymbols=[],B(a,function(t){var a=n.dataToCoord(t.value),s=o.getItemModel(t.value),l=s.getModel("itemStyle"),u=s.getModel(["emphasis","itemStyle"]),c=s.getModel(["progress","itemStyle"]),h={x:a,y:0,onclick:U(r._changeTimeline,r,t.value)},d=IX(s,l,e,h);d.ensureState("emphasis").style=u.getItemStyle(),d.ensureState("progress").style=c.getItemStyle(),Ac(d);var p=Iu(d);s.get("tooltip")?(p.dataIndex=t.value,p.dataModel=i):p.dataIndex=p.dataModel=null,r._tickSymbols.push(d)})},e.prototype._renderAxisLabel=function(t,e,n,i){var r=this;if(n.getLabelModel().get("show")){var o=i.getData(),a=n.getViewLabels();this._tickLabels=[],B(a,function(i){if(!i.tick.offInterval){var a=i.tick.value,s=o.getItemModel(a),l=s.getModel("label"),u=s.getModel(["emphasis","label"]),c=s.getModel(["progress","label"]),h=n.dataToCoord(a),d=new du({x:h,y:0,rotation:t.labelRotation-t.rotation,onclick:U(r._changeTimeline,r,a),silent:!1,style:sp(l,{text:i.formattedLabel,align:t.labelAlign,verticalAlign:t.labelBaseline})});d.ensureState("emphasis").style=sp(u),d.ensureState("progress").style=sp(c),e.add(d),Ac(d),SX(d).dataIndex=a,r._tickLabels.push(d)}})}},e.prototype._renderControl=function(t,e,n,i){var r=t.controlSize,o=t.rotation,a=i.getModel("controlStyle").getItemStyle(),s=i.getModel(["emphasis","controlStyle"]).getItemStyle(),l=i.getPlayState(),u=i.get("inverse",!0);function c(t,n,l,u){if(t){var c=Qr(at(i.get(["controlStyle",n+"BtnSize"]),r),r),h=function(t,e,n,i){var r=i.style,o=Od(t.get(["controlStyle",e]),i||{},new Xe(n[0],n[1],n[2],n[3]));r&&o.setStyle(r);return o}(i,n+"Icon",[0,-c/2,c,c],{x:t[0],y:t[1],originX:r/2,originY:0,rotation:u?-o:0,rectHover:!0,style:a,onclick:l});h.ensureState("emphasis").style=s,e.add(h),Ac(h)}}c(t.nextBtnPosition,"next",U(this._changeTimeline,this,u?"-":"+")),c(t.prevBtnPosition,"prev",U(this._changeTimeline,this,u?"+":"-")),c(t.playPosition,l?"stop":"play",U(this._handlePlayClick,this,!l),!0)},e.prototype._renderCurrentPointer=function(t,e,n,i){var r=i.getData(),o=i.getCurrentIndex(),a=r.getItemModel(o).getModel("checkpointStyle"),s=this,l={onCreate:function(t){t.draggable=!0,t.drift=U(s._handlePointerDrag,s),t.ondragend=U(s._handlePointerDragend,s),TX(t,s._progressLine,o,n,i,!0)},onUpdate:function(t){TX(t,s._progressLine,o,n,i)}};this._currentPointer=IX(a,a,this._mainGroup,{},this._currentPointer,l)},e.prototype._handlePlayClick=function(t){this._clearTimer(),this.api.dispatchAction({type:"timelinePlayChange",playState:t,from:this.uid})},e.prototype._handlePointerDrag=function(t,e,n){this._clearTimer(),this._pointerChangeTimeline([n.offsetX,n.offsetY])},e.prototype._handlePointerDragend=function(t){this._pointerChangeTimeline([t.offsetX,t.offsetY],!0)},e.prototype._pointerChangeTimeline=function(t,e){var n=this._toAxisCoord(t)[0],i=Wo(this._axis.getExtent().slice());n>i[1]&&(n=i[1]),n=0&&(s[a]=+s[a].toFixed(d)),[s,h]}var zX={min:Y(EX,"min"),max:Y(EX,"max"),average:Y(EX,"average"),median:Y(EX,"median")};function BX(t,e){if(e){var n=t.getData(),i=t.coordinateSystem,r=i&&i.dimensions;if(!function(t){return!isNaN(parseFloat(t.x))&&!isNaN(parseFloat(t.y))}(e)&&!X(e.coord)&&X(r)){var o=VX(e,n,i,t);if((e=C(e)).type&&zX[e.type]&&o.baseAxis&&o.valueAxis){var a=R(r,o.baseAxis.dim),s=R(r,o.valueAxis.dim),l=zX[e.type](n,o.valueAxis.dim,o.baseDataDim,o.valueDataDim,a,s);e.coord=l[0],e.value=l[1]}else e.coord=[null!=e.xAxis?e.xAxis:e.radiusAxis,null!=e.yAxis?e.yAxis:e.angleAxis]}if(null!=e.coord&&X(r))for(var u=e.coord,c=0;c<2;c++)zX[u[c]]&&(u[c]=HX(n,n.mapDimension(r[c]),u[c]));else{e.coord=[];var h=t.getBaseAxis();if(h&&e.type&&zX[e.type]){var d=i.getOtherAxis(h);d&&(e.value=HX(n,n.mapDimension(d.dim),e.type))}}return e}}function VX(t,e,n,i){var r={};return null!=t.valueIndex||null!=t.valueDim?(r.valueDataDim=null!=t.valueIndex?e.getDimension(t.valueIndex):t.valueDim,r.valueAxis=n.getAxis(function(t,e){var n=t.getData().getDimensionInfo(e);return n&&n.coordDim}(i,r.valueDataDim)),r.baseAxis=n.getOtherAxis(r.valueAxis),r.baseDataDim=e.mapDimension(r.baseAxis.dim)):(r.baseAxis=i.getBaseAxis(),r.valueAxis=n.getOtherAxis(r.baseAxis),r.baseDataDim=e.mapDimension(r.baseAxis.dim),r.valueDataDim=e.mapDimension(r.valueAxis.dim)),r}function GX(t,e){return!(t&&t.containData&&e.coord&&!NX(e))||t.containData(e.coord)}function FX(t,e){return t?function(t,n,i,r){return Uv(r<2?t.coord&&t.coord[r]:t.value,e[r])}:function(t,n,i,r){return Uv(t.value,e[r])}}function HX(t,e,n){if("average"===n){var i=0,r=0;return t.each(e,function(t,e){isNaN(t)||(i+=t,r++)}),i/r}return"median"===n?t.getMedian(e):t.getDataExtent(e)["max"===n?1:0]}var WX=Pa(),UX=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(){this.markerGroupMap=mt()},e.prototype.render=function(t,e,n){var i=this,r=this.markerGroupMap;r.each(function(t){WX(t).keep=!1}),e.eachSeries(function(t){var r=OX.getMarkerModelFromSeries(t,i.type);r&&i.renderSeries(t,r,e,n)}),r.each(function(t){!WX(t).keep&&i.group.remove(t.group)}),function(t,e,n){t.eachSeries(function(t){var i=OX.getMarkerModelFromSeries(t,n),r=e.get(t.id);if(i&&r&&r.group){var o=Zd(i),a=o.z,s=o.zlevel;qd(r.group,a,s)}})}(e,r,this.type)},e.prototype.markKeep=function(t){WX(t).keep=!0},e.prototype.toggleBlurSeries=function(t,e){var n=this;B(t,function(t){var i=OX.getMarkerModelFromSeries(t,n.type);i&&i.getData().eachItemGraphicEl(function(t){t&&(e?mc(t):xc(t))})})},e.type="marker",e}(Ky);function YX(t,e,n){var i=e.coordinateSystem,r=n.getWidth(),o=n.getHeight(),a=i&&i.getArea&&i.getArea();t.each(function(n){var s,l=t.getItemModel(n),u="coordinate"===l.get("relativeTo"),c=u?a?a.width:0:r,h=u?a?a.height:0:o,d=u&&a?a.x:0,p=u&&a?a.y:0,f=Vo(l.get("x"),c)+d,g=Vo(l.get("y"),h)+p;if(isNaN(f)||isNaN(g)){if(e.getMarkerPosition)s=e.getMarkerPosition(t.getValues(t.dimensions,n));else if(i){var v=t.get(i.dimensions[0],n),y=t.get(i.dimensions[1],n);s=i.dataToPoint([v,y])}}else s=[f,g];isNaN(f)||(s[0]=f),isNaN(g)||(s[1]=g),t.setItemLayout(n,s)})}var XX=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.updateTransform=function(t,e,n){e.eachSeries(function(t){var e=OX.getMarkerModelFromSeries(t,"markPoint");e&&(YX(e.getData(),t,n),this.markerGroupMap.get(t.id).updateLayout())},this)},e.prototype.renderSeries=function(t,e,n,i){var r=t.coordinateSystem,o=t.id,a=t.getData(),s=this.markerGroupMap,l=s.get(o)||s.set(o,new nC),u=function(t,e,n){var i;i=t?V(t&&t.dimensions,function(t){var n=e.getData();return k(k({},n.getDimensionInfo(n.mapDimension(t))||{}),{name:t,ordinalMeta:null})}):[{name:"value",type:"float"}];var r=new Hb(i,n),o=V(n.get("data"),Y(BX,e));t&&(o=F(o,Y(GX,t)));var a=FX(!!t,i);return r.initData(o,null,a),r}(r,t,e);e.setData(u),YX(e.getData(),t,i),u.each(function(t){var n=u.getItemModel(t),i=n.getShallow("symbol"),r=n.getShallow("symbolSize"),o=n.getShallow("symbolRotate"),s=n.getShallow("symbolOffset"),l=n.getShallow("symbolKeepAspect");if(Z(i)||Z(r)||Z(o)||Z(s)){var c=e.getRawValue(t),h=e.getDataParams(t);Z(i)&&(i=i(c,h)),Z(r)&&(r=r(c,h)),Z(o)&&(o=o(c,h)),Z(s)&&(s=s(c,h))}var d=n.getModel("itemStyle").getItemStyle(),p=n.get("z2"),f=qm(a,"color");d.fill||(d.fill=f),u.setItemVisual(t,{z2:at(p,0),symbol:i,symbolSize:r,symbolRotate:o,symbolOffset:s,symbolKeepAspect:l,style:d})}),l.updateData(u),this.group.add(l.group),u.eachItemGraphicEl(function(t){t.traverse(function(t){Iu(t).dataModel=e})}),this.markKeep(l),l.group.silent=e.get("silent")||t.get("silent")},e.type="markPoint",e}(UX);var ZX=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.createMarkerModelFromSeries=function(t,n,i){return new e(t,n,i)},e.type="markLine",e.defaultOption={z:5,symbol:["circle","arrow"],symbolSize:[8,16],symbolOffset:0,precision:2,tooltip:{trigger:"item"},label:{show:!0,position:"end",distance:5},lineStyle:{type:"dashed"},emphasis:{label:{show:!0},lineStyle:{width:3}},animationEasing:"linear"},e}(OX),jX=Pa(),qX=function(t,e,n,i){var r,o=t.getData();if(X(i))r=i;else{var a=i.type;if("min"===a||"max"===a||"average"===a||"median"===a||null!=i.xAxis||null!=i.yAxis){var s=void 0,l=void 0;if(null!=i.yAxis||null!=i.xAxis)s=e.getAxis(null!=i.yAxis?"y":"x"),l=ot(i.yAxis,i.xAxis);else{var u=VX(i,o,e,t);s=u.valueAxis,l=HX(o,Kb(o,u.valueDataDim),a)}var c="x"===s.dim?0:1,h=1-c,d=C(i),p={coord:[]};d.type=null,d.coord=[],d.coord[h]=-1/0,p.coord[h]=1/0;var f=n.get("precision");f>=0&&K(l)&&(l=+l.toFixed(Math.min(f,20))),d.coord[c]=p.coord[c]=l,r=[d,p,{type:a,valueIndex:i.valueIndex,value:l}]}else r=[]}var g=[BX(t,r[0]),BX(t,r[1]),k({},r[2])];return g[2].type=g[2].type||null,D(g[2],g[0]),D(g[2],g[1]),g};function KX(t){return!isNaN(t)&&!isFinite(t)}function $X(t,e,n,i){var r=1-t,o=i.dimensions[t];return KX(e[r])&&KX(n[r])&&e[t]===n[t]&&i.getAxis(o).containData(e[t])}function JX(t,e){if("cartesian2d"===t.type){var n=e[0].coord,i=e[1].coord;if(n&&i&&($X(1,n,i,t)||$X(0,n,i,t)))return!0}return GX(t,e[0])&&GX(t,e[1])}function QX(t,e,n,i,r){var o,a=i.coordinateSystem,s=t.getItemModel(e),l=Vo(s.get("x"),r.getWidth()),u=Vo(s.get("y"),r.getHeight());if(isNaN(l)||isNaN(u)){if(i.getMarkerPosition)o=i.getMarkerPosition(t.getValues(t.dimensions,e));else{var c=a.dimensions,h=t.get(c[0],e),d=t.get(c[1],e);o=a.dataToPoint([h,d])}if(wC(a,"cartesian2d")){var p=a.getAxis("x"),f=a.getAxis("y");c=a.dimensions;KX(t.get(c[0],e))?o[0]=p.toGlobalCoord(p.getExtent()[n?0:1]):KX(t.get(c[1],e))&&(o[1]=f.toGlobalCoord(f.getExtent()[n?0:1]))}isNaN(l)||(o[0]=l),isNaN(u)||(o[1]=u)}else o=[l,u];t.setItemLayout(e,o)}var tZ=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.updateTransform=function(t,e,n){e.eachSeries(function(t){var e=OX.getMarkerModelFromSeries(t,"markLine");if(e){var i=e.getData(),r=jX(e).from,o=jX(e).to;r.each(function(e){QX(r,e,!0,t,n),QX(o,e,!1,t,n)}),i.each(function(t){i.setItemLayout(t,[r.getItemLayout(t),o.getItemLayout(t)])}),this.markerGroupMap.get(t.id).updateLayout()}},this)},e.prototype.renderSeries=function(t,e,n,i){var r=t.coordinateSystem,o=t.id,a=t.getData(),s=this.markerGroupMap,l=s.get(o)||s.set(o,new QN);this.group.add(l.group);var u=function(t,e,n){var i;i=t?V(t&&t.dimensions,function(t){var n=e.getData();return k(k({},n.getDimensionInfo(n.mapDimension(t))||{}),{name:t,ordinalMeta:null})}):[{name:"value",type:"float"}];var r=new Hb(i,n),o=new Hb(i,n),a=new Hb([],n),s=V(n.get("data"),Y(qX,e,t,n));t&&(s=F(s,Y(JX,t)));var l=FX(!!t,i);return r.initData(V(s,function(t){return t[0]}),null,l),o.initData(V(s,function(t){return t[1]}),null,l),a.initData(V(s,function(t){return t[2]})),a.hasItemOption=!0,{from:r,to:o,line:a}}(r,t,e),c=u.from,h=u.to,d=u.line;jX(e).from=c,jX(e).to=h,e.setData(d);var p=e.get("symbol"),f=e.get("symbolSize"),g=e.get("symbolRotate"),v=e.get("symbolOffset");function y(e,n,r){var o=e.getItemModel(n);QX(e,n,r,t,i);var s=o.getModel("itemStyle").getItemStyle();null==s.fill&&(s.fill=qm(a,"color")),e.setItemVisual(n,{symbolKeepAspect:o.get("symbolKeepAspect"),symbolOffset:at(o.get("symbolOffset",!0),v[r?0:1]),symbolRotate:at(o.get("symbolRotate",!0),g[r?0:1]),symbolSize:at(o.get("symbolSize"),f[r?0:1]),symbol:at(o.get("symbol",!0),p[r?0:1]),style:s})}X(p)||(p=[p,p]),X(f)||(f=[f,f]),X(g)||(g=[g,g]),X(v)||(v=[v,v]),u.from.each(function(t){y(c,t,!0),y(h,t,!1)}),d.each(function(t){var e=d.getItemModel(t),n=e.getModel("lineStyle").getLineStyle();d.setItemLayout(t,[c.getItemLayout(t),h.getItemLayout(t)]);var i=e.get("z2");null==n.stroke&&(n.stroke=c.getItemVisual(t,"style").fill),d.setItemVisual(t,{z2:at(i,0),fromSymbolKeepAspect:c.getItemVisual(t,"symbolKeepAspect"),fromSymbolOffset:c.getItemVisual(t,"symbolOffset"),fromSymbolRotate:c.getItemVisual(t,"symbolRotate"),fromSymbolSize:c.getItemVisual(t,"symbolSize"),fromSymbol:c.getItemVisual(t,"symbol"),toSymbolKeepAspect:h.getItemVisual(t,"symbolKeepAspect"),toSymbolOffset:h.getItemVisual(t,"symbolOffset"),toSymbolRotate:h.getItemVisual(t,"symbolRotate"),toSymbolSize:h.getItemVisual(t,"symbolSize"),toSymbol:h.getItemVisual(t,"symbol"),style:n})}),l.updateData(d),u.line.eachItemGraphicEl(function(t){Iu(t).dataModel=e,t.traverse(function(t){Iu(t).dataModel=e})}),this.markKeep(l),l.group.silent=e.get("silent")||t.get("silent")},e.type="markLine",e}(UX);var eZ=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.createMarkerModelFromSeries=function(t,n,i){return new e(t,n,i)},e.type="markArea",e.defaultOption={z:1,tooltip:{trigger:"item"},animation:!1,label:{show:!0,position:"top"},itemStyle:{borderWidth:0},emphasis:{label:{show:!0,position:"top"}}},e}(OX),nZ=Pa(),iZ=function(t,e,n,i){var r=i[0],o=i[1];if(r&&o){var a=BX(t,r),s=BX(t,o),l=a.coord,u=s.coord;l[0]=ot(l[0],-1/0),l[1]=ot(l[1],-1/0),u[0]=ot(u[0],1/0),u[1]=ot(u[1],1/0);var c=A([{},a,s]);return c.coord=[a.coord,s.coord],c.x0=a.x,c.y0=a.y,c.x1=s.x,c.y1=s.y,c}};function rZ(t){return!isNaN(t)&&!isFinite(t)}function oZ(t,e,n,i){var r=1-t;return rZ(e[r])&&rZ(n[r])}function aZ(t,e){var n=e.coord[0],i=e.coord[1],r={coord:n,x:e.x0,y:e.y0},o={coord:i,x:e.x1,y:e.y1};return wC(t,"cartesian2d")?!(!n||!i||!oZ(1,n,i)&&!oZ(0,n,i))||function(t,e,n){return!(t&&t.containZone&&e.coord&&n.coord&&!NX(e)&&!NX(n))||t.containZone(e.coord,n.coord)}(t,r,o):GX(t,r)||GX(t,o)}function sZ(t,e,n,i,r){var o,a=i.coordinateSystem,s=t.getItemModel(e),l=Vo(s.get(n[0]),r.getWidth()),u=Vo(s.get(n[1]),r.getHeight());if(isNaN(l)||isNaN(u)){if(i.getMarkerPosition){var c=t.getValues(["x0","y0"],e),h=t.getValues(["x1","y1"],e),d=a.clampData(c),p=a.clampData(h),f=[];"x0"===n[0]?f[0]=d[0]>p[0]?h[0]:c[0]:f[0]=d[0]>p[0]?c[0]:h[0],"y0"===n[1]?f[1]=d[1]>p[1]?h[1]:c[1]:f[1]=d[1]>p[1]?c[1]:h[1],o=i.getMarkerPosition(f,n,!0)}else{var g=[m=t.get(n[0],e),x=t.get(n[1],e)];a.clampData&&a.clampData(g,g),o=a.dataToPoint(g,!0)}if(wC(a,"cartesian2d")){var v=a.getAxis("x"),y=a.getAxis("y"),m=t.get(n[0],e),x=t.get(n[1],e);rZ(m)?o[0]=v.toGlobalCoord(v.getExtent()["x0"===n[0]?0:1]):rZ(x)&&(o[1]=y.toGlobalCoord(y.getExtent()["y0"===n[1]?0:1]))}isNaN(l)||(o[0]=l),isNaN(u)||(o[1]=u)}else o=[l,u];return o}var lZ=[["x0","y0"],["x1","y0"],["x1","y1"],["x0","y1"]],uZ=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.updateTransform=function(t,e,n){e.eachSeries(function(t){var e=OX.getMarkerModelFromSeries(t,"markArea");if(e){var i=e.getData();i.each(function(e){var r=V(lZ,function(r){return sZ(i,e,r,t,n)});i.setItemLayout(e,r),i.getItemGraphicEl(e).setShape("points",r)})}},this)},e.prototype.renderSeries=function(t,e,n,i){var r=t.coordinateSystem,o=t.id,a=t.getData(),s=this.markerGroupMap,l=s.get(o)||s.set(o,{group:new vo});this.group.add(l.group),this.markKeep(l);var u=function(t,e,n){var i,r,o=["x0","y0","x1","y1"];if(t){var a=V(t&&t.dimensions,function(t){var n=e.getData();return k(k({},n.getDimensionInfo(n.mapDimension(t))||{}),{name:t,ordinalMeta:null})});r=V(o,function(t,e){return{name:t,type:a[e%2].type}}),i=new Hb(r,n)}else i=new Hb(r=[{name:"value",type:"float"}],n);var s=V(n.get("data"),Y(iZ,e,t,n));t&&(s=F(s,Y(aZ,t)));var l=t?function(t,e,n,i){return Uv(t.coord[Math.floor(i/2)][i%2],r[i])}:function(t,e,n,i){return Uv(t.value,r[i])};return i.initData(s,null,l),i.hasItemOption=!0,i}(r,t,e);e.setData(u),u.each(function(e){var n=V(lZ,function(n){return sZ(u,e,n,t,i)}),o=r.getAxis("x").scale,s=r.getAxis("y").scale,l=o.getExtent(),c=s.getExtent(),h=[o.parse(u.get("x0",e)),o.parse(u.get("x1",e))],d=[s.parse(u.get("y0",e)),s.parse(u.get("y1",e))];Wo(h),Wo(d);var p=!!(l[0]>h[1]||l[1]d[1]||c[1]=0},e.prototype.getOrient=function(){return"vertical"===this.get("orient")?{index:1,name:"vertical"}:{index:0,name:"horizontal"}},e.type="legend.plain",e.dependencies=["series"],e.defaultOption={z:4,show:!0,orient:"horizontal",left:"center",bottom:ug.size.m,align:"auto",backgroundColor:ug.color.transparent,borderColor:ug.color.border,borderRadius:0,borderWidth:0,padding:5,itemGap:8,itemWidth:25,itemHeight:14,symbolRotate:"inherit",symbolKeepAspect:!0,inactiveColor:ug.color.disabled,inactiveBorderColor:ug.color.disabled,inactiveBorderWidth:"auto",itemStyle:{color:"inherit",opacity:"inherit",borderColor:"inherit",borderWidth:"auto",borderCap:"inherit",borderJoin:"inherit",borderDashOffset:"inherit",borderMiterLimit:"inherit"},lineStyle:{width:"auto",color:"inherit",inactiveColor:ug.color.disabled,inactiveWidth:2,opacity:"inherit",type:"inherit",cap:"inherit",join:"inherit",dashOffset:"inherit",miterLimit:"inherit"},textStyle:{color:ug.color.secondary},selectedMode:!0,selector:!1,selectorLabel:{show:!0,borderRadius:10,padding:[3,5,3,5],fontSize:12,fontFamily:"sans-serif",color:ug.color.tertiary,borderWidth:1,borderColor:ug.color.border},emphasis:{selectorLabel:{show:!0,color:ug.color.quaternary}},selectorPosition:"auto",selectorItemGap:7,selectorButtonGap:10,tooltip:{show:!1},triggerEvent:!1},e}(lg),hZ=Y,dZ=B,pZ=vo,fZ=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.newlineDisabled=!1,n}return n(e,t),e.prototype.init=function(){this.group.add(this._contentGroup=new pZ),this.group.add(this._selectorGroup=new pZ),this._isFirstRender=!0},e.prototype.getContentGroup=function(){return this._contentGroup},e.prototype.getSelectorGroup=function(){return this._selectorGroup},e.prototype.render=function(t,e,n){var i=this._isFirstRender;if(this._isFirstRender=!1,this.resetInner(),t.get("show",!0)){var r=t.get("align"),o=t.get("orient");r&&"auto"!==r||(r="right"===t.get("left")&&"vertical"===o?"right":"left");var a=t.get("selector",!0),s=t.get("selectorPosition",!0);!a||s&&"auto"!==s||(s="horizontal"===o?"end":"start"),this.renderInner(r,t,e,n,a,o,s);var l=eg(t,n).refContainer,u=t.getBoxLayoutParams(),c=t.get("padding"),h=Jf(u,l,c),d=this.layoutInner(t,r,h,i,a,s),p=Jf(P({width:d.width,height:d.height},u),l,c);this.group.x=p.x-d.x,this.group.y=p.y-d.y,this.group.markRedraw(),this.group.add(this._backgroundEl=FU(d,t))}},e.prototype.resetInner=function(){this.getContentGroup().removeAll(),this._backgroundEl&&this.group.remove(this._backgroundEl),this.getSelectorGroup().removeAll()},e.prototype.renderInner=function(t,e,n,i,r,o,a){var s=this.getContentGroup(),l=mt(),u=e.get("selectedMode"),c=e.get("triggerEvent"),h=[];n.eachRawSeries(function(t){!t.get("legendHoverLink")&&h.push(t.id)}),dZ(e.getData(),function(r,o){var a=this,d=r.get("name");if(!this.newlineDisabled&&(""===d||"\n"===d)){var p=new pZ;return p.newline=!0,void s.add(p)}var f=n.getSeriesByName(d)[0];if(!l.get(d)){if(f){var g=f.getData(),v=g.getVisual("legendLineStyle")||{},y=g.getVisual("legendIcon"),m=g.getVisual("style"),x=this._createItem(f,d,o,r,e,t,v,m,y,u,i);x.on("click",hZ(gZ,d,null,i,h)).on("mouseover",hZ(vZ,f.name,null,i,h)).on("mouseout",hZ(yZ,f.name,null,i,h)),n.ssr&&x.eachChild(function(t){var e=Iu(t);e.seriesIndex=f.seriesIndex,e.dataIndex=o,e.ssrType="legend"}),c&&x.eachChild(function(t){a.packEventData(t,e,f,o,d)}),l.set(d,!0)}else n.eachRawSeries(function(a){var s=this;if(!l.get(d)&&a.legendVisualProvider){var p=a.legendVisualProvider;if(!p.containName(d))return;var f=p.indexOfName(d),g=p.getItemVisual(f,"style"),v=p.getItemVisual(f,"legendIcon"),y=gi(g.fill);y&&0===y[3]&&(y[3]=.2,g=k(k({},g),{fill:Mi(y,"rgba")}));var m=this._createItem(a,d,o,r,e,t,{},g,v,u,i);m.on("click",hZ(gZ,null,d,i,h)).on("mouseover",hZ(vZ,null,d,i,h)).on("mouseout",hZ(yZ,null,d,i,h)),n.ssr&&m.eachChild(function(t){var e=Iu(t);e.seriesIndex=a.seriesIndex,e.dataIndex=o,e.ssrType="legend"}),c&&m.eachChild(function(t){s.packEventData(t,e,a,o,d)}),l.set(d,!0)}},this);0}},this),r&&this._createSelector(r,e,i,o,a)},e.prototype.packEventData=function(t,e,n,i,r){var o={componentType:"legend",componentIndex:e.componentIndex,dataIndex:i,value:r,seriesIndex:n.seriesIndex};Iu(t).eventData=o},e.prototype._createSelector=function(t,e,n,i,r){var o=this.getSelectorGroup();dZ(t,function(t){var i=t.type,r=new du({style:{x:0,y:0,align:"center",verticalAlign:"middle"},onclick:function(){n.dispatchAction({type:"all"===i?"legendAllSelect":"legendInverseSelect",legendId:e.id})}});o.add(r),op(r,{normal:e.getModel("selectorLabel"),emphasis:e.getModel(["emphasis","selectorLabel"])},{defaultText:t.title}),Ac(r)})},e.prototype._createItem=function(t,e,n,i,r,o,a,s,l,u,c){var h=t.visualDrawType,d=r.get("itemWidth"),p=r.get("itemHeight"),f=r.isSelected(e),g=i.get("symbolRotate"),v=i.get("symbolKeepAspect"),y=i.get("icon"),m=function(t,e,n,i,r,o,a){function s(t,e){"auto"===t.lineWidth&&(t.lineWidth=e.lineWidth>0?2:0),dZ(t,function(n,i){"inherit"===t[i]&&(t[i]=e[i])})}var l=e.getModel("itemStyle"),u=l.getItemStyle(),c=0===t.lastIndexOf("empty",0)?"fill":"stroke",h=l.getShallow("decal");u.decal=h&&"inherit"!==h?Zx(h,a):i.decal,"inherit"===u.fill&&(u.fill=i[r]);"inherit"===u.stroke&&(u.stroke=i[c]);"inherit"===u.opacity&&(u.opacity=("fill"===r?i:n).opacity);s(u,i);var d=e.getModel("lineStyle"),p=d.getLineStyle();if(s(p,n),"auto"===u.fill&&(u.fill=i.fill),"auto"===u.stroke&&(u.stroke=i.fill),"auto"===p.stroke&&(p.stroke=i.fill),!o){var f=e.get("inactiveBorderWidth"),g=u[c];u.lineWidth="auto"===f?i.lineWidth>0&&g?2:0:u.lineWidth,u.fill=e.get("inactiveColor"),u.stroke=e.get("inactiveBorderColor"),p.stroke=d.get("inactiveColor"),p.lineWidth=d.get("inactiveWidth")}return{itemStyle:u,lineStyle:p}}(l=y||l||"roundRect",i,a,s,h,f,c),x=new pZ,_=i.getModel("textStyle");if(!Z(t.getLegendIcon)||y&&"inherit"!==y){var b="inherit"===y&&t.getData().getVisual("symbol")?"inherit"===g?t.getData().getVisual("symbolRotate"):g:0;x.add(function(t){var e=t.icon||"roundRect",n=xx(e,0,0,t.itemWidth,t.itemHeight,t.itemStyle.fill,t.symbolKeepAspect);n.setStyle(t.itemStyle),n.rotation=(t.iconRotate||0)*Math.PI/180,n.setOrigin([t.itemWidth/2,t.itemHeight/2]),e.indexOf("empty")>-1&&(n.style.stroke=n.style.fill,n.style.fill=ug.color.neutral00,n.style.lineWidth=2);return n}({itemWidth:d,itemHeight:p,icon:l,iconRotate:b,itemStyle:m.itemStyle,lineStyle:m.lineStyle,symbolKeepAspect:v}))}else x.add(t.getLegendIcon({itemWidth:d,itemHeight:p,icon:l,iconRotate:g,itemStyle:m.itemStyle,lineStyle:m.lineStyle,symbolKeepAspect:v}));var w="left"===o?d+5:-5,S=o,M=r.get("formatter"),I=e;j(M)&&M?I=M.replace("{name}",null!=e?e:""):Z(M)&&(I=M(e));var T=f?_.getTextColor():i.get("inactiveColor");x.add(new du({style:sp(_,{text:I,x:w,y:p/2,fill:T,align:S,verticalAlign:"middle"},{inheritColor:T})}));var C=new lu({shape:x.getBoundingRect(),style:{fill:"transparent"}}),D=i.getModel("tooltip");return D.get("show")&&Gd({el:C,componentModel:r,itemName:e,itemTooltipOption:D.option}),x.add(C),x.eachChild(function(t){t.silent=!0}),C.silent=!u,this.getContentGroup().add(x),Ac(x),x.__legendDataIndex=n,x},e.prototype.layoutInner=function(t,e,n,i,r,o){var a=this.getContentGroup(),s=this.getSelectorGroup();qf(t.get("orient"),a,t.get("itemGap"),n.width,n.height);var l=a.getBoundingRect(),u=[-l.x,-l.y];if(s.markRedraw(),a.markRedraw(),r){qf("horizontal",s,t.get("selectorItemGap",!0));var c=s.getBoundingRect(),h=[-c.x,-c.y],d=t.get("selectorButtonGap",!0),p=t.getOrient().index,f=0===p?"width":"height",g=0===p?"height":"width",v=0===p?"y":"x";"end"===o?h[p]+=l[f]+d:u[p]+=c[f]+d,h[1-p]+=l[g]/2-c[g]/2,s.x=h[0],s.y=h[1],a.x=u[0],a.y=u[1];var y={x:0,y:0};return y[f]=l[f]+d+c[f],y[g]=Math.max(l[g],c[g]),y[v]=Math.min(0,c[v]+h[1-p]),y}return a.x=u[0],a.y=u[1],this.group.getBoundingRect()},e.prototype.remove=function(){this.getContentGroup().removeAll(),this._isFirstRender=!0},e.type="legend.plain",e}(Ky);function gZ(t,e,n,i){yZ(t,e,n,i),n.dispatchAction({type:"legendToggleSelect",name:null!=t?t:e}),vZ(t,e,n,i)}function vZ(t,e,n,i){n.usingTHL()||n.dispatchAction({type:"highlight",seriesName:t,name:e,excludeSeriesId:i})}function yZ(t,e,n,i){n.usingTHL()||n.dispatchAction({type:"downplay",seriesName:t,name:e,excludeSeriesId:i})}function mZ(t,e,n){var i="allSelect"===t||"inverseSelect"===t,r={},o=[];n.eachComponent({mainType:"legend",query:e},function(n){i?n[t]():n[t](e.name),xZ(n,r),o.push(n.componentIndex)});var a={};return n.eachComponent("legend",function(t){B(r,function(e,n){t[e?"select":"unSelect"](n)}),xZ(t,a)}),i?{selected:a,legendIndex:o}:{name:e.name,selected:a}}function xZ(t,e){var n=e||{};return B(t.getData(),function(e){var i=e.get("name");if("\n"!==i&&""!==i){var r=t.isSelected(i);wt(n,i)?n[i]=n[i]&&r:n[i]=r}}),n}var _Z=as(function(t){var e=t.findComponents({mainType:"legend"});e&&e.length&&t.filterSeries(function(t){for(var n=0;nn[r],f=[-h.x,-h.y];e||(f[i]=l[s]);var g=[0,0],v=[-d.x,-d.y],y=at(t.get("pageButtonGap",!0),t.get("itemGap",!0));p&&("end"===t.get("pageButtonPosition",!0)?v[i]+=n[r]-d[r]:g[i]+=d[r]+y);v[1-i]+=h[o]/2-d[o]/2,l.setPosition(f),u.setPosition(g),c.setPosition(v);var m={x:0,y:0};if(m[r]=p?n[r]:h[r],m[o]=Math.max(h[o],d[o]),m[a]=Math.min(0,d[a]+v[1-i]),u.__rectSize=n[r],p){var x={x:0,y:0};x[r]=Math.max(n[r]-d[r]-y,0),x[o]=m[o],u.setClipPath(new lu({shape:x})),u.__rectSize=x[r]}else c.eachChild(function(t){t.attr({invisible:!0,silent:!0})});var _=this._getPageInfo(t);return null!=_.pageIndex&&rd(l,{x:_.contentPosition[0],y:_.contentPosition[1]},p?t:null),this._updatePageInfoView(t,_),m},e.prototype._pageGo=function(t,e,n){var i=this._getPageInfo(e)[t];null!=i&&n.dispatchAction({type:"legendScroll",scrollDataIndex:i,legendId:e.id})},e.prototype._updatePageInfoView=function(t,e){var n=this._controllerGroup;B(["pagePrev","pageNext"],function(i){var r=null!=e[i+"DataIndex"],o=n.childOfName(i);o&&(o.setStyle("fill",r?t.get("pageIconColor",!0):t.get("pageIconInactiveColor",!0)),o.cursor=r?"pointer":"default")});var i=n.childOfName("pageText"),r=t.get("pageFormatter"),o=e.pageIndex,a=null!=o?o+1:0,s=e.pageCount;i&&r&&i.setStyle("text",j(r)?r.replace("{current}",null==a?"":a+"").replace("{total}",null==s?"":s+""):r({current:a,total:s}))},e.prototype._getPageInfo=function(t){var e=t.get("scrollDataIndex",!0),n=this.getContentGroup(),i=this._containerGroup.__rectSize,r=t.getOrient().index,o=IZ[r],a=TZ[r],s=this._findTargetItemIndex(e),l=n.children(),u=l[s],c=l.length,h=c?1:0,d={contentPosition:[n.x,n.y],pageCount:h,pageIndex:h-1,pagePrevDataIndex:null,pageNextDataIndex:null};if(!u)return d;var p=m(u);d.contentPosition[r]=-p.s;for(var f=s+1,g=p,v=p,y=null;f<=c;++f)(!(y=m(l[f]))&&v.e>g.s+i||y&&!x(y,g.s))&&(g=v.i>g.i?v:y)&&(null==d.pageNextDataIndex&&(d.pageNextDataIndex=g.i),++d.pageCount),v=y;for(f=s-1,g=p,v=p,y=null;f>=-1;--f)(y=m(l[f]))&&x(v,y.s)||!(g.i=e&&t.s<=e+i}},e.prototype._findTargetItemIndex=function(t){return this._showController?(this.getContentGroup().eachChild(function(i,r){var o=i.__legendDataIndex;null==n&&null!=o&&(n=r),o===t&&(e=r)}),null!=e?e:n):0;var e,n},e.type="legend.scroll",e}(fZ);function DZ(t){gb(bZ),t.registerComponentModel(wZ),t.registerComponentView(CZ),function(t){t.registerAction("legendScroll","legendscroll",function(t,e){var n=t.scrollDataIndex;null!=n&&e.eachComponent({mainType:"legend",subType:"scroll",query:t},function(t){t.setScrollDataIndex(n)})})}(t)}var AZ=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="dataZoom.inside",e.defaultOption=Op(TU.defaultOption,{disabled:!1,zoomLock:!1,zoomOnMouseWheel:!0,moveOnMouseMove:!0,moveOnMouseWheel:!1,preventDefaultMouseMove:!0}),e}(TU),kZ=Pa();function LZ(t,e){if(e){t.removeKey(e.model.uid);var n=e.controller;n&&n.dispose()}}function PZ(t,e){t.isDisposed()||t.dispatchAction({type:"dataZoom",animation:{easing:"cubicOut",duration:100},batch:e})}function OZ(t,e,n,i){return t.coordinateSystem.containPoint([n,i])}function RZ(t){t.registerUpdateLifecycle("coordsys:aftercreate",function(t,e){var n=kZ(e),i=n.coordSysRecordMap||(n.coordSysRecordMap=mt());i.each(function(t){t.dataZoomInfoMap=null}),t.eachComponent({mainType:"dataZoom",subType:"inside"},function(t){B(bU(t).infoList,function(n){var r=n.model.uid,o=i.get(r)||i.set(r,function(t,e){var n={model:e,containsPoint:Y(OZ,e),dispatchAction:Y(PZ,t),dataZoomInfoMap:null,controller:null},i=n.controller=new uL(t.getZr());return B(["pan","zoom","scrollMove"],function(t){i.on(t,function(e){var i=[];n.dataZoomInfoMap.each(function(r){if(e.isAvailableBehavior(r.model.option)){var o=(r.getRange||{})[t],a=o&&o(r.dzReferCoordSysInfo,n.model.mainType,n.controller,e);!r.model.get("disabled",!0)&&a&&i.push({dataZoomId:r.model.id,start:a[0],end:a[1]})}}),i.length&&n.dispatchAction(i)})}),n}(e,n.model));(o.dataZoomInfoMap||(o.dataZoomInfoMap=mt())).set(t.uid,{dzReferCoordSysInfo:n,model:t,getRange:null})})}),i.each(function(t){var n,r=t.controller,o=t.dataZoomInfoMap;if(o){var a=o.keys()[0];null!=a&&(n=o.get(a))}if(n){var s=function(t,e,n){var i,r,o,a="type_",s={type_true:2,type_move:1,type_false:0,type_undefined:-1},l=!0;return t.each(function(t){var e=t.model,n=!e.get("disabled",!0)&&(!e.get("zoomLock",!0)||"move");s[a+n]>s[a+i]&&(i=n),l=l&&e.get("preventDefaultMouseMove",!0),r=at(e.get("cursorGrab",!0),r),o=at(e.get("cursorGrabbing",!0),o)}),{controlType:i,opt:{zoomOnMouseWheel:!0,moveOnMouseMove:!0,moveOnMouseWheel:!0,preventDefaultMouseMove:!!l,api:n,zInfo:{component:e.model},triggerInfo:{roamTrigger:null,isInSelf:e.containsPoint},cursorGrab:r,cursorGrabbing:o}}}(o,t,e);r.enable(s.controlType,s.opt),cm(t,"dispatchAction",n.model.get("throttle",!0),"fixRate")}else LZ(i,t)})})}var NZ=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="dataZoom.inside",e}return n(e,t),e.prototype.render=function(e,n,i){t.prototype.render.apply(this,arguments),e.noTarget()?this._clear():(this.range=e.getPercentRange(),function(t,e,n){kZ(t).coordSysRecordMap.each(function(t){var i=t.dataZoomInfoMap.get(e.uid);i&&(i.getRange=n)})}(i,e,{pan:U(EZ.pan,this),zoom:U(EZ.zoom,this),scrollMove:U(EZ.scrollMove,this)}))},e.prototype.dispose=function(){this._clear(),t.prototype.dispose.apply(this,arguments)},e.prototype._clear=function(){!function(t,e){for(var n=kZ(t).coordSysRecordMap,i=n.keys(),r=0;r0?s.pixelStart+s.pixelLength-s.pixel:s.pixel-s.pixelStart)/s.pixelLength*(o[1]-o[0])+o[0],u=Math.max(1/i.scale,0);o[0]=(o[0]-l)*u+l,o[1]=(o[1]-l)*u+l;var c=this.dataZoomModel.findRepresentativeAxisProxy().getMinMaxSpan();return ez(0,o,[0,100],0,c.minSpan,c.maxSpan),this.range=o,r[0]!==o[0]||r[1]!==o[1]?o:void 0}},pan:zZ(function(t,e,n,i,r,o){var a=BZ[i]([o.oldX,o.oldY],[o.newX,o.newY],e,r,n);return a.signal*(t[1]-t[0])*a.pixel/a.pixelLength}),scrollMove:zZ(function(t,e,n,i,r,o){return BZ[i]([0,0],[o.scrollDelta,o.scrollDelta],e,r,n).signal*(t[1]-t[0])*o.scrollDelta})};function zZ(t){return function(e,n,i,r){var o=this.range,a=o.slice(),s=e.axisModels[0];if(s)return ez(t(a,s,e,n,i,r),a,[0,100],"all"),this.range=a,o[0]!==a[0]||o[1]!==a[1]?a:void 0}}var BZ={grid:function(t,e,n,i,r){var o=n.axis,a={},s=r.model.coordinateSystem.getRect();return t=t||[0,0],"x"===o.dim?(a.pixel=e[0]-t[0],a.pixelLength=s.width,a.pixelStart=s.x,a.signal=o.inverse?1:-1):(a.pixel=e[1]-t[1],a.pixelLength=s.height,a.pixelStart=s.y,a.signal=o.inverse?-1:1),a},polar:function(t,e,n,i,r){var o=n.axis,a={},s=r.model.coordinateSystem,l=s.getRadiusAxis().getExtent(),u=s.getAngleAxis().getExtent();return t=t?s.pointToCoord(t):[0,0],e=s.pointToCoord(e),"radiusAxis"===n.mainType?(a.pixel=e[0]-t[0],a.pixelLength=l[1]-l[0],a.pixelStart=l[0],a.signal=o.inverse?1:-1):(a.pixel=e[1]-t[1],a.pixelLength=u[1]-u[0],a.pixelStart=u[0],a.signal=o.inverse?-1:1),a},singleAxis:function(t,e,n,i,r){var o=n.axis,a=r.model.coordinateSystem.getRect(),s={};return t=t||[0,0],"horizontal"===o.orient?(s.pixel=e[0]-t[0],s.pixelLength=a.width,s.pixelStart=a.x,s.signal=o.inverse?1:-1):(s.pixel=e[1]-t[1],s.pixelLength=a.height,s.pixelStart=a.y,s.signal=o.inverse?-1:1),s}};function VZ(t){RU(t),t.registerComponentModel(AZ),t.registerComponentView(NZ),RZ(t)}var GZ=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="dataZoom.slider",e.layoutMode="box",e.defaultOption=Op(TU.defaultOption,{show:!0,right:"ph",top:"ph",width:"ph",height:"ph",left:null,bottom:null,borderColor:ug.color.accent10,borderRadius:0,backgroundColor:ug.color.transparent,dataBackground:{lineStyle:{color:ug.color.accent30,width:.5},areaStyle:{color:ug.color.accent20,opacity:.2}},selectedDataBackground:{lineStyle:{color:ug.color.accent40,width:.5},areaStyle:{color:ug.color.accent20,opacity:.3}},fillerColor:"rgba(135,175,274,0.2)",handleIcon:"path://M-9.35,34.56V42m0-40V9.5m-2,0h4a2,2,0,0,1,2,2v21a2,2,0,0,1-2,2h-4a2,2,0,0,1-2-2v-21A2,2,0,0,1-11.35,9.5Z",handleSize:"100%",handleStyle:{color:ug.color.neutral00,borderColor:ug.color.accent20},moveHandleSize:7,moveHandleIcon:"path://M-320.9-50L-320.9-50c18.1,0,27.1,9,27.1,27.1V85.7c0,18.1-9,27.1-27.1,27.1l0,0c-18.1,0-27.1-9-27.1-27.1V-22.9C-348-41-339-50-320.9-50z M-212.3-50L-212.3-50c18.1,0,27.1,9,27.1,27.1V85.7c0,18.1-9,27.1-27.1,27.1l0,0c-18.1,0-27.1-9-27.1-27.1V-22.9C-239.4-41-230.4-50-212.3-50z M-103.7-50L-103.7-50c18.1,0,27.1,9,27.1,27.1V85.7c0,18.1-9,27.1-27.1,27.1l0,0c-18.1,0-27.1-9-27.1-27.1V-22.9C-130.9-41-121.8-50-103.7-50z",moveHandleStyle:{color:ug.color.accent40,opacity:.5},showDetail:!0,showDataShadow:"auto",realtime:!0,zoomLock:!1,textStyle:{color:ug.color.tertiary},brushSelect:!0,brushStyle:{color:ug.color.accent30,opacity:.3},emphasis:{handleLabel:{show:!0},handleStyle:{borderColor:ug.color.accent40},moveHandleStyle:{opacity:.8}},defaultLocationEdgeGap:15}),e}(TU),FZ=lu,HZ="horizontal",WZ="vertical",UZ=["line","bar","candlestick","scatter"],YZ={easing:"cubicOut",duration:100,delay:0},XZ=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n._displayables={},n}return n(e,t),e.prototype.init=function(t,e){this.api=e,this._onBrush=U(this._onBrush,this),this._onBrushEnd=U(this._onBrushEnd,this)},e.prototype.render=function(e,n,i,r){if(t.prototype.render.apply(this,arguments),cm(this,"_dispatchZoomAction",e.get("throttle"),"fixRate"),this._orient=e.getOrient(),!1!==e.get("show")){if(e.noTarget())return this._clear(),void this.group.removeAll();r&&"dataZoom"===r.type&&r.from===this.uid||this._buildView(),this._updateView()}else this.group.removeAll()},e.prototype.dispose=function(){this._clear(),t.prototype.dispose.apply(this,arguments)},e.prototype._clear=function(){hm(this,"_dispatchZoomAction");var t=this.api.getZr();t.off("mousemove",this._onBrush),t.off("mouseup",this._onBrushEnd)},e.prototype._buildView=function(){var t=this.group;t.removeAll(),this._brushing=!1,this._displayables.brushRect=null,this._resetLocation(),this._resetInterval();var e=this._displayables.sliderGroup=new vo;this._renderBackground(),this._renderHandle(),this._renderDataShadow(),t.add(e),this._positionGroup()},e.prototype._resetLocation=function(){var t=this.dataZoomModel,e=this.api,n=t.get("brushSelect")?7:0,i=eg(t,e).refContainer,r=this._findCoordRect(),o=t.get("defaultLocationEdgeGap",!0)||0,a=this._orient===HZ?{right:i.width-r.x-r.width,top:i.height-30-o-n,width:r.width,height:30}:{right:o,top:r.y,width:30,height:r.height},s=og(t.option);B(["right","top","width","height"],function(t){"ph"===s[t]&&(s[t]=a[t])});var l=Jf(s,i);this._location={x:l.x,y:l.y},this._size=[l.width,l.height],this._orient===WZ&&this._size.reverse()},e.prototype._positionGroup=function(){var t=this.group,e=this._location,n=this._orient,i=this.dataZoomModel.getFirstTargetAxisModel(),r=i&&i.get("inverse"),o=this._displayables.sliderGroup,a=(this._dataShadowInfo||{}).otherAxisInverse;o.attr(n!==HZ||r?n===HZ&&r?{scaleY:a?1:-1,scaleX:-1}:n!==WZ||r?{scaleY:a?-1:1,scaleX:-1,rotation:Math.PI/2}:{scaleY:a?-1:1,scaleX:1,rotation:Math.PI/2}:{scaleY:a?1:-1,scaleX:1});var s=t.getBoundingRect([o]),l=isNaN(s.x)?0:s.x,u=isNaN(s.y)?0:s.y;t.x=e.x-l,t.y=e.y-u,t.markRedraw()},e.prototype._getViewExtent=function(){return[0,this._size[0]]},e.prototype._renderBackground=function(){var t=this.dataZoomModel,e=this._size,n=this._displayables.sliderGroup,i=t.get("brushSelect");n.add(new FZ({silent:!0,shape:{x:0,y:0,width:e[0],height:e[1]},style:{fill:t.get("backgroundColor")},z2:-40}));var r=new FZ({shape:{x:0,y:0,width:e[0],height:e[1]},style:{fill:"transparent"},z2:0,onclick:U(this._onClickPanel,this)}),o=this.api.getZr();i?(r.on("mousedown",this._onBrushStart,this),r.cursor="crosshair",o.on("mousemove",this._onBrush),o.on("mouseup",this._onBrushEnd)):(o.off("mousemove",this._onBrush),o.off("mouseup",this._onBrushEnd)),n.add(r)},e.prototype._renderDataShadow=function(){var t=this._dataShadowInfo=this._prepareDataShadowInfo();if(this._displayables.dataShadowSegs=[],t){var e=this._size,n=this._shadowSize||[],i=t.series,r=i.getRawData(),o=i.getShadowDim&&i.getShadowDim(),a=o&&r.getDimensionInfo(o)?i.getShadowDim():t.otherDim;if(null!=a){var s=this._shadowPolygonPts,l=this._shadowPolylinePts;if(r!==this._shadowData||a!==this._shadowDim||e[0]!==n[0]||e[1]!==n[1]){var u=r.getDataExtent(t.thisDim),c=r.getDataExtent(a),h=.3*(c[1]-c[0]);c=[c[0]-h,c[1]+h];var d,p=[0,e[1]],f=[0,e[0]],g=[[e[0],0],[0,0]],v=[],y=f[1]/Math.max(1,r.count()-1),m=e[0]/(u[1]-u[0]),x="time"===t.thisAxis.type,_=-y,b=Math.round(r.count()/e[0]);r.each([t.thisDim,a],function(t,e,n){if(b>0&&n%b)x||(_+=y);else{_=x?(+t-u[0])*m:_+y;var i=null==e||isNaN(e)||""===e,r=i?0:Bo(e,c,p,!0);i&&!d&&n?(g.push([g[g.length-1][0],0]),v.push([v[v.length-1][0],0])):!i&&d&&(g.push([_,0]),v.push([_,0])),i||(g.push([_,r]),v.push([_,r])),d=i}}),s=this._shadowPolygonPts=g,l=this._shadowPolylinePts=v}this._shadowData=r,this._shadowDim=a,this._shadowSize=[e[0],e[1]];for(var w=this.dataZoomModel,S=0;S<3;S++){var M=I(1===S);this._displayables.sliderGroup.add(M),this._displayables.dataShadowSegs.push(M)}}}function I(t){var e=w.getModel(t?"selectedDataBackground":"dataBackground"),n=new vo,i=new Dh({shape:{points:s},segmentIgnoreThreshold:1,style:e.getModel("areaStyle").getAreaStyle(),silent:!0,z2:-20}),r=new kh({shape:{points:l},segmentIgnoreThreshold:1,style:e.getModel("lineStyle").getLineStyle(),silent:!0,z2:-19});return n.add(i),n.add(r),n}},e.prototype._prepareDataShadowInfo=function(){var t=this.dataZoomModel,e=t.get("showDataShadow");if(!1!==e){var n,i=this.ecModel;return t.eachTargetAxis(function(r,o){B(t.getAxisProxy(r,o).getTargetSeriesModels(),function(t){if(!(n||!0!==e&&R(UZ,t.get("type"))<0)){var a,s=i.getComponent(xU(r),o).axis,l=function(t){var e={x:"y",y:"x",radius:"angle",angle:"radius"};return e[t]}(r),u=t.coordinateSystem;null!=l&&u.getOtherAxis&&(a=u.getOtherAxis(s).inverse),l=t.getData().mapDimension(l);var c=t.getData().mapDimension(r);n={thisAxis:s,series:t,thisDim:c,otherDim:l,otherAxisInverse:a}}},this)},this),n}},e.prototype._renderHandle=function(){var t=this.group,e=this._displayables,n=e.handles=[null,null],i=e.handleLabels=[null,null],r=this._displayables.sliderGroup,o=this._size,a=this.dataZoomModel,s=this.api,l=a.get("borderRadius")||0,u=a.get("brushSelect"),c=e.filler=new FZ({silent:u,style:{fill:a.get("fillerColor")},textConfig:{position:"inside"}});r.add(c),r.add(new FZ({silent:!0,subPixelOptimize:!0,shape:{x:0,y:0,width:o[0],height:o[1],r:l},style:{stroke:a.get("dataBackgroundColor")||a.get("borderColor"),lineWidth:1,fill:ug.color.transparent}})),B([0,1],function(e){var o=a.get("handleIcon");!vx[o]&&o.indexOf("path://")<0&&o.indexOf("image://")<0&&(o="path://"+o);var s,l=xx(o,-1,0,2,2,null,!0);l.attr({cursor:(s=this._orient,"vertical"===s?"ns-resize":"ew-resize"),draggable:!0,drift:U(this._onDragMove,this,e),ondragend:U(this._onDragEnd,this),onmouseover:U(this._onOverDataInfoTriggerArea,this,!0),onmouseout:U(this._onOverDataInfoTriggerArea,this,!1),z2:5});var u=l.getBoundingRect(),c=a.get("handleSize");this._handleHeight=Vo(c,this._size[1]),this._handleWidth=u.width/u.height*this._handleHeight,l.setStyle(a.getModel("handleStyle").getItemStyle()),l.style.strokeNoScale=!0,l.rectHover=!0,l.ensureState("emphasis").style=a.getModel(["emphasis","handleStyle"]).getItemStyle(),Ac(l);var h=a.get("handleColor");null!=h&&(l.style.fill=h),r.add(n[e]=l);var d=a.getModel("textStyle"),p=(a.get("handleLabel")||{}).show||!1;t.add(i[e]=new du({silent:!0,invisible:!p,style:sp(d,{x:0,y:0,text:"",verticalAlign:"middle",align:"center",fill:d.getTextColor(),font:d.getFont()}),z2:10}))},this);var h=c;if(u){var d=Vo(a.get("moveHandleSize"),o[1]),p=e.moveHandle=new lu({style:a.getModel("moveHandleStyle").getItemStyle(),silent:!0,shape:{r:[0,0,2,2],y:o[1]-.5,height:d}}),f=.8*d,g=e.moveHandleIcon=xx(a.get("moveHandleIcon"),-f/2,-f/2,f,f,ug.color.neutral00,!0);g.silent=!0,g.y=o[1]+d/2-.5,p.ensureState("emphasis").style=a.getModel(["emphasis","moveHandleStyle"]).getItemStyle();var v=Math.min(o[1]/2,Math.max(d,10));(h=e.moveZone=new lu({invisible:!0,shape:{y:o[1]-v,height:d+v}})).on("mouseover",function(){s.enterEmphasis(p)}).on("mouseout",function(){s.leaveEmphasis(p)}),r.add(p),r.add(g),r.add(h)}h.attr({draggable:!0,cursor:"grab",drift:U(this._onActualMoveZoneDrift,this),ondragstart:U(this._onActualMoveZoneDragStart,this),ondragend:U(this._onActualMoveZoneDragEnd,this),onmouseover:U(this._onOverDataInfoTriggerArea,this,!0),onmouseout:U(this._onOverDataInfoTriggerArea,this,!1)})},e.prototype._resetInterval=function(){var t=this._range=this.dataZoomModel.getPercentRange(),e=this._getViewExtent();this._handleEnds=[Bo(t[0],[0,100],e,!0),Bo(t[1],[0,100],e,!0)]},e.prototype._updateInterval=function(t,e){var n=this.dataZoomModel,i=this._handleEnds,r=this._getViewExtent(),o=n.findRepresentativeAxisProxy().getMinMaxSpan(),a=[0,100];ez(e,i,r,n.get("zoomLock")?"all":t,null!=o.minSpan?Bo(o.minSpan,a,r,!0):null,null!=o.maxSpan?Bo(o.maxSpan,a,r,!0):null);var s=this._range,l=this._range=Wo([Bo(i[0],r,a,!0),Bo(i[1],r,a,!0)]);return!s||s[0]!==l[0]||s[1]!==l[1]},e.prototype._updateView=function(t){var e=this._displayables,n=this._handleEnds,i=Wo(n.slice()),r=this._size;B([0,1],function(t){var i=e.handles[t],o=this._handleHeight;i.attr({scaleX:o/2,scaleY:o/2,x:n[t]+(t?-1:1),y:r[1]/2-o/2})},this),e.filler.setShape({x:i[0],y:0,width:i[1]-i[0],height:r[1]});var o={x:i[0],width:i[1]-i[0]};e.moveHandle&&(e.moveHandle.setShape(o),e.moveZone.setShape(o),e.moveZone.getBoundingRect(),e.moveHandleIcon&&e.moveHandleIcon.attr("x",o.x+o.width/2));for(var a=e.dataShadowSegs,s=[0,i[0],i[1],r[0]],l=0;le[0]||n[1]<0||n[1]>e[1])){var i=this._handleEnds,r=(i[0]+i[1])/2,o=this._updateInterval("all",n[0]-r);this._updateView(),o&&this._dispatchZoomAction(!1)}},e.prototype._onBrushStart=function(t){var e=t.offsetX,n=t.offsetY;this._brushStart=new Pe(e,n),this._brushing=!0,this._brushStartTime=+new Date},e.prototype._onBrushEnd=function(t){if(this._brushing){var e=this._displayables.brushRect;if(this._brushing=!1,e){e.attr("ignore",!0);var n=e.shape;if(!(+new Date-this._brushStartTime<200&&Math.abs(n.width)<5)){var i=this._getViewExtent(),r=[0,100],o=this._handleEnds=[n.x,n.x+n.width],a=this.dataZoomModel.findRepresentativeAxisProxy().getMinMaxSpan();ez(0,o,i,0,null!=a.minSpan?Bo(a.minSpan,r,i,!0):null,null!=a.maxSpan?Bo(a.maxSpan,r,i,!0):null),this._range=Wo([Bo(o[0],i,r,!0),Bo(o[1],i,r,!0)]),this._updateView(),this._dispatchZoomAction(!1)}}}},e.prototype._onBrush=function(t){this._brushing&&(ye(t.event),this._updateBrushRect(t.offsetX,t.offsetY))},e.prototype._updateBrushRect=function(t,e){var n=this._displayables,i=this.dataZoomModel,r=n.brushRect;r||(r=n.brushRect=new FZ({silent:!0,style:i.getModel("brushStyle").getItemStyle()}),n.sliderGroup.add(r)),r.attr("ignore",!1);var o=this._brushStart,a=this._displayables.sliderGroup,s=a.transformCoordToLocal(t,e),l=a.transformCoordToLocal(o.x,o.y),u=this._size;s[0]=Math.max(Math.min(u[0],s[0]),0),r.setShape({x:l[0],y:0,width:s[0]-l[0],height:u[1]})},e.prototype._dispatchZoomAction=function(t){var e=this._range;this.api.dispatchAction({type:"dataZoom",from:this.uid,dataZoomId:this.dataZoomModel.id,animation:t?YZ:null,start:e[0],end:e[1]})},e.prototype._findCoordRect=function(){var t,e=bU(this.dataZoomModel).infoList;if(!t&&e.length){var n=e[0].model.coordinateSystem;t=n.getRect&&n.getRect()}if(!t){var i=this.api.getWidth(),r=this.api.getHeight();t={x:.2*i,y:.2*r,width:.6*i,height:.6*r}}return t},e.type="dataZoom.slider",e}(AU);function ZZ(t,e,n,i){var r=t.get("labelFormatter"),o=t.get("labelPrecision");null!=o&&"auto"!==o||(o=n.valuePrecision);var a=n.value[e],s=null==a||isNaN(a)?"":vw(i)||fw(i)?i.getLabel({value:Math.round(a)}):isFinite(o)?Ho(a,o,!0):a+"";return Z(r)?r(a,s):j(r)?r.replace("{value}",s):s}function jZ(t){t.registerComponentModel(GZ),t.registerComponentView(XZ),RU(t)}var qZ=function(t,e,n){var i=C((KZ[t]||{})[e]);return n&&X(i)?i[i.length-1]:i},KZ={color:{active:["#006edd","#e0ffff"],inactive:[ug.color.transparent]},colorHue:{active:[0,360],inactive:[0,0]},colorSaturation:{active:[.3,1],inactive:[0,0]},colorLightness:{active:[.9,.5],inactive:[0,0]},colorAlpha:{active:[.3,1],inactive:[0,0]},opacity:{active:[.3,1],inactive:[0,0]},symbol:{active:["circle","roundRect","diamond"],inactive:["none"]},symbolSize:{active:[10,50],inactive:[0,0]}},$Z=ZR.mapVisual,JZ=ZR.eachVisual,QZ=X,tj=B,ej=Wo,nj=Bo,ij=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.stateList=["inRange","outOfRange"],n.replacableOptionKeys=["inRange","outOfRange","target","controller","color"],n.layoutMode={type:"box",ignoreSize:!0},n.dataBound=[-1/0,1/0],n.targetVisuals={},n.controllerVisuals={},n}return n(e,t),e.prototype.init=function(t,e,n){this.mergeDefaultAndTheme(t,n)},e.prototype.optionUpdated=function(t,e){var n=this.option;!e&&JY(n,t,this.replacableOptionKeys),this.textStyleModel=this.getModel("textStyle"),this.resetItemSize(),this.completeVisualOption()},e.prototype.resetVisual=function(t){var e=this.stateList;t=U(t,this),this.controllerVisuals=$Y(this.option.controller,e,t),this.targetVisuals=$Y(this.option.target,e,t)},e.prototype.getItemSymbol=function(){return null},e.prototype.getTargetSeriesIndices=function(){var t=this,e=this.option.seriesTargets;if(e){var n=[];return tj(e,function(e){if(null!=e.seriesIndex)n.push(e.seriesIndex);else if(null!=e.seriesId){var i;t.ecModel.eachSeries(function(t){t.id===e.seriesId&&(i=t)}),i&&n.push(i.componentIndex)}}),n}var i=this.option.seriesId,r=this.option.seriesIndex;return null==r&&null==i&&(r="all"),V(Ba(this.ecModel,"series",{index:r,id:i},{useDefault:!1,enableAll:!0,enableNone:!1}).models,function(t){return t.componentIndex})},e.prototype.eachTargetSeries=function(t,e){B(this.getTargetSeriesIndices(),function(n){var i=this.ecModel.getSeriesByIndex(n);i&&t.call(e,i)},this)},e.prototype.isTargetSeries=function(t){var e=!1;return this.eachTargetSeries(function(n){n===t&&(e=!0)}),e},e.prototype.formatValueText=function(t,e,n){var i,r=this.option,o=r.precision,a=this.dataBound,s=r.formatter;n=n||["<",">"],X(t)&&(t=t.slice(),i=!0);var l=e?t:i?[u(t[0]),u(t[1])]:u(t);return j(s)?s.replace("{value}",i?l[0]:l).replace("{value2}",i?l[1]:l):Z(s)?i?s(t[0],t[1]):s(t):i?t[0]===a[0]?n[0]+" "+l[1]:t[1]===a[1]?n[1]+" "+l[0]:l[0]+" - "+l[1]:l;function u(t){return t===a[0]?"min":t===a[1]?"max":(+t).toFixed(Math.min(o,20))}},e.prototype.resetExtent=function(){var t=this.option,e=ej([t.min,t.max]);this._dataExtent=e},e.prototype.getDimension=function(t){var e=this,n=this.option.seriesTargets;if(n){var i=H(n,function(n){return null!=n.seriesIndex&&n.seriesIndex===t||null!=n.seriesId&&n.seriesId===e.ecModel.getSeriesByIndex(t).id});if(i)return i.dimension}return this.option.dimension},e.prototype.getDataDimensionIndex=function(t){var e=t.hostModel.seriesIndex,n=this.getDimension(e);if(null!=n)return t.getDimensionIndex(n);for(var i=t.dimensions,r=i.length-1;r>=0;r--){var o=i[r],a=t.getDimensionInfo(o);if(!a.isCalculationCoord)return a.storeDimIndex}},e.prototype.getExtent=function(){return this._dataExtent.slice()},e.prototype.completeVisualOption=function(){var t=this.ecModel,e=this.option,n={inRange:e.inRange,outOfRange:e.outOfRange},i=e.target||(e.target={}),r=e.controller||(e.controller={});D(i,n),D(r,n);var o=this.isCategory();function a(n){QZ(e.color)&&!n.inRange&&(n.inRange={color:e.color.slice().reverse()}),n.inRange=n.inRange||{color:t.get("gradientColor")}}a.call(this,i),a.call(this,r),function(t,e,n){var i=t[e],r=t[n];i&&!r&&(r=t[n]={},tj(i,function(t,e){if(ZR.isValidType(e)){var n=qZ(e,"inactive",o);null!=n&&(r[e]=n,"color"!==e||r.hasOwnProperty("opacity")||r.hasOwnProperty("colorAlpha")||(r.opacity=[0,0]))}}))}.call(this,i,"inRange","outOfRange"),function(t){var e=(t.inRange||{}).symbol||(t.outOfRange||{}).symbol,n=(t.inRange||{}).symbolSize||(t.outOfRange||{}).symbolSize,i=this.get("inactiveColor"),r=this.getItemSymbol()||"roundRect";tj(this.stateList,function(a){var s=this.itemSize,l=t[a];l||(l=t[a]={color:o?i:[i]}),null==l.symbol&&(l.symbol=e&&C(e)||(o?r:[r])),null==l.symbolSize&&(l.symbolSize=n&&C(n)||(o?s[0]:[s[0],s[0]])),l.symbol=$Z(l.symbol,function(t){return"none"===t?r:t});var u=l.symbolSize;if(null!=u){var c=-1/0;JZ(u,function(t){t>c&&(c=t)}),l.symbolSize=$Z(u,function(t){return nj(t,[0,c],[0,s[0]],!0)})}},this)}.call(this,r)},e.prototype.resetItemSize=function(){this.itemSize=[parseFloat(this.get("itemWidth")),parseFloat(this.get("itemHeight"))]},e.prototype.isCategory=function(){return!!this.option.categories},e.prototype.setSelected=function(t){},e.prototype.getSelected=function(){return null},e.prototype.getValueState=function(t){return null},e.prototype.getVisualMeta=function(t){return null},e.type="visualMap",e.dependencies=["series"],e.defaultOption={show:!0,z:4,min:0,max:200,left:0,right:null,top:null,bottom:0,itemWidth:null,itemHeight:null,inverse:!1,orient:"vertical",backgroundColor:ug.color.transparent,borderColor:ug.color.borderTint,contentColor:ug.color.theme[0],inactiveColor:ug.color.disabled,borderWidth:0,padding:ug.size.m,textGap:10,precision:0,textStyle:{color:ug.color.secondary}},e}(lg),rj=[20,140],oj=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.optionUpdated=function(e,n){t.prototype.optionUpdated.apply(this,arguments),this.resetExtent(),this.resetVisual(function(t){t.mappingMethod="linear",t.dataExtent=this.getExtent()}),this._resetRange()},e.prototype.resetItemSize=function(){t.prototype.resetItemSize.apply(this,arguments);var e=this.itemSize;(null==e[0]||isNaN(e[0]))&&(e[0]=rj[0]),(null==e[1]||isNaN(e[1]))&&(e[1]=rj[1])},e.prototype._resetRange=function(){var t=this.getExtent(),e=this.option.range;!e||e.auto?(t.auto=1,this.option.range=t):X(e)&&(e[0]>e[1]&&e.reverse(),e[0]=Math.max(e[0],t[0]),e[1]=Math.min(e[1],t[1]))},e.prototype.completeVisualOption=function(){t.prototype.completeVisualOption.apply(this,arguments),B(this.stateList,function(t){var e=this.option.controller[t].symbolSize;e&&e[0]!==e[1]&&(e[0]=e[1]/3)},this)},e.prototype.setSelected=function(t){this.option.range=t.slice(),this._resetRange()},e.prototype.getSelected=function(){var t=this.getExtent(),e=Wo((this.get("range")||[]).slice());return e[0]>t[1]&&(e[0]=t[1]),e[1]>t[1]&&(e[1]=t[1]),e[0]=n[1]||t<=e[1])?"inRange":"outOfRange"},e.prototype.findTargetDataIndices=function(t){var e=[];return this.eachTargetSeries(function(n){var i=[],r=n.getData();r.each(this.getDataDimensionIndex(r),function(e,n){t[0]<=e&&e<=t[1]&&i.push(n)},this),e.push({seriesId:n.id,dataIndex:i})},this),e},e.prototype.getVisualMeta=function(t){var e=aj(this,"outOfRange",this.getExtent()),n=aj(this,"inRange",this.option.range.slice()),i=[];function r(e,n){i.push({value:e,color:t(e,n)})}for(var o=0,a=0,s=n.length,l=e.length;at[1])break;n.push({color:this.getControllerVisual(o,"color",e),offset:r/100})}return n.push({color:this.getControllerVisual(t[1],"color",e),offset:1}),n},e.prototype._createBarPoints=function(t,e){var n=this.visualMapModel.itemSize;return[[n[0]-e[0],t[0]],[n[0],t[0]],[n[0],t[1]],[n[0]-e[1],t[1]]]},e.prototype._createBarGroup=function(t){var e=this._orient,n=this.visualMapModel.get("inverse");return new vo("horizontal"!==e||n?"horizontal"===e&&n?{scaleX:"bottom"===t?-1:1,rotation:-Math.PI/2}:"vertical"!==e||n?{scaleX:"left"===t?1:-1}:{scaleX:"left"===t?1:-1,scaleY:-1}:{scaleX:"bottom"===t?1:-1,rotation:Math.PI/2})},e.prototype._updateHandle=function(t,e){if(this._useHandle){var n=this._shapes,i=this.visualMapModel,r=n.handleThumbs,o=n.handleLabels,a=i.itemSize,s=i.getExtent(),l=this._applyTransform("left",n.mainGroup);dj([0,1],function(u){var c=r[u];c.setStyle("fill",e.handlesColor[u]),c.y=t[u];var h=hj(t[u],[0,a[1]],s,!0),d=this.getControllerVisual(h,"symbolSize");c.scaleX=c.scaleY=d/a[0],c.x=a[0]-d/2;var p=Cd(n.handleLabelPoints[u],Td(c,this.group));if("horizontal"===this._orient){var f="left"===l||"top"===l?(a[0]-d)/2:(a[0]-d)/-2;p[1]+=f}o[u].setStyle({x:p[0],y:p[1],text:i.formatValueText(this._dataInterval[u]),verticalAlign:"middle",align:"vertical"===this._orient?this._applyTransform("left",n.mainGroup):"center"})},this)}},e.prototype._showIndicator=function(t,e,n,i){var r=this.visualMapModel,o=r.getExtent(),a=r.itemSize,s=[0,a[1]],l=this._shapes,u=l.indicator;if(u){u.attr("invisible",!1);var c=this.getControllerVisual(t,"color",{convertOpacityToAlpha:!0}),h=this.getControllerVisual(t,"symbolSize"),d=hj(t,o,s,!0),p=a[0]-h/2,f={x:u.x,y:u.y};u.y=d,u.x=p;var g=Cd(l.indicatorLabelPoint,Td(u,this.group)),v=l.indicatorLabel;v.attr("invisible",!1);var y=this._applyTransform("left",l.mainGroup),m="horizontal"===this._orient;v.setStyle({text:(n||"")+r.formatValueText(e),verticalAlign:m?y:"middle",align:m?"center":y});var x={x:p,y:d,style:{fill:c}},_={style:{x:g[0],y:g[1]}};if(r.ecModel.isAnimationEnabled()&&!this._firstShowIndicator){var b={duration:100,easing:"cubicInOut",additive:!0};u.x=f.x,u.y=f.y,u.animateTo(x,b),v.animateTo(_,b)}else u.attr(x),v.attr(_);this._firstShowIndicator=!1;var w=this._shapes.handleLabels;if(w)for(var S=0;Sr[1]&&(u[1]=1/0),e&&(u[0]===-1/0?this._showIndicator(l,u[1],"< ",a):u[1]===1/0?this._showIndicator(l,u[0],"> ",a):this._showIndicator(l,l,"≈ ",a));var c=this._hoverLinkDataIndices,h=[];(e||yj(n))&&(h=this._hoverLinkDataIndices=n.findTargetDataIndices(u));var d=function(t,e){var n={},i={};return r(t||[],n),r(e||[],i,n),[o(n),o(i)];function r(t,e,n){for(var i=0,r=t.length;i=0&&(r.dimension=o,i.push(r))}}),t.getData().setVisual("visualMeta",i)}}];function wj(t,e,n,i){for(var r=e.targetVisuals[i],o=ZR.prepareVisualTypes(r),a={color:qm(t.getData(),"color")},s=0,l=o.length;s0:t.splitNumber>0)&&!t.calculable?"piecewise":"continuous"}),t.registerAction(xj,_j),B(bj,function(e){t.registerVisual(t.PRIORITY.VISUAL.COMPONENT,e)}),t.registerPreprocessor(Mj))}function Dj(t){t.registerComponentModel(oj),t.registerComponentView(gj),Cj(t)}var Aj=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n._pieceList=[],n}return n(e,t),e.prototype.optionUpdated=function(e,n){t.prototype.optionUpdated.apply(this,arguments),this.resetExtent();var i=this._mode=this._determineMode();this._pieceList=[],kj[this._mode].call(this,this._pieceList),this._resetSelected(e,n);var r=this.option.categories;this.resetVisual(function(t,e){"categories"===i?(t.mappingMethod="category",t.categories=C(r)):(t.dataExtent=this.getExtent(),t.mappingMethod="piecewise",t.pieceList=V(this._pieceList,function(t){return t=C(t),"inRange"!==e&&(t.visual=null),t}))})},e.prototype.completeVisualOption=function(){var e=this.option,n={},i=ZR.listVisualTypes(),r=this.isCategory();function o(t,e,n){return t&&t[e]&&t[e].hasOwnProperty(n)}B(e.pieces,function(t){B(i,function(e){t.hasOwnProperty(e)&&(n[e]=1)})}),B(n,function(t,n){var i=!1;B(this.stateList,function(t){i=i||o(e,t,n)||o(e.target,t,n)},this),!i&&B(this.stateList,function(t){(e[t]||(e[t]={}))[n]=qZ(n,"inRange"===t?"active":"inactive",r)})},this),t.prototype.completeVisualOption.apply(this,arguments)},e.prototype._resetSelected=function(t,e){var n=this.option,i=this._pieceList,r=(e?n:t).selected||{};if(n.selected=r,B(i,function(t,e){var n=this.getSelectedMapKey(t);r.hasOwnProperty(n)||(r[n]=!0)},this),"single"===n.selectedMode){var o=!1;B(i,function(t,e){var n=this.getSelectedMapKey(t);r[n]&&(o?r[n]=!1:o=!0)},this)}},e.prototype.getItemSymbol=function(){return this.get("itemSymbol")},e.prototype.getSelectedMapKey=function(t){return"categories"===this._mode?t.value+"":t.index+""},e.prototype.getPieceList=function(){return this._pieceList},e.prototype._determineMode=function(){var t=this.option;return t.pieces&&t.pieces.length>0?"pieces":this.option.categories?"categories":"splitNumber"},e.prototype.setSelected=function(t){this.option.selected=C(t)},e.prototype.getValueState=function(t){var e=ZR.findPieceIndex(t,this._pieceList);return null!=e&&this.option.selected[this.getSelectedMapKey(this._pieceList[e])]?"inRange":"outOfRange"},e.prototype.findTargetDataIndices=function(t){var e=[],n=this._pieceList;return this.eachTargetSeries(function(i){var r=[],o=i.getData();o.each(this.getDataDimensionIndex(o),function(e,i){ZR.findPieceIndex(e,n)===t&&r.push(i)},this),e.push({seriesId:i.id,dataIndex:r})},this),e},e.prototype.getRepresentValue=function(t){var e;if(this.isCategory())e=t.value;else if(null!=t.value)e=t.value;else{var n=t.interval||[];e=n[0]===-1/0&&n[1]===1/0?0:(n[0]+n[1])/2}return e},e.prototype.getVisualMeta=function(t){if(!this.isCategory()){var e=[],n=["",""],i=this,r=this._pieceList.slice();if(r.length){var o=r[0].interval[0];o!==-1/0&&r.unshift({interval:[-1/0,o]}),(o=r[r.length-1].interval[1])!==1/0&&r.push({interval:[o,1/0]})}else r.push({interval:[-1/0,1/0]});var a=-1/0;return B(r,function(t){var e=t.interval;e&&(e[0]>a&&s([a,e[0]],"outOfRange"),s(e.slice()),a=e[1])},this),{stops:e,outerColors:n}}function s(r,o){var a=i.getRepresentValue({interval:r});o||(o=i.getValueState(a));var s=t(a,o);r[0]===-1/0?n[0]=s:r[1]===1/0?n[1]=s:e.push({value:r[0],color:s},{value:r[1],color:s})}},e.type="visualMap.piecewise",e.defaultOption=Op(ij.defaultOption,{selected:null,minOpen:!1,maxOpen:!1,align:"auto",itemWidth:20,itemHeight:14,itemSymbol:"roundRect",pieces:null,categories:null,splitNumber:5,selectedMode:"multiple",itemGap:10,hoverLink:!0}),e}(ij),kj={splitNumber:function(t){var e=this.option,n=Math.min(e.precision,20),i=this.getExtent(),r=e.splitNumber;r=Math.max(parseInt(r,10),1),e.splitNumber=r;for(var o=(i[1]-i[0])/r;+o.toFixed(n)!==o&&n<5;)n++;e.precision=n,o=+o.toFixed(n),e.minOpen&&t.push({interval:[-1/0,i[0]],close:[0,0]});for(var a=0,s=i[0];a","≥"][e[0]]];t.text=t.text||this.formatValueText(null!=t.value?t.value:t.interval,!1,n)},this)}};function Lj(t,e){var n=t.inverse;("vertical"===t.orient?!n:n)&&e.reverse()}var Pj=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.doRender=function(){var t=this.group;t.removeAll();var e=this.visualMapModel,n=e.get("textGap"),i=e.textStyleModel,r=this._getItemAlign(),o=e.itemSize,a=this._getViewData(),s=a.endsText,l=ot(e.get("showLabel",!0),!s),u=!e.get("selectedMode");s&&this._renderEndsText(t,s[0],o,l,r),B(a.viewPieceList,function(a){var s=a.piece,c=new vo;c.onclick=U(this._onItemClick,this,s),this._enableHoverLink(c,a.indexInModelPieceList);var h=e.getRepresentValue(s);if(this._createItemSymbol(c,h,[0,0,o[0],o[1]],u),l){var d=this.visualMapModel.getValueState(h),p=i.get("align")||r;c.add(new du({style:sp(i,{x:"right"===p?-n:o[0]+n,y:o[1]/2,text:s.text,verticalAlign:i.get("verticalAlign")||"middle",align:p,opacity:at(i.get("opacity"),"outOfRange"===d?.5:1)}),silent:u}))}t.add(c)},this),s&&this._renderEndsText(t,s[1],o,l,r),qf(e.get("orient"),t,e.get("itemGap")),this.renderBackground(t),this.positionGroup(t)},e.prototype._enableHoverLink=function(t,e){var n=this;t.on("mouseover",function(){return i("highlight")}).on("mouseout",function(){return i("downplay")});var i=function(t){var i=n.visualMapModel;i.option.hoverLink&&n.api.dispatchAction({type:t,batch:cj(i.findTargetDataIndices(e),i)})}},e.prototype._getItemAlign=function(){var t=this.visualMapModel,e=t.option;if("vertical"===e.orient)return uj(t,this.api,t.itemSize);var n=e.align;return n&&"auto"!==n||(n="left"),n},e.prototype._renderEndsText=function(t,e,n,i,r){if(e){var o=new vo,a=this.visualMapModel.textStyleModel;o.add(new du({style:sp(a,{x:i?"right"===r?n[0]:0:n[0]/2,y:n[1]/2,verticalAlign:"middle",align:i?r:"center",text:e})})),t.add(o)}},e.prototype._getViewData=function(){var t=this.visualMapModel,e=V(t.getPieceList(),function(t,e){return{piece:t,indexInModelPieceList:e}}),n=t.get("text"),i=t.get("orient"),r=t.get("inverse");return("horizontal"===i?r:!r)?e.reverse():n&&(n=n.slice().reverse()),{viewPieceList:e,endsText:n}},e.prototype._createItemSymbol=function(t,e,n,i){var r=xx(this.getControllerVisual(e,"symbol"),n[0],n[1],n[2],n[3],this.getControllerVisual(e,"color"));r.silent=i,t.add(r)},e.prototype._onItemClick=function(t){var e=this.visualMapModel,n=e.option,i=n.selectedMode;if(i){var r=C(n.selected),o=e.getSelectedMapKey(t);"single"===i||!0===i?(r[o]=!0,B(r,function(t,e){r[e]=e===o})):r[o]=!r[o],this.api.dispatchAction({type:"selectDataRange",from:this.uid,visualMapId:this.visualMapModel.id,selected:r})}},e.type="visualMap.piecewise",e}(sj);function Oj(t){t.registerComponentModel(Aj),t.registerComponentView(Pj),Cj(t)}var Rj=function(){function t(t){this._thumbnailModel=t}return t.prototype.reset=function(t){this._renderVersion=t.getECUpdateCycleVersion()},t.prototype.renderContent=function(t){var e=t.api.getViewOfComponentModel(this._thumbnailModel);e&&(t.group.silent=!0,e.renderContent({group:t.group,targetTrans:t.targetTrans,z2Range:jd(t.group),roamType:t.roamType,viewportRect:t.viewportRect,renderVersion:this._renderVersion}))},t.prototype.updateWindow=function(t,e){var n=e.getViewOfComponentModel(this._thumbnailModel);n&&n.updateWindow({targetTrans:t,renderVersion:this._renderVersion})},t}(),Nj=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.preventAutoZ=!0,n}return n(e,t),e.prototype.optionUpdated=function(t,e){this._updateBridge()},e.prototype._updateBridge=function(){var t=this._birdge=this._birdge||new Rj(this);(this._target=null,this.ecModel.eachSeries(function(t){dE(t,null)}),this.shouldShow())&&dE(this.getTarget().baseMapProvider,t)},e.prototype.shouldShow=function(){return this.getShallow("show",!0)},e.prototype.getBridge=function(){return this._birdge},e.prototype.getTarget=function(){if(this._target)return this._target;var t=this.getReferringComponents("series",{useDefault:!1,enableAll:!1,enableNone:!1}).models[0];return t?"graph"!==t.subType&&(t=null):t=this.ecModel.queryComponents({mainType:"series",subType:"graph"})[0],this._target={baseMapProvider:t},this._target},e.type="thumbnail",e.layoutMode="box",e.dependencies=["series","geo"],e.defaultOption={show:!0,right:1,bottom:1,height:"25%",width:"25%",itemStyle:{borderColor:ug.color.border,borderWidth:2},windowStyle:{borderWidth:1,color:ug.color.neutral30,borderColor:ug.color.neutral40,opacity:.3},z:10},e}(lg),Ej=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n){if(this._api=n,this._model=t,this._coordSys||(this._coordSys=new nP),this._isEnabled()){this._renderVersion=n.getECUpdateCycleVersion();var i=this.group;i.removeAll();var r=t.getModel("itemStyle"),o=r.getItemStyle();null==o.fill&&(o.fill=e.get("backgroundColor")||ug.color.neutral00);var a=eg(t,n).refContainer,s=Jf(Kf(t,!0),a),l=o.lineWidth||0,u=this._contentRect=zd(s.clone(),l/2,!0,!0),c=new vo;i.add(c),c.setClipPath(new lu({shape:u.plain()}));var h=this._targetGroup=new vo;c.add(h);var d=s.plain();d.r=r.getShallow("borderRadius",!0),i.add(this._bgRect=new lu({style:o,shape:d,silent:!1,cursor:"grab"}));var p=t.getModel("windowStyle"),f=p.getShallow("borderRadius",!0);c.add(this._windowRect=new lu({shape:{x:0,y:0,width:0,height:0,r:f},style:p.getItemStyle(),silent:!1,cursor:"grab"})),this._dealRenderContent(),this._dealUpdateWindow(),Bj(t,this)}else this._clear()},e.prototype.renderContent=function(t){this._bridgeRendered=t,this._isEnabled()&&(this._dealRenderContent(),this._dealUpdateWindow(),Bj(this._model,this))},e.prototype._dealRenderContent=function(){var t=this._bridgeRendered;if(t&&t.renderVersion===this._renderVersion){var e=this._targetGroup,n=this._coordSys,i=this._contentRect;if(e.removeAll(),t){var r=t.group,o=r.getBoundingRect();e.add(r),this._bgRect.z2=t.z2Range.min-10,pP(n,o.x,o.y,o.width,o.height);var a=Jf({left:"center",top:"center",aspect:o.width/o.height},i);fP(n,a.x,a.y,a.width,a.height),lP(r,n,0),r.dirty(),this._windowRect.z2=t.z2Range.max+10,this._resetRoamController(t.roamType)}}},e.prototype.updateWindow=function(t){var e=this._bridgeRendered;e&&e.renderVersion===t.renderVersion&&(e.targetTrans=t.targetTrans),this._isEnabled()&&this._dealUpdateWindow()},e.prototype._dealUpdateWindow=function(){var t=this._bridgeRendered;if(t&&t.renderVersion===this._renderVersion){var e=Ae([],t.targetTrans),n=Ie([],rP(null,this._coordSys),e);this._transThisToTarget=Ae([],n);var i=t.viewportRect;(i=i?i.clone():new Xe(0,0,this._api.getWidth(),this._api.getHeight())).applyTransform(n);var r=this._windowRect,o=r.shape.r;r.setShape(P({r:o},i))}},e.prototype._resetRoamController=function(t){var e=this,n=this._api,i=this._roamController;i||(i=this._roamController=new uL(n.getZr())),t&&this._isEnabled()?(i.enable(t,{api:n,zInfo:{component:this._model},triggerInfo:{roamTrigger:null,isInSelf:function(t,n,i){return e._contentRect.contain(n,i)}}}),i.off("pan").off("zoom").on("pan",U(this._onPan,this)).on("zoom",U(this._onZoom,this))):i.disable()},e.prototype._onPan=function(t){var e=this._transThisToTarget;if(this._isEnabled()&&e){var n=Yt([],[t.oldX,t.oldY],e),i=Yt([],[t.oldX-t.dx,t.oldY-t.dy],e);this._api.dispatchAction(zj(this._model.getTarget().baseMapProvider,{dx:i[0]-n[0],dy:i[1]-n[1]}))}},e.prototype._onZoom=function(t){var e=this._transThisToTarget;if(this._isEnabled()&&e){var n=Yt([],[t.originX,t.originY],e);this._api.dispatchAction(zj(this._model.getTarget().baseMapProvider,{zoom:1/t.scale,originX:n[0],originY:n[1]}))}},e.prototype._isEnabled=function(){var t=this._model;return!(!t||!t.shouldShow())&&!!t.getTarget().baseMapProvider},e.prototype._clear=function(){this.group.removeAll(),this._bridgeRendered=null,this._roamController&&this._roamController.disable()},e.prototype.remove=function(){this._clear()},e.prototype.dispose=function(){this._clear()},e.type="thumbnail",e}(Ky);function zj(t,e){var n={type:"series"===t.mainType?t.subType+"Roam":t.mainType+"Roam"};return n[t.mainType+"Id"]=t.id,k(n,e),n}function Bj(t,e){var n=Zd(t);qd(e.group,n.z,n.zlevel)}var Vj={label:{enabled:!0},decal:{show:!1}},Gj=Pa(),Fj=Pa(),Hj=as(function(t,e){var n=t.getModel("aria");if(!n.get("enabled"))return;var i=Fj(t).scope||(Fj(t).scope={}),r=C(Vj);function o(t,e){if(!j(t))return t;var n=t;return B(e,function(t,e){n=n.replace(new RegExp("\\{\\s*"+e+"\\s*\\}","g"),t)}),n}D(r.label,t.getLocaleModel().get("aria"),!1),D(n.option,r,!1),function(){if(n.getModel("decal").get("show")){var e=mt();t.eachSeries(function(t){t.isColorBySeries()||(Gj(t).scope=e.get(t.type)||e.set(t.type,{}))}),t.eachSeries(function(e){if(Z(e.enableAriaDecal))e.enableAriaDecal();else{var n=e.getData();if(e.isColorBySeries()){var r=Rg(e.ecModel,e.name,i,t.getSeriesCount()),o=n.getVisual("decal");n.setVisual("decal",c(o,r))}else{var a=e.getRawData(),s={},l=Gj(e).scope;n.each(function(t){var e=n.getRawIndex(t);s[e]=t});var u=a.count();a.each(function(t){var i=s[t],r=a.getName(t)||t+"",o=Rg(e.ecModel,r,l,u),h=n.getItemVisual(i,"decal");n.setItemVisual(i,"decal",c(h,o))})}}function c(t,e){var n=t?k(k({},e),t):e;return n.dirty=!0,n}})}}(),function(){var i=e.getZr().dom;if(i){var r=t.getLocaleModel().get("aria"),a=n.getModel("label");if(a.option=P(a.option,r),a.get("enabled"))if(i.setAttribute("role","img"),a.get("description"))i.setAttribute("aria-label",a.get("description"));else{var s,l=t.getSeriesCount(),u=a.get(["data","maxCount"])||10,c=a.get(["series","maxCount"])||10,h=Math.min(l,c);if(!(l<1)){var d=function(){var e=t.get("title");return e&&e.length&&(e=e[0]),e&&e.text}();s=d?o(a.get(["general","withTitle"]),{title:d}):a.get(["general","withoutTitle"]);var p=[];s+=o(l>1?a.get(["series","multiple","prefix"]):a.get(["series","single","prefix"]),{seriesCount:l}),t.eachSeries(function(e,n){if(n1?a.get(["series","multiple",r]):a.get(["series","single",r]),{seriesId:e.seriesIndex,seriesName:e.get("name"),seriesType:(_=e.subType,b=t.getLocaleModel().get(["series","typeNames"]),b[_]||b.chart)});var s=e.getData();s.count()>u?i+=o(a.get(["data","partialData"]),{displayCnt:u}):i+=a.get(["data","allData"]);for(var c=a.get(["data","separator","middle"]),d=a.get(["data","separator","end"]),f=a.get(["data","excludeDimensionId"]),g=[],v=0;v":"gt",">=":"gte","=":"eq","!=":"ne","<>":"ne"},Yj=function(){function t(t){if(null==(this._condVal=j(t)?new RegExp(t):it(t)?t:null)){var e="";0,va(e)}}return t.prototype.evaluate=function(t){var e=typeof t;return j(e)?this._condVal.test(t):!!K(e)&&this._condVal.test(t+"")},t}(),Xj=function(){function t(){}return t.prototype.evaluate=function(){return this.value},t}(),Zj=function(){function t(){}return t.prototype.evaluate=function(){for(var t=this.children,e=0;e2&&l.push(e),e=[t,n]}function f(t,n,i,r){aq(t,i)&&aq(n,r)||e.push(t,n,i,r,i,r)}function g(t,n,i,r,o,a){var s=Math.abs(n-t),l=4*Math.tan(s/4)/3,u=nM:C2&&l.push(e),l}function lq(t,e,n,i,r,o,a,s,l,u){if(aq(t,n)&&aq(e,i)&&aq(r,a)&&aq(o,s))l.push(a,s);else{var c=2/u,h=c*c,d=a-t,p=s-e,f=Math.sqrt(d*d+p*p);d/=f,p/=f;var g=n-t,v=i-e,y=r-a,m=o-s,x=g*g+v*v,_=y*y+m*m;if(x=0&&_-w*w=0)l.push(a,s);else{var S=[],M=[];Hn(t,n,r,a,.5,S),Hn(e,i,o,s,.5,M),lq(S[0],M[0],S[1],M[1],S[2],M[2],S[3],M[3],l,u),lq(S[4],M[4],S[5],M[5],S[6],M[6],S[7],M[7],l,u)}}}}function uq(t,e,n){var i=t[e],r=t[1-e],o=Math.abs(i/r),a=Math.ceil(Math.sqrt(o*n)),s=Math.floor(n/a);0===s&&(s=1,a=n);for(var l=[],u=0;u0)for(u=0;uMath.abs(u),h=uq([l,u],c?0:1,e),d=(c?s:u)/h.length,p=0;p1?null:new Pe(p*l+t,p*u+e)}function pq(t,e,n){var i=new Pe;Pe.sub(i,n,e),i.normalize();var r=new Pe;return Pe.sub(r,t,e),r.dot(i)}function fq(t,e){var n=t[t.length-1];n&&n[0]===e[0]&&n[1]===e[1]||t.push(e)}function gq(t){var e=t.points,n=[],i=[];rl(e,n,i);var r=new Xe(n[0],n[1],i[0]-n[0],i[1]-n[1]),o=r.width,a=r.height,s=r.x,l=r.y,u=new Pe,c=new Pe;return o>a?(u.x=c.x=s+o/2,u.y=l,c.y=l+a):(u.y=c.y=l+a/2,u.x=s,c.x=s+o),function(t,e,n){for(var i=t.length,r=[],o=0;or,a=uq([i,r],o?0:1,e),s=o?"width":"height",l=o?"height":"width",u=o?"x":"y",c=o?"y":"x",h=t[s]/a.length,d=0;d0)for(var b=i/n,w=-i/2;w<=i/2;w+=b){var S=Math.sin(w),M=Math.cos(w),I=0;for(x=0;x0;l/=2){var u=0,c=0;(t&l)>0&&(u=1),(e&l)>0&&(c=1),s+=l*l*(3*u^c),0===c&&(1===u&&(t=l-1-t,e=l-1-e),a=t,t=e,e=a)}return s}function Pq(t){var e=1/0,n=1/0,i=-1/0,r=-1/0,o=V(t,function(t){var o=t.getBoundingRect(),a=t.getComputedTransform(),s=o.x+o.width/2+(a?a[4]:0),l=o.y+o.height/2+(a?a[5]:0);return e=Math.min(s,e),n=Math.min(l,n),i=Math.max(s,i),r=Math.max(l,r),[s,l]});return V(o,function(o,a){return{cp:o,z:Lq(o[0],o[1],e,n,i,r),path:t[a]}}).sort(function(t,e){return t.z-e.z}).map(function(t){return t.path})}function Oq(t){return mq(t.path,t.count)}function Rq(t){return X(t[0])}function Nq(t,e){for(var n=[],i=t.length,r=0;r=0;r--)if(!n[r].many.length){var l=n[s].many;if(l.length<=1){if(!s)return n;s=0}o=l.length;var u=Math.ceil(o/2);n[r].many=l.slice(u,o),n[s].many=l.slice(0,u),s++}return n}var Eq={clone:function(t){for(var e=[],n=1-Math.pow(1-t.path.style.opacity,1/t.count),i=0;i0){var s,l,u=i.getModel("universalTransition").get("delay"),c=k({setToFinal:!0},a);Rq(t)&&(s=t,l=e),Rq(e)&&(s=e,l=t);for(var h=s?s===t:t.length>e.length,d=s?Nq(l,s):Nq(h?e:t,[h?t:e]),p=0,f=0;f1e4))for(var r=n.getIndices(),o=0;o0&&i.group.traverse(function(t){t instanceof Kl&&!t.animators.length&&t.animateFrom({style:{opacity:0}},r)})})}function Zq(t){var e=t.getModel("universalTransition").get("seriesKey");return e||t.id}function jq(t){return X(t)?t.sort().join(","):t}function qq(t){if(t.hostModel)return t.hostModel.getModel("universalTransition").get("divideShape")}function Kq(t,e){for(var n=0;na.vmin?n+=a.vmin-i+(t-a.vmin)/(a.vmax-a.vmin)*a.gapReal:n+=t-i,i=a.vmax,r=!1;break}n+=a.vmin-i+a.gapReal,i=a.vmax}return r&&(n+=t-i),n},transformOut:function(t,e){if(e&&2===e.depth)return t;for(var n=Qq,i=tK,r=!0,o=0,a=0;al?s.vmin+(t-l)/(u-l)*(s.vmax-s.vmin):i+t-n,i=s.vmax,r=!1;break}n=u,i=s.vmax}return r&&(o=i+t-n),o}},t}();function Jq(t,e){return new $q(t,e)}var Qq=0,tK=0;function eK(t,e,n,i,r,o){"no"!==t&&B(n,function(n){var a=iK(n,o);if(a)for(var s=e.length-1;s>=0;s--){var l=e[s],u=i(l),c=3*r/4;u>a.vmin-c&&ue[0]&&n=0&&t<.99999})(s)||(s=0),r.gapParsed.type="tpPrct",r.gapParsed.val=s,o=!0}}if(!o){var l=e.parse(t.gap);(!isFinite(l)||l<0)&&(l=0),r.gapParsed.type="tpAbs",r.gapParsed.val=l}}if(r.vmin===r.vmax&&(r.gapParsed.type="tpAbs",r.gapParsed.val=0),n&&n.noNegative&&B(["vmin","vmax"],function(t){r[t]<0&&(r[t]=0)}),r.vmin>r.vmax){var u=r.vmax;r.vmax=r.vmin,r.vmin=u}i.push(r)}}),i.sort(function(t,e){return t.vmin-e.vmin});var r=-1/0;return B(i,function(t,e){r>t.vmin&&(i[e]=null),r=t.vmax}),{breaks:F(i,function(t){return!!t})}}function oK(t,e){return aK(e)===aK(t)}function aK(t){return t.start+"_\0_"+t.end}function sK(t,e,n){var i=[];B(t,function(t,n){var r=e(t);r&&"vmin"===r.type&&i.push([n])}),B(t,function(n,r){var o=e(n);if(o&&"vmax"===o.type){var a=H(i,function(n){return oK(e(t[n[0]]).parsedBreak.breakOption,o.parsedBreak.breakOption)});a&&a.push(r)}});var r=[];return B(i,function(e){2===e.length&&r.push(n?e:[t[e[0]],t[e[1]]])}),r}function lK(t,e,n,i){if(e.break){var r=e.break.parsedBreak,o=H(n,function(t){return oK(t.breakOption,e.break.parsedBreak.breakOption)}),a={lookup:i,depth:2},s={vmin:t.transformOut(r.vmin,a),vmax:t.transformOut(r.vmax,a),breakOption:r.breakOption,gapParsed:C(o.gapParsed),gapReal:r.gapReal};return{tickVal:s[e.break.type],vBreak:{type:e.break.type,parsedBreak:s}}}}function uK(t,e,n,i,r){r.original=rK(t,e,n);var o=r.transformed=rK(t,e,n),a=r.lookup;o.breaks=V(o.breaks,function(t,n){var r={depth:2},o=e.transformIn(t.vmin,r),s=e.transformIn(t.vmax,r),l={type:t.gapParsed.type,val:"tpAbs"===t.gapParsed.type?e.transformIn(t.vmin+t.gapParsed.val,r)-o:t.gapParsed.val};return a.from[i+n]=o,a.to[i+n]=t.vmin,a.from[i+n+1]=s,a.to[i+n+1]=t.vmax,{vmin:o,vmax:s,gapParsed:l,gapReal:t.gapReal,breakOption:t.breakOption}})}var cK={vmin:"start",vmax:"end"};function hK(t,e){return e&&((t=t||{}).break={type:cK[e.type],start:e.parsedBreak.vmin,end:e.parsedBreak.vmax}),t}function dK(){var t;t={createBreakScaleMapper:Jq,pruneTicksByBreak:eK,addBreaksToTicks:nK,parseAxisBreakOption:rK,identifyAxisBreak:oK,serializeAxisBreakIdentifier:aK,retrieveAxisBreakPairs:sK,getTicksBreakOutwardTransform:lK,parseAxisBreakOptionInwardTransform:uK,makeAxisLabelFormatterParamBreak:hK},Hp||(Hp=t)}var pK=Pa();function fK(t,e,n,i,r){var o=n.axis;if(!o.scale.isBlank()&&Wp()){var a=Wp().retrieveAxisBreakPairs(o.scale.getTicks({breakTicks:"only_break"}),function(t){return t.break},!1);if(a.length){var s=n.getModel("breakArea"),l=s.get("zigzagAmplitude"),u=s.get("zigzagMinSpan"),c=s.get("zigzagMaxSpan");u=Math.max(2,u||0),c=Math.max(u,c||0);var h=s.get("expandOnClick"),d=s.get("zigzagZ"),p=s.getModel("itemStyle").getItemStyle(),f=p.stroke,g=p.lineWidth,v=p.lineDash,y=p.fill,m=new vo({ignoreModelZ:!0}),x=o.isHorizontal(),_=pK(e).visualList||(pK(e).visualList=[]);B(_,function(t){return t.shouldRemove=!0});for(var b=function(t){var e=a[t][0].break.parsedBreak,s=[];s[0]=o.toGlobalCoord(o.dataToCoord(e.vmin,!0)),s[1]=o.toGlobalCoord(o.dataToCoord(e.vmax,!0)),s[1]=x;C&&(M=x);var D=[],A=[];D[h]=n,A[h]=r,T||C||(D[h]+=S?-l:l,A[h]-=S?l:-l),D[m]=M,A[m]=M,b.push(D),w.push(A);var k=void 0;if(I=0;e--)t[e].shouldRemove&&t.splice(e,1)}(_)}}}function gK(t,e,n,i){var r=t.axis,o=n.transform;ct(i.style);var a=r.getExtent();r.inverse&&(a=a.slice()).reverse();var s=V(Wp().retrieveAxisBreakPairs(r.scale.getTicks({breakTicks:"only_break"}),function(t){return t.break},!1),function(t){var e=t[0].break.parsedBreak,n=[r.dataToCoord(e.vmin,!0),r.dataToCoord(e.vmax,!0)];return n[0]>n[1]&&n.reverse(),{coordPair:n,brkId:Wp().serializeAxisBreakIdentifier(e.breakOption)}});s.sort(function(t,e){return t.coordPair[0]-e.coordPair[0]});for(var l=a[0],u=null,c=0;c=0?s[0].width:s[1].width)+u.x)/2-l.x,h=Math.min(c,c-u.x),d=Math.max(c,c-u.x);a=(c-(d<0?d:h>0?h:0))/u.x}var p=new Pe,f=new Pe;Pe.scale(p,i,-a),Pe.scale(f,i,1-a),eI(n[0],p),eI(n[1],f)}}function g(t){var e=n[0].localRect,i=new Pe(e[pd[t]]*o[0][0],e[pd[t]]*o[0][1]);return Math.abs(i.y)<1e-5}}function yK(t,e){var n={breaks:[]};return B(e.breaks,function(i){if(i){var r=H(t.get("breaks",!0),function(t){return Wp().identifyAxisBreak(t,i)});if(r){var o=e.type,a={isExpanded:!!r.isExpanded};r.isExpanded=o===ZC||o!==jC&&(o===qC?!r.isExpanded:r.isExpanded),n.breaks.push({start:r.start,end:r.end,isExpanded:!!r.isExpanded,old:a})}}}),n}function mK(){var t;t={adjustBreakLabelPair:vK,buildAxisBreakLine:gK,rectCoordBuildBreakAxis:fK,updateModelAxisBreak:yK},YC||(YC=t)}function xK(t,e){B(t,function(t){if(!t.model.get(["axisLabel","inside"])){var n=function(t){var e,n,i=t.model,r=t.scale;if(!i.get(["axisLabel","show"])||r.isBlank())return;var o=r.getExtent();n=r instanceof Mw?r.count():(e=r.getTicks()).length;var a,s=t.getLabelModel(),l=Zw(t),u=1;n>40&&(u=Math.ceil(n/40));for(var c=0;c=0&&r.push({dataGroupId:e.oldDataGroupIds[n],data:e.oldData[n],divide:qq(e.oldData[n]),groupIdDim:t.dimension})}),B(_a(t.to),function(t){var i=Kq(n.updatedSeries,t);if(i>=0){var r=n.updatedSeries[i].getData();o.push({dataGroupId:e.oldDataGroupIds[i],data:r,divide:qq(r),groupIdDim:t.dimension})}}),r.length>0&&o.length>0&&Xq(r,o,i)}(t,i,n,e)});else{var o=function(t,e){var n=mt(),i=mt(),r=mt();return B(t.oldSeries,function(e,n){var o=t.oldDataGroupIds[n],a=t.oldData[n],s=Zq(e),l=jq(s);i.set(l,{dataGroupId:o,data:a}),X(s)&&B(s,function(t){r.set(t,{key:l,dataGroupId:o,data:a})})}),B(e.updatedSeries,function(t){if(t.isUniversalTransitionEnabled()&&t.isAnimationEnabled()){var e=t.get("dataGroupId"),o=t.getData(),a=Zq(t),s=jq(a),l=i.get(s);if(l)n.set(s,{oldSeries:[{dataGroupId:l.dataGroupId,divide:qq(l.data),data:l.data}],newSeries:[{dataGroupId:e,divide:qq(o),data:o}]});else if(X(a)){var u=[];B(a,function(t){var e=i.get(t);e.data&&u.push({dataGroupId:e.dataGroupId,divide:qq(e.data),data:e.data})}),u.length&&n.set(s,{oldSeries:u,newSeries:[{dataGroupId:e,data:o,divide:qq(o)}]})}else{var c=r.get(a);if(c){var h=n.get(c.key);h||(h={oldSeries:[{dataGroupId:c.dataGroupId,data:c.data,divide:qq(c.data)}],newSeries:[]},n.set(c.key,h)),h.newSeries.push({dataGroupId:e,data:o,divide:qq(o)})}}}}),n}(i,n);B(o.keys(),function(t){var n=o.get(t);Xq(n.oldSeries,n.newSeries,e)})}B(n.updatedSeries,function(t){t[Fy]&&(t[Fy]=!1)})}for(var a=t.getSeries(),s=i.oldSeries=[],l=i.oldDataGroupIds=[],u=i.oldData=[],c=0;c Value { if usage.cached_input_tokens().is_some() || usage.cache_creation_input_tokens().is_some() { value["prompt_tokens_details"] = json!({ "cached_tokens": usage.cached_input_tokens().unwrap_or(0), - "cache_creation_tokens": usage.cache_creation_input_tokens().unwrap_or(0), + "cache_write_tokens": usage.cache_creation_input_tokens().unwrap_or(0), }); } if let Some(reasoning_tokens) = usage.reasoning_tokens { diff --git a/crates/switchyard-translation/tests/response_translation.rs b/crates/switchyard-translation/tests/response_translation.rs index cef7155dd7..7d3ce5df57 100644 --- a/crates/switchyard-translation/tests/response_translation.rs +++ b/crates/switchyard-translation/tests/response_translation.rs @@ -377,7 +377,7 @@ fn responses_reasoning_usage_translates_to_openai_chat_usage_details() -> TestRe json!({"reasoning_tokens": 3}) ); assert_eq!( - output["usage"]["prompt_tokens_details"]["cache_creation_tokens"], + output["usage"]["prompt_tokens_details"]["cache_write_tokens"], 2 ); let decoded = engine diff --git a/scripts/common.sh b/scripts/common.sh deleted file mode 100644 index cd27254f16..0000000000 --- a/scripts/common.sh +++ /dev/null @@ -1,117 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -SY_HOME="${SY_HOME:-$HOME/.switchyard}" -SY_PORT="${SY_PORT:-4123}" -CODEX_DIR="${CODEX_HOME:-$HOME/.codex}" -# Both installers use Codex's standalone profile file for the CLI. -CODEX_PROFILE_CONFIG="$CODEX_DIR/sy.config.toml" -ALIAS_START="# >>> switchyard codex alias >>>" -ALIAS_END="# <<< switchyard codex alias <<<" - -say() { printf '%s\n' "$*"; } -step() { printf '\n==> %s\n' "$*"; } - -strip_block() { - local path="$1" start="$2" end="$3" label="${4:-the switchyard block}" - if [[ ! -f "$path" ]] || ! grep -qF "$start" "$path"; then - return 1 - fi - if ! grep -qF "$end" "$path"; then - say " $path has no end marker for $label; not editing it" >&2 - return 1 - fi - if (( DRY_RUN )); then - say " would remove $label from $path" - return 0 - fi - local temp - temp="$(mktemp)" - awk -v start="$start" -v end="$end" ' - index($0, start) { skipping = 1 } - !skipping { print } - index($0, end) { skipping = 0 } - END { if (skipping) exit 1 } - ' "$path" > "$temp" - cat "$temp" > "$path" - rm -f "$temp" - say " removed $label from $path" - return 0 -} - -run() { - if (( DRY_RUN )); then - say " would run: $*" - else - "$@" - fi -} - -write_once() { - local path="$1" - if [[ -f "$path" ]]; then - say " keeping existing $path" - cat >/dev/null - return - fi - if (( DRY_RUN )); then - say " would create $path" - cat >/dev/null - else - mkdir -p "$(dirname "$path")" - cat > "$path" - say " created $path" - fi -} - -write_with_backup() { - local path="$1" - if (( DRY_RUN )); then - [[ -f "$path" ]] && say " would back up $path" - say " would write $path" - cat >/dev/null - return - fi - mkdir -p "$(dirname "$path")" - local incoming - incoming="$(mktemp)" - cat > "$incoming" - if [[ -f "$path" ]] && cmp -s "$incoming" "$path"; then - say " $path is already up to date" - rm -f "$incoming" - return - fi - if [[ -f "$path" ]]; then - local backup - backup="$path.switchyard-backup.$(date +%Y%m%d%H%M%S)" - cp "$path" "$backup" - say " backed up $path to $backup" - fi - cat "$incoming" > "$path" - rm -f "$incoming" - say " wrote $path" -} - -write_always() { - local path="$1" - if (( DRY_RUN )); then - say " would write $path" - cat >/dev/null - else - mkdir -p "$(dirname "$path")" - cat > "$path" - say " wrote $path" - fi -} - -remove_file() { - local path="$1" - if [[ ! -e "$path" ]]; then - say " nothing to remove at $path" - elif (( DRY_RUN )); then - say " would delete $path" - else - rm -f "$path" - say " deleted $path" - fi -} diff --git a/scripts/linux/common.sh b/scripts/linux/common.sh deleted file mode 100644 index 3a9b7782e1..0000000000 --- a/scripts/linux/common.sh +++ /dev/null @@ -1,8 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# shellcheck source=scripts/common.sh -source "$(dirname "${BASH_SOURCE[0]}")/../common.sh" - -SERVICE_NAME="switchyard.service" -SYSTEMD_USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" diff --git a/scripts/linux/install.sh b/scripts/linux/install.sh deleted file mode 100755 index c296ebcfc1..0000000000 --- a/scripts/linux/install.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Installs the Switchyard background server as a systemd --user service and sets -# up a `sy` Codex profile. -# -# Keeps existing composite.toml, replaces the service unit, and backs up -# sy.config.toml before replacing it. Use systemctl --user edit switchyard -# for service changes that survive reinstalling. -# Run with --dry-run to print what would happen. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" - -DRY_RUN=0 -case "$#:${1:-}" in - 0:) ;; - 1:--dry-run) DRY_RUN=1 ;; - *) printf 'Usage: %s [--dry-run]\n' "$0" >&2; exit 2 ;; -esac - -# shellcheck source=scripts/linux/common.sh -source "$SCRIPT_DIR/common.sh" - -if [[ "$SY_HOME" =~ [[:space:][:cntrl:]] || "$SY_HOME" == *\\ ]]; then - say "SY_HOME must not contain whitespace, control characters, or a trailing backslash." >&2 - exit 1 -fi -if [[ ! "$SY_PORT" =~ ^[0-9]+$ ]]; then - say "SY_PORT must contain only digits." >&2 - exit 1 -fi - -if [[ "$(uname -s)" != "Linux" ]]; then - say "This installer is for Linux only." >&2 - exit 1 -fi - -step "Building release binary" -run cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml" -p switchyard-server - -step "Installing binary into $SY_HOME/bin" -run mkdir -p "$SY_HOME/bin" -run install -m 755 "$REPO_ROOT/target/release/switchyard-server" "$SY_HOME/bin/switchyard-server" - -step "Writing server config" -write_once "$SY_HOME/composite.toml" < "$REPO_ROOT/scripts/config/composite.toml" - -step "Validating the server config" -if (( DRY_RUN )); then - say " would run: $SY_HOME/bin/switchyard-server --config $SY_HOME/composite.toml --dry-run" -else - "$SY_HOME/bin/switchyard-server" --config "$SY_HOME/composite.toml" --dry-run -fi - -step "Writing the systemd user service" -write_always "$SYSTEMD_USER_DIR/$SERVICE_NAME" <&2 - exit 1 -fi - -step "Adding the sy Codex profile" -# This profile only changes which router answers. Approval and sandbox -# settings are deliberately left out, so the profile cannot loosen how Codex -# asks before it acts. Set those yourself if you want them. -sed "s/@SY_PORT@/$SY_PORT/g" "$REPO_ROOT/scripts/config/codex.sy.toml" | write_with_backup "$CODEX_PROFILE_CONFIG" - -step "Done" -say "Server: http://127.0.0.1:$SY_PORT" -say "Logs: journalctl --user -u switchyard" -say "Manage it with: systemctl --user {status,restart,stop} $SERVICE_NAME" -say "Use it with: codex -p sy (requires Codex CLI 0.134.0 or newer)" -say "" -say "If you want the server running even when you are logged out, run:" -say " loginctl enable-linger \$USER" diff --git a/scripts/linux/uninstall.sh b/scripts/linux/uninstall.sh deleted file mode 100755 index f968a030d1..0000000000 --- a/scripts/linux/uninstall.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Removes what install.sh added: the systemd user service, the `sy` Codex -# profile, and any codex alias from an older install. Config and binaries stay -# put; the paths are printed so you can delete them yourself. -# -# Run with --dry-run to print what would happen. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -DRY_RUN=0 -case "$#:${1:-}" in - 0:) ;; - 1:--dry-run) DRY_RUN=1 ;; - *) printf 'Usage: %s [--dry-run]\n' "$0" >&2; exit 2 ;; -esac - -# shellcheck source=scripts/linux/common.sh -source "$SCRIPT_DIR/common.sh" - -step "Removing the sy Codex profile" -remove_file "$CODEX_PROFILE_CONFIG" - -step "Removing the codex alias" -for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do - if [[ -f "$rc" ]] && grep -qF "$ALIAS_START" "$rc"; then - strip_block "$rc" "$ALIAS_START" "$ALIAS_END" "the codex alias" - else - say " no alias in $rc" - fi -done - -step "Stopping the systemd user service" -if (( DRY_RUN )); then - say " would run: systemctl --user disable --now $SERVICE_NAME" - say " would delete $SYSTEMD_USER_DIR/$SERVICE_NAME" -else - systemctl --user disable --now "$SERVICE_NAME" 2>/dev/null || true - rm -f "$SYSTEMD_USER_DIR/$SERVICE_NAME" - systemctl --user daemon-reload - say " stopped and removed $SERVICE_NAME" -fi - -step "Done" -say "Left in place, delete them if you want:" -say " $SY_HOME (binary, config, routing log)" -say " $CODEX_PROFILE_CONFIG.switchyard-backup.* (backups taken at install time)" diff --git a/tests/test_linux_install.py b/tests/test_linux_install.py deleted file mode 100644 index 7d070c8e35..0000000000 --- a/tests/test_linux_install.py +++ /dev/null @@ -1,197 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -import os -import shutil -import subprocess -from pathlib import Path - -import pytest - -REPO = Path(__file__).resolve().parents[1] -START = "# >>> switchyard codex alias >>>" -END = "# <<< switchyard codex alias <<<" -RC = f"before\n{START}\nalias codex='codex -p sy'\n{END}\nafter\n" - - -@pytest.fixture -def setup(tmp_path): - scripts = tmp_path / "repo" / "scripts" / "linux" - shutil.copytree(REPO / "scripts" / "linux", scripts) - shutil.copy(REPO / "scripts" / "common.sh", scripts.parent / "common.sh") - shutil.copytree(REPO / "scripts" / "config", scripts.parent / "config") - home = tmp_path / "home" - home.mkdir() - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - env = { - **os.environ, - "HOME": str(home), - "SY_HOME": str(home / ".switchyard"), - "SY_PORT": "4123", - "XDG_CONFIG_HOME": str(home / ".config"), - "CODEX_HOME": str(home / ".codex"), - "TMPDIR": str(tmp_path), - "PATH": f"{bin_dir}:/usr/bin:/bin", - "SYSTEMCTL_LOG": str(tmp_path / "systemctl.log"), - "FAIL_SYSTEMCTL": "", - } - stubs = { - "cargo": "exit 0\n", - "uname": "echo Linux\n", - "sleep": "exit 0\n", - "install": 'printf "#!/bin/sh\\nexit 0\\n" > "$4"\nchmod +x "$4"\n', - "systemctl": 'echo "$*" >> "$SYSTEMCTL_LOG"\n' - '[[ "$*" != *"$FAIL_SYSTEMCTL"* || -z "$FAIL_SYSTEMCTL" ]]\n', - } - for name, body in stubs.items(): - stub = bin_dir / name - stub.write_text("#!/bin/bash\nset -eu\n" + body) - stub.chmod(0o755) - return scripts, home, bin_dir, env - - -def run(setup, script, *args): - scripts, _, _, env = setup - return subprocess.run( - ["bash", str(scripts / script), *args], - env=env, - capture_output=True, - text=True, - timeout=10, - ) - - -@pytest.mark.parametrize("script", ["install.sh", "uninstall.sh"]) -@pytest.mark.parametrize("args", [["--dryrun"], ["--help"], ["-n"], ["--dry-run", "extra"], [""]]) -def test_bad_arguments_leave_files_unchanged(setup, script, args): - _, home, _, env = setup - rc = home / ".bashrc" - rc.write_text(RC) - result = run(setup, script, *args) - assert result.returncode == 2 - assert "Usage:" in result.stderr - assert rc.read_text() == RC - assert not Path(env["SYSTEMCTL_LOG"]).exists() - - -@pytest.mark.parametrize("failure", ["mktemp", "awk", "missing-end", "second-missing-end"]) -def test_uninstall_failure_preserves_shell_files(setup, failure): - _, home, bin_dir, _ = setup - contents = RC - if failure == "missing-end": - contents = RC.replace(END + "\n", "") - elif failure == "second-missing-end": - contents += f"{START}\nalias codex='codex -p sy'\ntail\n" - else: - (bin_dir / failure).write_text("#!/bin/bash\nexit 1\n") - (bin_dir / failure).chmod(0o755) - for name in [".zshrc", ".bashrc"]: - (home / name).write_text(contents) - result = run(setup, "uninstall.sh") - assert result.returncode != 0 - for name in [".zshrc", ".bashrc"]: - assert (home / name).read_text() == contents - - -def test_uninstall_cleans_profile_and_aliases_before_systemctl_failure(setup): - _, home, _, env = setup - env["FAIL_SYSTEMCTL"] = "--user" - profile = Path(env["CODEX_HOME"]) / "sy.config.toml" - profile.parent.mkdir() - profile.write_text("old profile\n") - for name in [".zshrc", ".bashrc"]: - (home / name).write_text(RC) - result = run(setup, "uninstall.sh") - assert result.returncode != 0 - assert not profile.exists() - for name in [".zshrc", ".bashrc"]: - assert (home / name).read_text() == "before\nafter\n" - - -@pytest.mark.parametrize("script", ["install.sh", "uninstall.sh"]) -def test_dry_run_leaves_files_unchanged(setup, script): - _, home, _, env = setup - (home / ".bashrc").write_text(RC) - result = run(setup, script, "--dry-run") - assert result.returncode == 0, result.stderr - assert (home / ".bashrc").read_text() == RC - assert sorted(path.name for path in home.iterdir()) == [".bashrc"] - assert not Path(env["SYSTEMCTL_LOG"]).exists() - - -@pytest.mark.parametrize( - "key,value", - [ - ("SY_HOME", "/tmp/with space"), - ("SY_HOME", "/tmp/line\nbreak"), - ("SY_HOME", "/tmp/control\x01"), - ("SY_HOME", "/tmp/backslash\\"), - ("SY_PORT", "bad"), - ("SY_PORT", "4123\n"), - ], -) -def test_invalid_unit_values_fail_before_install(setup, key, value): - _, home, _, env = setup - env[key] = value - result = run(setup, "install.sh") - assert result.returncode != 0 - assert key in result.stderr - assert not list(home.iterdir()) - - -def test_reinstall_restarts_service_and_keeps_user_config(setup): - _, home, _, env = setup - (home / ".bashrc").write_text("user settings\n") - assert run(setup, "install.sh").returncode == 0 - config = Path(env["SY_HOME"]) / "composite.toml" - config.write_text("user config\n") - env["SY_PORT"] = "5000" - result = run(setup, "install.sh") - assert result.returncode == 0, result.stderr - assert config.read_text() == "user config\n" - assert (home / ".bashrc").read_text() == "user settings\n" - assert not (home / ".zshrc").exists() - profile = Path(env["CODEX_HOME"]) / "sy.config.toml" - assert "127.0.0.1:5000/v1" in profile.read_text() - backups = list(profile.parent.glob("sy.config.toml.switchyard-backup.*")) - assert len(backups) == 1 - assert "127.0.0.1:4123/v1" in backups[0].read_text() - calls = Path(env["SYSTEMCTL_LOG"]).read_text().splitlines() - assert ( - calls - == [ - "--user daemon-reload", - "--user enable switchyard.service", - "--user restart switchyard.service", - "--user is-active --quiet switchyard.service", - ] - * 2 - ) - - -def test_failed_start_leaves_existing_profile_unchanged(setup): - _, _, _, env = setup - env["FAIL_SYSTEMCTL"] = "is-active" - profile = Path(env["CODEX_HOME"]) / "sy.config.toml" - profile.parent.mkdir() - profile.write_text("user profile\n") - result = run(setup, "install.sh") - assert result.returncode != 0 - assert "journalctl" in result.stderr - assert profile.read_text() == "user profile\n" - - -def test_default_make_only_prints_help(setup): - _, home, _, env = setup - result = subprocess.run( - ["make", "-f", str(REPO / "Makefile")], - cwd=REPO, - env=env, - capture_output=True, - text=True, - timeout=10, - ) - assert result.returncode == 0, result.stderr - assert "install-linux-dry-run" in result.stdout - assert not list(home.iterdir())