From 72729d8624f7d924e5a53d6b1196015f759421c3 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Mon, 31 Aug 2026 08:50:30 +1000 Subject: [PATCH 1/8] PLG+DOCS: plugins dev docs for temp files + adguard export cleanup --- .gitignore | 1 + docs/PLUGINS_DEV.md | 23 +++++++++++++ server/plugins/__template/rename_me.py | 8 +++++ server/plugins/adguard_export/README.md | 4 ++- server/plugins/adguard_export/script.py | 17 ++++++++-- test/plugins/test_adguard_export.py | 45 ++++++++++++++++++++++++- 6 files changed, 94 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 460fa930e..eb332d93b 100755 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ front/api/* **/plugins/heartbeat/* **/%40eaDir/ **/@eaDir/ +.claude/settings.local.json __pycache__/ *.py[cod] diff --git a/docs/PLUGINS_DEV.md b/docs/PLUGINS_DEV.md index 2154ea027..f43797453 100755 --- a/docs/PLUGINS_DEV.md +++ b/docs/PLUGINS_DEV.md @@ -296,6 +296,27 @@ To always map a static value (not read from plugin output): --- +## Persisting Plugin Data (State & Config Files) + +Plugin settings (`config.json`) are already persisted for you. If your plugin also needs to write its **own files** to disk between runs — a cache, a "what did I already do" tracker, an exported artifact — pick the right base path from `const.py` rather than hardcoding one: + +| Purpose | Import from `const` | Default path | Use for | +|---|---|---|---| +| Internal state | `dbFolderPath` | `/data/db` | Bookkeeping the user never edits directly: sync state, dedupe caches, "managed items" trackers, etc. | +| Config artifacts | `configPath` | `/data/config` | Files that are conceptually configuration: exports meant to be reviewed/edited by the user, generated config snippets, backups. | + +```python +from const import dbFolderPath, configPath + +STATE_FILE = os.path.join(dbFolderPath, f"state.{pluginName}.json") +``` + +**Why it matters:** `/data/db` and `/data/config` are separate mount points. Users can point `/data/db` at fast/ephemeral storage (its contents are usually rebuildable) and `/data/config` at durable, backed-up storage — or the reverse, depending on their setup. Don't hardcode `/app/db`, `/app/config`, or write loose files directly under the bare data root (`dataPath`); those bypass this separation, and `/app/...` paths are the pre-`v25.10.1` legacy layout (see [MIGRATION.md](MIGRATION.md)). + +If you rename or move where a plugin stores its state file across a release, migrate the old file on startup instead of silently dropping user state — see `server/plugins/adguard_export/script.py` for a worked example. + +--- + ## UI Component Types Plugin results are displayed in the web interface using various component types. See **[PLUGINS_DEV_UI_COMPONENTS.md](PLUGINS_DEV_UI_COMPONENTS.md)** for complete documentation. @@ -353,6 +374,8 @@ See: [UI Components](PLUGINS_DEV_UI_COMPONENTS.md) - **Example Plugins:** `/app/server/plugins/*/` - Study working implementations - **Logs:** `/tmp/log/plugins/` - Plugin output and execution logs - **Backend Logs:** `/tmp/log/app.log` - Core system logs +- **Persistent state:** `dbFolderPath` (`/data/db`) via `from const import dbFolderPath` - see [Persisting Plugin Data](#persisting-plugin-data-state--config-files) +- **Config artifacts:** `configPath` (`/data/config`) via `from const import configPath` - see [Persisting Plugin Data](#persisting-plugin-data-state--config-files) --- diff --git a/server/plugins/__template/rename_me.py b/server/plugins/__template/rename_me.py index 6af13603b..f6885ff97 100755 --- a/server/plugins/__template/rename_me.py +++ b/server/plugins/__template/rename_me.py @@ -9,6 +9,11 @@ sys.path.extend([f"{INSTALL_PATH}/server/plugins", f"{INSTALL_PATH}/server"]) from const import logPath # noqa: E402, E261 [flake8 lint suppression] +# If your plugin needs to persist its own files between runs, import dbFolderPath +# (internal state, e.g. "/data/db") and/or configPath (user-facing config +# artifacts, e.g. "/data/config") instead of hardcoding a path — see +# docs/PLUGINS_DEV.md#persisting-plugin-data-state--config-files +# from const import dbFolderPath, configPath from plugin_helper import Plugin_Objects # noqa: E402, E261 [flake8 lint suppression] from logger import mylog, Logger # noqa: E402, E261 [flake8 lint suppression] from helper import get_setting_value # noqa: E402, E261 [flake8 lint suppression] @@ -28,6 +33,9 @@ LOG_FILE = os.path.join(LOG_PATH, f'script.{pluginName}.log') RESULT_FILE = os.path.join(LOG_PATH, f'last_result.{pluginName}.log') +# Example: a plugin-managed state file (uncomment and adjust if you need one) +# STATE_FILE = os.path.join(dbFolderPath, f'state.{pluginName}.json') + # Initialize the Plugin obj output file plugin_objects = Plugin_Objects(RESULT_FILE) diff --git a/server/plugins/adguard_export/README.md b/server/plugins/adguard_export/README.md index 16b801971..0071a3863 100644 --- a/server/plugins/adguard_export/README.md +++ b/server/plugins/adguard_export/README.md @@ -94,9 +94,11 @@ Devices with an unrecognised or empty type are exported without a tag. When `ADGUARDEXP_DELETE=true`, the plugin only removes clients it previously created — it will never delete clients you added manually in AdGuard Home. Ownership is tracked in a local state file at: ```text -/app/db/state.ADGUARDEXP.json +/data/db/state.ADGUARDEXP.json ``` +> Upgrading from an older version? A state file found at the pre-`v25.10.1` legacy location (`/data/state.ADGUARDEXP.json`) is migrated to the path above automatically on first run — no manual action needed. + --- ## Logs diff --git a/server/plugins/adguard_export/script.py b/server/plugins/adguard_export/script.py index 033837ad5..92cff2e87 100644 --- a/server/plugins/adguard_export/script.py +++ b/server/plugins/adguard_export/script.py @@ -22,7 +22,7 @@ INSTALL_PATH = os.getenv('NETALERTX_APP', '/app') sys.path.extend([f"{INSTALL_PATH}/server/plugins", f"{INSTALL_PATH}/server"]) -from const import dataPath, logPath # noqa: E402, E261 +from const import dataPath, dbFolderPath, logPath # noqa: E402, E261 from plugin_helper import Plugin_Objects # noqa: E402, E261 from logger import mylog, Logger # noqa: E402, E261 from helper import get_setting_value # noqa: E402, E261 @@ -43,7 +43,20 @@ # Define paths LOG_PATH = logPath + "/plugins" RESULT_FILE = os.path.join(LOG_PATH, f"last_result.{pluginName}.log") -STATE_FILE = os.path.join(dataPath, f"state.{pluginName}.json") +STATE_FILE = os.path.join(dbFolderPath, f"state.{pluginName}.json") + +# Pre-v25.10.1 builds stored the state file directly under the data root +# (dataPath) instead of dbFolderPath (/data/db). Migrate it once so upgrading +# doesn't silently reset which AdGuard clients we own. +_LEGACY_STATE_FILE = os.path.join(dataPath, f"state.{pluginName}.json") + + +def _migrate_legacy_state_file() -> None: + if not os.path.exists(STATE_FILE) and os.path.exists(_LEGACY_STATE_FILE): + os.rename(_LEGACY_STATE_FILE, STATE_FILE) + + +_migrate_legacy_state_file() plugin_objects = Plugin_Objects(RESULT_FILE) diff --git a/test/plugins/test_adguard_export.py b/test/plugins/test_adguard_export.py index debd0fa8b..70670e401 100644 --- a/test/plugins/test_adguard_export.py +++ b/test/plugins/test_adguard_export.py @@ -23,6 +23,8 @@ # so this is safe to run inside the container too. # --------------------------------------------------------------------------- _tmp_log = tempfile.mkdtemp() +_tmp_data = tempfile.mkdtemp() +_tmp_db = tempfile.mkdtemp() def _stub(name: str, **attrs): @@ -35,7 +37,13 @@ def _stub(name: str, **attrs): _stub("pytz", timezone=lambda tz: tz) _stub("conf") -_stub("const", dataPath=_tmp_log, logPath=_tmp_log, fullDbPath=os.path.join(_tmp_log, "test.db")) +_stub( + "const", + dataPath=_tmp_data, + dbFolderPath=_tmp_db, + logPath=_tmp_log, + fullDbPath=os.path.join(_tmp_db, "test.db"), +) _stub("plugin_helper", Plugin_Objects=MagicMock) _stub("logger", mylog=lambda *a: None, Logger=MagicMock) _stub("helper", get_setting_value=lambda k: "") @@ -62,6 +70,7 @@ def _stub(name: str, **attrs): from script import ( # noqa: E402 AdGuardClient, _TYPE_TAG_MAP, + _migrate_legacy_state_file, build_agrd_client, device_type_to_tag, get_netalertx_devices, @@ -205,6 +214,40 @@ def test_save_sorts_names(self, tmp_path): assert data["managed"] == ["apple", "mango", "zebra"] +# --------------------------------------------------------------------------- +# _migrate_legacy_state_file +# --------------------------------------------------------------------------- + + +class TestMigrateLegacyStateFile: + def test_migrates_legacy_file_to_new_location(self, tmp_path): + legacy = tmp_path / "state.json" + new = tmp_path / "db" / "state.json" + new.parent.mkdir() + legacy.write_text(json.dumps({"managed": ["alpha"]})) + with patch("script.STATE_FILE", str(new)), patch("script._LEGACY_STATE_FILE", str(legacy)): + _migrate_legacy_state_file() + assert not legacy.exists() + assert json.loads(new.read_text())["managed"] == ["alpha"] + + def test_does_not_overwrite_existing_new_file(self, tmp_path): + legacy = tmp_path / "legacy.json" + new = tmp_path / "new.json" + legacy.write_text(json.dumps({"managed": ["old"]})) + new.write_text(json.dumps({"managed": ["current"]})) + with patch("script.STATE_FILE", str(new)), patch("script._LEGACY_STATE_FILE", str(legacy)): + _migrate_legacy_state_file() + assert legacy.exists() + assert json.loads(new.read_text())["managed"] == ["current"] + + def test_no_op_when_neither_file_exists(self, tmp_path): + legacy = tmp_path / "legacy.json" + new = tmp_path / "new.json" + with patch("script.STATE_FILE", str(new)), patch("script._LEGACY_STATE_FILE", str(legacy)): + _migrate_legacy_state_file() + assert not new.exists() + + # --------------------------------------------------------------------------- # get_netalertx_devices # --------------------------------------------------------------------------- From e44b17faa75b612f5052bfe6e5292c049c383535 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Mon, 31 Aug 2026 10:01:09 +1000 Subject: [PATCH 2/8] PLG+DOCS: better scaffolding --- .../skills/plugin-development/plugin-skill.md | 6 +- .gemini/skills/skills-index/SKILL.md | 2 +- .../PULL_REQUEST_TEMPLATE/code-pr-template.md | 1 + .../skills/plugin-run-development/SKILL.md | 6 +- .github/skills/skills-overview/SKILL.md | 2 +- docs/PLUGINS_DEV.md | 12 ++ server/plugins/__template/rename_me.py | 9 +- server/plugins/unifi_import/script.py | 18 ++- test/plugins/test___template.py | 70 +++++++++ test/plugins/test_adguard_export.py | 14 +- test/plugins/test_plugin_conventions.py | 111 ++++++++++++++ test/plugins/test_unifi_import.py | 140 ++++++++++++++++++ 12 files changed, 381 insertions(+), 10 deletions(-) create mode 100644 test/plugins/test___template.py create mode 100644 test/plugins/test_plugin_conventions.py create mode 100644 test/plugins/test_unifi_import.py diff --git a/.gemini/skills/plugin-development/plugin-skill.md b/.gemini/skills/plugin-development/plugin-skill.md index 691986cf8..39129d510 100644 --- a/.gemini/skills/plugin-development/plugin-skill.md +++ b/.gemini/skills/plugin-development/plugin-skill.md @@ -41,7 +41,7 @@ server/plugins// - `_RUN`: execution phase - `_RUN_SCHD`: cron-like schedule - `_CMD`: script path -- `_RUN_TIMEOUT`: timeout in seconds +- `_RUN_TIMEOUT`: timeout in seconds — **this is enforced by the core plugin runner as the whole script's kill-timeout** (`server/plugin.py` passes it straight to `subprocess(..., timeout=...)`). It is not a safe per-HTTP-call timeout — don't reuse it for individual network calls in a loop, or one slow call can burn the whole budget and get the process killed before it writes its result file. - `_WATCH`: columns to watch for changes ## Data Contract @@ -79,6 +79,10 @@ plugin_objects.write_result_file() # Exactly once at end | importer | Import from services | `schedule` | | system | Core functionality | `schedule` | +## Before Opening a PR + +Check the plugin against the [Conventions Checklist](../../../docs/PLUGINS_DEV.md#conventions-checklist) in `docs/PLUGINS_DEV.md` — `RUN` default, schedule precedent, `RUN_TIMEOUT` semantics, reusing core settings, description length, and the multi-instance settings pattern. Most plugin PR review comments trace back to one of these. + ## Starting Point Copy from `server/plugins/__template` and customize. Read `docs/PLUGINS_DEV.md` for the full development guide. diff --git a/.gemini/skills/skills-index/SKILL.md b/.gemini/skills/skills-index/SKILL.md index ea21fd9c0..b61302058 100644 --- a/.gemini/skills/skills-index/SKILL.md +++ b/.gemini/skills/skills-index/SKILL.md @@ -22,7 +22,7 @@ Skills with the same purpose exist in both, sometimes under different names and | Settings & config | `settings` | `settings-management` | Gemini version is more comprehensive (22-point guide + PR checklist); Copilot version covers `ccd()` and `get_setting_value()` usage | | MCP activation | `mcp-activation` | `mcp-activation` | Gemini version covers Gemini CLI session restart; Copilot version covers VS Code window reload | | Project navigation | `project-navigation` | `project-navigation` | Copilot version has full path tables and env vars; Gemini version is a brief reference | -| Plugin dev | `plugin-development` | `plugin-run-development` | Copilot version is comprehensive (data contract, phases, formats); Gemini version is a brief checklist pointing to `docs/PLUGINS_DEV.md` | +| Plugin dev | `plugin-development` | `plugin-run-development` | Both cover data contract, phases, formats, the `RUN_TIMEOUT` kill-timer gotcha, and a pre-PR pointer to the Conventions Checklist in `docs/PLUGINS_DEV.md`; kept in sync manually | | Devcontainer | `devcontainer-management` | `devcontainer-services` + `devcontainer-setup` + `devcontainer-configs` | Gemini combines into one (uses `docker exec`); Copilot splits into 3 focused skills | | PR review | `pr-analysis` | `pr-analysis` | How to classify and respond to PR comments; pre-flight skill loading checklist | | Logging | `logging-standards` | `logging-standards` | `mylog` levels, message format, what not to log | diff --git a/.github/PULL_REQUEST_TEMPLATE/code-pr-template.md b/.github/PULL_REQUEST_TEMPLATE/code-pr-template.md index 4a2e18e75..272b3fadd 100755 --- a/.github/PULL_REQUEST_TEMPLATE/code-pr-template.md +++ b/.github/PULL_REQUEST_TEMPLATE/code-pr-template.md @@ -44,6 +44,7 @@ Please check the relevant option(s): - [ ] I have tested my changes locally - [ ] I have updated relevant documentation (if applicable) - [ ] I have verified my changes do not break existing behavior +- [ ] If this PR adds/modifies a plugin, it passes the [Conventions Checklist](../../docs/PLUGINS_DEV.md#conventions-checklist) - [ ] I am willing to respond to requested changes and feedback --- diff --git a/.github/skills/plugin-run-development/SKILL.md b/.github/skills/plugin-run-development/SKILL.md index 372cac108..5df100d1b 100644 --- a/.github/skills/plugin-run-development/SKILL.md +++ b/.github/skills/plugin-run-development/SKILL.md @@ -42,7 +42,7 @@ server/plugins// - `_RUN`: execution phase - `_RUN_SCHD`: cron-like schedule - `_CMD`: script path -- `_RUN_TIMEOUT`: timeout in seconds +- `_RUN_TIMEOUT`: timeout in seconds — **this is enforced by the core plugin runner as the whole script's kill-timeout** (`server/plugin.py` passes it straight to `subprocess(..., timeout=...)`). It is not a safe per-HTTP-call timeout — don't reuse it for individual network calls in a loop, or one slow call can burn the whole budget and get the process killed before it writes its result file. - `_WATCH`: columns to watch for changes ## Data Contract @@ -80,6 +80,10 @@ plugin_objects.write_result_file() # Exactly once at end | importer | Import from services | `schedule` | | system | Core functionality | `schedule` | +## Before Opening a PR + +Check the plugin against the [Conventions Checklist](../../../docs/PLUGINS_DEV.md#conventions-checklist) in `docs/PLUGINS_DEV.md` — `RUN` default, schedule precedent, `RUN_TIMEOUT` semantics, reusing core settings, description length, and the multi-instance settings pattern. Most plugin PR review comments trace back to one of these. + ## Starting Point Copy from `server/plugins/__template` and customize. diff --git a/.github/skills/skills-overview/SKILL.md b/.github/skills/skills-overview/SKILL.md index e02b5f96f..c116e5a76 100644 --- a/.github/skills/skills-overview/SKILL.md +++ b/.github/skills/skills-overview/SKILL.md @@ -22,7 +22,7 @@ Skills with the same purpose exist in both, sometimes under different names and | Settings & config | `settings-management` | `settings` | Gemini version is more comprehensive (22-point guide + PR checklist); Copilot version covers `ccd()` and `get_setting_value()` usage | | MCP activation | `mcp-activation` | `mcp-activation` | Copilot version covers VS Code window reload; Gemini version covers Gemini CLI session restart | | Project navigation | `project-navigation` | `project-navigation` | Copilot version has full path tables and env vars; Gemini version is a brief reference | -| Plugin dev | `plugin-run-development` | `plugin-development` | Copilot version is comprehensive (data contract, phases, formats); Gemini version is a brief checklist pointing to `docs/PLUGINS_DEV.md` | +| Plugin dev | `plugin-run-development` | `plugin-development` | Both cover data contract, phases, formats, the `RUN_TIMEOUT` kill-timer gotcha, and a pre-PR pointer to the Conventions Checklist in `docs/PLUGINS_DEV.md`; kept in sync manually | | Devcontainer | `devcontainer-services` + `devcontainer-setup` + `devcontainer-configs` | `devcontainer-management` | Copilot splits into 3 focused skills; Gemini combines into one (uses `docker exec`) | | PR review | `pr-analysis` | `pr-analysis` | How to classify and respond to PR comments; pre-flight skill loading checklist | | Logging | `logging-standards` | `logging-standards` | `mylog` levels, message format, what not to log | diff --git a/docs/PLUGINS_DEV.md b/docs/PLUGINS_DEV.md index f43797453..5e467c45b 100755 --- a/docs/PLUGINS_DEV.md +++ b/docs/PLUGINS_DEV.md @@ -229,6 +229,18 @@ These control core plugin behavior: See [PLUGINS_DEV_SETTINGS.md](PLUGINS_DEV_SETTINGS.md) for full component types and examples. +### Conventions Checklist + +Check your plugin against these repo-wide conventions before opening a PR (verified against `server/plugins/*/config.json`): + +- **`RUN` defaults to `"disabled"`.** True for the large majority of plugins; only core maintenance plugins (`csv_backup`, `db_cleanup`, `maintenance`, `vendor_update`) default to `schedule`. A new optional plugin should load disabled until the user configures it. +- **Pick `RUN_SCHD` from precedent, not an arbitrary value.** Check the closest existing plugin for its schedule (e.g. `pihole_api_scan` uses `*/5 * * * *`) rather than inventing a new cadence — consistency keeps first-time setup predictable across plugins. +- **`RUN_TIMEOUT` is a subprocess kill-timer, not a per-request budget.** The core plugin runner (`server/plugin.py`) passes this same value as the hard timeout for the *entire* script (`subprocess` `timeout=`). If your script makes multiple sequential network calls (e.g. two upstream instances, or a per-device lookup in a loop), don't also reuse `RUN_TIMEOUT` as each individual call's timeout — one slow call can then consume the whole budget and get the process killed before it writes its result file, silently dropping the entire run. +- **Reuse existing core settings instead of duplicating them.** If NetAlertX already has a concept your plugin needs (e.g. `API_TOKEN` for its own GraphQL/API endpoint), read it with `get_setting_value("API_TOKEN")` rather than adding a plugin-specific `_API_TOKEN` — see `server/plugins/sync/sync.py` for the pattern. +- **Keep `description` strings short.** They render directly in the Settings UI. Put implementation rationale and design trade-offs in the plugin's README or code comments, not the UI-facing description. +- **For "one or more instances of the same thing," use the nested array + popup-form settings pattern**, not a fixed hardcoded count (e.g. "primary"/"secondary"). See `rest_import` (`RSTIMPRT`)'s `imports` setting for a working example — it also gives each instance its own sub-settings (URL, credentials, per-instance flags) for free. +- **Persist plugin state under `dbFolderPath`, config artifacts under `configPath`** — see [Persisting Plugin Data](#persisting-plugin-data-state--config-files) below. + --- ## Filters & Data Display diff --git a/server/plugins/__template/rename_me.py b/server/plugins/__template/rename_me.py index f6885ff97..2ac5cfd44 100755 --- a/server/plugins/__template/rename_me.py +++ b/server/plugins/__template/rename_me.py @@ -92,7 +92,14 @@ def get_device_data(some_setting): device_data = [] # do some processing, call exteranl APIs, and return a device_data list - # ... + # ... + # + # If you call a network API here, remember RUN_TIMEOUT is the whole + # script's kill-timeout (enforced by server/plugin.py), not a safe + # per-request timeout - don't reuse its value as the timeout for each + # individual HTTP call if you might make several in a loop, or one slow + # call can burn the whole budget and get the process killed before it + # writes RESULT_FILE. See docs/PLUGINS_DEV.md#conventions-checklist. # # Sample data for testing purposes, you can adjust the processing in main() as needed # ... before adding it to the plugin_objects.add_object(...) diff --git a/server/plugins/unifi_import/script.py b/server/plugins/unifi_import/script.py index 4dc88c141..e22433562 100755 --- a/server/plugins/unifi_import/script.py +++ b/server/plugins/unifi_import/script.py @@ -19,7 +19,7 @@ from helper import get_setting_value, normalize_string # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression] from pytz import timezone # noqa: E402 [flake8 lint suppression] -from const import logPath # noqa: E402 [flake8 lint suppression] +from const import dbFolderPath, logPath # noqa: E402 [flake8 lint suppression] # Make sure the TIMEZONE for logging is correct conf.tz = timezone(get_setting_value('TIMEZONE')) @@ -32,7 +32,21 @@ LOG_PATH = logPath + '/plugins' LOG_FILE = os.path.join(LOG_PATH, f'script.{pluginName}.log') RESULT_FILE = os.path.join(LOG_PATH, f'last_result.{pluginName}.log') -LOCK_FILE = os.path.join(LOG_PATH, f'full_run.{pluginName}.lock') +LOCK_FILE = os.path.join(dbFolderPath, f'full_run.{pluginName}.lock') + +# LOG_PATH (/tmp/log) is ephemeral (tmpfs) - a lock file rooted there gets +# wiped on every container restart, silently re-triggering a "once"-mode +# full import each time. Migrate any pre-existing lock file to dbFolderPath +# (/data/db) once so upgrading doesn't lose the current lock state. +_LEGACY_LOCK_FILE = os.path.join(LOG_PATH, f'full_run.{pluginName}.lock') + + +def _migrate_legacy_lock_file() -> None: + if not os.path.exists(LOCK_FILE) and os.path.exists(_LEGACY_LOCK_FILE): + os.rename(_LEGACY_LOCK_FILE, LOCK_FILE) + + +_migrate_legacy_lock_file() urllib3.disable_warnings(InsecureRequestWarning) diff --git a/test/plugins/test___template.py b/test/plugins/test___template.py new file mode 100644 index 000000000..1ceca81cf --- /dev/null +++ b/test/plugins/test___template.py @@ -0,0 +1,70 @@ +""" +Tests for the __template plugin scaffold (server/plugins/__template/rename_me.py). + +This is the copy-paste starting point for every new plugin, so keeping it +covered by a passing test (and demonstrating the expected test shape) gives +new plugin authors something to copy alongside the script itself. + +Run from inside the NetAlertX container, or locally - NetAlertX-specific +modules are stubbed out automatically before the script is imported. + + pytest "test/plugins/test___template.py" -v +""" + +import os +import sys +import tempfile +import types +from unittest.mock import MagicMock + +_tmp_log = tempfile.mkdtemp() +_tmp_db = tempfile.mkdtemp() + + +def _stub(name: str, **attrs): + # Additive: several plugin test files stub the same generic module names + # (helper, plugin_helper, const, ...) with different attribute subsets. + # If another test already registered this name, add whatever attributes + # it doesn't have yet instead of skipping outright - a plain skip-if- + # present guard makes collection order decide which test's dependencies + # win, breaking whichever test runs later in the same pytest session. + mod = sys.modules.get(name) + if mod is None: + mod = types.ModuleType(name) + sys.modules[name] = mod + for k, v in attrs.items(): + if not hasattr(mod, k): + setattr(mod, k, v) + + +_stub("pytz", timezone=lambda tz: tz) +_stub("conf") +_stub("const", dataPath=_tmp_db, dbFolderPath=_tmp_db, configPath=_tmp_db, logPath=_tmp_log) +_stub("plugin_helper", Plugin_Objects=MagicMock) +_stub("logger", mylog=lambda *a: None, Logger=MagicMock) +_stub("helper", get_setting_value=lambda k: "") + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "server", "plugins", "__template")) + +import rename_me # noqa: E402 + + +class TestGetDeviceData: + def test_returns_the_sample_devices(self): + data = rename_me.get_device_data(some_setting="anything") + assert len(data) == 2 + assert {d["mac_address"] for d in data} == {"00:11:22:33:44:55", "00:11:22:33:44:66"} + for device in data: + for key in ("mac_address", "ip_address", "hostname", "vendor", "device_type", "last_seen"): + assert key in device + + +class TestMain: + def test_writes_one_object_per_device_and_result_file_once(self): + rename_me.plugin_objects = MagicMock() + rename_me.plugin_objects.__len__ = lambda self: 2 + + rename_me.main() + + assert rename_me.plugin_objects.add_object.call_count == 2 + rename_me.plugin_objects.write_result_file.assert_called_once() diff --git a/test/plugins/test_adguard_export.py b/test/plugins/test_adguard_export.py index 70670e401..fe81bfb6c 100644 --- a/test/plugins/test_adguard_export.py +++ b/test/plugins/test_adguard_export.py @@ -28,11 +28,19 @@ def _stub(name: str, **attrs): - if name not in sys.modules: + # Additive: several plugin test files stub the same generic module names + # (helper, plugin_helper, const, ...) with different attribute subsets. + # If another test already registered this name, add whatever attributes + # it doesn't have yet instead of skipping outright - a plain skip-if- + # present guard makes collection order decide which test's dependencies + # win, breaking whichever test runs later in the same pytest session. + mod = sys.modules.get(name) + if mod is None: mod = types.ModuleType(name) - for k, v in attrs.items(): - setattr(mod, k, v) sys.modules[name] = mod + for k, v in attrs.items(): + if not hasattr(mod, k): + setattr(mod, k, v) _stub("pytz", timezone=lambda tz: tz) diff --git a/test/plugins/test_plugin_conventions.py b/test/plugins/test_plugin_conventions.py new file mode 100644 index 000000000..9e556c508 --- /dev/null +++ b/test/plugins/test_plugin_conventions.py @@ -0,0 +1,111 @@ +""" +Repo-wide convention checks for `server/plugins/*/config.json`. + +These enforce the "Conventions Checklist" in docs/PLUGINS_DEV.md so a plugin +PR fails CI instead of relying on a reviewer noticing by hand. + + pytest test/plugins/test_plugin_conventions.py -v +""" + +import json +import os + +import pytest + +_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +_PLUGINS_DIR = os.path.join(_ROOT, 'server', 'plugins') + +# Core/maintenance plugins that intentionally run on a schedule out of the box +# (see docs/PLUGINS_DEV.md#conventions-checklist) instead of defaulting to +# "disabled" like every optional/import-style plugin. +_ALLOWED_NON_DISABLED_RUN_DEFAULTS = { + "csv_backup": "schedule", + "db_cleanup": "schedule", + "maintenance": "schedule", + "vendor_update": "schedule", + "sync": "unused", +} + +# Settings UI real-estate: descriptions render directly in Settings, not a +# README. Cap chosen well above every current plugin's length (longest is +# ~135 chars) so it only catches a genuine outlier, not routine phrasing. +_MAX_DESCRIPTION_LENGTH = 200 + + +def _discover_plugin_dirs(): + """Every plugin folder with a config.json, skipping __-prefixed folders + and any folder carrying an `ignore_plugin` marker (same rules the app's + own loader uses - see server/utils/plugin_utils.py:get_plugins_configs).""" + names = [] + for entry in sorted(os.listdir(_PLUGINS_DIR)): + plugin_dir = os.path.join(_PLUGINS_DIR, entry) + if not os.path.isdir(plugin_dir) or entry.startswith('__'): + continue + if os.path.isfile(os.path.join(plugin_dir, 'ignore_plugin')): + continue + if os.path.isfile(os.path.join(plugin_dir, 'config.json')): + names.append(entry) + return names + + +_PLUGIN_NAMES = _discover_plugin_dirs() + + +def _load_config(plugin_name): + path = os.path.join(_PLUGINS_DIR, plugin_name, 'config.json') + with open(path) as f: + return json.load(f) + + +@pytest.mark.parametrize('plugin_name', _PLUGIN_NAMES) +def test_config_json_is_valid_json(plugin_name): + path = os.path.join(_PLUGINS_DIR, plugin_name, 'config.json') + with open(path) as f: + content = f.read() + try: + json.loads(content) + except json.JSONDecodeError as e: + pytest.fail(f'{plugin_name}/config.json is not valid JSON: {e}') + + +@pytest.mark.parametrize('plugin_name', _PLUGIN_NAMES) +def test_run_defaults_to_disabled(plugin_name): + config = _load_config(plugin_name) + run_setting = next( + (s for s in config.get('settings', []) if s.get('function') == 'RUN'), + None, + ) + if run_setting is None: + return # no RUN setting (e.g. a config-only plugin) - nothing to check + + default = run_setting.get('default_value') + allowed = _ALLOWED_NON_DISABLED_RUN_DEFAULTS.get(plugin_name) + if allowed is not None: + assert default == allowed, ( + f'{plugin_name}: expected the allow-listed RUN default {allowed!r}, got {default!r}. ' + 'If this plugin no longer needs an exception, remove it from ' + '_ALLOWED_NON_DISABLED_RUN_DEFAULTS.' + ) + else: + assert default == 'disabled', ( + f'{plugin_name}: RUN defaults to {default!r}, expected "disabled". ' + 'Non-core plugins must load disabled until the user configures them - ' + 'see docs/PLUGINS_DEV.md#conventions-checklist. If this is a core/maintenance ' + 'plugin that legitimately needs to run out of the box, add it to ' + '_ALLOWED_NON_DISABLED_RUN_DEFAULTS in this test.' + ) + + +@pytest.mark.parametrize('plugin_name', _PLUGIN_NAMES) +def test_description_is_concise(plugin_name): + config = _load_config(plugin_name) + for desc in config.get('description', []): + if desc.get('language_code') != 'en_us': + continue + text = desc.get('string', '') + assert len(text) <= _MAX_DESCRIPTION_LENGTH, ( + f'{plugin_name}: description is {len(text)} chars (max {_MAX_DESCRIPTION_LENGTH}). ' + 'This renders directly in the Settings UI - keep it short and move ' + 'implementation rationale to the README instead. ' + 'See docs/PLUGINS_DEV.md#conventions-checklist.' + ) diff --git a/test/plugins/test_unifi_import.py b/test/plugins/test_unifi_import.py new file mode 100644 index 000000000..bd1f1719b --- /dev/null +++ b/test/plugins/test_unifi_import.py @@ -0,0 +1,140 @@ +""" +Tests for unifi_import/script.py - focused on the LOCK_FILE persistence fix +(moved from the ephemeral LOG_PATH/tmpfs to durable dbFolderPath, with a +one-time migration for existing installs). + +Run from inside the NetAlertX container, or locally - NetAlertX-specific +modules are stubbed out automatically before the script is imported. + + pytest test/plugins/test_unifi_import.py -v +""" + +import importlib.util +import os +import sys +import tempfile +import types +from unittest.mock import MagicMock, patch + +import pytest + +_tmp_log = tempfile.mkdtemp() +_tmp_db = tempfile.mkdtemp() + + +def _stub(name: str, **attrs): + # Additive: several plugin test files stub the same generic module names + # (helper, plugin_helper, const, ...) with different attribute subsets. + # If another test already registered this name, add whatever attributes + # it doesn't have yet instead of skipping outright - a plain skip-if- + # present guard makes collection order decide which test's dependencies + # win, breaking whichever test runs later in the same pytest session. + mod = sys.modules.get(name) + if mod is None: + mod = types.ModuleType(name) + sys.modules[name] = mod + for k, v in attrs.items(): + if not hasattr(mod, k): + setattr(mod, k, v) + + +_stub("pytz", timezone=lambda tz: tz) +_stub("conf") +_stub("const", dbFolderPath=_tmp_db, logPath=_tmp_log) +_stub( + "plugin_helper", + Plugin_Objects=MagicMock, + rmBadChars=lambda s: s, + is_typical_router_ip=lambda ip: False, + is_mac=lambda v: isinstance(v, str) and len(v.split(":")) == 6, +) +_stub("logger", mylog=lambda *a: None, Logger=MagicMock) +_stub("helper", get_setting_value=lambda k: "", normalize_string=lambda s: s) + +if "pyunifi" not in sys.modules: + _pyunifi = types.ModuleType("pyunifi") + _pyunifi_controller = types.ModuleType("pyunifi.controller") + _pyunifi_controller.Controller = MagicMock + _pyunifi.controller = _pyunifi_controller + sys.modules["pyunifi"] = _pyunifi + sys.modules["pyunifi.controller"] = _pyunifi_controller + +if "urllib3" not in sys.modules: + _urllib3 = types.ModuleType("urllib3") + _urllib3.disable_warnings = lambda *a, **k: None + _urllib3_exc = types.ModuleType("urllib3.exceptions") + _urllib3_exc.InsecureRequestWarning = type("InsecureRequestWarning", (Warning,), {}) + _urllib3.exceptions = _urllib3_exc + sys.modules["urllib3"] = _urllib3 + sys.modules["urllib3.exceptions"] = _urllib3_exc + +# unifi_import's module file is named "script.py", same as several other +# plugins (e.g. adguard_export) - load it under a private module name +# instead of a plain `import script`, so this test doesn't collide with +# another plugin's test importing its own same-named script.py in the same +# pytest process. +_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "server", "plugins", "unifi_import", "script.py") +_spec = importlib.util.spec_from_file_location("unifi_import_script", _SCRIPT_PATH) +script = importlib.util.module_from_spec(_spec) +sys.modules["unifi_import_script"] = script +_spec.loader.exec_module(script) + +_migrate_legacy_lock_file = script._migrate_legacy_lock_file +check_full_run_state = script.check_full_run_state +read_lock_file = script.read_lock_file +set_lock_file_value = script.set_lock_file_value + + +class TestMigrateLegacyLockFile: + def test_migrates_legacy_file_to_new_location(self, tmp_path): + legacy = tmp_path / "full_run.UNFIMP.lock" + new = tmp_path / "db" / "full_run.UNFIMP.lock" + new.parent.mkdir() + legacy.write_text("1") + with patch.object(script, "LOCK_FILE", str(new)), patch.object(script, "_LEGACY_LOCK_FILE", str(legacy)): + _migrate_legacy_lock_file() + assert not legacy.exists() + assert new.read_text() == "1" + + def test_does_not_overwrite_existing_new_file(self, tmp_path): + legacy = tmp_path / "legacy.lock" + new = tmp_path / "new.lock" + legacy.write_text("1") + new.write_text("0") + with patch.object(script, "LOCK_FILE", str(new)), patch.object(script, "_LEGACY_LOCK_FILE", str(legacy)): + _migrate_legacy_lock_file() + assert legacy.exists() + assert new.read_text() == "0" + + def test_no_op_when_neither_file_exists(self, tmp_path): + legacy = tmp_path / "legacy.lock" + new = tmp_path / "new.lock" + with patch.object(script, "LOCK_FILE", str(new)), patch.object(script, "_LEGACY_LOCK_FILE", str(legacy)): + _migrate_legacy_lock_file() + assert not new.exists() + + +class TestLockFileRoundTrip: + def test_read_missing_lock_file_returns_false(self, tmp_path): + with patch.object(script, "LOCK_FILE", str(tmp_path / "nonexistent.lock")): + assert read_lock_file() is False + + def test_set_and_read_round_trip(self, tmp_path): + lock = tmp_path / "full_run.lock" + with patch.object(script, "LOCK_FILE", str(lock)): + set_lock_file_value("once", False) + assert read_lock_file() is True + + @pytest.mark.parametrize( + "config_value,lock_file_value,expected", + [ + ("always", False, True), + ("always", True, True), + ("once", False, True), + ("once", True, False), + ("disabled", False, False), + ("disabled", True, False), + ], + ) + def test_check_full_run_state(self, config_value, lock_file_value, expected): + assert check_full_run_state(config_value, lock_file_value) is expected From 81202afa316df972813a0589bcf901a187da7a44 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Mon, 31 Aug 2026 11:27:25 +1000 Subject: [PATCH 3/8] PLG+DOCS: better scaffolding --- docs/PLUGINS_DEV.md | 4 +- docs/PLUGINS_DEV_DATA_CONTRACT.md | 2 + server/plugins/_publisher_ntfy/ntfy.py | 18 +++- .../plugins/_publisher_pushsafer/pushsafer.py | 18 +++- server/plugins/_publisher_telegram/tg.py | 18 ++-- server/plugins/adguard_export/script.py | 2 +- .../plugins/adguard_import/adguard_import.py | 2 +- server/plugins/nbtscan_scan/nbtscan.py | 7 +- server/plugins/nmap_dev_scan/config.json | 3 +- server/plugins/plugin_helper.py | 22 +++++ test/plugins/test___template.py | 23 ++--- test/plugins/test_adguard_export.py | 27 +++--- test/plugins/test_ntfy_custom_headers.py | 3 +- test/plugins/test_pushsafer.py | 77 ++++++++++++++++ test/plugins/test_tg.py | 90 +++++++++++++++++++ test/plugins/test_unifi_import.py | 25 +++--- test/test_plugin_helper.py | 21 ++++- 17 files changed, 307 insertions(+), 55 deletions(-) create mode 100644 test/plugins/test_pushsafer.py create mode 100644 test/plugins/test_tg.py diff --git a/docs/PLUGINS_DEV.md b/docs/PLUGINS_DEV.md index 5e467c45b..6125a0a67 100755 --- a/docs/PLUGINS_DEV.md +++ b/docs/PLUGINS_DEV.md @@ -235,7 +235,9 @@ Check your plugin against these repo-wide conventions before opening a PR (verif - **`RUN` defaults to `"disabled"`.** True for the large majority of plugins; only core maintenance plugins (`csv_backup`, `db_cleanup`, `maintenance`, `vendor_update`) default to `schedule`. A new optional plugin should load disabled until the user configures it. - **Pick `RUN_SCHD` from precedent, not an arbitrary value.** Check the closest existing plugin for its schedule (e.g. `pihole_api_scan` uses `*/5 * * * *`) rather than inventing a new cadence — consistency keeps first-time setup predictable across plugins. -- **`RUN_TIMEOUT` is a subprocess kill-timer, not a per-request budget.** The core plugin runner (`server/plugin.py`) passes this same value as the hard timeout for the *entire* script (`subprocess` `timeout=`). If your script makes multiple sequential network calls (e.g. two upstream instances, or a per-device lookup in a loop), don't also reuse `RUN_TIMEOUT` as each individual call's timeout — one slow call can then consume the whole budget and get the process killed before it writes its result file, silently dropping the entire run. +- **`RUN_TIMEOUT` is a subprocess kill-timer, not a per-request budget.** The core plugin runner (`server/plugin.py`) passes this same value as the hard timeout for the *entire* script (`subprocess` `timeout=`). If your script makes multiple sequential network calls (e.g. two upstream instances, or a per-device lookup in a loop), don't also reuse `RUN_TIMEOUT` as each individual call's timeout — one slow call can then consume the whole budget and get the process killed before it writes its result file, silently dropping the entire run. Two correct alternatives, depending on the shape of your loop: + - **Looping over a config-declared, known-length list** (e.g. a subnets or IPs setting) — mark that `params` entry with `"timeoutMultiplier": true` in `config.json`. The framework then multiplies the *outer* kill-timeout by that list's length before running your script, so each iteration can safely use the full `RUN_TIMEOUT` internally. See `arp_scan/config.json`'s `subnets` param for a working example. + - **Looping over a runtime-variable-length collection** (e.g. a notification queue, where length isn't known until the script runs) — `timeoutMultiplier` doesn't apply here since there's no config-declared count. Instead, divide the *inner* per-call timeout down using `plugin_helper.per_item_timeout(run_timeout, item_count)`, so N sequential calls can't collectively exceed the outer budget. See `server/plugins/_publisher_ntfy/ntfy.py`'s notification loop for a working example. - **Reuse existing core settings instead of duplicating them.** If NetAlertX already has a concept your plugin needs (e.g. `API_TOKEN` for its own GraphQL/API endpoint), read it with `get_setting_value("API_TOKEN")` rather than adding a plugin-specific `_API_TOKEN` — see `server/plugins/sync/sync.py` for the pattern. - **Keep `description` strings short.** They render directly in the Settings UI. Put implementation rationale and design trade-offs in the plugin's README or code comments, not the UI-facing description. - **For "one or more instances of the same thing," use the nested array + popup-form settings pattern**, not a fixed hardcoded count (e.g. "primary"/"secondary"). See `rest_import` (`RSTIMPRT`)'s `imports` setting for a working example — it also gives each instance its own sub-settings (URL, credentials, per-instance flags) for free. diff --git a/docs/PLUGINS_DEV_DATA_CONTRACT.md b/docs/PLUGINS_DEV_DATA_CONTRACT.md index 2765b39bb..316cb8208 100644 --- a/docs/PLUGINS_DEV_DATA_CONTRACT.md +++ b/docs/PLUGINS_DEV_DATA_CONTRACT.md @@ -85,6 +85,8 @@ The library automatically: | 11 | `helpVal3` | string | *conditional* | Helper value 3. If used, all help values must be supplied | | 12 | `helpVal4` | string | *conditional* | Helper value 4. If used, all help values must be supplied | +> **Gotcha:** `plugin_helper.py`'s `Plugin_Object.__init__` applies `helpVal1-4 or ""` internally, collapsing any falsy value (`0`, `False`, `""`) to `""` - indistinguishable from "not supplied." `watchedValue1-4` are stored as-is with no such coercion. If a real `0` or `False` is a meaningful value you need to preserve (not just "absent"), put it in a `watchedValue*` column instead of a `helpVal*` one. + ## Usage Guide ### Empty/Null Values diff --git a/server/plugins/_publisher_ntfy/ntfy.py b/server/plugins/_publisher_ntfy/ntfy.py index 7529de588..a3db81e76 100755 --- a/server/plugins/_publisher_ntfy/ntfy.py +++ b/server/plugins/_publisher_ntfy/ntfy.py @@ -13,7 +13,7 @@ import conf # noqa: E402 [flake8 lint suppression] from const import confFileName, logPath # noqa: E402 [flake8 lint suppression] -from plugin_helper import Plugin_Objects, handleEmpty # noqa: E402 [flake8 lint suppression] +from plugin_helper import Plugin_Objects, handleEmpty, per_item_timeout # noqa: E402 [flake8 lint suppression] from utils.datetime_utils import timeNowUTC # noqa: E402 [flake8 lint suppression] from logger import mylog, Logger # noqa: E402 [flake8 lint suppression] from helper import get_setting_value # noqa: E402 [flake8 lint suppression] @@ -55,11 +55,18 @@ def main(): # Retrieve new notifications new_notifications = notifications.getNew() + # RUN_TIMEOUT is enforced by the core plugin runner as this whole + # script's kill-timeout, not a safe per-request timeout - divide it + # across the queue so a burst of notifications can't let one slow send() + # call consume the whole budget and get the process killed mid-loop. + run_timeout = int(get_setting_value('NTFY_RUN_TIMEOUT') or 10) + per_call_timeout = per_item_timeout(run_timeout, len(new_notifications)) + # Process the new notifications (see the Notifications DB table for structure or check the /php/server/query_json.php?file=table_notifications.json endpoint) for notification in new_notifications: # Send notification - response_text, response_status_code = send(notification["HTML"], notification["Text"]) + response_text, response_status_code = send(notification["HTML"], notification["Text"], per_call_timeout) # Log result plugin_objects.add_object( @@ -131,11 +138,14 @@ def build_custom_headers(entries, reserved_headers): # ------------------------------------------------------------------------------- -def send(html, text): +def send(html, text, timeout=None): response_text = '' response_status_code = '' + if timeout is None: + timeout = int(get_setting_value('NTFY_RUN_TIMEOUT') or 10) + # settings token = get_setting_value('NTFY_TOKEN') user = get_setting_value('NTFY_USER') @@ -178,7 +188,7 @@ def send(html, text): headers = headers, params = url_query_string if url_query_string != '' else None, verify = verify_ssl, - timeout = get_setting_value('NTFY_RUN_TIMEOUT') + timeout = timeout ) response_status_code = response.status_code diff --git a/server/plugins/_publisher_pushsafer/pushsafer.py b/server/plugins/_publisher_pushsafer/pushsafer.py index d63f67fe8..2c1882309 100755 --- a/server/plugins/_publisher_pushsafer/pushsafer.py +++ b/server/plugins/_publisher_pushsafer/pushsafer.py @@ -10,7 +10,7 @@ import conf # noqa: E402 [flake8 lint suppression] from const import confFileName, logPath # noqa: E402 [flake8 lint suppression] -from plugin_helper import Plugin_Objects, handleEmpty # noqa: E402 [flake8 lint suppression] +from plugin_helper import Plugin_Objects, handleEmpty, per_item_timeout # noqa: E402 [flake8 lint suppression] from logger import mylog, Logger # noqa: E402 [flake8 lint suppression] from helper import get_setting_value, hide_string # noqa: E402 [flake8 lint suppression] from utils.datetime_utils import timeNowUTC # noqa: E402 [flake8 lint suppression] @@ -52,11 +52,18 @@ def main(): # Retrieve new notifications new_notifications = notifications.getNew() + # RUN_TIMEOUT is enforced by the core plugin runner as this whole + # script's kill-timeout, not a safe per-request timeout - divide it + # across the queue so a burst of notifications can't let one slow send() + # call consume the whole budget and get the process killed mid-loop. + run_timeout = int(get_setting_value("PUSHSAFER_RUN_TIMEOUT") or 10) + per_call_timeout = per_item_timeout(run_timeout, len(new_notifications)) + # Process the new notifications (see the Notifications DB table for structure or check the /php/server/query_json.php?file=table_notifications.json endpoint) for notification in new_notifications: # Send notification - response_text, response_status_code = send(notification["Text"]) + response_text, response_status_code = send(notification["Text"], per_call_timeout) # Log result plugin_objects.add_object( @@ -74,13 +81,16 @@ def main(): # ------------------------------------------------------------------------------- -def send(text): +def send(text, timeout=None): response_text = '' response_status_code = '' token = get_setting_value('PUSHSAFER_TOKEN') + if timeout is None: + timeout = int(get_setting_value("PUSHSAFER_RUN_TIMEOUT") or 10) + mylog('verbose', [f'[{pluginName}] PUSHSAFER_TOKEN: "{hide_string(token)}"']) try: @@ -97,7 +107,7 @@ def send(text): "ut" : 'Open NetAlertX', "k" : token, } - response = requests.post(url, data=post_fields, timeout=get_setting_value("PUSHSAFER_RUN_TIMEOUT")) + response = requests.post(url, data=post_fields, timeout=timeout) response_status_code = response.status_code # Check if the request was successful (status code 200) diff --git a/server/plugins/_publisher_telegram/tg.py b/server/plugins/_publisher_telegram/tg.py index c5c814568..8f2fe29fd 100755 --- a/server/plugins/_publisher_telegram/tg.py +++ b/server/plugins/_publisher_telegram/tg.py @@ -11,7 +11,7 @@ import conf # noqa: E402 [flake8 lint suppression] from const import confFileName, logPath # noqa: E402 [flake8 lint suppression] -from plugin_helper import Plugin_Objects # noqa: E402 [flake8 lint suppression] +from plugin_helper import Plugin_Objects, per_item_timeout # noqa: E402 [flake8 lint suppression] from utils.datetime_utils import timeNowUTC # noqa: E402 [flake8 lint suppression] from logger import mylog, Logger # noqa: E402 [flake8 lint suppression] from helper import get_setting_value # noqa: E402 [flake8 lint suppression] @@ -53,10 +53,17 @@ def main(): # Retrieve new notifications new_notifications = notifications.getNew() + # RUN_TIMEOUT is enforced by the core plugin runner as this whole + # script's kill-timeout, not a safe per-request timeout - divide it + # across the queue so a burst of notifications can't let one slow send() + # call consume the whole budget and get the process killed mid-loop. + run_timeout = int(get_setting_value('TELEGRAM_RUN_TIMEOUT')) + per_call_timeout = per_item_timeout(run_timeout, len(new_notifications)) + # Process the new notifications (see the Notifications DB table for structure or check the /php/server/query_json.php?file=table_notifications.json endpoint) for notification in new_notifications: # Send notification - result = send(notification["Text"]) + result = send(notification["Text"], per_call_timeout) # Log result plugin_objects.add_object( @@ -79,13 +86,14 @@ def check_config(): # ------------------------------------------------------------------------------- -def send(text): +def send(text, timeout=None): """ Send a Telegram notification. """ limit = get_setting_value('TELEGRAM_SIZE') - run_timeout = int(get_setting_value('TELEGRAM_RUN_TIMEOUT')) - curl_timeout = str(max(1, run_timeout - 1)) + if timeout is None: + timeout = int(get_setting_value('TELEGRAM_RUN_TIMEOUT')) + curl_timeout = str(max(1, timeout - 1)) # Ensure the final payload, including the truncation marker, # never exceeds TELEGRAM_SIZE. diff --git a/server/plugins/adguard_export/script.py b/server/plugins/adguard_export/script.py index 92cff2e87..3ac85fee3 100644 --- a/server/plugins/adguard_export/script.py +++ b/server/plugins/adguard_export/script.py @@ -374,7 +374,7 @@ def main(): # Read settings # ------------------------------------------------------------------ agrd_url = get_setting_value("ADGUARDEXP_URL") or "http://localhost:3000" - agrd_user = get_setting_value("ADGUARDEXP_USER") or "" + agrd_user = get_setting_value("ADGUARDEXP_USER") or "admin" agrd_pass = get_setting_value("ADGUARDEXP_PASSWORD") or "" verify_ssl_str = get_setting_value("ADGUARDEXP_VERIFYSSL") or "true" include_offline_str = get_setting_value("ADGUARDEXP_INCLUDE_OFFLINE") or "true" diff --git a/server/plugins/adguard_import/adguard_import.py b/server/plugins/adguard_import/adguard_import.py index 48cf6dade..3fb79066f 100644 --- a/server/plugins/adguard_import/adguard_import.py +++ b/server/plugins/adguard_import/adguard_import.py @@ -66,7 +66,7 @@ def main(): user = get_setting_value("ADGUARDIMP_USER") pw = get_setting_value("ADGUARDIMP_PASS") fake_mac_enabled = get_setting_value("ADGUARDIMP_FAKE_MAC") - timeout = int(get_setting_value("ADGUARDIMP_RUN_TIMEOUT") or 5) + timeout = int(get_setting_value("ADGUARDIMP_RUN_TIMEOUT") or 30) auth = (user, pw) if user or pw else None diff --git a/server/plugins/nbtscan_scan/nbtscan.py b/server/plugins/nbtscan_scan/nbtscan.py index 7de4673c3..cc7781326 100755 --- a/server/plugins/nbtscan_scan/nbtscan.py +++ b/server/plugins/nbtscan_scan/nbtscan.py @@ -36,8 +36,11 @@ def main(): mylog('verbose', [f'[{pluginName}] In script']) - # timeout = get_setting_value('NBLOOKUP_RUN_TIMEOUT') - timeout = 20 + # The "ips" param in config.json is marked timeoutMultiplier: true, so the + # framework already scales the outer subprocess kill-timeout by device + # count - use the real per-device budget here instead of a hardcoded + # value that could exceed what the multiplier actually grants. + timeout = int(get_setting_value('NBTSCAN_RUN_TIMEOUT') or 10) # Initialize the Plugin obj output file plugin_objects = Plugin_Objects(RESULT_FILE) diff --git a/server/plugins/nmap_dev_scan/config.json b/server/plugins/nmap_dev_scan/config.json index 2d3430090..fed19d32f 100755 --- a/server/plugins/nmap_dev_scan/config.json +++ b/server/plugins/nmap_dev_scan/config.json @@ -44,7 +44,8 @@ "name": "subnets", "type": "setting", "value": "SCAN_SUBNETS", - "base64": true + "base64": true, + "timeoutMultiplier": true } ], "settings": [ diff --git a/server/plugins/plugin_helper.py b/server/plugins/plugin_helper.py index 66d8b1ce7..0178a6126 100755 --- a/server/plugins/plugin_helper.py +++ b/server/plugins/plugin_helper.py @@ -264,6 +264,28 @@ def normalize_mac(mac): return ':'.join(normalized_parts) +# ------------------------------------------------------------------- +def per_item_timeout(run_timeout, item_count, floor=1): + """ + Divide a RUN_TIMEOUT budget evenly across `item_count` sequential + operations (e.g. one HTTP call per queued notification) so no single + item can consume the whole script's kill-timeout - the core plugin + runner (server/plugin.py) enforces RUN_TIMEOUT as the entire + subprocess's hard timeout, not a per-call one. + + Returns run_timeout unchanged when there's 0 or 1 items, so the common + single-item case sees no behavior change. For a config-declared, + known-length list (e.g. a subnets/IPs setting), prefer the config.json + "timeoutMultiplier" mechanism instead - it scales the outer timeout up + rather than dividing the inner one down. Use this helper for + runtime-variable-length loops (e.g. a notification queue) where + timeoutMultiplier doesn't apply. + """ + if item_count <= 1: + return run_timeout + return max(floor, run_timeout // item_count) + + # ------------------------------------------------------------------- class Plugin_Object: """ diff --git a/test/plugins/test___template.py b/test/plugins/test___template.py index 1ceca81cf..0997c13e2 100644 --- a/test/plugins/test___template.py +++ b/test/plugins/test___template.py @@ -20,21 +20,16 @@ _tmp_log = tempfile.mkdtemp() _tmp_db = tempfile.mkdtemp() +_stubbed_module_names = [] + def _stub(name: str, **attrs): - # Additive: several plugin test files stub the same generic module names - # (helper, plugin_helper, const, ...) with different attribute subsets. - # If another test already registered this name, add whatever attributes - # it doesn't have yet instead of skipping outright - a plain skip-if- - # present guard makes collection order decide which test's dependencies - # win, breaking whichever test runs later in the same pytest session. - mod = sys.modules.get(name) - if mod is None: + if name not in sys.modules: mod = types.ModuleType(name) - sys.modules[name] = mod - for k, v in attrs.items(): - if not hasattr(mod, k): + for k, v in attrs.items(): setattr(mod, k, v) + sys.modules[name] = mod + _stubbed_module_names.append(name) _stub("pytz", timezone=lambda tz: tz) @@ -48,6 +43,12 @@ def _stub(name: str, **attrs): import rename_me # noqa: E402 +# Stops these fake entries from shadowing the real modules for other test +# files collected later in the same pytest session (rename_me's own +# module-level `from x import y` bindings are already resolved by now). +for _name in _stubbed_module_names: + sys.modules.pop(_name, None) + class TestGetDeviceData: def test_returns_the_sample_devices(self): diff --git a/test/plugins/test_adguard_export.py b/test/plugins/test_adguard_export.py index fe81bfb6c..a6aaeed38 100644 --- a/test/plugins/test_adguard_export.py +++ b/test/plugins/test_adguard_export.py @@ -26,21 +26,16 @@ _tmp_data = tempfile.mkdtemp() _tmp_db = tempfile.mkdtemp() +_stubbed_module_names = [] + def _stub(name: str, **attrs): - # Additive: several plugin test files stub the same generic module names - # (helper, plugin_helper, const, ...) with different attribute subsets. - # If another test already registered this name, add whatever attributes - # it doesn't have yet instead of skipping outright - a plain skip-if- - # present guard makes collection order decide which test's dependencies - # win, breaking whichever test runs later in the same pytest session. - mod = sys.modules.get(name) - if mod is None: + if name not in sys.modules: mod = types.ModuleType(name) - sys.modules[name] = mod - for k, v in attrs.items(): - if not hasattr(mod, k): + for k, v in attrs.items(): setattr(mod, k, v) + sys.modules[name] = mod + _stubbed_module_names.append(name) _stub("pytz", timezone=lambda tz: tz) @@ -60,6 +55,9 @@ def _stub(name: str, **attrs): # Stub requests only when it isn't installed (e.g. bare system Python locally). # In the container and CI, the real package is present and will be used. +# Tracked via _stubbed_module_names (not the real package) so it gets popped +# below like the other stubs, instead of leaking an incomplete fake `requests` +# (missing .post/.get) to other test files collected later in the same run. if "requests" not in sys.modules: _req = types.ModuleType("requests") _req.Session = MagicMock @@ -69,6 +67,7 @@ def _stub(name: str, **attrs): _req.exceptions = _req_exc sys.modules["requests"] = _req sys.modules["requests.exceptions"] = _req_exc + _stubbed_module_names.extend(["requests", "requests.exceptions"]) # --------------------------------------------------------------------------- # Import the functions under test (must come after the stubs above). @@ -87,6 +86,12 @@ def _stub(name: str, **attrs): sync_to_adguard, ) +# Stops these fake entries from shadowing the real modules for other test +# files collected later in the same pytest session (script's own +# module-level `from x import y` bindings are already resolved by now). +for _name in _stubbed_module_names: + sys.modules.pop(_name, None) + # --------------------------------------------------------------------------- # Helpers diff --git a/test/plugins/test_ntfy_custom_headers.py b/test/plugins/test_ntfy_custom_headers.py index 8fc8863cd..6f5af6a28 100644 --- a/test/plugins/test_ntfy_custom_headers.py +++ b/test/plugins/test_ntfy_custom_headers.py @@ -39,7 +39,7 @@ def _stub(name: str, **attrs): _stub("pytz", timezone=lambda tz: tz) _stub("conf", tz=None) _stub("const", confFileName="app.conf", logPath=_tmp_log) -_stub("plugin_helper", Plugin_Objects=MagicMock, handleEmpty=lambda v: v) +_stub("plugin_helper", Plugin_Objects=MagicMock, handleEmpty=lambda v: v, per_item_timeout=lambda run_timeout, count, floor=1: run_timeout) _stub("utils") _stub("utils.datetime_utils", timeNowUTC=lambda: "2026-01-01 00:00:00") _stub("logger", mylog=lambda *a: None, Logger=MagicMock) @@ -57,6 +57,7 @@ def _stub(name: str, **attrs): _req.exceptions = _req_exc sys.modules["requests"] = _req sys.modules["requests.exceptions"] = _req_exc + _stubbed_module_names.extend(["requests", "requests.exceptions"]) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "server", "plugins", "_publisher_ntfy")) diff --git a/test/plugins/test_pushsafer.py b/test/plugins/test_pushsafer.py new file mode 100644 index 000000000..fc43ea926 --- /dev/null +++ b/test/plugins/test_pushsafer.py @@ -0,0 +1,77 @@ +""" +Tests for _publisher_pushsafer/pushsafer.py - focused on the per-notification +timeout wiring (RUN_TIMEOUT divided across a queue via +plugin_helper.per_item_timeout, instead of reused unchanged per call). + +Run from inside the NetAlertX container, or locally - NetAlertX-specific +modules are stubbed out automatically before the script is imported. + + pytest test/plugins/test_pushsafer.py -v +""" + +import os +import sys +import tempfile +import types +from unittest.mock import MagicMock, patch + +_tmp_log = tempfile.mkdtemp() + +_stubbed_module_names = [] + + +def _stub(name: str, **attrs): + if name not in sys.modules: + mod = types.ModuleType(name) + for k, v in attrs.items(): + setattr(mod, k, v) + sys.modules[name] = mod + _stubbed_module_names.append(name) + + +_stub("pytz", timezone=lambda tz: tz) +_stub("conf", tz=None) +_stub("const", confFileName="app.conf", logPath=_tmp_log) +_stub("plugin_helper", Plugin_Objects=MagicMock, handleEmpty=lambda v: v, per_item_timeout=lambda run_timeout, count, floor=1: run_timeout) +_stub("logger", mylog=lambda *a: None, Logger=MagicMock) +_stub("helper", get_setting_value=lambda k: "", hide_string=lambda s: "***") +_stub("utils") +_stub("utils.datetime_utils", timeNowUTC=lambda: "2026-01-01 00:00:00") +_stub("models") +_stub("models.notification_instance", NotificationInstance=MagicMock) +_stub("database", DB=MagicMock) + +if "requests" not in sys.modules: + _req = types.ModuleType("requests") + _req.post = MagicMock + _req_exc = types.ModuleType("requests.exceptions") + _req_exc.RequestException = type("RequestException", (Exception,), {}) + _req.exceptions = _req_exc + sys.modules["requests"] = _req + sys.modules["requests.exceptions"] = _req_exc + _stubbed_module_names.extend(["requests", "requests.exceptions"]) + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "server", "plugins", "_publisher_pushsafer")) + +import pushsafer # noqa: E402 + +# Stops these fake entries from shadowing the real modules for other test +# files collected later in the same pytest session (pushsafer's own +# module-level `from x import y` bindings are already resolved by now). +for _name in _stubbed_module_names: + sys.modules.pop(_name, None) + + +class TestSendTimeout: + def test_uses_explicit_timeout_when_given(self): + mock_response = MagicMock(status_code=200, text="ok") + with patch("pushsafer.requests.post", return_value=mock_response) as mock_post: + pushsafer.send("hello", timeout=3) + assert mock_post.call_args.kwargs["timeout"] == 3 + + def test_falls_back_to_setting_when_timeout_not_given(self): + mock_response = MagicMock(status_code=200, text="ok") + with patch("pushsafer.get_setting_value", return_value="10"), \ + patch("pushsafer.requests.post", return_value=mock_response) as mock_post: + pushsafer.send("hello") + assert mock_post.call_args.kwargs["timeout"] == 10 diff --git a/test/plugins/test_tg.py b/test/plugins/test_tg.py new file mode 100644 index 000000000..818e762a8 --- /dev/null +++ b/test/plugins/test_tg.py @@ -0,0 +1,90 @@ +""" +Tests for _publisher_telegram/tg.py - focused on the per-notification timeout +wiring (RUN_TIMEOUT divided across a queue via plugin_helper.per_item_timeout, +instead of reused unchanged per call). + +Run from inside the NetAlertX container, or locally - NetAlertX-specific +modules are stubbed out automatically before the script is imported. + + pytest test/plugins/test_tg.py -v +""" + +import os +import sys +import tempfile +import types +from unittest.mock import MagicMock, patch + +_tmp_log = tempfile.mkdtemp() + +_stubbed_module_names = [] + + +def _stub(name: str, **attrs): + if name not in sys.modules: + mod = types.ModuleType(name) + for k, v in attrs.items(): + setattr(mod, k, v) + sys.modules[name] = mod + _stubbed_module_names.append(name) + + +_stub("pytz", timezone=lambda tz: tz) +_stub("conf", tz=None) +_stub("const", confFileName="app.conf", logPath=_tmp_log) +_stub("plugin_helper", Plugin_Objects=MagicMock, per_item_timeout=lambda run_timeout, count, floor=1: run_timeout) +_stub("logger", mylog=lambda *a: None, Logger=MagicMock) +_stub("helper", get_setting_value=lambda k: "") +_stub("utils") +_stub("utils.datetime_utils", timeNowUTC=lambda: "2026-01-01 00:00:00") +_stub("models") +_stub("models.notification_instance", NotificationInstance=MagicMock) +_stub("database", DB=MagicMock) + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "server", "plugins", "_publisher_telegram")) + +import tg # noqa: E402 + +# Stops these fake entries from shadowing the real modules for other test +# files collected later in the same pytest session (tg's own module-level +# `from x import y` bindings are already resolved by now). +for _name in _stubbed_module_names: + sys.modules.pop(_name, None) + + +def _mock_proc(stdout='{"ok": true}'): + return MagicMock(stdout=stdout, returncode=0) + + +def _settings(run_timeout="10", size=4096, host="123:ABC", url="123:ABC"): + values = { + "TELEGRAM_SIZE": size, + "TELEGRAM_RUN_TIMEOUT": run_timeout, + "TELEGRAM_HOST": host, + "TELEGRAM_URL": url, + } + return lambda key: values.get(key, "") + + +class TestSendTimeout: + def test_uses_explicit_timeout_for_curl(self): + with patch("tg.get_setting_value", side_effect=_settings(run_timeout="1000")), \ + patch("tg.subprocess.run", return_value=_mock_proc()) as mock_run: + tg.send("hello", timeout=5) + cmd = mock_run.call_args.args[0] + assert cmd[cmd.index("--connect-timeout") + 1] == "4" # timeout - 1 + assert cmd[cmd.index("--max-time") + 1] == "4" + + def test_falls_back_to_setting_when_timeout_not_given(self): + with patch("tg.get_setting_value", side_effect=_settings(run_timeout="10")), \ + patch("tg.subprocess.run", return_value=_mock_proc()) as mock_run: + tg.send("hello") + cmd = mock_run.call_args.args[0] + assert cmd[cmd.index("--connect-timeout") + 1] == "9" # setting(10) - 1 + + def test_curl_timeout_floor_is_one(self): + with patch("tg.get_setting_value", side_effect=_settings()), \ + patch("tg.subprocess.run", return_value=_mock_proc()) as mock_run: + tg.send("hello", timeout=1) + cmd = mock_run.call_args.args[0] + assert cmd[cmd.index("--connect-timeout") + 1] == "1" # max(1, 1-1) diff --git a/test/plugins/test_unifi_import.py b/test/plugins/test_unifi_import.py index bd1f1719b..4fe215554 100644 --- a/test/plugins/test_unifi_import.py +++ b/test/plugins/test_unifi_import.py @@ -21,21 +21,16 @@ _tmp_log = tempfile.mkdtemp() _tmp_db = tempfile.mkdtemp() +_stubbed_module_names = [] + def _stub(name: str, **attrs): - # Additive: several plugin test files stub the same generic module names - # (helper, plugin_helper, const, ...) with different attribute subsets. - # If another test already registered this name, add whatever attributes - # it doesn't have yet instead of skipping outright - a plain skip-if- - # present guard makes collection order decide which test's dependencies - # win, breaking whichever test runs later in the same pytest session. - mod = sys.modules.get(name) - if mod is None: + if name not in sys.modules: mod = types.ModuleType(name) - sys.modules[name] = mod - for k, v in attrs.items(): - if not hasattr(mod, k): + for k, v in attrs.items(): setattr(mod, k, v) + sys.modules[name] = mod + _stubbed_module_names.append(name) _stub("pytz", timezone=lambda tz: tz) @@ -58,6 +53,7 @@ def _stub(name: str, **attrs): _pyunifi.controller = _pyunifi_controller sys.modules["pyunifi"] = _pyunifi sys.modules["pyunifi.controller"] = _pyunifi_controller + _stubbed_module_names.extend(["pyunifi", "pyunifi.controller"]) if "urllib3" not in sys.modules: _urllib3 = types.ModuleType("urllib3") @@ -67,6 +63,7 @@ def _stub(name: str, **attrs): _urllib3.exceptions = _urllib3_exc sys.modules["urllib3"] = _urllib3 sys.modules["urllib3.exceptions"] = _urllib3_exc + _stubbed_module_names.extend(["urllib3", "urllib3.exceptions"]) # unifi_import's module file is named "script.py", same as several other # plugins (e.g. adguard_export) - load it under a private module name @@ -79,6 +76,12 @@ def _stub(name: str, **attrs): sys.modules["unifi_import_script"] = script _spec.loader.exec_module(script) +# Stops these fake entries from shadowing the real modules for other test +# files collected later in the same pytest session (script's own +# module-level `from x import y` bindings are already resolved by now). +for _name in _stubbed_module_names: + sys.modules.pop(_name, None) + _migrate_legacy_lock_file = script._migrate_legacy_lock_file check_full_run_state = script.check_full_run_state read_lock_file = script.read_lock_file diff --git a/test/test_plugin_helper.py b/test/test_plugin_helper.py index 07ccf152d..4c2fa3c83 100644 --- a/test/test_plugin_helper.py +++ b/test/test_plugin_helper.py @@ -1,4 +1,4 @@ -from server.plugins.plugin_helper import is_mac, normalize_mac +from server.plugins.plugin_helper import is_mac, normalize_mac, per_item_timeout def test_is_mac_accepts_wildcard(): @@ -27,4 +27,21 @@ def test_normalize_mac_preserves_internet_root(): # Stays lowercase assert normalize_mac("internet") == "internet" assert normalize_mac("Internet") == "internet" - assert normalize_mac("INTERNET") == "internet" \ No newline at end of file + assert normalize_mac("INTERNET") == "internet" + + +def test_per_item_timeout_unchanged_for_zero_or_one_items(): + # The common case (0 or 1 queued items) must see no behavior change. + assert per_item_timeout(10, 0) == 10 + assert per_item_timeout(10, 1) == 10 + + +def test_per_item_timeout_divides_budget_across_items(): + assert per_item_timeout(10, 5) == 2 + assert per_item_timeout(9, 2) == 4 # integer division, not rounded + + +def test_per_item_timeout_never_goes_below_floor(): + # A large queue must not divide the per-item timeout down to 0. + assert per_item_timeout(10, 100) == 1 + assert per_item_timeout(10, 100, floor=2) == 2 \ No newline at end of file From d1556331649a72d4838b9fd33c578353c62d128e Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Mon, 31 Aug 2026 13:45:30 +1000 Subject: [PATCH 4/8] better scaffolding, robustness --- .../skills/devcontainer-management/SKILL.md | 2 +- .gemini/skills/pr-analysis/SKILL.md | 1 + .gemini/skills/testing-workflow/SKILL.md | 41 +++- .github/workflows/code-checks.yml | 13 + docs/PLUGINS_DEV_DATA_CONTRACT.md | 2 +- scripts/check_skill_pairs.py | 70 ++++++ server/plugins/plugin_helper.py | 11 +- test/plugins/test_plugin_conventions.py | 228 ++++++++++++++++++ test/test_plugin_helper.py | 24 +- 9 files changed, 383 insertions(+), 9 deletions(-) create mode 100644 scripts/check_skill_pairs.py diff --git a/.gemini/skills/devcontainer-management/SKILL.md b/.gemini/skills/devcontainer-management/SKILL.md index 5c3f54fbb..596aa0370 100644 --- a/.gemini/skills/devcontainer-management/SKILL.md +++ b/.gemini/skills/devcontainer-management/SKILL.md @@ -26,6 +26,6 @@ Prefix commands with `docker exec ` to run them inside the environ docker exec bash /workspaces/NetAlertX/.devcontainer/scripts/setup.sh ``` -*Note: This script wipes `/tmp` ramdisks, resets DBs, and restarts services (python server, cron,php-fpm, nginx).* +*Note: This script wipes `/tmp` ramdisks, ensures `/data`, `/data/config`, `/data/db` exist, and restarts services (python server, cron, php-fpm, nginx). It does **not** reset or delete any existing database content - it only creates the DB directory if missing.* ``` diff --git a/.gemini/skills/pr-analysis/SKILL.md b/.gemini/skills/pr-analysis/SKILL.md index be9b408eb..1f90e2741 100644 --- a/.gemini/skills/pr-analysis/SKILL.md +++ b/.gemini/skills/pr-analysis/SKILL.md @@ -53,6 +53,7 @@ For each comment, determine: - **No local DB helpers** — no `DummyDB`, `make_db`, or inline DDL defined outside `test/db_test_helpers.py`. - No inline imports — all imports at the top of the file. - Tests live under a subdirectory of `test/` matching the source path, not in `test/` root. +- Secret scan (`runtime-tools-secret_scanning`) before committing. ## Stacked / Base-Branch Issues diff --git a/.gemini/skills/testing-workflow/SKILL.md b/.gemini/skills/testing-workflow/SKILL.md index 10bc10c6e..e8aea3712 100644 --- a/.gemini/skills/testing-workflow/SKILL.md +++ b/.gemini/skills/testing-workflow/SKILL.md @@ -92,9 +92,11 @@ The test environment is pre-configured with: - `/app` — primary location where Python runs in production - `/app/server` — symlink to `/workspaces/NetAlertX/server` - `/app/server/plugins` — symlink to `/workspaces/NetAlertX/server/plugins` +- `/opt/venv/lib/pythonX.Y/site-packages` - `/workspaces/NetAlertX/test` - `/workspaces/NetAlertX/server` - `/workspaces/NetAlertX` +- `/usr/lib/pythonX.Y/site-packages` ## Docker Test Image @@ -105,4 +107,41 @@ docker buildx build -t netalertx-test . ``` Takes ~30 seconds; ~90 seconds if the venv stage changed. -3. Verify Python can read it: `python3 -c "from helper import get_setting_value; print(get_setting_value('API_TOKEN'))"` \ No newline at end of file + +## Pitfall: `sys.modules` Stubbing Leaks Across Test Files + +Some plugin tests (e.g. `test/plugins/test_ntfy_custom_headers.py`) stub NetAlertX +modules (`conf`, `helper`, `models.notification_instance`, etc.) via +`sys.modules[name] = fake_module` so the plugin script can be imported standalone, +outside the container. Because `sys.modules` is a single process-wide cache shared +by the whole pytest session, a fake module inserted by one test file silently +shadows the real module for every other test file collected afterwards — pytest +imports all test files during collection, before any test runs, so this can happen +regardless of alphabetical/directory order. + +Symptom: `AttributeError: does not have +the attribute 'get_setting_value'` (or similar) in an unrelated test file, where +the module repr has no `from ''` suffix — a giveaway that a stub, not the +real module, was resolved. + +Fix pattern: track which module names your stub actually inserted, and pop them +back out of `sys.modules` immediately after the one-time import that needed them +(the already-imported script keeps its bound names regardless): + +```python +_stubbed_module_names = [] + +def _stub(name, **attrs): + if name not in sys.modules: + mod = types.ModuleType(name) + for k, v in attrs.items(): + setattr(mod, k, v) + sys.modules[name] = mod + _stubbed_module_names.append(name) + +# ... _stub(...) calls, then the one-time import ... +import ntfy + +for _name in _stubbed_module_names: + sys.modules.pop(_name, None) +``` \ No newline at end of file diff --git a/.github/workflows/code-checks.yml b/.github/workflows/code-checks.yml index 9087a5701..b80fb8590 100644 --- a/.github/workflows/code-checks.yml +++ b/.github/workflows/code-checks.yml @@ -114,3 +114,16 @@ jobs: echo "🐳 Running Docker-based tests..." chmod +x ./scripts/run_tests_in_docker_environment.sh ./scripts/run_tests_in_docker_environment.sh + + check-skill-pairs: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 🔍 Check for skill-pair drift + continue-on-error: true + run: python3 scripts/check_skill_pairs.py "origin/${{ github.base_ref }}" diff --git a/docs/PLUGINS_DEV_DATA_CONTRACT.md b/docs/PLUGINS_DEV_DATA_CONTRACT.md index 316cb8208..a73a06ab1 100644 --- a/docs/PLUGINS_DEV_DATA_CONTRACT.md +++ b/docs/PLUGINS_DEV_DATA_CONTRACT.md @@ -85,7 +85,7 @@ The library automatically: | 11 | `helpVal3` | string | *conditional* | Helper value 3. If used, all help values must be supplied | | 12 | `helpVal4` | string | *conditional* | Helper value 4. If used, all help values must be supplied | -> **Gotcha:** `plugin_helper.py`'s `Plugin_Object.__init__` applies `helpVal1-4 or ""` internally, collapsing any falsy value (`0`, `False`, `""`) to `""` - indistinguishable from "not supplied." `watchedValue1-4` are stored as-is with no such coercion. If a real `0` or `False` is a meaningful value you need to preserve (not just "absent"), put it in a `watchedValue*` column instead of a `helpVal*` one. +> **Note:** `plugin_helper.py`'s `Plugin_Object.__init__` defaults an omitted/`None` `helpVal1-4` to `""` - a real `0` or `False` you pass explicitly is preserved as-is (checked via `is not None`, not truthiness), same as `watchedValue1-4`. ## Usage Guide diff --git a/scripts/check_skill_pairs.py b/scripts/check_skill_pairs.py new file mode 100644 index 000000000..fa39e122c --- /dev/null +++ b/scripts/check_skill_pairs.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +""" +Flag PRs that touch one half of a paired .gemini/.github skill file without +touching the other. `.gemini/skills/skills-index/SKILL.md` documents these +pairs and says to "keep body content identical between both files" - but +nothing previously enforced that, and the plugin-development pair had +already drifted apart before this check existed. + +This can't verify the two files still say the *same thing* (that needs +judgment - some pairs are intentionally different in depth), only that a +change to one side didn't forget the other exists. Exit non-zero (but the +CI step calling this is non-blocking) when a pair looks one-sided. + + python3 scripts/check_skill_pairs.py origin/main +""" + +import subprocess +import sys + +# Kept in sync with the tables in .gemini/skills/skills-index/SKILL.md and +# .github/skills/skills-overview/SKILL.md. +PAIRS = [ + (".gemini/skills/plugin-development/plugin-skill.md", ".github/skills/plugin-run-development/SKILL.md"), + (".gemini/skills/testing-workflow/SKILL.md", ".github/skills/testing-workflow/SKILL.md"), + (".gemini/skills/settings/SKILL.md", ".github/skills/settings-management/SKILL.md"), + (".gemini/skills/mcp-activation/SKILL.md", ".github/skills/mcp-activation/SKILL.md"), + (".gemini/skills/project-navigation/SKILL.md", ".github/skills/project-navigation/SKILL.md"), + (".gemini/skills/pr-analysis/SKILL.md", ".github/skills/pr-analysis/SKILL.md"), + (".gemini/skills/logging-standards/SKILL.md", ".github/skills/logging-standards/SKILL.md"), + (".gemini/skills/devcontainer-management/SKILL.md", ".github/skills/devcontainer-services/SKILL.md"), + (".gemini/skills/devcontainer-management/SKILL.md", ".github/skills/devcontainer-setup/SKILL.md"), + (".gemini/skills/devcontainer-management/SKILL.md", ".github/skills/devcontainer-configs/SKILL.md"), +] + + +def changed_files(base_ref): + result = subprocess.run( + ["git", "diff", "--name-only", f"{base_ref}...HEAD"], + capture_output=True, text=True, check=True, + ) + return set(result.stdout.splitlines()) + + +def main(): + if len(sys.argv) != 2: + print("usage: check_skill_pairs.py ", file=sys.stderr) + return 2 + + changed = changed_files(sys.argv[1]) + problems = [] + for gemini_path, github_path in PAIRS: + gemini_changed = gemini_path in changed + github_changed = github_path in changed + if gemini_changed != github_changed: + touched, untouched = (gemini_path, github_path) if gemini_changed else (github_path, gemini_path) + problems.append(f"- {touched} changed but its pair {untouched} wasn't.") + + if problems: + print("Possible skill-pair drift (only one side of a pair was touched):") + print("\n".join(problems)) + print("\nIf the change is Gemini/Copilot-specific on purpose, ignore this. " + "Otherwise update both sides - see .gemini/skills/skills-index/SKILL.md.") + return 1 + + print("No skill-pair drift detected.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server/plugins/plugin_helper.py b/server/plugins/plugin_helper.py index 0178a6126..ba641b0ed 100755 --- a/server/plugins/plugin_helper.py +++ b/server/plugins/plugin_helper.py @@ -322,10 +322,13 @@ def __init__( self.extra = extra self.userData = "" self.foreignKey = foreignKey - self.helpVal1 = helpVal1 or "" - self.helpVal2 = helpVal2 or "" - self.helpVal3 = helpVal3 or "" - self.helpVal4 = helpVal4 or "" + # `is not None` (not `or`) so a real 0/False passed by a plugin + # survives - only an actually-omitted value (None) falls back to the + # empty-string default. See docs/PLUGINS_DEV_DATA_CONTRACT.md. + self.helpVal1 = helpVal1 if helpVal1 is not None else "" + self.helpVal2 = helpVal2 if helpVal2 is not None else "" + self.helpVal3 = helpVal3 if helpVal3 is not None else "" + self.helpVal4 = helpVal4 if helpVal4 is not None else "" def write(self): """ diff --git a/test/plugins/test_plugin_conventions.py b/test/plugins/test_plugin_conventions.py index 9e556c508..b187be2d3 100644 --- a/test/plugins/test_plugin_conventions.py +++ b/test/plugins/test_plugin_conventions.py @@ -7,8 +7,11 @@ pytest test/plugins/test_plugin_conventions.py -v """ +import ast +import glob import json import os +import re import pytest @@ -57,6 +60,10 @@ def _load_config(plugin_name): return json.load(f) +def _plugin_py_files(plugin_name): + return glob.glob(os.path.join(_PLUGINS_DIR, plugin_name, '*.py')) + + @pytest.mark.parametrize('plugin_name', _PLUGIN_NAMES) def test_config_json_is_valid_json(plugin_name): path = os.path.join(_PLUGINS_DIR, plugin_name, 'config.json') @@ -109,3 +116,224 @@ def test_description_is_concise(plugin_name): 'implementation rationale to the README instead. ' 'See docs/PLUGINS_DEV.md#conventions-checklist.' ) + + +# --------------------------------------------------------------------------- +# Hardcoded fallback vs. config.json default_value drift +# +# Deliberately narrow: only matches the exact `get_setting_value("X") or +# ` shape every real drift found in this repo actually used. A +# fallback expressed any other way (a named constant, a function call, ...) +# is silently skipped rather than flagged - false negatives are fine here, +# false positives aren't. +# --------------------------------------------------------------------------- +_HARDCODED_DEFAULT_RE = re.compile( + r'''get_setting_value\(\s*["']([A-Za-z0-9_]+)["']\s*\)\s*or\s+ + (?P + "[^"\\]*" + | '[^'\\]*' + | -?\d+(?:\.\d+)? + | True|False + | \[\] + )''', + re.VERBOSE, +) + + +def _normalize_default_value(value): + if isinstance(value, bool): + return str(value).lower() + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, list): + return '[]' if not value else repr(value) + if isinstance(value, str): + stripped = value.strip() + return stripped.lower() if stripped.lower() in ('true', 'false') else stripped + return str(value) + + +def _normalize_code_literal(text): + text = text.strip() + if text in ('True', 'False'): + return text.lower() + if text == '[]': + return '[]' + if len(text) >= 2 and text[0] == text[-1] and text[0] in ('"', "'"): + return text[1:-1] + return text # bare number, left as-is + + +def _setting_defaults(config): + prefix = config.get('unique_prefix', '') + return { + f"{prefix}_{s['function']}": s.get('default_value') + for s in config.get('settings', []) + if s.get('function') + } + + +@pytest.mark.parametrize('plugin_name', _PLUGIN_NAMES) +def test_hardcoded_default_matches_config(plugin_name): + defaults = _setting_defaults(_load_config(plugin_name)) + if not defaults: + return + + mismatches = [] + for py_file in _plugin_py_files(plugin_name): + with open(py_file) as f: + source = f.read() + for match in _HARDCODED_DEFAULT_RE.finditer(source): + setting_key = match.group(1) + if setting_key not in defaults: + continue # not one of this plugin's own settings (e.g. a core setting) + literal_text = match.group('literal') + code_value = _normalize_code_literal(literal_text) + config_value = _normalize_default_value(defaults[setting_key]) + if code_value != config_value: + mismatches.append( + f"{os.path.basename(py_file)}: get_setting_value('{setting_key}') or {literal_text} " + f"(-> {code_value!r}) does not match config.json's default_value {config_value!r}" + ) + + assert not mismatches, ( + f"{plugin_name}: hardcoded fallback(s) drifted from config.json's declared default - " + "a missing/empty setting should fall back to the documented default, not a stale one:\n" + + "\n".join(mismatches) + ) + + +# --------------------------------------------------------------------------- +# RUN_TIMEOUT reused as a per-call timeout inside a loop +# +# RUN_TIMEOUT is enforced by the core plugin runner (server/plugin.py) as +# the whole script's kill-timeout, not a safe per-call budget - see +# docs/PLUGINS_DEV.md#conventions-checklist. Correct patterns, either of +# which exempts a plugin from this check: +# - config.json's "timeoutMultiplier": true on a params[] entry, for a +# config-declared, known-length loop (see arp_scan). +# - plugin_helper.per_item_timeout(), for a runtime-variable-length loop +# (see the publisher plugins). +# --------------------------------------------------------------------------- +def _has_timeout_multiplier(config): + return any(p.get('timeoutMultiplier') for p in config.get('params', [])) + + +def _resolve_int_literal(node, tree): + """Best-effort: resolve `node` to a literal int, either directly or via + a same-module variable assigned a literal int elsewhere. Returns None if + it can't be resolved (treated as "could be anything" - i.e. risky).""" + if isinstance(node, ast.Constant) and isinstance(node.value, int): + return node.value + if isinstance(node, ast.Name): + for n in ast.walk(tree): + if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == node.id for t in n.targets): + if isinstance(n.value, ast.Constant) and isinstance(n.value.value, int): + return n.value.value + return None + + +def _loop_always_runs_at_most_once(loop_node, tree): + """`for _ in range(1):` (or a variable statically known to be 1) can + never exceed a single-call budget, unlike `for x in :` + - not the bug shape this check targets.""" + if not isinstance(loop_node, ast.For): + return False + it = loop_node.iter + if isinstance(it, ast.Call) and isinstance(it.func, ast.Name) and it.func.id == 'range' and len(it.args) == 1: + count = _resolve_int_literal(it.args[0], tree) + return count is not None and count <= 1 + return False + + +def _collect_run_timeout_vars(tree, source): + run_timeout_vars = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + value_src = ast.get_source_segment(source, node.value) or '' + if 'get_setting_value' in value_src and 'RUN_TIMEOUT' in value_src: + for target in node.targets: + if isinstance(target, ast.Name): + run_timeout_vars.add(target.id) + return run_timeout_vars + + +def _call_reuses_run_timeout(call_node, source, run_timeout_vars): + for kw in call_node.keywords: + if kw.arg != 'timeout': + continue + kw_src = ast.get_source_segment(source, kw.value) or '' + references_var = any( + isinstance(n, ast.Name) and n.id in run_timeout_vars + for n in ast.walk(kw.value) + ) + if references_var or 'RUN_TIMEOUT' in kw_src: + return kw_src + return None + + +def _run_timeout_loop_issues(py_file): + with open(py_file) as f: + source = f.read() + + if 'per_item_timeout(' in source: + return [] + + try: + tree = ast.parse(source, filename=py_file) + except SyntaxError: + return [] # a real syntax error is caught elsewhere (py_compile in CI) + + run_timeout_vars = _collect_run_timeout_vars(tree, source) + + # Functions whose OWN body (anywhere inside it) makes a risky timeout= + # call - a loop calling one of these by name is just as exposed as a + # loop making the risky call directly (this is the actual shape of the + # nmap_dev_scan bug: the loop calls a per-interface helper, and the + # helper - not the loop itself - is the one passing timeout=). + risky_functions = set() + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef): + for call_node in ast.walk(node): + if isinstance(call_node, ast.Call) and _call_reuses_run_timeout(call_node, source, run_timeout_vars): + risky_functions.add(node.name) + break + + issues = [] + for loop_node in ast.walk(tree): + if not isinstance(loop_node, (ast.For, ast.While)): + continue + if _loop_always_runs_at_most_once(loop_node, tree): + continue + for call_node in ast.walk(loop_node): + if not isinstance(call_node, ast.Call): + continue + kw_src = _call_reuses_run_timeout(call_node, source, run_timeout_vars) + if kw_src: + issues.append(f"{os.path.basename(py_file)}:{call_node.lineno}: timeout={kw_src}") + elif isinstance(call_node.func, ast.Name) and call_node.func.id in risky_functions: + issues.append( + f"{os.path.basename(py_file)}:{call_node.lineno}: " + f"calls {call_node.func.id}(), which reuses RUN_TIMEOUT as a per-call timeout" + ) + + return issues + + +@pytest.mark.parametrize('plugin_name', _PLUGIN_NAMES) +def test_run_timeout_not_reused_in_loop(plugin_name): + config = _load_config(plugin_name) + if _has_timeout_multiplier(config): + return + + issues = [] + for py_file in _plugin_py_files(plugin_name): + issues.extend(_run_timeout_loop_issues(py_file)) + + assert not issues, ( + f"{plugin_name}: RUN_TIMEOUT appears reused as a per-call timeout inside a loop. " + "RUN_TIMEOUT is the whole script's kill-timeout, not a safe per-call budget - use " + 'config.json\'s "timeoutMultiplier" for a config-declared, known-length loop, or ' + 'plugin_helper.per_item_timeout() for a runtime-variable-length one. ' + "See docs/PLUGINS_DEV.md#conventions-checklist:\n" + "\n".join(issues) + ) diff --git a/test/test_plugin_helper.py b/test/test_plugin_helper.py index 4c2fa3c83..1f9f68ec8 100644 --- a/test/test_plugin_helper.py +++ b/test/test_plugin_helper.py @@ -1,4 +1,4 @@ -from server.plugins.plugin_helper import is_mac, normalize_mac, per_item_timeout +from server.plugins.plugin_helper import Plugin_Object, is_mac, normalize_mac, per_item_timeout def test_is_mac_accepts_wildcard(): @@ -44,4 +44,24 @@ def test_per_item_timeout_divides_budget_across_items(): def test_per_item_timeout_never_goes_below_floor(): # A large queue must not divide the per-item timeout down to 0. assert per_item_timeout(10, 100) == 1 - assert per_item_timeout(10, 100, floor=2) == 2 \ No newline at end of file + assert per_item_timeout(10, 100, floor=2) == 2 + + +def test_helpval_preserves_real_zero_and_false(): + # A device with a legitimate 0/False value (e.g. VLAN 0, an "inactive" + # flag) must not have it silently collapsed to "" - only an actually + # omitted (None) value should fall back to the empty-string default. + obj = Plugin_Object(helpVal1=0, helpVal2=False, helpVal3="", helpVal4=None) + assert obj.helpVal1 == 0 + assert obj.helpVal2 is False + assert obj.helpVal3 == "" + assert obj.helpVal4 == "" + + +def test_watched_columns_unaffected_by_helpval_fix(): + # watchedValue1-4 were never coerced and must stay that way. + obj = Plugin_Object(watched1=0, watched2=False, watched3="", watched4=None) + assert obj.watched1 == 0 + assert obj.watched2 is False + assert obj.watched3 == "" + assert obj.watched4 is None \ No newline at end of file From a394d16fb68b27482ede46b80fcb2799f2930be4 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Mon, 31 Aug 2026 14:01:35 +1000 Subject: [PATCH 5/8] better scaffolding, robustness --- .claude/skills/plugin-development/SKILL.md | 76 ++++++++++ .claude/skills/pr-analysis/SKILL.md | 62 ++++++++ .claude/skills/testing-workflow/SKILL.md | 133 ++++++++++++++++++ .../skills/devcontainer-management/SKILL.md | 2 +- .gemini/skills/skills-index/SKILL.md | 31 ++-- .github/skills/devcontainer-setup/SKILL.md | 2 +- .github/skills/skills-overview/SKILL.md | 31 ++-- .gitignore | 2 + CLAUDE.md | 91 ++++++++++++ scripts/check_skill_pairs.py | 67 +++++---- 10 files changed, 436 insertions(+), 61 deletions(-) create mode 100644 .claude/skills/plugin-development/SKILL.md create mode 100644 .claude/skills/pr-analysis/SKILL.md create mode 100644 .claude/skills/testing-workflow/SKILL.md create mode 100644 CLAUDE.md diff --git a/.claude/skills/plugin-development/SKILL.md b/.claude/skills/plugin-development/SKILL.md new file mode 100644 index 000000000..eccc9741c --- /dev/null +++ b/.claude/skills/plugin-development/SKILL.md @@ -0,0 +1,76 @@ +--- +name: plugin-development +description: Create and run NetAlertX plugins. Use this when asked to create a plugin, run a plugin, test a plugin, or develop plugin functionality. +--- + +# Plugin Development + +## Expected Workflow + +1. Read this skill and `docs/PLUGINS_DEV.md` for full context. +2. Find or create the plugin in `server/plugins//`. +3. Read the plugin's `config.json` and script to understand its functionality and settings. +4. Run: `python3 server/plugins//script.py` +5. Retrieve the result from `/tmp/log/plugins/last_result..log` quickly — the backend processes and deletes it almost immediately. + +## Plugin Structure + +```text +server/plugins// +├── config.json # Manifest: settings, data contract, DB column mapping +├── script.py # Main script (or equivalent, depending on data_source) +└── README.md # Setup/usage docs +``` + +- `code_name` must match the folder name. +- `unique_prefix` drives every setting key and filename (e.g. `ARPSCAN` → `ARPSCAN_RUN`, `last_result.ARPSCAN.log`). Uppercase letters only, no underscores/numbers, must be unique across all plugins. +- Ensure `sys.path` includes `/app/server/plugins` and `/app/server` (as in `server/plugins/__template/rename_me.py`). + +## Settings Pattern + +- `_RUN`: execution phase (see below). Should default to `"disabled"` for any non-core plugin. +- `_RUN_SCHD`: cron-like schedule — check a similar existing plugin for precedent (e.g. `pihole_api_scan` uses `*/5 * * * *`) rather than inventing a new cadence. +- `_CMD`: script path. +- `_RUN_TIMEOUT`: timeout in seconds — **enforced by the core plugin runner as the whole script's kill-timeout** (`server/plugin.py` passes it straight to `subprocess(..., timeout=...)`). Not a safe per-HTTP-call timeout — don't reuse it for individual network calls in a loop, or one slow call can burn the whole budget and get the process killed before it writes its result file. Two correct alternatives: `config.json`'s `"timeoutMultiplier": true` on a `params[]` entry for a config-declared, known-length loop (see `arp_scan`); `plugin_helper.per_item_timeout()` for a runtime-variable-length loop (see the `_publisher_*` plugins). +- `_WATCH`: columns to watch for changes. + +## Data Contract + +```python +from plugin_helper import Plugin_Objects + +plugin_objects = Plugin_Objects(RESULT_FILE) +plugin_objects.add_object(...) # once per discovered item +plugin_objects.write_result_file() # exactly once, at the end +``` + +Full column spec: `docs/PLUGINS_DEV_DATA_CONTRACT.md`. Note `helpVal1-4`/`watchedValue1-4` both preserve a real `0`/`False` you pass explicitly — only an omitted (`None`) value defaults to `""`. + +## Execution Phases + +| Phase | Trigger | +|-------|---------| +| `once` | Once at startup | +| `schedule` | On cron schedule | +| `always_after_scan` | After every scan | +| `before_name_updates` | Before name resolution | +| `on_new_device` | When new device detected | +| `on_notification` | When notification triggered | + +## Plugin Formats + +| Format | Purpose | Phase | +|--------|---------|-------| +| publisher | Send notifications | `on_notification` | +| dev scanner | Create/manage devices | `schedule` | +| name discovery | Discover device names | `before_name_updates` | +| importer | Import from services | `schedule` | +| system | Core functionality | `schedule` | + +## Before Opening a PR + +Check the plugin against the [Conventions Checklist](../../../docs/PLUGINS_DEV.md#conventions-checklist) — `RUN` default, schedule precedent, `RUN_TIMEOUT` semantics, reusing core settings instead of duplicating them, description length (renders in the Settings UI — keep it short), and the multi-instance settings pattern (nested array + popup-form, see `rest_import`, not a hardcoded "primary"/"secondary" pair). Most plugin PR review comments trace back to one of these, and `test/plugins/test_plugin_conventions.py` mechanically enforces the RUN-default, description-length, hardcoded-default-drift, and RUN_TIMEOUT-reuse-in-loop items — run it after touching a plugin. + +## Starting Point + +Copy `server/plugins/__template/` and customize. Read `docs/PLUGINS_DEV.md` for the full development guide. diff --git a/.claude/skills/pr-analysis/SKILL.md b/.claude/skills/pr-analysis/SKILL.md new file mode 100644 index 000000000..a411eae8d --- /dev/null +++ b/.claude/skills/pr-analysis/SKILL.md @@ -0,0 +1,62 @@ +--- +name: pr-analysis +description: How to analyze and respond to GitHub PR review comments in NetAlertX. Use this whenever you are addressing PR feedback, review threads, or inline code comments. +--- + +# PR Analysis + +## Before Writing Any Test Code — Non-Negotiable Checklist + +Run through this before creating or editing any file under `test/`: + +1. **Helpers first:** Check `test/db_test_helpers.py` for existing factories (`make_db`, `make_device_dict`, `insert_device_from_dict`, `DummyDB`). Use them. If what you need doesn't exist, add it there — never define it locally in the test file. +2. **MAC literals must be lowercase:** Every MAC string in fixtures, parametrize, assertions, docstrings, and comments must be lowercase hex (e.g. `aa:bb:cc:dd:ee:01`). No exceptions. +3. **Test file location:** Place tests under a subdirectory of `test/` that mirrors the source path (e.g. `test/scan/` for `server/scan/`). Never put test files directly in `test/`. +4. **No inline imports:** All imports at the top of the file. + +## Before Acting on Any PR Comment + +1. Load the `code-standards` skill — all code changes must comply with it before replying. +2. Load the `testing-workflow` skill — any test additions or changes must follow it. +3. Load any domain-specific skill relevant to the files being changed (e.g. `database-patterns` for DB writes, `settings-management` for config). + +## Comment Classification + +For each comment, determine: + +| Type | Action | +|------|--------| +| Request for code change | Make the change, validate it, then reply with the short commit hash | +| Question about code | Reply with a concise answer (no restatement of the question) | +| Suggestion / feedback | Decide if it is actionable. If yes, act and reply. If not, do not reply. | +| General / praise | Do not reply. | + +## Acting on Comments — Step by Step + +1. **Identify all actionable comments** before touching any file. +2. **Load relevant skills** to understand conventions that apply. +3. **Prepare a plan** — list each file and the exact change required. +4. **Make changes one comment at a time** — keep commits focused. +5. **Run targeted tests** after each change (`testing-workflow` skill). +6. **Reply** only after the commit is pushed. Include the short SHA. + +## Reply Guidelines + +- Be concise. Do not summarize or restate the original comment. +- State what was done and (optionally) why. +- Include the short commit hash when relevant. +- Do not thank or compliment the reviewer. + +## What to Check After Every Batch of Changes + +- **MAC literals lowercase** — grep for uppercase hex in every changed test file: `grep -Pn '[0-9A-F]{2}:[0-9A-F]' test/` must be empty. +- **No local DB helpers** — no `DummyDB`, `make_db`, or inline DDL defined outside `test/db_test_helpers.py`. +- No inline imports — all imports at the top of the file. +- Tests live under a subdirectory of `test/` matching the source path, not in `test/` root. +- Secret scan before committing. + +## Stacked / Base-Branch Issues + +When a PR targets a non-default branch (e.g. `next_release`): +- Do **not** retarget the branch yourself; note it in a reply so the author can do it from the GitHub UI. +- Check CI failures on the **base branch** first before checking your branch. diff --git a/.claude/skills/testing-workflow/SKILL.md b/.claude/skills/testing-workflow/SKILL.md new file mode 100644 index 000000000..346a272f4 --- /dev/null +++ b/.claude/skills/testing-workflow/SKILL.md @@ -0,0 +1,133 @@ +--- +name: testing-workflow +description: Read before running tests. Detailed instructions for single tests, full suites, authentication, obtaining the API Token, and a real cross-test pollution pitfall. Use this when asked to run tests, check failures, or debug failing tests. +--- + +# Testing Workflow + +**Crucial:** Tests MUST be run inside the devcontainer to access the correct runtime environment (DB, config, dependencies). + +## 0. Pre-requisites: Environment Check + +Before running any tests, verify you are inside the development container: + +```bash +ls -d /workspaces/NetAlertX +``` + +If this directory does not exist, you are likely on the host machine — load the `devcontainer-management` skill (or its `.github`/`.gemini` equivalents) to enter the container or run commands inside it. + +## 1. Check for Pre-Existing Failures First + +Before attributing any failure to your own changes, see what was already broken: + +```bash +cd /workspaces/NetAlertX; pytest test/ --tb=no -q 2>&1 | tail -20 +``` + +Do not fix pre-existing failures unless that is the explicit goal. + +## 2. Full Test Suite (default) + +Unless the user explicitly asks for "fast"/"quick" tests, run the full suite. Don't optimize for time — comprehensive coverage is the priority. + +```bash +cd /workspaces/NetAlertX; pytest test/ +``` + +## 3. Fast Unit Tests (only when explicitly requested) + +Excludes tests marked `docker` or `feature_complete`: + +```bash +cd /workspaces/NetAlertX; pytest test/ -m 'not docker and not feature_complete' +``` + +## 4. Running Specific Tests + +```bash +cd /workspaces/NetAlertX; pytest test/ +# e.g. pytest test/api_endpoints/test_mcp_extended_endpoints.py +# or a single test: pytest test/plugins/test_adguard_export.py::TestManagedNames::test_round_trip +``` + +## PYTHONPATH + +Pre-configured with: +- `/app` — primary location where Python runs in production +- `/app/server`, `/app/server/plugins` — symlinks to `/workspaces/NetAlertX/server[/plugins]` +- `/opt/venv/lib/pythonX.Y/site-packages`, `/usr/lib/pythonX.Y/site-packages` +- `/workspaces/NetAlertX`, `/workspaces/NetAlertX/server`, `/workspaces/NetAlertX/test` + +## Authentication & Environment Reset + +After making code changes, reset the environment to pick up the new code and get a fresh `API_TOKEN`: + +```bash +bash /workspaces/NetAlertX/.devcontainer/scripts/setup.sh +sleep 5 # let nginx/python server/etc. stabilize +python3 -c "from helper import get_setting_value; print(get_setting_value('API_TOKEN'))" +``` + +Use the retrieved token for any subsequent authenticated API/test calls. + +### Troubleshooting 403 Forbidden / empty token + +1. Confirm the server is running; re-run `setup.sh` if needed. +2. Verify config loaded: `cat /data/config/app.conf`, or `get_setting_value("API_TOKEN")` returns non-empty. + +## Docker Test Image + +If the Dockerfile or dependencies changed, rebuild before running tests: + +```bash +docker buildx build -t netalertx-test . +``` + +~30 seconds normally, ~90 seconds if the venv stage changed. + +## Pitfall: `sys.modules` Stubbing Leaks Across Test Files + +Some plugin tests (e.g. `test/plugins/test_ntfy_custom_headers.py`) stub NetAlertX +modules (`conf`, `helper`, `models.notification_instance`, etc.) via +`sys.modules[name] = fake_module` so the plugin script can be imported standalone, +outside the container. Because `sys.modules` is a single process-wide cache shared +by the whole pytest session, a fake module inserted by one test file silently +shadows the real module for every other test file collected afterwards — pytest +imports all test files during collection, before any test runs, so this can happen +regardless of alphabetical/directory order. + +Symptom: `AttributeError: does not have +the attribute 'get_setting_value'` (or similar) in an unrelated test file, where +the module repr has no `from ''` suffix — a giveaway that a stub, not the +real module, was resolved. + +Fix pattern: track which module names your stub actually inserted, and pop them +back out of `sys.modules` immediately after the one-time import that needed them +(the already-imported script keeps its bound names regardless): + +```python +_stubbed_module_names = [] + +def _stub(name, **attrs): + if name not in sys.modules: + mod = types.ModuleType(name) + for k, v in attrs.items(): + setattr(mod, k, v) + sys.modules[name] = mod + _stubbed_module_names.append(name) + +# ... _stub(...) calls, then the one-time import ... +import ntfy + +for _name in _stubbed_module_names: + sys.modules.pop(_name, None) +``` + +Reproduce cross-file pollution locally by running the suspect file together with +the affected one in a single pytest invocation (order matters less than you'd +think — collection happens for all files first): + +```bash +pytest test/plugins/test_ntfy_custom_headers.py test/backend/test_notification_templates.py -v +``` diff --git a/.gemini/skills/devcontainer-management/SKILL.md b/.gemini/skills/devcontainer-management/SKILL.md index 596aa0370..cd77b1fac 100644 --- a/.gemini/skills/devcontainer-management/SKILL.md +++ b/.gemini/skills/devcontainer-management/SKILL.md @@ -26,6 +26,6 @@ Prefix commands with `docker exec ` to run them inside the environ docker exec bash /workspaces/NetAlertX/.devcontainer/scripts/setup.sh ``` -*Note: This script wipes `/tmp` ramdisks, ensures `/data`, `/data/config`, `/data/db` exist, and restarts services (python server, cron, php-fpm, nginx). It does **not** reset or delete any existing database content - it only creates the DB directory if missing.* +*Note: This script wipes `/tmp` ramdisks, ensures `/data`, `/data/config`, `/data/db` exist, and restarts services (python server, cron, php-fpm, nginx) by symlinking and running `/entrypoint.sh` (`install/production-filesystem/entrypoint.d/`). By default it does **not** delete existing database or config content - `entrypoint.d/25-first-run-db.sh` and `20-first-run-config.sh` only wipe them when `ALWAYS_FRESH_INSTALL=true` is set in the environment (default: unset/`false`, so content is preserved).* ``` diff --git a/.gemini/skills/skills-index/SKILL.md b/.gemini/skills/skills-index/SKILL.md index b61302058..45ffc23c5 100644 --- a/.gemini/skills/skills-index/SKILL.md +++ b/.gemini/skills/skills-index/SKILL.md @@ -1,31 +1,32 @@ --- name: skills-index -description: Index of all available skills across both Gemini CLI (.gemini/skills/) and GitHub Copilot (.github/skills/). Load this to find the right skill for a task, or to locate the counterpart skill in the other AI system. +description: Index of all available skills across Gemini CLI (.gemini/skills/), GitHub Copilot (.github/skills/), and Claude Code (.claude/skills/). Load this to find the right skill for a task, or to locate the counterpart skill in another assistant's tree. --- # Skills Index — Cross-Reference -Two AI assistants are configured for this project, each with their own skill directory: +Three AI assistants are configured for this project, each with their own skill directory: - **Gemini CLI** → `.gemini/skills/` - **GitHub Copilot** → `.github/skills/` +- **Claude Code** → `.claude/skills/` (currently mirrors only the 3 highest-value skills below, not the full set) -Skills with the same purpose exist in both, sometimes under different names and with different depth. This index maps them so you can find the richer version when needed. +Skills with the same purpose exist in more than one, sometimes under different names and with different depth. This index maps them so you can find the richer version when needed. A CI check (`scripts/check_skill_pairs.py`, run as `check-skill-pairs` in `.github/workflows/code-checks.yml`) flags PRs that touch some but not all files in a mirrored group - non-blocking, since some divergence is intentional. --- -## Shared Skills (exist in both) +## Shared Skills (exist in more than one tree) -| Topic | Gemini Skill | Copilot Skill | Notes | -|-------|-------------|--------------|-------| -| Testing | `testing-workflow` | `testing-workflow` | Gemini version emphasises full suite preference and container detection; Copilot version covers `testFailure` tool and PYTHONPATH | -| Settings & config | `settings` | `settings-management` | Gemini version is more comprehensive (22-point guide + PR checklist); Copilot version covers `ccd()` and `get_setting_value()` usage | -| MCP activation | `mcp-activation` | `mcp-activation` | Gemini version covers Gemini CLI session restart; Copilot version covers VS Code window reload | -| Project navigation | `project-navigation` | `project-navigation` | Copilot version has full path tables and env vars; Gemini version is a brief reference | -| Plugin dev | `plugin-development` | `plugin-run-development` | Both cover data contract, phases, formats, the `RUN_TIMEOUT` kill-timer gotcha, and a pre-PR pointer to the Conventions Checklist in `docs/PLUGINS_DEV.md`; kept in sync manually | -| Devcontainer | `devcontainer-management` | `devcontainer-services` + `devcontainer-setup` + `devcontainer-configs` | Gemini combines into one (uses `docker exec`); Copilot splits into 3 focused skills | -| PR review | `pr-analysis` | `pr-analysis` | How to classify and respond to PR comments; pre-flight skill loading checklist | -| Logging | `logging-standards` | `logging-standards` | `mylog` levels, message format, what not to log | +| Topic | Gemini Skill | Copilot Skill | Claude Skill | Notes | +|-------|-------------|--------------|--------------|-------| +| Testing | `testing-workflow` | `testing-workflow` | `testing-workflow` | All three cover the full-suite-by-default rule, PYTHONPATH, auth/token retrieval, and the `sys.modules` stubbing pitfall | +| Settings & config | `settings` | `settings-management` | — | Gemini version is more comprehensive (22-point guide + PR checklist); Copilot version covers `ccd()` and `get_setting_value()` usage | +| MCP activation | `mcp-activation` | `mcp-activation` | — | Gemini version covers Gemini CLI session restart; Copilot version covers VS Code window reload | +| Project navigation | `project-navigation` | `project-navigation` | — | Copilot version has full path tables and env vars; Gemini version is a brief reference | +| Plugin dev | `plugin-development` | `plugin-run-development` | `plugin-development` | All three cover data contract, phases, formats, the `RUN_TIMEOUT` kill-timer gotcha (`timeoutMultiplier`/`per_item_timeout()`), and a pre-PR pointer to the Conventions Checklist in `docs/PLUGINS_DEV.md` | +| Devcontainer | `devcontainer-management` | `devcontainer-services` + `devcontainer-setup` + `devcontainer-configs` | — | Gemini combines into one (uses `docker exec`); Copilot splits into 3 focused skills | +| PR review | `pr-analysis` | `pr-analysis` | `pr-analysis` | How to classify and respond to PR comments; pre-flight skill loading checklist | +| Logging | `logging-standards` | `logging-standards` | — | `mylog` levels, message format, what not to log | --- @@ -64,3 +65,5 @@ When adding a skill, create it in **both** directories to keep both AI systems c - `.github/skills//SKILL.md` — add an entry to the skills table in `.github/copilot-instructions.md` Keep the body content identical between both files. Only the frontmatter `name`/`description` may differ slightly to match each system's discovery heuristics. + +If the skill is high-value enough to also mirror to Claude Code, add `.claude/skills//SKILL.md` too, and add the group to `GROUPS` in `scripts/check_skill_pairs.py` so drift gets flagged. Claude Code has no `activate_skill()`/`testFailure`/`runTests`/`report_progress` equivalents - adapt any such tool references to plain `Bash` commands instead of copying them verbatim. diff --git a/.github/skills/devcontainer-setup/SKILL.md b/.github/skills/devcontainer-setup/SKILL.md index 9b554d625..ac6c661a1 100644 --- a/.github/skills/devcontainer-setup/SKILL.md +++ b/.github/skills/devcontainer-setup/SKILL.md @@ -21,7 +21,7 @@ The setup script forcefully resets all runtime state. It is idempotent—every r 4. Links `/entrypoint.d` and `/app` symlinks 5. Creates `/data`, `/data/config`, `/data/db` directories 6. Creates all log files -7. Runs `/entrypoint.sh` to start services +7. Runs `/entrypoint.sh` to start services - by default this **preserves** existing DB/config content; `entrypoint.d/25-first-run-db.sh` and `20-first-run-config.sh` only wipe them when `ALWAYS_FRESH_INSTALL=true` is set in the environment 8. Writes version to `.VERSION` ## When to Use diff --git a/.github/skills/skills-overview/SKILL.md b/.github/skills/skills-overview/SKILL.md index c116e5a76..7045a0fd5 100644 --- a/.github/skills/skills-overview/SKILL.md +++ b/.github/skills/skills-overview/SKILL.md @@ -1,31 +1,32 @@ --- name: skills-overview -description: Index of all available skills across both GitHub Copilot (.github/skills/) and Gemini CLI (.gemini/skills/). Load this to find the right skill for a task, or to locate the counterpart skill in the other AI system. +description: Index of all available skills across GitHub Copilot (.github/skills/), Gemini CLI (.gemini/skills/), and Claude Code (.claude/skills/). Load this to find the right skill for a task, or to locate the counterpart skill in another assistant's tree. --- # Skills Index — Cross-Reference -Two AI assistants are configured for this project, each with their own skill directory: +Three AI assistants are configured for this project, each with their own skill directory: - **GitHub Copilot** → `.github/skills/` - **Gemini CLI** → `.gemini/skills/` +- **Claude Code** → `.claude/skills/` (currently mirrors only the 3 highest-value skills below, not the full set) -Skills with the same purpose exist in both, sometimes under different names and with different depth. This index maps them so you can find the richer version when needed. +Skills with the same purpose exist in more than one, sometimes under different names and with different depth. This index maps them so you can find the richer version when needed. A CI check (`scripts/check_skill_pairs.py`, run as `check-skill-pairs` in `.github/workflows/code-checks.yml`) flags PRs that touch some but not all files in a mirrored group - non-blocking, since some divergence is intentional. --- -## Shared Skills (exist in both) +## Shared Skills (exist in more than one tree) -| Topic | Copilot Skill | Gemini Skill | Notes | -|-------|--------------|--------------|-------| -| Testing | `testing-workflow` | `testing-workflow` | Copilot version covers `testFailure` tool and PYTHONPATH; Gemini version emphasises full suite preference and container detection | -| Settings & config | `settings-management` | `settings` | Gemini version is more comprehensive (22-point guide + PR checklist); Copilot version covers `ccd()` and `get_setting_value()` usage | -| MCP activation | `mcp-activation` | `mcp-activation` | Copilot version covers VS Code window reload; Gemini version covers Gemini CLI session restart | -| Project navigation | `project-navigation` | `project-navigation` | Copilot version has full path tables and env vars; Gemini version is a brief reference | -| Plugin dev | `plugin-run-development` | `plugin-development` | Both cover data contract, phases, formats, the `RUN_TIMEOUT` kill-timer gotcha, and a pre-PR pointer to the Conventions Checklist in `docs/PLUGINS_DEV.md`; kept in sync manually | -| Devcontainer | `devcontainer-services` + `devcontainer-setup` + `devcontainer-configs` | `devcontainer-management` | Copilot splits into 3 focused skills; Gemini combines into one (uses `docker exec`) | -| PR review | `pr-analysis` | `pr-analysis` | How to classify and respond to PR comments; pre-flight skill loading checklist | -| Logging | `logging-standards` | `logging-standards` | `mylog` levels, message format, what not to log | +| Topic | Copilot Skill | Gemini Skill | Claude Skill | Notes | +|-------|--------------|--------------|--------------|-------| +| Testing | `testing-workflow` | `testing-workflow` | `testing-workflow` | All three cover the full-suite-by-default rule, PYTHONPATH, auth/token retrieval, and the `sys.modules` stubbing pitfall | +| Settings & config | `settings-management` | `settings` | — | Gemini version is more comprehensive (22-point guide + PR checklist); Copilot version covers `ccd()` and `get_setting_value()` usage | +| MCP activation | `mcp-activation` | `mcp-activation` | — | Copilot version covers VS Code window reload; Gemini version covers Gemini CLI session restart | +| Project navigation | `project-navigation` | `project-navigation` | — | Copilot version has full path tables and env vars; Gemini version is a brief reference | +| Plugin dev | `plugin-run-development` | `plugin-development` | `plugin-development` | All three cover data contract, phases, formats, the `RUN_TIMEOUT` kill-timer gotcha (`timeoutMultiplier`/`per_item_timeout()`), and a pre-PR pointer to the Conventions Checklist in `docs/PLUGINS_DEV.md` | +| Devcontainer | `devcontainer-services` + `devcontainer-setup` + `devcontainer-configs` | `devcontainer-management` | — | Copilot splits into 3 focused skills; Gemini combines into one (uses `docker exec`) | +| PR review | `pr-analysis` | `pr-analysis` | `pr-analysis` | How to classify and respond to PR comments; pre-flight skill loading checklist | +| Logging | `logging-standards` | `logging-standards` | — | `mylog` levels, message format, what not to log | --- @@ -64,3 +65,5 @@ When adding a skill, create it in **both** directories to keep both AI systems c - `.gemini/skills//SKILL.md` — auto-discovered by Gemini CLI via YAML frontmatter Keep the body content identical between both files. Only the frontmatter `name`/`description` may differ slightly to match each system's discovery heuristics. + +If the skill is high-value enough to also mirror to Claude Code, add `.claude/skills//SKILL.md` too, and add the group to `GROUPS` in `scripts/check_skill_pairs.py` so drift gets flagged. Claude Code has no `activate_skill()`/`testFailure`/`runTests`/`report_progress` equivalents - adapt any such tool references to plain `Bash` commands instead of copying them verbatim. diff --git a/.gitignore b/.gitignore index eb332d93b..9cb71482d 100755 --- a/.gitignore +++ b/.gitignore @@ -31,10 +31,12 @@ front/api/* **/%40eaDir/ **/@eaDir/ .claude/settings.local.json +.claude/scheduled_tasks.lock __pycache__/ *.py[cod] *$py.class +.pytest_cache/ **/last_result.log **/script.log diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..ee3c3d724 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +NetAlertX is a network visibility / asset-intelligence platform: continuous device discovery, presence/intruder detection, IPAM drift tracking, notifications, and multi-site sync, aimed at homelabs, MSPs, and NOCs. Backend is Python, frontend is PHP/JS served by Nginx, data lives in SQLite plus flat config files. + +## Commands + +Almost everything must run **inside the devcontainer** (Docker) — the host machine lacks the runtime environment (DB, `/data/config`, dependencies). Check with `ls -d /workspaces/NetAlertX`; if absent, you're on the host. + +```bash +# Full test suite (default — comprehensive coverage over speed, don't optimize for time unless asked) +cd /workspaces/NetAlertX; pytest test/ + +# One file or directory +pytest test/plugins/test_adguard_export.py +pytest test/plugins/ + +# Fast/unit-only (only when explicitly asked for "fast"/"quick" tests) +pytest test/ -m 'not docker and not feature_complete' + +# Reset the environment / pick up code changes / get a fresh API_TOKEN +bash /workspaces/NetAlertX/.devcontainer/scripts/setup.sh +sleep 5 +python3 -c "from helper import get_setting_value; print(get_setting_value('API_TOKEN'))" + +# Lint (matches CI in .github/workflows/code-checks.yml) +flake8 . --max-line-length=180 --ignore=E221,E222,E251,E203 + +# Full CI-equivalent run (regenerates devcontainer Dockerfile, rebuilds, runs everything) +./scripts/run_tests_in_docker_environment.sh +``` + +Rebuild the test image (`docker buildx build -t netalertx-test .`) only if the Dockerfile or dependencies changed — otherwise skip it, it's slow. + +Outside the container, most plugin unit tests (`test/plugins/test_*.py`) still run standalone — they stub NetAlertX modules into `sys.modules` before importing the plugin script. See the stubbing pitfall below before adding one. + +## Architecture + +### Backend layout + +- `server/__main__.py` — entry point. `server/plugin.py` — plugin runner/scheduler. `server/api_server/` — Flask + GraphQL API. +- `server/const.py` / `server/config_paths.py` — resolve the three runtime path roots. `server/conf.py` — process-wide config variables (a deliberate workaround for cross-module globals). +- `server/db/` — the only layer allowed to touch SQLite directly (`db_helper.py`). `server/models/` — domain handlers on top of it (e.g. `DeviceInstance` in `models/device_instance.py`). Never query the DB from elsewhere — go through a model or `db_helper.py`. +- `server/scan/`, `server/messaging/`, `server/workflows/`, `server/utils/` — scanning pipeline, notification dispatch, the workflow-automation engine, and shared utilities (`utils/datetime_utils.py`'s `timeNowUTC()` is the *only* place `datetime.now()` should be called — everything is stored in UTC). + +### Frontend + +`front/` is PHP + vanilla JS served by Nginx — no build step, no bundler, no `package.json`. Pages are top-level `.php` files; shared logic under `front/php/`. + +### Data & path conventions + +Three distinct roots, each with a different persistence contract — get this wrong and data silently disappears on restart: +- `dbFolderPath` (`/data/db`) — durable. Plugin-internal state (caches, "what did I already do" trackers) belongs here. +- `configPath` (`/data/config`) — durable, user-facing. `app.conf` lives here; config-like plugin artifacts (exports, backups) belong here too. +- `logPath` (`/tmp/log`, plus `/tmp/api`, `/tmp/db_is_locked`, nginx state) — **ephemeral tmpfs**, wiped on every container restart. Never put anything here you need to survive a restart. (`server/plugins/adguard_export`, `unifi_import` were both fixed this way after shipping with state files rooted in `logPath` — check any plugin that opens a file outside its `RESULT_FILE` against this before assuming it's fine.) + +All three are exported from `server/const.py` (`dbFolderPath`, `configPath`, `dataPath`, `logPath`) and importable by any plugin. + +### Plugin system (`server/plugins/*/`) + +Every plugin is a folder with `config.json` (manifest: settings, data contract, DB column mapping), an optional `script.py`-equivalent, and a `README.md`. Start from `server/plugins/__template/`. Full reference: `docs/PLUGINS_DEV.md` (its "Conventions Checklist" section is CI-enforced — see below). + +Non-obvious things that have caused real, shipped bugs in this codebase: + +- **`RUN_TIMEOUT` is the whole subprocess's kill-timeout, enforced by `server/plugin.py`, not a safe per-call HTTP/subprocess timeout.** A plugin that loops over N things and reuses `RUN_TIMEOUT` as each individual call's timeout can have one slow call burn the whole budget and get SIGKILLed before it writes its result file — silently losing the entire run. Two correct answers depending on the loop shape: + - Looping over a **config-declared, known-length list** (e.g. a subnets setting) → mark that `params[]` entry `"timeoutMultiplier": true` in `config.json`; the framework scales the *outer* kill-timeout by the list length. See `arp_scan/config.json`. + - Looping over a **runtime-variable-length collection** (e.g. a notification queue) → `plugin_helper.per_item_timeout(run_timeout, item_count)` divides the *inner* per-call budget instead. See `server/plugins/_publisher_ntfy/ntfy.py`. + - `test/plugins/test_plugin_conventions.py` mechanically checks for the unguarded reuse pattern (AST-based, including the case where the loop calls a helper function that does the risky call) — run it after touching any plugin that makes network/subprocess calls in a loop. +- **`plugin_helper.Plugin_Object`**: `helpVal1-4` and `watchedValue1-4` both preserve a real `0`/`False` you pass explicitly (checked via `is not None`) — only an actually-omitted (`None`) value defaults to `""`. Don't reintroduce a bare `x or ""` coercion here; it silently discards legitimate falsy values (this was a real, if narrowly-triggered, bug). +- A plugin's hardcoded Python fallback (`get_setting_value("X") or `) must match that setting's `config.json` `default_value` — `test_plugin_conventions.py` checks this too. `RUN` should default to `"disabled"` for every non-core plugin; description strings render directly in the Settings UI and should stay short (README is for implementation detail). +- Plugin unit tests that stub NetAlertX modules into `sys.modules` (so a script imports standalone outside the container) **must pop every stubbed name back out immediately after the one-time import** — otherwise the fake module leaks and shadows the real one for every other test file collected in the same pytest session, regardless of file/alphabetical order. See `test/plugins/test_ntfy_custom_headers.py` for the pattern, or `docs/PLUGINS_DEV.md` / the `testing-workflow` skill for the full writeup. + +### Data contract (plugin → DB) + +Plugins write pipe-delimited rows to `RESULT_FILE` via `plugin_helper.Plugin_Objects`/`Plugin_Object` — 9 required columns, 4 optional `helpVal*` ones. Full column spec and validation rules: `docs/PLUGINS_DEV_DATA_CONTRACT.md`. + +### Skills + +Procedural/how-to knowledge (running tests, resetting the DB, devcontainer management, PR analysis, etc.) lives as paired files in `.gemini/skills//` and `.github/skills//` (see `.gemini/skills/skills-index/SKILL.md` for the pairing map) — Claude Code should treat both as equally authoritative sources for the same procedures. The pairing convention is "keep body content identical"; a CI job (`check-skill-pairs` in `.github/workflows/code-checks.yml`) flags PRs that edit one side of a pair without the other, but it only checks *presence*, not content — if you edit one side, check whether the other needs the same update. + +## Code conventions + +- DB columns are camelCase, never snake_case (`deviceInstanceId`, not `device_instance_id`). +- Every `subprocess` call needs an explicit timeout; a nested subprocess call needs its own — an outer timeout doesn't propagate. +- Always run MACs through `normalize_mac()` (`plugin_helper.py`) before writing to DB; MAC literals in tests must be lowercase. +- No inline imports — everything at module top level. +- Reuse `test/db_test_helpers.py` for DB mocks/fixtures in tests rather than redefining `DummyDB`/`make_db` locally. +- Keep files under ~500 lines; split rather than grow. diff --git a/scripts/check_skill_pairs.py b/scripts/check_skill_pairs.py index fa39e122c..ae58234c1 100644 --- a/scripts/check_skill_pairs.py +++ b/scripts/check_skill_pairs.py @@ -1,15 +1,18 @@ #!/usr/bin/env python3 """ -Flag PRs that touch one half of a paired .gemini/.github skill file without -touching the other. `.gemini/skills/skills-index/SKILL.md` documents these -pairs and says to "keep body content identical between both files" - but -nothing previously enforced that, and the plugin-development pair had -already drifted apart before this check existed. +Flag PRs that touch some but not all files in a group of mirrored skill +files (`.gemini/skills/`, `.github/skills/`, `.claude/skills/`) without +touching the others. `.gemini/skills/skills-index/SKILL.md` documents these +groups and says to "keep body content identical" across them - but nothing +previously enforced that, and the plugin-development pair had already +drifted apart before this check existed. -This can't verify the two files still say the *same thing* (that needs -judgment - some pairs are intentionally different in depth), only that a -change to one side didn't forget the other exists. Exit non-zero (but the -CI step calling this is non-blocking) when a pair looks one-sided. +This can't verify the files still say the *same thing* (that needs +judgment - some groups are intentionally different in depth, and the three +devcontainer-management targets each cover only part of the Gemini file), +only that a change to one file didn't forget the others exist. Exit +non-zero (but the CI step calling this is non-blocking) when a group looks +one-sided. python3 scripts/check_skill_pairs.py origin/main """ @@ -18,18 +21,19 @@ import sys # Kept in sync with the tables in .gemini/skills/skills-index/SKILL.md and -# .github/skills/skills-overview/SKILL.md. -PAIRS = [ - (".gemini/skills/plugin-development/plugin-skill.md", ".github/skills/plugin-run-development/SKILL.md"), - (".gemini/skills/testing-workflow/SKILL.md", ".github/skills/testing-workflow/SKILL.md"), - (".gemini/skills/settings/SKILL.md", ".github/skills/settings-management/SKILL.md"), - (".gemini/skills/mcp-activation/SKILL.md", ".github/skills/mcp-activation/SKILL.md"), - (".gemini/skills/project-navigation/SKILL.md", ".github/skills/project-navigation/SKILL.md"), - (".gemini/skills/pr-analysis/SKILL.md", ".github/skills/pr-analysis/SKILL.md"), - (".gemini/skills/logging-standards/SKILL.md", ".github/skills/logging-standards/SKILL.md"), - (".gemini/skills/devcontainer-management/SKILL.md", ".github/skills/devcontainer-services/SKILL.md"), - (".gemini/skills/devcontainer-management/SKILL.md", ".github/skills/devcontainer-setup/SKILL.md"), - (".gemini/skills/devcontainer-management/SKILL.md", ".github/skills/devcontainer-configs/SKILL.md"), +# .github/skills/skills-overview/SKILL.md. Most groups are 2 files (Gemini + +# Copilot); a few skills are also mirrored to .claude/skills/ as a 3rd member. +GROUPS = [ + [".gemini/skills/plugin-development/plugin-skill.md", ".github/skills/plugin-run-development/SKILL.md", ".claude/skills/plugin-development/SKILL.md"], + [".gemini/skills/testing-workflow/SKILL.md", ".github/skills/testing-workflow/SKILL.md", ".claude/skills/testing-workflow/SKILL.md"], + [".gemini/skills/pr-analysis/SKILL.md", ".github/skills/pr-analysis/SKILL.md", ".claude/skills/pr-analysis/SKILL.md"], + [".gemini/skills/settings/SKILL.md", ".github/skills/settings-management/SKILL.md"], + [".gemini/skills/mcp-activation/SKILL.md", ".github/skills/mcp-activation/SKILL.md"], + [".gemini/skills/project-navigation/SKILL.md", ".github/skills/project-navigation/SKILL.md"], + [".gemini/skills/logging-standards/SKILL.md", ".github/skills/logging-standards/SKILL.md"], + [".gemini/skills/devcontainer-management/SKILL.md", ".github/skills/devcontainer-services/SKILL.md"], + [".gemini/skills/devcontainer-management/SKILL.md", ".github/skills/devcontainer-setup/SKILL.md"], + [".gemini/skills/devcontainer-management/SKILL.md", ".github/skills/devcontainer-configs/SKILL.md"], ] @@ -48,21 +52,22 @@ def main(): changed = changed_files(sys.argv[1]) problems = [] - for gemini_path, github_path in PAIRS: - gemini_changed = gemini_path in changed - github_changed = github_path in changed - if gemini_changed != github_changed: - touched, untouched = (gemini_path, github_path) if gemini_changed else (github_path, gemini_path) - problems.append(f"- {touched} changed but its pair {untouched} wasn't.") + for group in GROUPS: + touched = [path for path in group if path in changed] + untouched = [path for path in group if path not in changed] + if touched and untouched: + problems.append( + f"- touched {', '.join(touched)} but not {', '.join(untouched)}." + ) if problems: - print("Possible skill-pair drift (only one side of a pair was touched):") + print("Possible skill-group drift (only some mirrored files were touched):") print("\n".join(problems)) - print("\nIf the change is Gemini/Copilot-specific on purpose, ignore this. " - "Otherwise update both sides - see .gemini/skills/skills-index/SKILL.md.") + print("\nIf the change is genuinely tool-specific, ignore this. " + "Otherwise update the other file(s) too - see .gemini/skills/skills-index/SKILL.md.") return 1 - print("No skill-pair drift detected.") + print("No skill-group drift detected.") return 0 From 5e615ddff0ea61e667eedb6c25d8dc8ef849b013 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Mon, 31 Aug 2026 14:39:46 +1000 Subject: [PATCH 6/8] FE: dashed links in network view for non-ethernet connections #1763 --- front/js/network-tree.js | 19 +- front/js/ui_components.js | 2 +- front/lib/treeviz/treeviz.iife.js | 8 +- front/lib/treeviz/treeviz.iife.old.js | 4178 ------------------------- front/network.php | 1 - 5 files changed, 22 insertions(+), 4186 deletions(-) delete mode 100644 front/lib/treeviz/treeviz.iife.old.js diff --git a/front/js/network-tree.js b/front/js/network-tree.js index 078239d58..b9d9600de 100644 --- a/front/js/network-tree.js +++ b/front/js/network-tree.js @@ -81,6 +81,7 @@ function getChildren(node, list, path, visited = []) devParentRelType: node.devParentRelType, devVlan: node.devVlan, devSSID: node.devSSID, + devIsEthernet: !isNaN(node.devParentPort) && node.devParentPort !== "", hiddenChildren: hiddenMacs.includes(node.devMac), qty: children.length, children: children @@ -208,7 +209,7 @@ function initTree(myHierarchy) // override original value let screenWidthEm = pxToEm(finalWidthPx); - // handle canvas and node size if only a few nodes + // handle canvas and node size if only a few nodes emSize > 1 ? emSize = 1 : emSize = emSize; let nodeHeightPx = emToPx(emSize*1); @@ -231,7 +232,14 @@ function initTree(myHierarchy) (!emptyArr.includes(nodeData.data.devParentPort)) ? port = nodeData.data.devParentPort : port = ""; - (port == "" || port == 0 || port == 'None' ) ? portBckgIcon = `` : portBckgIcon = ``; + if (nodeData.data.devSSID !== ""){ + portBckgIcon = ``; + } else if (nodeData.data.devIsEthernet) { + portBckgIcon = ``; + } else { + portBckgIcon = ``; + } + portHtml = (port == "" || port == 0 || port == 'None' ) ? "   " : port; @@ -318,6 +326,13 @@ function initTree(myHierarchy) idKey: "devMac", hasFlatData: false, relationnalField: "children", + linkStyle: (nodeData) => { + // Return "solid", "dashed", "dotted", or "dashdot" + // Can vary per link based on node data: + console.log(nodeData.data.devIsEthernet); + + return nodeData.data.devIsEthernet ? "solid" : "dashed"; + }, linkLabel: { render: (parent, child) => { // Return text or HTML to display on the connection line diff --git a/front/js/ui_components.js b/front/js/ui_components.js index 4fcf96fe5..77ee54935 100755 --- a/front/js/ui_components.js +++ b/front/js/ui_components.js @@ -1077,7 +1077,7 @@ function initHoverNodeInfo() { const html = `
-
${icon || ''}
${encodeSpecialChars(name)}
+
${safeAtob(icon) || ''}
${encodeSpecialChars(name)}

diff --git a/front/lib/treeviz/treeviz.iife.js b/front/lib/treeviz/treeviz.iife.js index 127724271..35341866d 100644 --- a/front/lib/treeviz/treeviz.iife.js +++ b/front/lib/treeviz/treeviz.iife.js @@ -1,4 +1,4 @@ -var Za=Object.defineProperty;var Qa=(tt,X,et)=>X in tt?Za(tt,X,{enumerable:!0,configurable:!0,writable:!0,value:et}):tt[X]=et;var ft=(tt,X,et)=>(Qa(tt,typeof X!="symbol"?X+"":X,et),et);(function(){"use strict";function tt(t){var e=0,n=t.children,r=n&&n.length;if(!r)e=1;else for(;--r>=0;)e+=n[r].value;t.value=e}function X(){return this.eachAfter(tt)}function et(t,e){let n=-1;for(const r of this)t.call(e,r,++n,this);return this}function $n(t,e){for(var n=this,r=[n],i,o,a=-1;n=r.pop();)if(t.call(e,n,++a,this),i=n.children)for(o=i.length-1;o>=0;--o)r.push(i[o]);return this}function zn(t,e){for(var n=this,r=[n],i=[],o,a,u,f=-1;n=r.pop();)if(i.push(n),o=n.children)for(a=0,u=o.length;a=0;)n+=r[i].value;e.value=n})}function En(t){return this.eachBefore(function(e){e.children&&e.children.sort(t)})}function Mn(t){for(var e=this,n=Tn(e,t),r=[e];e!==n;)e=e.parent,r.push(e);for(var i=r.length;t!==n;)r.splice(i,0,t),t=t.parent;return r}function Tn(t,e){if(t===e)return t;var n=t.ancestors(),r=e.ancestors(),i=null;for(t=n.pop(),e=r.pop();t===e;)i=t,t=n.pop(),e=r.pop();return i}function Cn(){for(var t=this,e=[t];t=t.parent;)e.push(t);return e}function Ln(){return Array.from(this)}function In(){var t=[];return this.eachBefore(function(e){e.children||t.push(e)}),t}function Hn(){var t=this,e=[];return t.each(function(n){n!==t&&e.push({source:n.parent,target:n})}),e}function*Fn(){var t=this,e,n=[t],r,i,o;do for(e=n.reverse(),n=[];t=e.pop();)if(yield t,r=t.children)for(i=0,o=r.length;i=0;--u)i.push(o=a[u]=new at(a[u])),o.parent=r,o.depth=r.depth+1;return n.eachBefore(Ne)}function Dn(){return Ut(this).eachBefore(On)}function qn(t){return t.children}function Rn(t){return Array.isArray(t)?t[1]:null}function On(t){t.data.value!==void 0&&(t.value=t.data.value),t.data=t.data.data}function Ne(t){var e=0;do t.height=e;while((t=t.parent)&&t.height<++e)}function at(t){this.data=t,this.depth=this.height=0,this.parent=null}at.prototype=Ut.prototype={constructor:at,count:X,each:et,eachAfter:zn,eachBefore:$n,find:An,sum:Sn,sort:En,path:Mn,ancestors:Cn,descendants:Ln,leaves:In,links:Hn,copy:Dn,[Symbol.iterator]:Fn};function Kt(t){return t==null?null:$e(t)}function $e(t){if(typeof t!="function")throw new Error;return t}function ht(){return 0}function dt(t){return function(){return t}}function Pn(t){t.x0=Math.round(t.x0),t.y0=Math.round(t.y0),t.x1=Math.round(t.x1),t.y1=Math.round(t.y1)}function Vn(t,e,n,r,i){for(var o=t.children,a,u=-1,f=o.length,s=t.value&&(r-e)/t.value;++uGn(n(z,E,i))),w=y.map(Ae),A=new Set(y).add("");for(const z of w)A.has(z)||(A.add(z),y.push(z),w.push(Ae(z)),o.push(Zt));a=(z,E)=>y[E],u=(z,E)=>w[E]}for(l=0,f=o.length;l=0&&(p=o[y],p.data===Zt);--y)p.data=null}if(d.parent=Wn,d.eachBefore(function(y){y.depth=y.parent.depth+1,--f}).eachBefore(Ne),d.parent=null,f>0)throw new Error("cycle");return d}return r.id=function(i){return arguments.length?(t=Kt(i),r):t},r.parentId=function(i){return arguments.length?(e=Kt(i),r):e},r.path=function(i){return arguments.length?(n=Kt(i),r):n},r}function Gn(t){t=`${t}`;let e=t.length;return Qt(t,e-1)&&!Qt(t,e-2)&&(t=t.slice(0,-1)),t[0]==="/"?t:`/${t}`}function Ae(t){let e=t.length;if(e<2)return"";for(;--e>1&&!Qt(t,e););return t.slice(0,e)}function Qt(t,e){if(t[e]==="/"){let n=0;for(;e>0&&t[--e]==="\\";)++n;if(!(n&1))return!0}return!1}function Un(t,e){return t.parent===e.parent?1:2}function Jt(t){var e=t.children;return e?e[0]:t.t}function jt(t){var e=t.children;return e?e[e.length-1]:t.t}function Kn(t,e,n){var r=n/(e.i-t.i);e.c-=r,e.s+=n,t.c+=r,e.z+=n,e.m+=n}function Zn(t){for(var e=0,n=0,r=t.children,i=r.length,o;--i>=0;)o=r[i],o.z+=e,o.m+=e,e+=o.s+(n+=o.c)}function Qn(t,e,n){return t.a.parent===e.parent?t.a:n}function zt(t,e){this._=t,this.parent=null,this.children=null,this.A=null,this.a=this,this.z=0,this.m=0,this.c=0,this.s=0,this.t=null,this.i=e}zt.prototype=Object.create(at.prototype);function Jn(t){for(var e=new zt(t,0),n,r=[e],i,o,a,u;n=r.pop();)if(o=n._.children)for(n.children=new Array(u=o.length),a=u-1;a>=0;--a)r.push(i=n.children[a]=new zt(o[a],a)),i.parent=n;return(e.parent=new zt(null,0)).children=[e],e}function jn(){var t=Un,e=1,n=1,r=null;function i(s){var l=Jn(s);if(l.eachAfter(o),l.parent.m=-l.z,l.eachBefore(a),r)s.eachBefore(f);else{var d=s,c=s,p=s;s.eachBefore(function(w){w.xc.x&&(c=w),w.depth>p.depth&&(p=w)});var m=d===c?1:t(d,c)/2,x=m-d.x,_=e/(c.x+m+x),y=n/(p.depth||1);s.eachBefore(function(w){w.x=(w.x+x)*_,w.y=w.depth*y})}return s}function o(s){var l=s.children,d=s.parent.children,c=s.i?d[s.i-1]:null;if(l){Zn(s);var p=(l[0].z+l[l.length-1].z)/2;c?(s.z=c.z+t(s._,c._),s.m=s.z-p):s.z=p}else c&&(s.z=c.z+t(s._,c._));s.parent.A=u(s,c,s.parent.A||d[0])}function a(s){s._.x=s.z+s.parent.m,s.m+=s.parent.m}function u(s,l,d){if(l){for(var c=s,p=s,m=l,x=c.parent.children[0],_=c.m,y=p.m,w=m.m,A=x.m,z;m=jt(m),c=Jt(c),m&&c;)x=Jt(x),p=jt(p),p.a=s,z=m.z+w-c.z-_+t(m._,c._),z>0&&(Kn(Qn(m,s,d),s,z),_+=z,y+=z),w+=m.m,_+=c.m,A+=x.m,y+=p.m;m&&!jt(p)&&(p.t=m,p.m+=w-y),c&&!Jt(x)&&(x.t=c,x.m+=_-A,d=s)}return d}function f(s){s.x*=e,s.y=s.depth*n}return i.separation=function(s){return arguments.length?(t=s,i):t},i.size=function(s){return arguments.length?(r=!1,e=+s[0],n=+s[1],i):r?null:[e,n]},i.nodeSize=function(s){return arguments.length?(r=!0,e=+s[0],n=+s[1],i):r?[e,n]:null},i}function tr(t,e,n,r,i){for(var o=t.children,a,u=-1,f=o.length,s=t.value&&(i-n)/t.value;++uw&&(w=s),L=_*_*E,A=Math.max(w/L,L/y),A>z){_-=s;break}z=A}a.push(f={value:_,dice:p1?r:1)},n}(er);function ir(){var t=rr,e=!1,n=1,r=1,i=[0],o=ht,a=ht,u=ht,f=ht,s=ht;function l(c){return c.x0=c.y0=0,c.x1=n,c.y1=r,c.eachBefore(d),i=[0],e&&c.eachBefore(Pn),c}function d(c){var p=i[c.depth],m=c.x0+p,x=c.y0+p,_=c.x1-p,y=c.y1-p;_=0&&(e=t.slice(0,n))!=="xmlns"&&(t=t.slice(n+1)),Se.hasOwnProperty(e)?{space:Se[e],local:t}:t}function or(t){return function(){var e=this.ownerDocument,n=this.namespaceURI;return n===te&&e.documentElement.namespaceURI===te?e.createElement(t):e.createElementNS(n,t)}}function ar(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function Ee(t){var e=At(t);return(e.local?ar:or)(e)}function sr(){}function ee(t){return t==null?sr:function(){return this.querySelector(t)}}function ur(t){typeof t!="function"&&(t=ee(t));for(var e=this._groups,n=e.length,r=new Array(n),i=0;i=A&&(A=w+1);!(E=_[A])&&++A=0;)(a=r[i])&&(o&&a.compareDocumentPosition(o)^4&&o.parentNode.insertBefore(a,o),o=a);return this}function Cr(t){t||(t=Lr);function e(d,c){return d&&c?t(d.__data__,c.__data__):!d-!c}for(var n=this._groups,r=n.length,i=new Array(r),o=0;oe?1:t>=e?0:NaN}function Ir(){var t=arguments[0];return arguments[0]=this,t.apply(null,arguments),this}function Hr(){return Array.from(this)}function Fr(){for(var t=this._groups,e=0,n=t.length;e1?this.each((e==null?Gr:typeof e=="function"?Kr:Ur)(t,e,n??"")):st(this.node(),t)}function st(t,e){return t.style.getPropertyValue(e)||He(t).getComputedStyle(t,null).getPropertyValue(e)}function Qr(t){return function(){delete this[t]}}function Jr(t,e){return function(){this[t]=e}}function jr(t,e){return function(){var n=e.apply(this,arguments);n==null?delete this[t]:this[t]=n}}function ti(t,e){return arguments.length>1?this.each((e==null?Qr:typeof e=="function"?jr:Jr)(t,e)):this.node()[t]}function Fe(t){return t.trim().split(/^|\s+/)}function ne(t){return t.classList||new De(t)}function De(t){this._node=t,this._names=Fe(t.getAttribute("class")||"")}De.prototype={add:function(t){var e=this._names.indexOf(t);e<0&&(this._names.push(t),this._node.setAttribute("class",this._names.join(" ")))},remove:function(t){var e=this._names.indexOf(t);e>=0&&(this._names.splice(e,1),this._node.setAttribute("class",this._names.join(" ")))},contains:function(t){return this._names.indexOf(t)>=0}};function qe(t,e){for(var n=ne(t),r=-1,i=e.length;++r=0&&(n=e.slice(r+1),e=e.slice(0,r)),{type:e,name:n}})}function Si(t){return function(){var e=this.__on;if(e){for(var n=0,r=-1,i=e.length,o;n{}};function ie(){for(var t=0,e=arguments.length,n={},r;t=0&&(r=n.slice(i+1),n=n.slice(0,i)),n&&!e.hasOwnProperty(n))throw new Error("unknown type: "+n);return{type:n,name:r}})}Et.prototype=ie.prototype={constructor:Et,on:function(t,e){var n=this._,r=Ri(t+"",n),i,o=-1,a=r.length;if(arguments.length<2){for(;++o0)for(var n=new Array(i),r=0,i,o;r>8&15|e>>4&240,e>>4&15|e&240,(e&15)<<4|e&15,1):n===8?Tt(e>>24&255,e>>16&255,e>>8&255,(e&255)/255):n===4?Tt(e>>12&15|e>>8&240,e>>8&15|e>>4&240,e>>4&15|e&240,((e&15)<<4|e&15)/255):null):(e=Bi.exec(t))?new F(e[1],e[2],e[3],1):(e=Xi.exec(t))?new F(e[1]*255/100,e[2]*255/100,e[3]*255/100,1):(e=Yi.exec(t))?Tt(e[1],e[2],e[3],e[4]):(e=Gi.exec(t))?Tt(e[1]*255/100,e[2]*255/100,e[3]*255/100,e[4]):(e=Ui.exec(t))?Ke(e[1],e[2]/100,e[3]/100,1):(e=Ki.exec(t))?Ke(e[1],e[2]/100,e[3]/100,e[4]):We.hasOwnProperty(t)?Ye(We[t]):t==="transparent"?new F(NaN,NaN,NaN,0):null}function Ye(t){return new F(t>>16&255,t>>8&255,t&255,1)}function Tt(t,e,n,r){return r<=0&&(t=e=n=NaN),new F(t,e,n,r)}function Ji(t){return t instanceof gt||(t=xt(t)),t?(t=t.rgb(),new F(t.r,t.g,t.b,t.opacity)):new F}function ue(t,e,n,r){return arguments.length===1?Ji(t):new F(t,e,n,r??1)}function F(t,e,n,r){this.r=+t,this.g=+e,this.b=+n,this.opacity=+r}se(F,ue,Ve(gt,{brighter(t){return t=t==null?Mt:Math.pow(Mt,t),new F(this.r*t,this.g*t,this.b*t,this.opacity)},darker(t){return t=t==null?yt:Math.pow(yt,t),new F(this.r*t,this.g*t,this.b*t,this.opacity)},rgb(){return this},clamp(){return new F(rt(this.r),rt(this.g),rt(this.b),Ct(this.opacity))},displayable(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:Ge,formatHex:Ge,formatHex8:ji,formatRgb:Ue,toString:Ue}));function Ge(){return`#${it(this.r)}${it(this.g)}${it(this.b)}`}function ji(){return`#${it(this.r)}${it(this.g)}${it(this.b)}${it((isNaN(this.opacity)?1:this.opacity)*255)}`}function Ue(){const t=Ct(this.opacity);return`${t===1?"rgb(":"rgba("}${rt(this.r)}, ${rt(this.g)}, ${rt(this.b)}${t===1?")":`, ${t})`}`}function Ct(t){return isNaN(t)?1:Math.max(0,Math.min(1,t))}function rt(t){return Math.max(0,Math.min(255,Math.round(t)||0))}function it(t){return t=rt(t),(t<16?"0":"")+t.toString(16)}function Ke(t,e,n,r){return r<=0?t=e=n=NaN:n<=0||n>=1?t=e=NaN:e<=0&&(t=NaN),new q(t,e,n,r)}function Ze(t){if(t instanceof q)return new q(t.h,t.s,t.l,t.opacity);if(t instanceof gt||(t=xt(t)),!t)return new q;if(t instanceof q)return t;t=t.rgb();var e=t.r/255,n=t.g/255,r=t.b/255,i=Math.min(e,n,r),o=Math.max(e,n,r),a=NaN,u=o-i,f=(o+i)/2;return u?(e===o?a=(n-r)/u+(n0&&f<1?0:a,new q(a,u,f,t.opacity)}function to(t,e,n,r){return arguments.length===1?Ze(t):new q(t,e,n,r??1)}function q(t,e,n,r){this.h=+t,this.s=+e,this.l=+n,this.opacity=+r}se(q,to,Ve(gt,{brighter(t){return t=t==null?Mt:Math.pow(Mt,t),new q(this.h,this.s,this.l*t,this.opacity)},darker(t){return t=t==null?yt:Math.pow(yt,t),new q(this.h,this.s,this.l*t,this.opacity)},rgb(){var t=this.h%360+(this.h<0)*360,e=isNaN(t)||isNaN(this.s)?0:this.s,n=this.l,r=n+(n<.5?n:1-n)*e,i=2*n-r;return new F(le(t>=240?t-240:t+120,i,r),le(t,i,r),le(t<120?t+240:t-120,i,r),this.opacity)},clamp(){return new q(Qe(this.h),Lt(this.s),Lt(this.l),Ct(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl(){const t=Ct(this.opacity);return`${t===1?"hsl(":"hsla("}${Qe(this.h)}, ${Lt(this.s)*100}%, ${Lt(this.l)*100}%${t===1?")":`, ${t})`}`}}));function Qe(t){return t=(t||0)%360,t<0?t+360:t}function Lt(t){return Math.max(0,Math.min(1,t||0))}function le(t,e,n){return(t<60?e+(n-e)*t/60:t<180?n:t<240?e+(n-e)*(240-t)/60:e)*255}const Je=t=>()=>t;function eo(t,e){return function(n){return t+n*e}}function no(t,e,n){return t=Math.pow(t,n),e=Math.pow(e,n)-t,n=1/n,function(r){return Math.pow(t+r*e,n)}}function ro(t){return(t=+t)==1?je:function(e,n){return n-e?no(e,n,t):Je(isNaN(e)?n:e)}}function je(t,e){var n=e-t;return n?eo(t,n):Je(isNaN(t)?e:t)}const tn=function t(e){var n=ro(e);function r(i,o){var a=n((i=ue(i)).r,(o=ue(o)).r),u=n(i.g,o.g),f=n(i.b,o.b),s=je(i.opacity,o.opacity);return function(l){return i.r=a(l),i.g=u(l),i.b=f(l),i.opacity=s(l),i+""}}return r.gamma=t,r}(1);function Q(t,e){return t=+t,e=+e,function(n){return t*(1-n)+e*n}}var ce=/[-+]?(?:\d+\.?\d*|\.?\d+)(?:[eE][-+]?\d+)?/g,fe=new RegExp(ce.source,"g");function io(t){return function(){return t}}function oo(t){return function(e){return t(e)+""}}function ao(t,e){var n=ce.lastIndex=fe.lastIndex=0,r,i,o,a=-1,u=[],f=[];for(t=t+"",e=e+"";(r=ce.exec(t))&&(i=fe.exec(e));)(o=i.index)>n&&(o=e.slice(n,o),u[a]?u[a]+=o:u[++a]=o),(r=r[0])===(i=i[0])?u[a]?u[a]+=i:u[++a]=i:(u[++a]=null,f.push({i:a,x:Q(r,i)})),n=fe.lastIndex;return n180?l+=360:l-s>180&&(s+=360),c.push({i:d.push(i(d)+"rotate(",null,r)-2,x:Q(s,l)})):l&&d.push(i(d)+"rotate("+l+r)}function u(s,l,d,c){s!==l?c.push({i:d.push(i(d)+"skewX(",null,r)-2,x:Q(s,l)}):l&&d.push(i(d)+"skewX("+l+r)}function f(s,l,d,c,p,m){if(s!==d||l!==c){var x=p.push(i(p)+"scale(",null,",",null,")");m.push({i:x-4,x:Q(s,d)},{i:x-2,x:Q(l,c)})}else(d!==1||c!==1)&&p.push(i(p)+"scale("+d+","+c+")")}return function(s,l){var d=[],c=[];return s=t(s),l=t(l),o(s.translateX,s.translateY,l.translateX,l.translateY,d,c),a(s.rotate,l.rotate,d,c),u(s.skewX,l.skewX,d,c),f(s.scaleX,s.scaleY,l.scaleX,l.scaleY,d,c),s=l=null,function(p){for(var m=-1,x=c.length,_;++m=0&&t._call.call(void 0,e),t=t._next;--lt}function ln(){ot=(Ft=bt.now())+Dt,lt=_t=0;try{mo()}finally{lt=0,_o(),ot=0}}function xo(){var t=bt.now(),e=t-Ft;e>an&&(Dt-=e,Ft=t)}function _o(){for(var t,e=Ht,n,r=1/0;e;)e._call?(r>e._time&&(r=e._time),t=e,e=e._next):(n=e._next,e._next=null,e=t?t._next=n:Ht=n);vt=t,pe(r)}function pe(t){if(!lt){_t&&(_t=clearTimeout(_t));var e=t-ot;e>24?(t<1/0&&(_t=setTimeout(ln,t-bt.now()-Dt)),wt&&(wt=clearInterval(wt))):(wt||(Ft=bt.now(),wt=setInterval(xo,an)),lt=1,sn(ln))}}function cn(t,e,n){var r=new qt;return e=e==null?0:+e,r.restart(i=>{r.stop(),t(i+e)},e,n),r}var wo=ie("start","end","cancel","interrupt"),vo=[],fn=0,hn=1,ge=2,Rt=3,dn=4,ye=5,Ot=6;function Pt(t,e,n,r,i,o){var a=t.__transition;if(!a)t.__transition={};else if(n in a)return;bo(t,n,{name:e,index:r,group:i,on:wo,tween:vo,time:o.time,delay:o.delay,duration:o.duration,ease:o.ease,timer:null,state:fn})}function me(t,e){var n=R(t,e);if(n.state>fn)throw new Error("too late; already scheduled");return n}function W(t,e){var n=R(t,e);if(n.state>Rt)throw new Error("too late; already running");return n}function R(t,e){var n=t.__transition;if(!n||!(n=n[e]))throw new Error("transition not found");return n}function bo(t,e,n){var r=t.__transition,i;r[e]=n,n.timer=un(o,0,n.time);function o(s){n.state=hn,n.timer.restart(a,n.delay,n.time),n.delay<=s&&a(s-n.delay)}function a(s){var l,d,c,p;if(n.state!==hn)return f();for(l in r)if(p=r[l],p.name===n.name){if(p.state===Rt)return cn(a);p.state===dn?(p.state=Ot,p.timer.stop(),p.on.call("interrupt",t,t.__data__,p.index,p.group),delete r[l]):+lge&&r.state=0&&(e=e.slice(0,n)),!e||e==="start"})}function Jo(t,e,n){var r,i,o=Qo(e)?me:W;return function(){var a=o(this,t),u=a.on;u!==r&&(i=(r=u).copy()).on(e,n),a.on=i}}function jo(t,e){var n=this._id;return arguments.length<2?R(this.node(),n).on.on(t):this.each(Jo(n,t,e))}function ta(t){return function(){var e=this.parentNode;for(var n in this.__transition)if(+n!==t)return;e&&e.removeChild(this)}}function ea(){return this.on("end.remove",ta(this._id))}function na(t){var e=this._name,n=this._id;typeof t!="function"&&(t=ee(t));for(var r=this._groups,i=r.length,o=new Array(i),a=0;a()=>t;function Aa(t,{sourceEvent:e,target:n,transform:r,dispatch:i}){Object.defineProperties(this,{type:{value:t,enumerable:!0,configurable:!0},sourceEvent:{value:e,enumerable:!0,configurable:!0},target:{value:n,enumerable:!0,configurable:!0},transform:{value:r,enumerable:!0,configurable:!0},_:{value:i}})}function U(t,e,n){this.k=t,this.x=e,this.y=n}U.prototype={constructor:U,scale:function(t){return t===1?this:new U(this.k*t,this.x,this.y)},translate:function(t,e){return t===0&e===0?this:new U(this.k,this.x+this.k*t,this.y+this.k*e)},apply:function(t){return[t[0]*this.k+this.x,t[1]*this.k+this.y]},applyX:function(t){return t*this.k+this.x},applyY:function(t){return t*this.k+this.y},invert:function(t){return[(t[0]-this.x)/this.k,(t[1]-this.y)/this.k]},invertX:function(t){return(t-this.x)/this.k},invertY:function(t){return(t-this.y)/this.k},rescaleX:function(t){return t.copy().domain(t.range().map(this.invertX,this).map(t.invert,t))},rescaleY:function(t){return t.copy().domain(t.range().map(this.invertY,this).map(t.invert,t))},toString:function(){return"translate("+this.x+","+this.y+") scale("+this.k+")"}};var mn=new U(1,0,0);U.prototype;function _e(t){t.stopImmediatePropagation()}function kt(t){t.preventDefault(),t.stopImmediatePropagation()}function Sa(t){return(!t.ctrlKey||t.type==="wheel")&&!t.button}function Ea(){var t=this;return t instanceof SVGElement?(t=t.ownerSVGElement||t,t.hasAttribute("viewBox")?(t=t.viewBox.baseVal,[[t.x,t.y],[t.x+t.width,t.y+t.height]]):[[0,0],[t.width.baseVal.value,t.height.baseVal.value]]):[[0,0],[t.clientWidth,t.clientHeight]]}function xn(){return this.__zoom||mn}function Ma(t){return-t.deltaY*(t.deltaMode===1?.05:t.deltaMode?1:.002)*(t.ctrlKey?10:1)}function Ta(){return navigator.maxTouchPoints||"ontouchstart"in this}function Ca(t,e,n){var r=t.invertX(e[0][0])-n[0][0],i=t.invertX(e[1][0])-n[1][0],o=t.invertY(e[0][1])-n[0][1],a=t.invertY(e[1][1])-n[1][1];return t.translate(i>r?(r+i)/2:Math.min(0,r)||Math.max(0,i),a>o?(o+a)/2:Math.min(0,o)||Math.max(0,a))}function La(){var t=Sa,e=Ea,n=Ca,r=Ma,i=Ta,o=[0,1/0],a=[[-1/0,-1/0],[1/0,1/0]],u=250,f=go,s=ie("start","zoom","end"),l,d,c,p=500,m=150,x=0,_=10;function y(h){h.property("__zoom",xn).on("wheel.zoom",Xt,{passive:!1}).on("mousedown.zoom",Yt).on("dblclick.zoom",Gt).filter(i).on("touchstart.zoom",Ga).on("touchmove.zoom",Ua).on("touchend.zoom touchcancel.zoom",Ka).style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}y.transform=function(h,v,g,b){var k=h.selection?h.selection():h;k.property("__zoom",xn),h!==k?E(h,v,g,b):k.interrupt().each(function(){L(this,arguments).event(b).start().zoom(null,typeof v=="function"?v.apply(this,arguments):v).end()})},y.scaleBy=function(h,v,g,b){y.scaleTo(h,function(){var k=this.__zoom.k,N=typeof v=="function"?v.apply(this,arguments):v;return k*N},g,b)},y.scaleTo=function(h,v,g,b){y.transform(h,function(){var k=e.apply(this,arguments),N=this.__zoom,$=g==null?z(k):typeof g=="function"?g.apply(this,arguments):g,S=N.invert($),T=typeof v=="function"?v.apply(this,arguments):v;return n(A(w(N,T),$,S),k,a)},g,b)},y.translateBy=function(h,v,g,b){y.transform(h,function(){return n(this.__zoom.translate(typeof v=="function"?v.apply(this,arguments):v,typeof g=="function"?g.apply(this,arguments):g),e.apply(this,arguments),a)},null,b)},y.translateTo=function(h,v,g,b,k){y.transform(h,function(){var N=e.apply(this,arguments),$=this.__zoom,S=b==null?z(N):typeof b=="function"?b.apply(this,arguments):b;return n(mn.translate(S[0],S[1]).scale($.k).translate(typeof v=="function"?-v.apply(this,arguments):-v,typeof g=="function"?-g.apply(this,arguments):-g),N,a)},b,k)};function w(h,v){return v=Math.max(o[0],Math.min(o[1],v)),v===h.k?h:new U(v,h.x,h.y)}function A(h,v,g){var b=v[0]-g[0]*h.k,k=v[1]-g[1]*h.k;return b===h.x&&k===h.y?h:new U(h.k,b,k)}function z(h){return[(+h[0][0]+ +h[1][0])/2,(+h[0][1]+ +h[1][1])/2]}function E(h,v,g,b){h.on("start.zoom",function(){L(this,arguments).event(b).start()}).on("interrupt.zoom end.zoom",function(){L(this,arguments).event(b).end()}).tween("zoom",function(){var k=this,N=arguments,$=L(k,N).event(b),S=e.apply(k,N),T=g==null?z(S):typeof g=="function"?g.apply(k,N):g,B=Math.max(S[1][0]-S[0][0],S[1][1]-S[0][1]),I=k.__zoom,O=typeof v=="function"?v.apply(k,N):v,K=f(I.invert(T).concat(B/I.k),O.invert(T).concat(B/O.k));return function(P){if(P===1)P=O;else{var Z=K(P),ke=B/Z[2];P=new U(ke,T[0]-Z[0]*ke,T[1]-Z[1]*ke)}$.zoom(null,P)}})}function L(h,v,g){return!g&&h.__zooming||new j(h,v)}function j(h,v){this.that=h,this.args=v,this.active=0,this.sourceEvent=null,this.extent=e.apply(h,v),this.taps=0}j.prototype={event:function(h){return h&&(this.sourceEvent=h),this},start:function(){return++this.active===1&&(this.that.__zooming=this,this.emit("start")),this},zoom:function(h,v){return this.mouse&&h!=="mouse"&&(this.mouse[1]=v.invert(this.mouse[0])),this.touch0&&h!=="touch"&&(this.touch0[1]=v.invert(this.touch0[0])),this.touch1&&h!=="touch"&&(this.touch1[1]=v.invert(this.touch1[0])),this.that.__zoom=v,this.emit("zoom"),this},end:function(){return--this.active===0&&(delete this.that.__zooming,this.emit("end")),this},emit:function(h){var v=D(this.that).datum();s.call(h,this.that,new Aa(h,{sourceEvent:this.sourceEvent,target:y,transform:this.that.__zoom,dispatch:s}),v)}};function Xt(h,...v){if(!t.apply(this,arguments))return;var g=L(this,v).event(h),b=this.__zoom,k=Math.max(o[0],Math.min(o[1],b.k*Math.pow(2,r.apply(this,arguments)))),N=nt(h);if(g.wheel)(g.mouse[0][0]!==N[0]||g.mouse[0][1]!==N[1])&&(g.mouse[1]=b.invert(g.mouse[0]=N)),clearTimeout(g.wheel);else{if(b.k===k)return;g.mouse=[N,b.invert(N)],Vt(this),g.start()}kt(h),g.wheel=setTimeout($,m),g.zoom("mouse",n(A(w(b,k),g.mouse[0],g.mouse[1]),g.extent,a));function $(){g.wheel=null,g.end()}}function Yt(h,...v){if(c||!t.apply(this,arguments))return;var g=h.currentTarget,b=L(this,v,!0).event(h),k=D(h.view).on("mousemove.zoom",T,!0).on("mouseup.zoom",B,!0),N=nt(h,g),$=h.clientX,S=h.clientY;Pi(h.view),_e(h),b.mouse=[N,this.__zoom.invert(N)],Vt(this),b.start();function T(I){if(kt(I),!b.moved){var O=I.clientX-$,K=I.clientY-S;b.moved=O*O+K*K>x}b.event(I).zoom("mouse",n(A(b.that.__zoom,b.mouse[0]=nt(I,g),b.mouse[1]),b.extent,a))}function B(I){k.on("mousemove.zoom mouseup.zoom",null),Vi(I.view,b.moved),kt(I),b.event(I).end()}}function Gt(h,...v){if(t.apply(this,arguments)){var g=this.__zoom,b=nt(h.changedTouches?h.changedTouches[0]:h,this),k=g.invert(b),N=g.k*(h.shiftKey?.5:2),$=n(A(w(g,N),b,k),e.apply(this,v),a);kt(h),u>0?D(this).transition().duration(u).call(E,$,b,h):D(this).call(y.transform,$,b,h)}}function Ga(h,...v){if(t.apply(this,arguments)){var g=h.touches,b=g.length,k=L(this,v,h.changedTouches.length===b).event(h),N,$,S,T;for(_e(h),$=0;${const e=document.querySelector(`#${t}`);if(e===null)throw new Error(`Cannot find dom element with id:${t}`);const n=e.clientWidth,r=e.clientHeight;if(r===0||n===0)throw new Error("The tree can't be display because the svg height or width of the container is null");return{areaWidth:n,areaHeight:r}},Nt=(t,e,n)=>{try{const r=t.find(a=>a.id===n),i=r.ancestors()[1].id;return e.some(a=>a.id===i)?r.ancestors()[1]:Nt(t,e,i)}catch{return t.find(i=>i.id===n)}},wn=(t,e,n)=>n.isHorizontal?"translate("+e+","+t+")":"translate("+t+","+e+")";class ct{static add(e,n){this.queue.push({delayNextCallback:e+this.extraDelayBetweenCallbacks,callback:n}),this.log(this.queue.map(r=>r.delayNextCallback),"<-- New task !!!"),this.runner||(this.runnerFunction(),this.runner=setInterval(()=>this.runnerFunction(),this.runnerSpeed))}static runnerFunction(){if(this.queue[0]){if(this.queue[0].callback){this.log("Executing task, delaying next task...");try{this.queue[0].callback()}catch(e){console.error(e)}finally{this.queue[0].callback=null}}this.queue[0].delayNextCallback-=this.runnerSpeed,this.log(this.queue.map(e=>e.delayNextCallback)),this.queue[0].delayNextCallback<=0&&this.queue.shift()}else this.log("No task found"),clearInterval(this.runner),this.runner=0}static log(...e){this.showQueueLog&&console.log(...e)}}ft(ct,"queue",[]),ft(ct,"runner"),ft(ct,"runnerSpeed",100),ft(ct,"extraDelayBetweenCallbacks",100),ft(ct,"showQueueLog",!1);const Ia=t=>{const{htmlId:e,isHorizontal:n,hasPan:r,hasZoom:i,mainAxisNodeSpacing:o,nodeHeight:a,nodeWidth:u,marginBottom:f,marginLeft:s,marginRight:l,marginTop:d}=t,c={top:d,right:l,bottom:f,left:s},{areaHeight:p,areaWidth:m}=_n(t.htmlId),x=m-c.left-c.right,_=p-c.top-c.bottom,y=J.select("#"+e).append("svg").attr("width",m).attr("height",p),w=y.append("g"),A=J.zoom().on("zoom",E=>{w.attr("transform",()=>E.transform)});return y.call(A),r||y.on("mousedown.zoom",null).on("touchstart.zoom",null).on("touchmove.zoom",null).on("touchend.zoom",null),i||y.on("wheel.zoom",null).on("mousewheel.zoom",null).on("mousemove.zoom",null).on("DOMMouseScroll.zoom",null).on("dblclick.zoom",null),w.append("g").attr("transform",o==="auto"?"translate(0,0)":n?"translate("+c.left+","+(c.top+_/2-a/2)+")":"translate("+(c.left+x/2-u/2)+","+c.top+")")},we=(t,e,n)=>{const{isHorizontal:r,nodeHeight:i,nodeWidth:o,linkShape:a}=n;return a==="orthogonal"?r?`M ${t.y} ${t.x+i/2} +var Qa=Object.defineProperty;var Ja=(tt,W,et)=>W in tt?Qa(tt,W,{enumerable:!0,configurable:!0,writable:!0,value:et}):tt[W]=et;var ht=(tt,W,et)=>(Ja(tt,typeof W!="symbol"?W+"":W,et),et);(function(){"use strict";function tt(t){var e=0,n=t.children,r=n&&n.length;if(!r)e=1;else for(;--r>=0;)e+=n[r].value;t.value=e}function W(){return this.eachAfter(tt)}function et(t,e){let n=-1;for(const r of this)t.call(e,r,++n,this);return this}function En(t,e){for(var n=this,r=[n],i,o,a=-1;n=r.pop();)if(t.call(e,n,++a,this),i=n.children)for(o=i.length-1;o>=0;--o)r.push(i[o]);return this}function Mn(t,e){for(var n=this,r=[n],i=[],o,a,s,f=-1;n=r.pop();)if(i.push(n),o=n.children)for(a=0,s=o.length;a=0;)n+=r[i].value;e.value=n})}function Ln(t){return this.eachBefore(function(e){e.children&&e.children.sort(t)})}function In(t){for(var e=this,n=Hn(e,t),r=[e];e!==n;)e=e.parent,r.push(e);for(var i=r.length;t!==n;)r.splice(i,0,t),t=t.parent;return r}function Hn(t,e){if(t===e)return t;var n=t.ancestors(),r=e.ancestors(),i=null;for(t=n.pop(),e=r.pop();t===e;)i=t,t=n.pop(),e=r.pop();return i}function Fn(){for(var t=this,e=[t];t=t.parent;)e.push(t);return e}function qn(){return Array.from(this)}function Dn(){var t=[];return this.eachBefore(function(e){e.children||t.push(e)}),t}function Rn(){var t=this,e=[];return t.each(function(n){n!==t&&e.push({source:n.parent,target:n})}),e}function*On(){var t=this,e,n=[t],r,i,o;do for(e=n.reverse(),n=[];t=e.pop();)if(yield t,r=t.children)for(i=0,o=r.length;i=0;--s)i.push(o=a[s]=new at(a[s])),o.parent=r,o.depth=r.depth+1;return n.eachBefore(ze)}function Pn(){return Kt(this).eachBefore(Xn)}function Vn(t){return t.children}function Bn(t){return Array.isArray(t)?t[1]:null}function Xn(t){t.data.value!==void 0&&(t.value=t.data.value),t.data=t.data.data}function ze(t){var e=0;do t.height=e;while((t=t.parent)&&t.height<++e)}function at(t){this.data=t,this.depth=this.height=0,this.parent=null}at.prototype=Kt.prototype={constructor:at,count:W,each:et,eachAfter:Mn,eachBefore:En,find:Tn,sum:Cn,sort:Ln,path:In,ancestors:Fn,descendants:qn,leaves:Dn,links:Rn,copy:Pn,[Symbol.iterator]:On};function Zt(t){return t==null?null:Ae(t)}function Ae(t){if(typeof t!="function")throw new Error;return t}function dt(){return 0}function pt(t){return function(){return t}}function Wn(t){t.x0=Math.round(t.x0),t.y0=Math.round(t.y0),t.x1=Math.round(t.x1),t.y1=Math.round(t.y1)}function Yn(t,e,n,r,i){for(var o=t.children,a,s=-1,f=o.length,u=t.value&&(r-e)/t.value;++sQn(n(z,E,i))),w=g.map(Ee),A=new Set(g).add("");for(const z of w)A.has(z)||(A.add(z),g.push(z),w.push(Ee(z)),o.push(Qt));a=(z,E)=>g[E],s=(z,E)=>w[E]}for(l=0,f=o.length;l=0&&(p=o[g],p.data===Qt);--g)p.data=null}if(d.parent=Gn,d.eachBefore(function(g){g.depth=g.parent.depth+1,--f}).eachBefore(ze),d.parent=null,f>0)throw new Error("cycle");return d}return r.id=function(i){return arguments.length?(t=Zt(i),r):t},r.parentId=function(i){return arguments.length?(e=Zt(i),r):e},r.path=function(i){return arguments.length?(n=Zt(i),r):n},r}function Qn(t){t=`${t}`;let e=t.length;return Jt(t,e-1)&&!Jt(t,e-2)&&(t=t.slice(0,-1)),t[0]==="/"?t:`/${t}`}function Ee(t){let e=t.length;if(e<2)return"";for(;--e>1&&!Jt(t,e););return t.slice(0,e)}function Jt(t,e){if(t[e]==="/"){let n=0;for(;e>0&&t[--e]==="\\";)++n;if(!(n&1))return!0}return!1}function Jn(t,e){return t.parent===e.parent?1:2}function jt(t){var e=t.children;return e?e[0]:t.t}function te(t){var e=t.children;return e?e[e.length-1]:t.t}function jn(t,e,n){var r=n/(e.i-t.i);e.c-=r,e.s+=n,t.c+=r,e.z+=n,e.m+=n}function tr(t){for(var e=0,n=0,r=t.children,i=r.length,o;--i>=0;)o=r[i],o.z+=e,o.m+=e,e+=o.s+(n+=o.c)}function er(t,e,n){return t.a.parent===e.parent?t.a:n}function zt(t,e){this._=t,this.parent=null,this.children=null,this.A=null,this.a=this,this.z=0,this.m=0,this.c=0,this.s=0,this.t=null,this.i=e}zt.prototype=Object.create(at.prototype);function nr(t){for(var e=new zt(t,0),n,r=[e],i,o,a,s;n=r.pop();)if(o=n._.children)for(n.children=new Array(s=o.length),a=s-1;a>=0;--a)r.push(i=n.children[a]=new zt(o[a],a)),i.parent=n;return(e.parent=new zt(null,0)).children=[e],e}function rr(){var t=Jn,e=1,n=1,r=null;function i(u){var l=nr(u);if(l.eachAfter(o),l.parent.m=-l.z,l.eachBefore(a),r)u.eachBefore(f);else{var d=u,c=u,p=u;u.eachBefore(function(w){w.xc.x&&(c=w),w.depth>p.depth&&(p=w)});var m=d===c?1:t(d,c)/2,_=m-d.x,x=e/(c.x+m+_),g=n/(p.depth||1);u.eachBefore(function(w){w.x=(w.x+_)*x,w.y=w.depth*g})}return u}function o(u){var l=u.children,d=u.parent.children,c=u.i?d[u.i-1]:null;if(l){tr(u);var p=(l[0].z+l[l.length-1].z)/2;c?(u.z=c.z+t(u._,c._),u.m=u.z-p):u.z=p}else c&&(u.z=c.z+t(u._,c._));u.parent.A=s(u,c,u.parent.A||d[0])}function a(u){u._.x=u.z+u.parent.m,u.m+=u.parent.m}function s(u,l,d){if(l){for(var c=u,p=u,m=l,_=c.parent.children[0],x=c.m,g=p.m,w=m.m,A=_.m,z;m=te(m),c=jt(c),m&&c;)_=jt(_),p=te(p),p.a=u,z=m.z+w-c.z-x+t(m._,c._),z>0&&(jn(er(m,u,d),u,z),x+=z,g+=z),w+=m.m,x+=c.m,A+=_.m,g+=p.m;m&&!te(p)&&(p.t=m,p.m+=w-g),c&&!jt(_)&&(_.t=c,_.m+=x-A,d=u)}return d}function f(u){u.x*=e,u.y=u.depth*n}return i.separation=function(u){return arguments.length?(t=u,i):t},i.size=function(u){return arguments.length?(r=!1,e=+u[0],n=+u[1],i):r?null:[e,n]},i.nodeSize=function(u){return arguments.length?(r=!0,e=+u[0],n=+u[1],i):r?[e,n]:null},i}function ir(t,e,n,r,i){for(var o=t.children,a,s=-1,f=o.length,u=t.value&&(i-n)/t.value;++sw&&(w=u),L=x*x*E,A=Math.max(w/L,L/g),A>z){x-=u;break}z=A}a.push(f={value:x,dice:p1?r:1)},n}(or);function sr(){var t=ur,e=!1,n=1,r=1,i=[0],o=dt,a=dt,s=dt,f=dt,u=dt;function l(c){return c.x0=c.y0=0,c.x1=n,c.y1=r,c.eachBefore(d),i=[0],e&&c.eachBefore(Wn),c}function d(c){var p=i[c.depth],m=c.x0+p,_=c.y0+p,x=c.x1-p,g=c.y1-p;x=0&&(e=t.slice(0,n))!=="xmlns"&&(t=t.slice(n+1)),Me.hasOwnProperty(e)?{space:Me[e],local:t}:t}function lr(t){return function(){var e=this.ownerDocument,n=this.namespaceURI;return n===ee&&e.documentElement.namespaceURI===ee?e.createElement(t):e.createElementNS(n,t)}}function cr(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function Te(t){var e=At(t);return(e.local?cr:lr)(e)}function fr(){}function ne(t){return t==null?fr:function(){return this.querySelector(t)}}function hr(t){typeof t!="function"&&(t=ne(t));for(var e=this._groups,n=e.length,r=new Array(n),i=0;i=A&&(A=w+1);!(E=x[A])&&++A=0;)(a=r[i])&&(o&&a.compareDocumentPosition(o)^4&&o.parentNode.insertBefore(a,o),o=a);return this}function Fr(t){t||(t=qr);function e(d,c){return d&&c?t(d.__data__,c.__data__):!d-!c}for(var n=this._groups,r=n.length,i=new Array(r),o=0;oe?1:t>=e?0:NaN}function Dr(){var t=arguments[0];return arguments[0]=this,t.apply(null,arguments),this}function Rr(){return Array.from(this)}function Or(){for(var t=this._groups,e=0,n=t.length;e1?this.each((e==null?Qr:typeof e=="function"?jr:Jr)(t,e,n??"")):ut(this.node(),t)}function ut(t,e){return t.style.getPropertyValue(e)||qe(t).getComputedStyle(t,null).getPropertyValue(e)}function ei(t){return function(){delete this[t]}}function ni(t,e){return function(){this[t]=e}}function ri(t,e){return function(){var n=e.apply(this,arguments);n==null?delete this[t]:this[t]=n}}function ii(t,e){return arguments.length>1?this.each((e==null?ei:typeof e=="function"?ri:ni)(t,e)):this.node()[t]}function De(t){return t.trim().split(/^|\s+/)}function re(t){return t.classList||new Re(t)}function Re(t){this._node=t,this._names=De(t.getAttribute("class")||"")}Re.prototype={add:function(t){var e=this._names.indexOf(t);e<0&&(this._names.push(t),this._node.setAttribute("class",this._names.join(" ")))},remove:function(t){var e=this._names.indexOf(t);e>=0&&(this._names.splice(e,1),this._node.setAttribute("class",this._names.join(" ")))},contains:function(t){return this._names.indexOf(t)>=0}};function Oe(t,e){for(var n=re(t),r=-1,i=e.length;++r=0&&(n=e.slice(r+1),e=e.slice(0,r)),{type:e,name:n}})}function Ci(t){return function(){var e=this.__on;if(e){for(var n=0,r=-1,i=e.length,o;n{}};function oe(){for(var t=0,e=arguments.length,n={},r;t=0&&(r=n.slice(i+1),n=n.slice(0,i)),n&&!e.hasOwnProperty(n))throw new Error("unknown type: "+n);return{type:n,name:r}})}Et.prototype=oe.prototype={constructor:Et,on:function(t,e){var n=this._,r=Bi(t+"",n),i,o=-1,a=r.length;if(arguments.length<2){for(;++o0)for(var n=new Array(i),r=0,i,o;r>8&15|e>>4&240,e>>4&15|e&240,(e&15)<<4|e&15,1):n===8?Tt(e>>24&255,e>>16&255,e>>8&255,(e&255)/255):n===4?Tt(e>>12&15|e>>8&240,e>>8&15|e>>4&240,e>>4&15|e&240,((e&15)<<4|e&15)/255):null):(e=Ui.exec(t))?new F(e[1],e[2],e[3],1):(e=Ki.exec(t))?new F(e[1]*255/100,e[2]*255/100,e[3]*255/100,1):(e=Zi.exec(t))?Tt(e[1],e[2],e[3],e[4]):(e=Qi.exec(t))?Tt(e[1]*255/100,e[2]*255/100,e[3]*255/100,e[4]):(e=Ji.exec(t))?Qe(e[1],e[2]/100,e[3]/100,1):(e=ji.exec(t))?Qe(e[1],e[2]/100,e[3]/100,e[4]):We.hasOwnProperty(t)?Ue(We[t]):t==="transparent"?new F(NaN,NaN,NaN,0):null}function Ue(t){return new F(t>>16&255,t>>8&255,t&255,1)}function Tt(t,e,n,r){return r<=0&&(t=e=n=NaN),new F(t,e,n,r)}function no(t){return t instanceof gt||(t=xt(t)),t?(t=t.rgb(),new F(t.r,t.g,t.b,t.opacity)):new F}function le(t,e,n,r){return arguments.length===1?no(t):new F(t,e,n,r??1)}function F(t,e,n,r){this.r=+t,this.g=+e,this.b=+n,this.opacity=+r}se(F,le,Xe(gt,{brighter(t){return t=t==null?Mt:Math.pow(Mt,t),new F(this.r*t,this.g*t,this.b*t,this.opacity)},darker(t){return t=t==null?mt:Math.pow(mt,t),new F(this.r*t,this.g*t,this.b*t,this.opacity)},rgb(){return this},clamp(){return new F(rt(this.r),rt(this.g),rt(this.b),Ct(this.opacity))},displayable(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:Ke,formatHex:Ke,formatHex8:ro,formatRgb:Ze,toString:Ze}));function Ke(){return`#${it(this.r)}${it(this.g)}${it(this.b)}`}function ro(){return`#${it(this.r)}${it(this.g)}${it(this.b)}${it((isNaN(this.opacity)?1:this.opacity)*255)}`}function Ze(){const t=Ct(this.opacity);return`${t===1?"rgb(":"rgba("}${rt(this.r)}, ${rt(this.g)}, ${rt(this.b)}${t===1?")":`, ${t})`}`}function Ct(t){return isNaN(t)?1:Math.max(0,Math.min(1,t))}function rt(t){return Math.max(0,Math.min(255,Math.round(t)||0))}function it(t){return t=rt(t),(t<16?"0":"")+t.toString(16)}function Qe(t,e,n,r){return r<=0?t=e=n=NaN:n<=0||n>=1?t=e=NaN:e<=0&&(t=NaN),new q(t,e,n,r)}function Je(t){if(t instanceof q)return new q(t.h,t.s,t.l,t.opacity);if(t instanceof gt||(t=xt(t)),!t)return new q;if(t instanceof q)return t;t=t.rgb();var e=t.r/255,n=t.g/255,r=t.b/255,i=Math.min(e,n,r),o=Math.max(e,n,r),a=NaN,s=o-i,f=(o+i)/2;return s?(e===o?a=(n-r)/s+(n0&&f<1?0:a,new q(a,s,f,t.opacity)}function io(t,e,n,r){return arguments.length===1?Je(t):new q(t,e,n,r??1)}function q(t,e,n,r){this.h=+t,this.s=+e,this.l=+n,this.opacity=+r}se(q,io,Xe(gt,{brighter(t){return t=t==null?Mt:Math.pow(Mt,t),new q(this.h,this.s,this.l*t,this.opacity)},darker(t){return t=t==null?mt:Math.pow(mt,t),new q(this.h,this.s,this.l*t,this.opacity)},rgb(){var t=this.h%360+(this.h<0)*360,e=isNaN(t)||isNaN(this.s)?0:this.s,n=this.l,r=n+(n<.5?n:1-n)*e,i=2*n-r;return new F(ce(t>=240?t-240:t+120,i,r),ce(t,i,r),ce(t<120?t+240:t-120,i,r),this.opacity)},clamp(){return new q(je(this.h),Lt(this.s),Lt(this.l),Ct(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl(){const t=Ct(this.opacity);return`${t===1?"hsl(":"hsla("}${je(this.h)}, ${Lt(this.s)*100}%, ${Lt(this.l)*100}%${t===1?")":`, ${t})`}`}}));function je(t){return t=(t||0)%360,t<0?t+360:t}function Lt(t){return Math.max(0,Math.min(1,t||0))}function ce(t,e,n){return(t<60?e+(n-e)*t/60:t<180?n:t<240?e+(n-e)*(240-t)/60:e)*255}const tn=t=>()=>t;function oo(t,e){return function(n){return t+n*e}}function ao(t,e,n){return t=Math.pow(t,n),e=Math.pow(e,n)-t,n=1/n,function(r){return Math.pow(t+r*e,n)}}function uo(t){return(t=+t)==1?en:function(e,n){return n-e?ao(e,n,t):tn(isNaN(e)?n:e)}}function en(t,e){var n=e-t;return n?oo(t,n):tn(isNaN(t)?e:t)}const nn=function t(e){var n=uo(e);function r(i,o){var a=n((i=le(i)).r,(o=le(o)).r),s=n(i.g,o.g),f=n(i.b,o.b),u=en(i.opacity,o.opacity);return function(l){return i.r=a(l),i.g=s(l),i.b=f(l),i.opacity=u(l),i+""}}return r.gamma=t,r}(1);function Q(t,e){return t=+t,e=+e,function(n){return t*(1-n)+e*n}}var fe=/[-+]?(?:\d+\.?\d*|\.?\d+)(?:[eE][-+]?\d+)?/g,he=new RegExp(fe.source,"g");function so(t){return function(){return t}}function lo(t){return function(e){return t(e)+""}}function co(t,e){var n=fe.lastIndex=he.lastIndex=0,r,i,o,a=-1,s=[],f=[];for(t=t+"",e=e+"";(r=fe.exec(t))&&(i=he.exec(e));)(o=i.index)>n&&(o=e.slice(n,o),s[a]?s[a]+=o:s[++a]=o),(r=r[0])===(i=i[0])?s[a]?s[a]+=i:s[++a]=i:(s[++a]=null,f.push({i:a,x:Q(r,i)})),n=he.lastIndex;return n180?l+=360:l-u>180&&(u+=360),c.push({i:d.push(i(d)+"rotate(",null,r)-2,x:Q(u,l)})):l&&d.push(i(d)+"rotate("+l+r)}function s(u,l,d,c){u!==l?c.push({i:d.push(i(d)+"skewX(",null,r)-2,x:Q(u,l)}):l&&d.push(i(d)+"skewX("+l+r)}function f(u,l,d,c,p,m){if(u!==d||l!==c){var _=p.push(i(p)+"scale(",null,",",null,")");m.push({i:_-4,x:Q(u,d)},{i:_-2,x:Q(l,c)})}else(d!==1||c!==1)&&p.push(i(p)+"scale("+d+","+c+")")}return function(u,l){var d=[],c=[];return u=t(u),l=t(l),o(u.translateX,u.translateY,l.translateX,l.translateY,d,c),a(u.rotate,l.rotate,d,c),s(u.skewX,l.skewX,d,c),f(u.scaleX,u.scaleY,l.scaleX,l.scaleY,d,c),u=l=null,function(p){for(var m=-1,_=c.length,x;++m<_;)d[(x=c[m]).i]=x.x(p);return d.join("")}}}var po=an(fo,"px, ","px)","deg)"),yo=an(ho,", ",")",")"),go=1e-12;function un(t){return((t=Math.exp(t))+1/t)/2}function mo(t){return((t=Math.exp(t))-1/t)/2}function _o(t){return((t=Math.exp(2*t))-1)/(t+1)}const xo=function t(e,n,r){function i(o,a){var s=o[0],f=o[1],u=o[2],l=a[0],d=a[1],c=a[2],p=l-s,m=d-f,_=p*p+m*m,x,g;if(_=0&&t._call.call(void 0,e),t=t._next;--lt}function fn(){ot=(Ft=kt.now())+qt,lt=wt=0;try{vo()}finally{lt=0,ko(),ot=0}}function bo(){var t=kt.now(),e=t-Ft;e>sn&&(qt-=e,Ft=t)}function ko(){for(var t,e=Ht,n,r=1/0;e;)e._call?(r>e._time&&(r=e._time),t=e,e=e._next):(n=e._next,e._next=null,e=t?t._next=n:Ht=n);bt=t,ye(r)}function ye(t){if(!lt){wt&&(wt=clearTimeout(wt));var e=t-ot;e>24?(t<1/0&&(wt=setTimeout(fn,t-kt.now()-qt)),vt&&(vt=clearInterval(vt))):(vt||(Ft=kt.now(),vt=setInterval(bo,sn)),lt=1,ln(fn))}}function hn(t,e,n){var r=new Dt;return e=e==null?0:+e,r.restart(i=>{r.stop(),t(i+e)},e,n),r}var $o=oe("start","end","cancel","interrupt"),No=[],dn=0,pn=1,ge=2,Rt=3,yn=4,me=5,Ot=6;function Pt(t,e,n,r,i,o){var a=t.__transition;if(!a)t.__transition={};else if(n in a)return;zo(t,n,{name:e,index:r,group:i,on:$o,tween:No,time:o.time,delay:o.delay,duration:o.duration,ease:o.ease,timer:null,state:dn})}function _e(t,e){var n=D(t,e);if(n.state>dn)throw new Error("too late; already scheduled");return n}function B(t,e){var n=D(t,e);if(n.state>Rt)throw new Error("too late; already running");return n}function D(t,e){var n=t.__transition;if(!n||!(n=n[e]))throw new Error("transition not found");return n}function zo(t,e,n){var r=t.__transition,i;r[e]=n,n.timer=cn(o,0,n.time);function o(u){n.state=pn,n.timer.restart(a,n.delay,n.time),n.delay<=u&&a(u-n.delay)}function a(u){var l,d,c,p;if(n.state!==pn)return f();for(l in r)if(p=r[l],p.name===n.name){if(p.state===Rt)return hn(a);p.state===yn?(p.state=Ot,p.timer.stop(),p.on.call("interrupt",t,t.__data__,p.index,p.group),delete r[l]):+lge&&r.state=0&&(e=e.slice(0,n)),!e||e==="start"})}function na(t,e,n){var r,i,o=ea(e)?_e:B;return function(){var a=o(this,t),s=a.on;s!==r&&(i=(r=s).copy()).on(e,n),a.on=i}}function ra(t,e){var n=this._id;return arguments.length<2?D(this.node(),n).on.on(t):this.each(na(n,t,e))}function ia(t){return function(){var e=this.parentNode;for(var n in this.__transition)if(+n!==t)return;e&&e.removeChild(this)}}function oa(){return this.on("end.remove",ia(this._id))}function aa(t){var e=this._name,n=this._id;typeof t!="function"&&(t=ne(t));for(var r=this._groups,i=r.length,o=new Array(i),a=0;a()=>t;function Ta(t,{sourceEvent:e,target:n,transform:r,dispatch:i}){Object.defineProperties(this,{type:{value:t,enumerable:!0,configurable:!0},sourceEvent:{value:e,enumerable:!0,configurable:!0},target:{value:n,enumerable:!0,configurable:!0},transform:{value:r,enumerable:!0,configurable:!0},_:{value:i}})}function U(t,e,n){this.k=t,this.x=e,this.y=n}U.prototype={constructor:U,scale:function(t){return t===1?this:new U(this.k*t,this.x,this.y)},translate:function(t,e){return t===0&e===0?this:new U(this.k,this.x+this.k*t,this.y+this.k*e)},apply:function(t){return[t[0]*this.k+this.x,t[1]*this.k+this.y]},applyX:function(t){return t*this.k+this.x},applyY:function(t){return t*this.k+this.y},invert:function(t){return[(t[0]-this.x)/this.k,(t[1]-this.y)/this.k]},invertX:function(t){return(t-this.x)/this.k},invertY:function(t){return(t-this.y)/this.k},rescaleX:function(t){return t.copy().domain(t.range().map(this.invertX,this).map(t.invert,t))},rescaleY:function(t){return t.copy().domain(t.range().map(this.invertY,this).map(t.invert,t))},toString:function(){return"translate("+this.x+","+this.y+") scale("+this.k+")"}};var xn=new U(1,0,0);U.prototype;function we(t){t.stopImmediatePropagation()}function $t(t){t.preventDefault(),t.stopImmediatePropagation()}function Ca(t){return(!t.ctrlKey||t.type==="wheel")&&!t.button}function La(){var t=this;return t instanceof SVGElement?(t=t.ownerSVGElement||t,t.hasAttribute("viewBox")?(t=t.viewBox.baseVal,[[t.x,t.y],[t.x+t.width,t.y+t.height]]):[[0,0],[t.width.baseVal.value,t.height.baseVal.value]]):[[0,0],[t.clientWidth,t.clientHeight]]}function wn(){return this.__zoom||xn}function Ia(t){return-t.deltaY*(t.deltaMode===1?.05:t.deltaMode?1:.002)*(t.ctrlKey?10:1)}function Ha(){return navigator.maxTouchPoints||"ontouchstart"in this}function Fa(t,e,n){var r=t.invertX(e[0][0])-n[0][0],i=t.invertX(e[1][0])-n[1][0],o=t.invertY(e[0][1])-n[0][1],a=t.invertY(e[1][1])-n[1][1];return t.translate(i>r?(r+i)/2:Math.min(0,r)||Math.max(0,i),a>o?(o+a)/2:Math.min(0,o)||Math.max(0,a))}function qa(){var t=Ca,e=La,n=Fa,r=Ia,i=Ha,o=[0,1/0],a=[[-1/0,-1/0],[1/0,1/0]],s=250,f=xo,u=oe("start","zoom","end"),l,d,c,p=500,m=150,_=0,x=10;function g(h){h.property("__zoom",wn).on("wheel.zoom",Yt,{passive:!1}).on("mousedown.zoom",Gt).on("dblclick.zoom",Ut).filter(i).on("touchstart.zoom",Ua).on("touchmove.zoom",Ka).on("touchend.zoom touchcancel.zoom",Za).style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}g.transform=function(h,v,y,b){var k=h.selection?h.selection():h;k.property("__zoom",wn),h!==k?E(h,v,y,b):k.interrupt().each(function(){L(this,arguments).event(b).start().zoom(null,typeof v=="function"?v.apply(this,arguments):v).end()})},g.scaleBy=function(h,v,y,b){g.scaleTo(h,function(){var k=this.__zoom.k,$=typeof v=="function"?v.apply(this,arguments):v;return k*$},y,b)},g.scaleTo=function(h,v,y,b){g.transform(h,function(){var k=e.apply(this,arguments),$=this.__zoom,N=y==null?z(k):typeof y=="function"?y.apply(this,arguments):y,S=$.invert(N),T=typeof v=="function"?v.apply(this,arguments):v;return n(A(w($,T),N,S),k,a)},y,b)},g.translateBy=function(h,v,y,b){g.transform(h,function(){return n(this.__zoom.translate(typeof v=="function"?v.apply(this,arguments):v,typeof y=="function"?y.apply(this,arguments):y),e.apply(this,arguments),a)},null,b)},g.translateTo=function(h,v,y,b,k){g.transform(h,function(){var $=e.apply(this,arguments),N=this.__zoom,S=b==null?z($):typeof b=="function"?b.apply(this,arguments):b;return n(xn.translate(S[0],S[1]).scale(N.k).translate(typeof v=="function"?-v.apply(this,arguments):-v,typeof y=="function"?-y.apply(this,arguments):-y),$,a)},b,k)};function w(h,v){return v=Math.max(o[0],Math.min(o[1],v)),v===h.k?h:new U(v,h.x,h.y)}function A(h,v,y){var b=v[0]-y[0]*h.k,k=v[1]-y[1]*h.k;return b===h.x&&k===h.y?h:new U(h.k,b,k)}function z(h){return[(+h[0][0]+ +h[1][0])/2,(+h[0][1]+ +h[1][1])/2]}function E(h,v,y,b){h.on("start.zoom",function(){L(this,arguments).event(b).start()}).on("interrupt.zoom end.zoom",function(){L(this,arguments).event(b).end()}).tween("zoom",function(){var k=this,$=arguments,N=L(k,$).event(b),S=e.apply(k,$),T=y==null?z(S):typeof y=="function"?y.apply(k,$):y,X=Math.max(S[1][0]-S[0][0],S[1][1]-S[0][1]),I=k.__zoom,R=typeof v=="function"?v.apply(k,$):v,K=f(I.invert(T).concat(X/I.k),R.invert(T).concat(X/R.k));return function(O){if(O===1)O=R;else{var Z=K(O),Ne=X/Z[2];O=new U(Ne,T[0]-Z[0]*Ne,T[1]-Z[1]*Ne)}N.zoom(null,O)}})}function L(h,v,y){return!y&&h.__zooming||new j(h,v)}function j(h,v){this.that=h,this.args=v,this.active=0,this.sourceEvent=null,this.extent=e.apply(h,v),this.taps=0}j.prototype={event:function(h){return h&&(this.sourceEvent=h),this},start:function(){return++this.active===1&&(this.that.__zooming=this,this.emit("start")),this},zoom:function(h,v){return this.mouse&&h!=="mouse"&&(this.mouse[1]=v.invert(this.mouse[0])),this.touch0&&h!=="touch"&&(this.touch0[1]=v.invert(this.touch0[0])),this.touch1&&h!=="touch"&&(this.touch1[1]=v.invert(this.touch1[0])),this.that.__zoom=v,this.emit("zoom"),this},end:function(){return--this.active===0&&(delete this.that.__zooming,this.emit("end")),this},emit:function(h){var v=P(this.that).datum();u.call(h,this.that,new Ta(h,{sourceEvent:this.sourceEvent,target:g,transform:this.that.__zoom,dispatch:u}),v)}};function Yt(h,...v){if(!t.apply(this,arguments))return;var y=L(this,v).event(h),b=this.__zoom,k=Math.max(o[0],Math.min(o[1],b.k*Math.pow(2,r.apply(this,arguments)))),$=nt(h);if(y.wheel)(y.mouse[0][0]!==$[0]||y.mouse[0][1]!==$[1])&&(y.mouse[1]=b.invert(y.mouse[0]=$)),clearTimeout(y.wheel);else{if(b.k===k)return;y.mouse=[$,b.invert($)],Vt(this),y.start()}$t(h),y.wheel=setTimeout(N,m),y.zoom("mouse",n(A(w(b,k),y.mouse[0],y.mouse[1]),y.extent,a));function N(){y.wheel=null,y.end()}}function Gt(h,...v){if(c||!t.apply(this,arguments))return;var y=h.currentTarget,b=L(this,v,!0).event(h),k=P(h.view).on("mousemove.zoom",T,!0).on("mouseup.zoom",X,!0),$=nt(h,y),N=h.clientX,S=h.clientY;Wi(h.view),we(h),b.mouse=[$,this.__zoom.invert($)],Vt(this),b.start();function T(I){if($t(I),!b.moved){var R=I.clientX-N,K=I.clientY-S;b.moved=R*R+K*K>_}b.event(I).zoom("mouse",n(A(b.that.__zoom,b.mouse[0]=nt(I,y),b.mouse[1]),b.extent,a))}function X(I){k.on("mousemove.zoom mouseup.zoom",null),Yi(I.view,b.moved),$t(I),b.event(I).end()}}function Ut(h,...v){if(t.apply(this,arguments)){var y=this.__zoom,b=nt(h.changedTouches?h.changedTouches[0]:h,this),k=y.invert(b),$=y.k*(h.shiftKey?.5:2),N=n(A(w(y,$),b,k),e.apply(this,v),a);$t(h),s>0?P(this).transition().duration(s).call(E,N,b,h):P(this).call(g.transform,N,b,h)}}function Ua(h,...v){if(t.apply(this,arguments)){var y=h.touches,b=y.length,k=L(this,v,h.changedTouches.length===b).event(h),$,N,S,T;for(we(h),N=0;N{const e=document.querySelector(`#${t}`);if(e===null)throw new Error(`Cannot find dom element with id:${t}`);const n=e.clientWidth,r=e.clientHeight;if(r===0||n===0)throw new Error("The tree can't be display because the svg height or width of the container is null");return{areaWidth:n,areaHeight:r}},Nt=(t,e,n)=>{try{const r=t.find(a=>a.id===n),i=r.ancestors()[1].id;return e.some(a=>a.id===i)?r.ancestors()[1]:Nt(t,e,i)}catch{return t.find(i=>i.id===n)}},bn=(t,e,n)=>n.isHorizontal?"translate("+e+","+t+")":"translate("+t+","+e+")";class ct{static add(e,n){this.queue.push({delayNextCallback:e+this.extraDelayBetweenCallbacks,callback:n}),this.log(this.queue.map(r=>r.delayNextCallback),"<-- New task !!!"),this.runner||(this.runnerFunction(),this.runner=setInterval(()=>this.runnerFunction(),this.runnerSpeed))}static runnerFunction(){if(this.queue[0]){if(this.queue[0].callback){this.log("Executing task, delaying next task...");try{this.queue[0].callback()}catch(e){console.error(e)}finally{this.queue[0].callback=null}}this.queue[0].delayNextCallback-=this.runnerSpeed,this.log(this.queue.map(e=>e.delayNextCallback)),this.queue[0].delayNextCallback<=0&&this.queue.shift()}else this.log("No task found"),clearInterval(this.runner),this.runner=0}static log(...e){this.showQueueLog&&console.log(...e)}}ht(ct,"queue",[]),ht(ct,"runner"),ht(ct,"runnerSpeed",100),ht(ct,"extraDelayBetweenCallbacks",100),ht(ct,"showQueueLog",!1);const Da=t=>{const{htmlId:e,isHorizontal:n,hasPan:r,hasZoom:i,mainAxisNodeSpacing:o,nodeHeight:a,nodeWidth:s,marginBottom:f,marginLeft:u,marginRight:l,marginTop:d}=t,c={top:d,right:l,bottom:f,left:u},{areaHeight:p,areaWidth:m}=vn(t.htmlId),_=m-c.left-c.right,x=p-c.top-c.bottom,g=J.select("#"+e).append("svg").attr("width",m).attr("height",p),w=g.append("g"),A=J.zoom().on("zoom",E=>{w.attr("transform",()=>E.transform)});return g.call(A),r||g.on("mousedown.zoom",null).on("touchstart.zoom",null).on("touchmove.zoom",null).on("touchend.zoom",null),i||g.on("wheel.zoom",null).on("mousewheel.zoom",null).on("mousemove.zoom",null).on("DOMMouseScroll.zoom",null).on("dblclick.zoom",null),w.append("g").attr("transform",o==="auto"?"translate(0,0)":n?"translate("+c.left+","+(c.top+x/2-a/2)+")":"translate("+(c.left+_/2-s/2)+","+c.top+")")},ve=(t,e,n)=>{const{isHorizontal:r,nodeHeight:i,nodeWidth:o,linkShape:a}=n;return a==="orthogonal"?r?`M ${t.y} ${t.x+i/2} L ${(t.y+e.y+o)/2} ${t.x+i/2} L ${(t.y+e.y+o)/2} ${e.x+i/2} ${e.y+o} ${e.x+i/2}`:`M ${t.x+o/2} ${t.y} @@ -7,12 +7,12 @@ var Za=Object.defineProperty;var Qa=(tt,X,et)=>X in tt?Za(tt,X,{enumerable:!0,co ${e.x+o/2} ${e.y+i} `:a==="curve"?r?`M ${t.y} ${t.x+i/2} L ${t.y-(t.y-e.y-o)/2+15} ${t.x+i/2} Q${t.y-(t.y-e.y-o)/2} ${t.x+i/2} - ${t.y-(t.y-e.y-o)/2} ${t.x+i/2-vn(t.x,e.x,15)} + ${t.y-(t.y-e.y-o)/2} ${t.x+i/2-kn(t.x,e.x,15)} L ${t.y-(t.y-e.y-o)/2} ${e.x+i/2} L ${e.y+o} ${e.x+i/2}`:`M ${t.x+o/2} ${t.y} L ${t.x+o/2} ${t.y-(t.y-e.y-i)/2+15} Q${t.x+o/2} ${t.y-(t.y-e.y-i)/2} - ${t.x+o/2-vn(t.x,e.x,15)} ${t.y-(t.y-e.y-i)/2} + ${t.x+o/2-kn(t.x,e.x,15)} ${t.y-(t.y-e.y-i)/2} L ${e.x+o/2} ${t.y-(t.y-e.y-i)/2} L ${e.x+o/2} ${e.y+i} `:r?`M ${t.y} ${t.x+i/2} C ${(t.y+e.y+o)/2} ${t.x+i/2} @@ -20,4 +20,4 @@ var Za=Object.defineProperty;var Qa=(tt,X,et)=>X in tt?Za(tt,X,{enumerable:!0,co ${e.y+o} ${e.x+i/2}`:`M ${t.x+o/2} ${t.y} C ${t.x+o/2} ${(t.y+e.y+i)/2} ${e.x+o/2} ${(t.y+e.y+i)/2} - ${e.x+o/2} ${e.y+i} `},vn=(t,e,n)=>t>e?n:tt.enter().insert("path","g").attr("class","link").attr("d",i=>{const o=Nt(n,r,i.id),a={x:o.x0,y:o.y0};return we(a,a,e)}).attr("fill","none").attr("stroke-width",i=>e.linkWidth(i)).attr("stroke",i=>e.linkColor(i)),Fa=(t,e,n,r)=>{t.exit().transition().duration(e.duration).style("opacity",0).attr("d",i=>{const o=Nt(r,n,i.id),a={x:o.x0,y:o.y0};return we(a,a,e)}).remove()},bn=(t,e)=>{var n,r,i,o;if(t.nodeType===3){const a=(n=t.textContent)==null?void 0:n.trim();a&&e.append("tspan").text(a)}else if(t.nodeType===1)if(t.tagName==="TSPAN"||t.tagName==="tspan"){const a=e.append("tspan").text(((r=t.textContent)==null?void 0:r.trim())||"");t.getAttribute("dy")&&a.attr("dy",t.getAttribute("dy"))}else if(t.tagName==="STRONG"||t.tagName==="strong")e.append("tspan").attr("font-weight","bold").text(((i=t.textContent)==null?void 0:i.trim())||"");else if(t.tagName==="I"||t.tagName==="i")e.append("tspan").attr("font-style","italic").text(((o=t.textContent)==null?void 0:o.trim())||"");else for(let a=0;at==="quadraticBeziers"?e?0:20:0,Da=(t,e,n)=>{var i;const r=t.merge(e);if(r.transition().duration(n.duration).attr("d",o=>we(o,o.parent,n)).attr("fill","none").attr("stroke-width",o=>n.linkWidth(o)).attr("stroke",o=>n.linkColor(o)),n.linkLabel){const o=(i=r.node())==null?void 0:i.parentNode,u=D(o).selectAll("text.link-label").data(r.data(),(s,l)=>`link-label-${l}`);u.exit().remove(),u.enter().append("text").attr("class","link-label").attr("text-anchor","middle").attr("dominant-baseline","middle").attr("fill",n.linkLabel.color||"#000000").attr("font-size",n.linkLabel.fontSize||12).attr("pointer-events","none").attr("opacity",0).merge(u).attr("x",function(s){const l=kn(n.linkShape||"quadraticBeziers",n.isHorizontal);return n.isHorizontal?s.parent.y+(s.y-s.parent.y)-n.nodeWidth/4+l:s.parent.x+(s.x-s.parent.x)+n.nodeWidth/2}).attr("y",function(s){const l=kn(n.linkShape||"quadraticBeziers",n.isHorizontal);return n.isHorizontal?s.parent.x+(s.x-s.parent.x)+n.nodeHeight/2:s.parent.y+(s.y-s.parent.y)-n.nodeHeight/2+l}).text("").each(function(s){D(this).selectAll("tspan").remove();const l={...s.parent,data:s.parent.data,settings:n},d={...s,data:s.data,settings:n},c=n.linkLabel.render(l,d),p=D(this);if(c.includes("")){const x=new DOMParser().parseFromString(`${c}`,"text/xml");bn(x.documentElement,p)}else p.text(c)}).transition().delay(n.duration).duration(300).attr("opacity",1)}},qa=(t,e,n,r)=>{const i=t.enter().append("g").attr("class","node").attr("id",o=>o==null?void 0:o.id).attr("transform",o=>{const a=Nt(n,r,o.id);return wn(a.x0,a.y0,e)});return i.append("foreignObject").attr("width",e.nodeWidth).attr("height",e.nodeHeight),i},Ra=(t,e,n,r)=>{const i=t.exit().transition().duration(e.duration).style("opacity",0).attr("transform",o=>{const a=Nt(r,n,o.id);return wn(a.x0,a.y0,e)}).remove();i.select("rect").style("fill-opacity",1e-6),i.select("circle").attr("r",1e-6),i.select("text").style("fill-opacity",1e-6)},Oa=(t,e,n)=>{const r=t.merge(e);r.transition().duration(n.duration).attr("transform",i=>n.isHorizontal?"translate("+i.y+","+i.x+")":"translate("+i.x+","+i.y+")"),r.select("foreignObject").attr("width",n.nodeWidth).attr("height",n.nodeHeight).style("overflow","visible").on("click",(i,o)=>n.onNodeClick({...o,settings:n})).on("mouseenter",(i,o)=>n.onNodeMouseEnter({...o,settings:n})).on("mouseleave",(i,o)=>n.onNodeMouseLeave({...o,settings:n})).html(i=>n.renderNode({...i,settings:n}))},Pa=(t,e)=>{const{idKey:n,relationnalField:r,hasFlatData:i}=e;return i?J.stratify().id(o=>o[n]).parentId(o=>o[r])(t):J.hierarchy(t,o=>o[r])},Va=t=>{const{areaHeight:e,areaWidth:n}=_n(t.htmlId);return t.mainAxisNodeSpacing==="auto"&&t.isHorizontal?J.tree().size([e-t.nodeHeight,n-t.nodeWidth]):t.mainAxisNodeSpacing==="auto"&&!t.isHorizontal?J.tree().size([n-t.nodeWidth,e-t.nodeHeight]):t.isHorizontal===!0?J.tree().nodeSize([t.nodeHeight*t.secondaryAxisNodeSpacing,t.nodeWidth]):J.tree().nodeSize([t.nodeWidth*t.secondaryAxisNodeSpacing,t.nodeHeight])},ve={create:Wa};typeof window<"u"&&(window.Treeviz=ve);function Wa(t){let n={...{data:[],htmlId:"",idKey:"id",relationnalField:"father",hasFlatData:!0,nodeWidth:160,nodeHeight:100,mainAxisNodeSpacing:300,renderNode:()=>"Node",linkColor:()=>"#ffcc80",linkWidth:()=>10,linkShape:"quadraticBeziers",isHorizontal:!0,hasPan:!1,hasZoom:!1,duration:600,onNodeClick:()=>{},onNodeMouseEnter:()=>{},onNodeMouseLeave:()=>{},marginBottom:0,marginLeft:0,marginRight:0,marginTop:0,secondaryAxisNodeSpacing:1.25},...t},r=[];function i(s,l){const d=l.descendants(),c=l.descendants().slice(1),{mainAxisNodeSpacing:p}=n;p!=="auto"&&d.forEach(w=>{w.y=w.depth*n.nodeWidth*p}),d.forEach(w=>{const A=r.find(z=>z.id===w.id);w.x0=A?A.x0:w.x,w.y0=A?A.y0:w.y});const m=s.selectAll("g.node").data(d,w=>w[n.idKey]),x=qa(m,n,d,r);Oa(x,m,n),Ra(m,n,d,r);const _=s.selectAll("path.link").data(c,w=>w.id),y=Ha(_,n,d,r);Da(y,_,n),Fa(_,n,d,r),r=[...d]}function o(s,l){ct.add(n.duration,()=>{l&&(n={...n,...l});const d=Pa(s,n),p=Va(n)(d);i(f,p)})}function a(s){const l=s?document.querySelector(`#${n.htmlId} svg g`):document.querySelector(`#${n.htmlId}`);if(l)for(;l.firstChild;)l.removeChild(l.firstChild);r=[]}const u={refresh:o,clean:a},f=Ia(n);return u}var $t=[{id:1,text_1:"Chaos",text_2:"Void",father:null,color:"#FF5722"},{id:2,text_1:"Tartarus",text_2:"Abyss",father:1,color:"#FFC107"},{id:3,text_1:"Gaia",text_2:"Earth",father:1,color:"#8BC34A"},{id:4,text_1:"Eros",text_2:"Desire",father:1,color:"#00BCD4"}],Ba=[{id:1,text_1:"Chaos",text_2:" Void",father:null,color:"#2196F3"},{id:2,text_1:"Tartarus",text_2:"Abyss",father:1,color:"#F44336"},{id:3,text_1:"Gaia",text_2:"Earth",father:1,color:"#673AB7"},{id:4,text_1:"Eros",text_2:"Desire",father:1,color:"#009688"},{id:5,text_1:"Uranus",text_2:"Sky",father:3,color:"#4CAF50"},{id:6,text_1:"Ourea",text_2:"Mountains",father:3,color:"#FF9800"}],Xa=[{id:1,text_1:"Chaos",text_2:"Void",father:null,color:"#2196F3"},{id:2,text_1:"Tartarus",text_2:"Abyss",father:1,color:"#F44336"},{id:3,text_1:"Gaia",text_2:"Earth",father:1,color:"#673AB7"},{id:4,text_1:"Eros",text_2:"Desire",father:1,color:"#009688"},{id:5,text_1:"Uranus",text_2:"Sky",father:3,color:"#4CAF50"},{id:6,text_1:"Ourea",text_2:"Mountains",father:3,color:"#FF9800"},{id:7,text_1:"Hermes",text_2:" Sky",father:4,color:"#2196F3"},{id:8,text_1:"Aphrodite",text_2:"Love",father:4,color:"#8BC34A"},{id:3.3,text_1:"Love",text_2:"Peace",father:8,color:"#c72e99"},{id:4.1,text_1:"Hope",text_2:"Life",father:8,color:"#2eecc7"}],Bt=ve.create({data:$t,htmlId:"tree",idKey:"id",hasFlatData:!0,relationnalField:"father",nodeWidth:120,hasPan:!0,hasZoom:!0,nodeHeight:80,mainAxisNodeSpacing:2,isHorizontal:!1,renderNode:function(e){return"
"+e.data.text_1+"
is
"+e.data.text_2+"
"},linkWidth:t=>t.data.id*2,linkColor:()=>"#B0BEC5",linkLabel:{render:(t,e)=>"is child",color:"#455A64",fontSize:11},onNodeClick:t=>{console.log(t.data)},onNodeMouseEnter:t=>{console.log(t.data)}});Bt.refresh($t);var Nn=!0;const C=document.querySelector("#add"),M=document.querySelector("#remove"),be=document.querySelector("#doTasks");C==null||C.addEventListener("click",function(){console.log("addButton clicked"),Nn?Bt.refresh(Ba):Bt.refresh(Xa),Nn=!1}),M==null||M.addEventListener("click",function(){console.log("removeButton clicked"),Bt.refresh($t)}),be==null||be.addEventListener("click",function(){C==null||C.click(),M==null||M.click(),C==null||C.click(),M==null||M.click(),M==null||M.click(),C==null||C.click(),M==null||M.click(),C==null||C.click(),C==null||C.click(),M==null||M.click(),M==null||M.click()});var Ya=ve.create({data:$t,htmlId:"tree-horizontal",idKey:"id",hasFlatData:!0,relationnalField:"father",nodeWidth:120,hasPan:!0,hasZoom:!0,nodeHeight:80,mainAxisNodeSpacing:2,isHorizontal:!0,renderNode:function(e){return"
"+e.data.text_1+"
is
"+e.data.text_2+"
"},linkWidth:t=>t.data.id*2,linkShape:"curve",linkColor:()=>"#B0BEC5",linkLabel:{render:(t,e)=>"is child",color:"#455A64",fontSize:11},onNodeClick:t=>{console.log(t.data)}});Ya.refresh($t)})(); + ${e.x+o/2} ${e.y+i} `},kn=(t,e,n)=>t>e?n:t{switch(t){case"dashed":return`${e*2},${e*1.2}`;case"dotted":return`${e*.1},${e*1.5}`;case"dashdot":return`${e*2},${e*1.2},${e*.1},${e*1.2}`;case"solid":default:return null}},Nn=t=>t==="dotted"||t==="dashdot"?"round":"butt",Ra=(t,e,n,r)=>t.enter().insert("path","g").attr("class","link").attr("d",i=>{const o=Nt(n,r,i.id),a={x:o.x0,y:o.y0};return ve(a,a,e)}).attr("fill","none").attr("stroke-width",i=>e.linkWidth(i)).attr("stroke",i=>e.linkColor(i)).attr("stroke-dasharray",i=>{var o;return $n((o=e.linkStyle)==null?void 0:o.call(e,i),e.linkWidth(i))}).attr("stroke-linecap",i=>{var o;return Nn((o=e.linkStyle)==null?void 0:o.call(e,i))}),Oa=(t,e,n,r)=>{t.exit().transition().duration(e.duration).style("opacity",0).attr("d",i=>{const o=Nt(r,n,i.id),a={x:o.x0,y:o.y0};return ve(a,a,e)}).remove()},zn=(t,e)=>t==="quadraticBeziers"?e?0:20:0,Pa=(t,e,n)=>{var i;const r=t.merge(e);if(r.transition().duration(n.duration).attr("d",o=>ve(o,o.parent,n)).attr("fill","none").attr("stroke-width",o=>n.linkWidth(o)).attr("stroke",o=>n.linkColor(o)).attr("stroke-dasharray",o=>{var a;return $n((a=n.linkStyle)==null?void 0:a.call(n,o),n.linkWidth(o))}).attr("stroke-linecap",o=>{var a;return Nn((a=n.linkStyle)==null?void 0:a.call(n,o))}),n.linkLabel){const o=(i=r.node())==null?void 0:i.parentNode,s=P(o).selectAll("text.link-label").data(r.data(),(u,l)=>`link-label-${l}`);s.exit().remove(),s.enter().append("text").attr("class","link-label").attr("text-anchor","middle").attr("dominant-baseline","middle").attr("fill",n.linkLabel.color||"#000000").attr("font-size",n.linkLabel.fontSize||12).attr("pointer-events","none").attr("opacity",0).merge(s).attr("x",function(u){const l=zn(n.linkShape||"quadraticBeziers",n.isHorizontal);return n.isHorizontal?u.parent.y+(u.y-u.parent.y)-n.nodeWidth/4+l:u.parent.x+(u.x-u.parent.x)+n.nodeWidth/2}).attr("y",function(u){const l=zn(n.linkShape||"quadraticBeziers",n.isHorizontal);return n.isHorizontal?u.parent.x+(u.x-u.parent.x)+n.nodeHeight/2:u.parent.y+(u.y-u.parent.y)-n.nodeHeight/2+l}).text("").each(function(u){const l={...u.parent,data:u.parent.data,settings:n},d={...u,data:u.data,settings:n},c=n.linkLabel.render(l,d);P(this).text(c)}).transition().delay(n.duration).duration(300).attr("opacity",1)}},Va=(t,e,n,r)=>{const i=t.enter().append("g").attr("class","node").attr("id",o=>o==null?void 0:o.id).attr("transform",o=>{const a=Nt(n,r,o.id);return bn(a.x0,a.y0,e)});return i.append("foreignObject").attr("width",e.nodeWidth).attr("height",e.nodeHeight),i},Ba=(t,e,n,r)=>{const i=t.exit().transition().duration(e.duration).style("opacity",0).attr("transform",o=>{const a=Nt(r,n,o.id);return bn(a.x0,a.y0,e)}).remove();i.select("rect").style("fill-opacity",1e-6),i.select("circle").attr("r",1e-6),i.select("text").style("fill-opacity",1e-6)},Xa=(t,e,n)=>{const r=t.merge(e);r.transition().duration(n.duration).attr("transform",i=>n.isHorizontal?"translate("+i.y+","+i.x+")":"translate("+i.x+","+i.y+")"),r.select("foreignObject").attr("width",n.nodeWidth).attr("height",n.nodeHeight).style("overflow","visible").on("click",(i,o)=>n.onNodeClick({...o,settings:n})).on("mouseenter",(i,o)=>n.onNodeMouseEnter({...o,settings:n})).on("mouseleave",(i,o)=>n.onNodeMouseLeave({...o,settings:n})).html(i=>n.renderNode({...i,settings:n}))},Wa=(t,e)=>{const{idKey:n,relationnalField:r,hasFlatData:i}=e;return i?J.stratify().id(o=>o[n]).parentId(o=>o[r])(t):J.hierarchy(t,o=>o[r])},Ya=t=>{const{areaHeight:e,areaWidth:n}=vn(t.htmlId);return t.mainAxisNodeSpacing==="auto"&&t.isHorizontal?J.tree().size([e-t.nodeHeight,n-t.nodeWidth]):t.mainAxisNodeSpacing==="auto"&&!t.isHorizontal?J.tree().size([n-t.nodeWidth,e-t.nodeHeight]):t.isHorizontal===!0?J.tree().nodeSize([t.nodeHeight*t.secondaryAxisNodeSpacing,t.nodeWidth]):J.tree().nodeSize([t.nodeWidth*t.secondaryAxisNodeSpacing,t.nodeHeight])},be={create:Ga};typeof window<"u"&&(window.Treeviz=be);function Ga(t){let n={...{data:[],htmlId:"",idKey:"id",relationnalField:"father",hasFlatData:!0,nodeWidth:160,nodeHeight:100,mainAxisNodeSpacing:300,renderNode:()=>"Node",linkColor:()=>"#ffcc80",linkWidth:()=>10,linkStyle:()=>"solid",linkShape:"quadraticBeziers",isHorizontal:!0,hasPan:!1,hasZoom:!1,duration:600,onNodeClick:()=>{},onNodeMouseEnter:()=>{},onNodeMouseLeave:()=>{},marginBottom:0,marginLeft:0,marginRight:0,marginTop:0,secondaryAxisNodeSpacing:1.25},...t},r=[];function i(u,l){const d=l.descendants(),c=l.descendants().slice(1),{mainAxisNodeSpacing:p}=n;p!=="auto"&&d.forEach(w=>{w.y=w.depth*n.nodeWidth*p}),d.forEach(w=>{const A=r.find(z=>z.id===w.id);w.x0=A?A.x0:w.x,w.y0=A?A.y0:w.y});const m=u.selectAll("g.node").data(d,w=>w[n.idKey]),_=Va(m,n,d,r);Xa(_,m,n),Ba(m,n,d,r);const x=u.selectAll("path.link").data(c,w=>w.id),g=Ra(x,n,d,r);Pa(g,x,n),Oa(x,n,d,r),r=[...d]}function o(u,l){ct.add(n.duration,()=>{l&&(n={...n,...l});const d=Wa(u,n),p=Ya(n)(d);i(f,p)})}function a(u){const l=u?document.querySelector(`#${n.htmlId} svg g`):document.querySelector(`#${n.htmlId}`);if(l)for(;l.firstChild;)l.removeChild(l.firstChild);r=[]}const s={refresh:o,clean:a},f=Da(n);return s}var ft=[{id:1,text_1:"Chaos",text_2:"Void",father:null,color:"#FF5722"},{id:2,text_1:"Tartarus",text_2:"Abyss",father:1,color:"#FFC107"},{id:3,text_1:"Gaia",text_2:"Earth",father:1,color:"#8BC34A"},{id:4,text_1:"Eros",text_2:"Desire",father:1,color:"#00BCD4"}],An=[{id:1,text_1:"Chaos",text_2:" Void",father:null,color:"#2196F3"},{id:2,text_1:"Tartarus",text_2:"Abyss",father:1,color:"#F44336"},{id:3,text_1:"Gaia",text_2:"Earth",father:1,color:"#673AB7"},{id:4,text_1:"Eros",text_2:"Desire",father:1,color:"#009688"},{id:5,text_1:"Uranus",text_2:"Sky",father:3,color:"#4CAF50"},{id:6,text_1:"Ourea",text_2:"Mountains",father:3,color:"#FF9800"}],Sn=[{id:1,text_1:"Chaos",text_2:"Void",father:null,color:"#2196F3"},{id:2,text_1:"Tartarus",text_2:"Abyss",father:1,color:"#F44336"},{id:3,text_1:"Gaia",text_2:"Earth",father:1,color:"#673AB7"},{id:4,text_1:"Eros",text_2:"Desire",father:1,color:"#009688"},{id:5,text_1:"Uranus",text_2:"Sky",father:3,color:"#4CAF50"},{id:6,text_1:"Ourea",text_2:"Mountains",father:3,color:"#FF9800"},{id:7,text_1:"Hermes",text_2:" Sky",father:4,color:"#2196F3"},{id:8,text_1:"Aphrodite",text_2:"Love",father:4,color:"#8BC34A"},{id:3.3,text_1:"Love",text_2:"Peace",father:8,color:"#c72e99"},{id:4.1,text_1:"Hope",text_2:"Life",father:8,color:"#2eecc7"}],Xt=be.create({data:ft,htmlId:"tree",idKey:"id",hasFlatData:!0,relationnalField:"father",nodeWidth:120,hasPan:!0,hasZoom:!0,nodeHeight:80,mainAxisNodeSpacing:2,isHorizontal:!1,renderNode:function(e){return"
"+e.data.text_1+"
is
"+e.data.text_2+"
"},linkWidth:t=>t.data.id*2,linkColor:()=>"#B0BEC5",linkLabel:{render:(t,e)=>"is child",color:"#455A64",fontSize:11},onNodeClick:t=>{console.log(t.data)},onNodeMouseEnter:t=>{console.log(t.data)}});Xt.refresh(ft);var ke=!0;const C=document.querySelector("#add"),M=document.querySelector("#remove"),$e=document.querySelector("#doTasks");var Wt=be.create({data:ft,htmlId:"tree-horizontal",idKey:"id",hasFlatData:!0,relationnalField:"father",nodeWidth:120,hasPan:!0,hasZoom:!0,nodeHeight:80,mainAxisNodeSpacing:2,isHorizontal:!0,renderNode:function(e){return"
"+e.data.text_1+"
is
"+e.data.text_2+"
"},linkWidth:t=>t.data.id*2,linkStyle:t=>t.data.id%2===0?"dashed":"solid",linkShape:"curve",linkColor:()=>"#B0BEC5",linkLabel:{render:(t,e)=>"is child",color:"#455A64",fontSize:11},onNodeClick:t=>{console.log(t.data)}});Wt.refresh(ft),C==null||C.addEventListener("click",function(){console.log("addButton clicked"),ke?Xt.refresh(An):Xt.refresh(Sn),ke?Wt.refresh(An):Wt.refresh(Sn),ke=!1}),M==null||M.addEventListener("click",function(){console.log("removeButton clicked"),Xt.refresh(ft),Wt.refresh(ft)}),$e==null||$e.addEventListener("click",function(){C==null||C.click(),M==null||M.click(),C==null||C.click(),M==null||M.click(),M==null||M.click(),C==null||C.click(),M==null||M.click(),C==null||C.click(),C==null||C.click(),M==null||M.click(),M==null||M.click()})})(); diff --git a/front/lib/treeviz/treeviz.iife.old.js b/front/lib/treeviz/treeviz.iife.old.js deleted file mode 100644 index 31fbf36bc..000000000 --- a/front/lib/treeviz/treeviz.iife.old.js +++ /dev/null @@ -1,4178 +0,0 @@ -"use strict"; -var Treeviz = (() => { - var __defProp = Object.defineProperty; - var __getOwnPropDesc = Object.getOwnPropertyDescriptor; - var __getOwnPropNames = Object.getOwnPropertyNames; - var __hasOwnProp = Object.prototype.hasOwnProperty; - var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); - var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); - }; - var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; - }; - var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); - - // src/index.ts - var index_exports = {}; - __export(index_exports, { - Treeviz: () => Treeviz, - create: () => create2 - }); - - // node_modules/d3-hierarchy/src/hierarchy/count.js - function count(node) { - var sum = 0, children2 = node.children, i = children2 && children2.length; - if (!i) sum = 1; - else while (--i >= 0) sum += children2[i].value; - node.value = sum; - } - __name(count, "count"); - function count_default() { - return this.eachAfter(count); - } - __name(count_default, "default"); - - // node_modules/d3-hierarchy/src/hierarchy/each.js - function each_default(callback, that) { - let index = -1; - for (const node of this) { - callback.call(that, node, ++index, this); - } - return this; - } - __name(each_default, "default"); - - // node_modules/d3-hierarchy/src/hierarchy/eachBefore.js - function eachBefore_default(callback, that) { - var node = this, nodes = [node], children2, i, index = -1; - while (node = nodes.pop()) { - callback.call(that, node, ++index, this); - if (children2 = node.children) { - for (i = children2.length - 1; i >= 0; --i) { - nodes.push(children2[i]); - } - } - } - return this; - } - __name(eachBefore_default, "default"); - - // node_modules/d3-hierarchy/src/hierarchy/eachAfter.js - function eachAfter_default(callback, that) { - var node = this, nodes = [node], next = [], children2, i, n, index = -1; - while (node = nodes.pop()) { - next.push(node); - if (children2 = node.children) { - for (i = 0, n = children2.length; i < n; ++i) { - nodes.push(children2[i]); - } - } - } - while (node = next.pop()) { - callback.call(that, node, ++index, this); - } - return this; - } - __name(eachAfter_default, "default"); - - // node_modules/d3-hierarchy/src/hierarchy/find.js - function find_default(callback, that) { - let index = -1; - for (const node of this) { - if (callback.call(that, node, ++index, this)) { - return node; - } - } - } - __name(find_default, "default"); - - // node_modules/d3-hierarchy/src/hierarchy/sum.js - function sum_default(value) { - return this.eachAfter(function(node) { - var sum = +value(node.data) || 0, children2 = node.children, i = children2 && children2.length; - while (--i >= 0) sum += children2[i].value; - node.value = sum; - }); - } - __name(sum_default, "default"); - - // node_modules/d3-hierarchy/src/hierarchy/sort.js - function sort_default(compare) { - return this.eachBefore(function(node) { - if (node.children) { - node.children.sort(compare); - } - }); - } - __name(sort_default, "default"); - - // node_modules/d3-hierarchy/src/hierarchy/path.js - function path_default(end) { - var start2 = this, ancestor = leastCommonAncestor(start2, end), nodes = [start2]; - while (start2 !== ancestor) { - start2 = start2.parent; - nodes.push(start2); - } - var k = nodes.length; - while (end !== ancestor) { - nodes.splice(k, 0, end); - end = end.parent; - } - return nodes; - } - __name(path_default, "default"); - function leastCommonAncestor(a, b) { - if (a === b) return a; - var aNodes = a.ancestors(), bNodes = b.ancestors(), c = null; - a = aNodes.pop(); - b = bNodes.pop(); - while (a === b) { - c = a; - a = aNodes.pop(); - b = bNodes.pop(); - } - return c; - } - __name(leastCommonAncestor, "leastCommonAncestor"); - - // node_modules/d3-hierarchy/src/hierarchy/ancestors.js - function ancestors_default() { - var node = this, nodes = [node]; - while (node = node.parent) { - nodes.push(node); - } - return nodes; - } - __name(ancestors_default, "default"); - - // node_modules/d3-hierarchy/src/hierarchy/descendants.js - function descendants_default() { - return Array.from(this); - } - __name(descendants_default, "default"); - - // node_modules/d3-hierarchy/src/hierarchy/leaves.js - function leaves_default() { - var leaves = []; - this.eachBefore(function(node) { - if (!node.children) { - leaves.push(node); - } - }); - return leaves; - } - __name(leaves_default, "default"); - - // node_modules/d3-hierarchy/src/hierarchy/links.js - function links_default() { - var root2 = this, links = []; - root2.each(function(node) { - if (node !== root2) { - links.push({ source: node.parent, target: node }); - } - }); - return links; - } - __name(links_default, "default"); - - // node_modules/d3-hierarchy/src/hierarchy/iterator.js - function* iterator_default() { - var node = this, current, next = [node], children2, i, n; - do { - current = next.reverse(), next = []; - while (node = current.pop()) { - yield node; - if (children2 = node.children) { - for (i = 0, n = children2.length; i < n; ++i) { - next.push(children2[i]); - } - } - } - } while (next.length); - } - __name(iterator_default, "default"); - - // node_modules/d3-hierarchy/src/hierarchy/index.js - function hierarchy(data, children2) { - if (data instanceof Map) { - data = [void 0, data]; - if (children2 === void 0) children2 = mapChildren; - } else if (children2 === void 0) { - children2 = objectChildren; - } - var root2 = new Node(data), node, nodes = [root2], child, childs, i, n; - while (node = nodes.pop()) { - if ((childs = children2(node.data)) && (n = (childs = Array.from(childs)).length)) { - node.children = childs; - for (i = n - 1; i >= 0; --i) { - nodes.push(child = childs[i] = new Node(childs[i])); - child.parent = node; - child.depth = node.depth + 1; - } - } - } - return root2.eachBefore(computeHeight); - } - __name(hierarchy, "hierarchy"); - function node_copy() { - return hierarchy(this).eachBefore(copyData); - } - __name(node_copy, "node_copy"); - function objectChildren(d) { - return d.children; - } - __name(objectChildren, "objectChildren"); - function mapChildren(d) { - return Array.isArray(d) ? d[1] : null; - } - __name(mapChildren, "mapChildren"); - function copyData(node) { - if (node.data.value !== void 0) node.value = node.data.value; - node.data = node.data.data; - } - __name(copyData, "copyData"); - function computeHeight(node) { - var height = 0; - do - node.height = height; - while ((node = node.parent) && node.height < ++height); - } - __name(computeHeight, "computeHeight"); - function Node(data) { - this.data = data; - this.depth = this.height = 0; - this.parent = null; - } - __name(Node, "Node"); - Node.prototype = hierarchy.prototype = { - constructor: Node, - count: count_default, - each: each_default, - eachAfter: eachAfter_default, - eachBefore: eachBefore_default, - find: find_default, - sum: sum_default, - sort: sort_default, - path: path_default, - ancestors: ancestors_default, - descendants: descendants_default, - leaves: leaves_default, - links: links_default, - copy: node_copy, - [Symbol.iterator]: iterator_default - }; - - // node_modules/d3-hierarchy/src/accessors.js - function optional(f) { - return f == null ? null : required(f); - } - __name(optional, "optional"); - function required(f) { - if (typeof f !== "function") throw new Error(); - return f; - } - __name(required, "required"); - - // node_modules/d3-hierarchy/src/constant.js - function constantZero() { - return 0; - } - __name(constantZero, "constantZero"); - function constant_default(x) { - return function() { - return x; - }; - } - __name(constant_default, "default"); - - // node_modules/d3-hierarchy/src/treemap/round.js - function round_default(node) { - node.x0 = Math.round(node.x0); - node.y0 = Math.round(node.y0); - node.x1 = Math.round(node.x1); - node.y1 = Math.round(node.y1); - } - __name(round_default, "default"); - - // node_modules/d3-hierarchy/src/treemap/dice.js - function dice_default(parent, x0, y0, x1, y1) { - var nodes = parent.children, node, i = -1, n = nodes.length, k = parent.value && (x1 - x0) / parent.value; - while (++i < n) { - node = nodes[i], node.y0 = y0, node.y1 = y1; - node.x0 = x0, node.x1 = x0 += node.value * k; - } - } - __name(dice_default, "default"); - - // node_modules/d3-hierarchy/src/stratify.js - var preroot = { depth: -1 }; - var ambiguous = {}; - var imputed = {}; - function defaultId(d) { - return d.id; - } - __name(defaultId, "defaultId"); - function defaultParentId(d) { - return d.parentId; - } - __name(defaultParentId, "defaultParentId"); - function stratify_default() { - var id2 = defaultId, parentId = defaultParentId, path; - function stratify(data) { - var nodes = Array.from(data), currentId = id2, currentParentId = parentId, n, d, i, root2, parent, node, nodeId, nodeKey, nodeByKey = /* @__PURE__ */ new Map(); - if (path != null) { - const I = nodes.map((d2, i2) => normalize(path(d2, i2, data))); - const P = I.map(parentof); - const S = new Set(I).add(""); - for (const i2 of P) { - if (!S.has(i2)) { - S.add(i2); - I.push(i2); - P.push(parentof(i2)); - nodes.push(imputed); - } - } - currentId = /* @__PURE__ */ __name((_, i2) => I[i2], "currentId"); - currentParentId = /* @__PURE__ */ __name((_, i2) => P[i2], "currentParentId"); - } - for (i = 0, n = nodes.length; i < n; ++i) { - d = nodes[i], node = nodes[i] = new Node(d); - if ((nodeId = currentId(d, i, data)) != null && (nodeId += "")) { - nodeKey = node.id = nodeId; - nodeByKey.set(nodeKey, nodeByKey.has(nodeKey) ? ambiguous : node); - } - if ((nodeId = currentParentId(d, i, data)) != null && (nodeId += "")) { - node.parent = nodeId; - } - } - for (i = 0; i < n; ++i) { - node = nodes[i]; - if (nodeId = node.parent) { - parent = nodeByKey.get(nodeId); - if (!parent) throw new Error("missing: " + nodeId); - if (parent === ambiguous) throw new Error("ambiguous: " + nodeId); - if (parent.children) parent.children.push(node); - else parent.children = [node]; - node.parent = parent; - } else { - if (root2) throw new Error("multiple roots"); - root2 = node; - } - } - if (!root2) throw new Error("no root"); - if (path != null) { - while (root2.data === imputed && root2.children.length === 1) { - root2 = root2.children[0], --n; - } - for (let i2 = nodes.length - 1; i2 >= 0; --i2) { - node = nodes[i2]; - if (node.data !== imputed) break; - node.data = null; - } - } - root2.parent = preroot; - root2.eachBefore(function(node2) { - node2.depth = node2.parent.depth + 1; - --n; - }).eachBefore(computeHeight); - root2.parent = null; - if (n > 0) throw new Error("cycle"); - return root2; - } - __name(stratify, "stratify"); - stratify.id = function(x) { - return arguments.length ? (id2 = optional(x), stratify) : id2; - }; - stratify.parentId = function(x) { - return arguments.length ? (parentId = optional(x), stratify) : parentId; - }; - stratify.path = function(x) { - return arguments.length ? (path = optional(x), stratify) : path; - }; - return stratify; - } - __name(stratify_default, "default"); - function normalize(path) { - path = `${path}`; - let i = path.length; - if (slash(path, i - 1) && !slash(path, i - 2)) path = path.slice(0, -1); - return path[0] === "/" ? path : `/${path}`; - } - __name(normalize, "normalize"); - function parentof(path) { - let i = path.length; - if (i < 2) return ""; - while (--i > 1) if (slash(path, i)) break; - return path.slice(0, i); - } - __name(parentof, "parentof"); - function slash(path, i) { - if (path[i] === "/") { - let k = 0; - while (i > 0 && path[--i] === "\\") ++k; - if ((k & 1) === 0) return true; - } - return false; - } - __name(slash, "slash"); - - // node_modules/d3-hierarchy/src/tree.js - function defaultSeparation(a, b) { - return a.parent === b.parent ? 1 : 2; - } - __name(defaultSeparation, "defaultSeparation"); - function nextLeft(v) { - var children2 = v.children; - return children2 ? children2[0] : v.t; - } - __name(nextLeft, "nextLeft"); - function nextRight(v) { - var children2 = v.children; - return children2 ? children2[children2.length - 1] : v.t; - } - __name(nextRight, "nextRight"); - function moveSubtree(wm, wp, shift) { - var change = shift / (wp.i - wm.i); - wp.c -= change; - wp.s += shift; - wm.c += change; - wp.z += shift; - wp.m += shift; - } - __name(moveSubtree, "moveSubtree"); - function executeShifts(v) { - var shift = 0, change = 0, children2 = v.children, i = children2.length, w; - while (--i >= 0) { - w = children2[i]; - w.z += shift; - w.m += shift; - shift += w.s + (change += w.c); - } - } - __name(executeShifts, "executeShifts"); - function nextAncestor(vim, v, ancestor) { - return vim.a.parent === v.parent ? vim.a : ancestor; - } - __name(nextAncestor, "nextAncestor"); - function TreeNode(node, i) { - this._ = node; - this.parent = null; - this.children = null; - this.A = null; - this.a = this; - this.z = 0; - this.m = 0; - this.c = 0; - this.s = 0; - this.t = null; - this.i = i; - } - __name(TreeNode, "TreeNode"); - TreeNode.prototype = Object.create(Node.prototype); - function treeRoot(root2) { - var tree = new TreeNode(root2, 0), node, nodes = [tree], child, children2, i, n; - while (node = nodes.pop()) { - if (children2 = node._.children) { - node.children = new Array(n = children2.length); - for (i = n - 1; i >= 0; --i) { - nodes.push(child = node.children[i] = new TreeNode(children2[i], i)); - child.parent = node; - } - } - } - (tree.parent = new TreeNode(null, 0)).children = [tree]; - return tree; - } - __name(treeRoot, "treeRoot"); - function tree_default() { - var separation = defaultSeparation, dx = 1, dy = 1, nodeSize = null; - function tree(root2) { - var t = treeRoot(root2); - t.eachAfter(firstWalk), t.parent.m = -t.z; - t.eachBefore(secondWalk); - if (nodeSize) root2.eachBefore(sizeNode); - else { - var left = root2, right = root2, bottom = root2; - root2.eachBefore(function(node) { - if (node.x < left.x) left = node; - if (node.x > right.x) right = node; - if (node.depth > bottom.depth) bottom = node; - }); - var s = left === right ? 1 : separation(left, right) / 2, tx = s - left.x, kx = dx / (right.x + s + tx), ky = dy / (bottom.depth || 1); - root2.eachBefore(function(node) { - node.x = (node.x + tx) * kx; - node.y = node.depth * ky; - }); - } - return root2; - } - __name(tree, "tree"); - function firstWalk(v) { - var children2 = v.children, siblings = v.parent.children, w = v.i ? siblings[v.i - 1] : null; - if (children2) { - executeShifts(v); - var midpoint = (children2[0].z + children2[children2.length - 1].z) / 2; - if (w) { - v.z = w.z + separation(v._, w._); - v.m = v.z - midpoint; - } else { - v.z = midpoint; - } - } else if (w) { - v.z = w.z + separation(v._, w._); - } - v.parent.A = apportion(v, w, v.parent.A || siblings[0]); - } - __name(firstWalk, "firstWalk"); - function secondWalk(v) { - v._.x = v.z + v.parent.m; - v.m += v.parent.m; - } - __name(secondWalk, "secondWalk"); - function apportion(v, w, ancestor) { - if (w) { - var vip = v, vop = v, vim = w, vom = vip.parent.children[0], sip = vip.m, sop = vop.m, sim = vim.m, som = vom.m, shift; - while (vim = nextRight(vim), vip = nextLeft(vip), vim && vip) { - vom = nextLeft(vom); - vop = nextRight(vop); - vop.a = v; - shift = vim.z + sim - vip.z - sip + separation(vim._, vip._); - if (shift > 0) { - moveSubtree(nextAncestor(vim, v, ancestor), v, shift); - sip += shift; - sop += shift; - } - sim += vim.m; - sip += vip.m; - som += vom.m; - sop += vop.m; - } - if (vim && !nextRight(vop)) { - vop.t = vim; - vop.m += sim - sop; - } - if (vip && !nextLeft(vom)) { - vom.t = vip; - vom.m += sip - som; - ancestor = v; - } - } - return ancestor; - } - __name(apportion, "apportion"); - function sizeNode(node) { - node.x *= dx; - node.y = node.depth * dy; - } - __name(sizeNode, "sizeNode"); - tree.separation = function(x) { - return arguments.length ? (separation = x, tree) : separation; - }; - tree.size = function(x) { - return arguments.length ? (nodeSize = false, dx = +x[0], dy = +x[1], tree) : nodeSize ? null : [dx, dy]; - }; - tree.nodeSize = function(x) { - return arguments.length ? (nodeSize = true, dx = +x[0], dy = +x[1], tree) : nodeSize ? [dx, dy] : null; - }; - return tree; - } - __name(tree_default, "default"); - - // node_modules/d3-hierarchy/src/treemap/slice.js - function slice_default(parent, x0, y0, x1, y1) { - var nodes = parent.children, node, i = -1, n = nodes.length, k = parent.value && (y1 - y0) / parent.value; - while (++i < n) { - node = nodes[i], node.x0 = x0, node.x1 = x1; - node.y0 = y0, node.y1 = y0 += node.value * k; - } - } - __name(slice_default, "default"); - - // node_modules/d3-hierarchy/src/treemap/squarify.js - var phi = (1 + Math.sqrt(5)) / 2; - function squarifyRatio(ratio, parent, x0, y0, x1, y1) { - var rows = [], nodes = parent.children, row, nodeValue, i0 = 0, i1 = 0, n = nodes.length, dx, dy, value = parent.value, sumValue, minValue, maxValue, newRatio, minRatio, alpha, beta; - while (i0 < n) { - dx = x1 - x0, dy = y1 - y0; - do - sumValue = nodes[i1++].value; - while (!sumValue && i1 < n); - minValue = maxValue = sumValue; - alpha = Math.max(dy / dx, dx / dy) / (value * ratio); - beta = sumValue * sumValue * alpha; - minRatio = Math.max(maxValue / beta, beta / minValue); - for (; i1 < n; ++i1) { - sumValue += nodeValue = nodes[i1].value; - if (nodeValue < minValue) minValue = nodeValue; - if (nodeValue > maxValue) maxValue = nodeValue; - beta = sumValue * sumValue * alpha; - newRatio = Math.max(maxValue / beta, beta / minValue); - if (newRatio > minRatio) { - sumValue -= nodeValue; - break; - } - minRatio = newRatio; - } - rows.push(row = { value: sumValue, dice: dx < dy, children: nodes.slice(i0, i1) }); - if (row.dice) dice_default(row, x0, y0, x1, value ? y0 += dy * sumValue / value : y1); - else slice_default(row, x0, y0, value ? x0 += dx * sumValue / value : x1, y1); - value -= sumValue, i0 = i1; - } - return rows; - } - __name(squarifyRatio, "squarifyRatio"); - var squarify_default = (/* @__PURE__ */ __name(function custom(ratio) { - function squarify(parent, x0, y0, x1, y1) { - squarifyRatio(ratio, parent, x0, y0, x1, y1); - } - __name(squarify, "squarify"); - squarify.ratio = function(x) { - return custom((x = +x) > 1 ? x : 1); - }; - return squarify; - }, "custom"))(phi); - - // node_modules/d3-hierarchy/src/treemap/index.js - function treemap_default() { - var tile = squarify_default, round = false, dx = 1, dy = 1, paddingStack = [0], paddingInner = constantZero, paddingTop = constantZero, paddingRight = constantZero, paddingBottom = constantZero, paddingLeft = constantZero; - function treemap(root2) { - root2.x0 = root2.y0 = 0; - root2.x1 = dx; - root2.y1 = dy; - root2.eachBefore(positionNode); - paddingStack = [0]; - if (round) root2.eachBefore(round_default); - return root2; - } - __name(treemap, "treemap"); - function positionNode(node) { - var p = paddingStack[node.depth], x0 = node.x0 + p, y0 = node.y0 + p, x1 = node.x1 - p, y1 = node.y1 - p; - if (x1 < x0) x0 = x1 = (x0 + x1) / 2; - if (y1 < y0) y0 = y1 = (y0 + y1) / 2; - node.x0 = x0; - node.y0 = y0; - node.x1 = x1; - node.y1 = y1; - if (node.children) { - p = paddingStack[node.depth + 1] = paddingInner(node) / 2; - x0 += paddingLeft(node) - p; - y0 += paddingTop(node) - p; - x1 -= paddingRight(node) - p; - y1 -= paddingBottom(node) - p; - if (x1 < x0) x0 = x1 = (x0 + x1) / 2; - if (y1 < y0) y0 = y1 = (y0 + y1) / 2; - tile(node, x0, y0, x1, y1); - } - } - __name(positionNode, "positionNode"); - treemap.round = function(x) { - return arguments.length ? (round = !!x, treemap) : round; - }; - treemap.size = function(x) { - return arguments.length ? (dx = +x[0], dy = +x[1], treemap) : [dx, dy]; - }; - treemap.tile = function(x) { - return arguments.length ? (tile = required(x), treemap) : tile; - }; - treemap.padding = function(x) { - return arguments.length ? treemap.paddingInner(x).paddingOuter(x) : treemap.paddingInner(); - }; - treemap.paddingInner = function(x) { - return arguments.length ? (paddingInner = typeof x === "function" ? x : constant_default(+x), treemap) : paddingInner; - }; - treemap.paddingOuter = function(x) { - return arguments.length ? treemap.paddingTop(x).paddingRight(x).paddingBottom(x).paddingLeft(x) : treemap.paddingTop(); - }; - treemap.paddingTop = function(x) { - return arguments.length ? (paddingTop = typeof x === "function" ? x : constant_default(+x), treemap) : paddingTop; - }; - treemap.paddingRight = function(x) { - return arguments.length ? (paddingRight = typeof x === "function" ? x : constant_default(+x), treemap) : paddingRight; - }; - treemap.paddingBottom = function(x) { - return arguments.length ? (paddingBottom = typeof x === "function" ? x : constant_default(+x), treemap) : paddingBottom; - }; - treemap.paddingLeft = function(x) { - return arguments.length ? (paddingLeft = typeof x === "function" ? x : constant_default(+x), treemap) : paddingLeft; - }; - return treemap; - } - __name(treemap_default, "default"); - - // node_modules/d3-selection/src/namespaces.js - var xhtml = "http://www.w3.org/1999/xhtml"; - var namespaces_default = { - svg: "http://www.w3.org/2000/svg", - xhtml, - xlink: "http://www.w3.org/1999/xlink", - xml: "http://www.w3.org/XML/1998/namespace", - xmlns: "http://www.w3.org/2000/xmlns/" - }; - - // node_modules/d3-selection/src/namespace.js - function namespace_default(name) { - var prefix = name += "", i = prefix.indexOf(":"); - if (i >= 0 && (prefix = name.slice(0, i)) !== "xmlns") name = name.slice(i + 1); - return namespaces_default.hasOwnProperty(prefix) ? { space: namespaces_default[prefix], local: name } : name; - } - __name(namespace_default, "default"); - - // node_modules/d3-selection/src/creator.js - function creatorInherit(name) { - return function() { - var document2 = this.ownerDocument, uri = this.namespaceURI; - return uri === xhtml && document2.documentElement.namespaceURI === xhtml ? document2.createElement(name) : document2.createElementNS(uri, name); - }; - } - __name(creatorInherit, "creatorInherit"); - function creatorFixed(fullname) { - return function() { - return this.ownerDocument.createElementNS(fullname.space, fullname.local); - }; - } - __name(creatorFixed, "creatorFixed"); - function creator_default(name) { - var fullname = namespace_default(name); - return (fullname.local ? creatorFixed : creatorInherit)(fullname); - } - __name(creator_default, "default"); - - // node_modules/d3-selection/src/selector.js - function none() { - } - __name(none, "none"); - function selector_default(selector) { - return selector == null ? none : function() { - return this.querySelector(selector); - }; - } - __name(selector_default, "default"); - - // node_modules/d3-selection/src/selection/select.js - function select_default(select) { - if (typeof select !== "function") select = selector_default(select); - for (var groups = this._groups, m = groups.length, subgroups = new Array(m), j = 0; j < m; ++j) { - for (var group = groups[j], n = group.length, subgroup = subgroups[j] = new Array(n), node, subnode, i = 0; i < n; ++i) { - if ((node = group[i]) && (subnode = select.call(node, node.__data__, i, group))) { - if ("__data__" in node) subnode.__data__ = node.__data__; - subgroup[i] = subnode; - } - } - } - return new Selection(subgroups, this._parents); - } - __name(select_default, "default"); - - // node_modules/d3-selection/src/array.js - function array(x) { - return x == null ? [] : Array.isArray(x) ? x : Array.from(x); - } - __name(array, "array"); - - // node_modules/d3-selection/src/selectorAll.js - function empty() { - return []; - } - __name(empty, "empty"); - function selectorAll_default(selector) { - return selector == null ? empty : function() { - return this.querySelectorAll(selector); - }; - } - __name(selectorAll_default, "default"); - - // node_modules/d3-selection/src/selection/selectAll.js - function arrayAll(select) { - return function() { - return array(select.apply(this, arguments)); - }; - } - __name(arrayAll, "arrayAll"); - function selectAll_default(select) { - if (typeof select === "function") select = arrayAll(select); - else select = selectorAll_default(select); - for (var groups = this._groups, m = groups.length, subgroups = [], parents = [], j = 0; j < m; ++j) { - for (var group = groups[j], n = group.length, node, i = 0; i < n; ++i) { - if (node = group[i]) { - subgroups.push(select.call(node, node.__data__, i, group)); - parents.push(node); - } - } - } - return new Selection(subgroups, parents); - } - __name(selectAll_default, "default"); - - // node_modules/d3-selection/src/matcher.js - function matcher_default(selector) { - return function() { - return this.matches(selector); - }; - } - __name(matcher_default, "default"); - function childMatcher(selector) { - return function(node) { - return node.matches(selector); - }; - } - __name(childMatcher, "childMatcher"); - - // node_modules/d3-selection/src/selection/selectChild.js - var find = Array.prototype.find; - function childFind(match) { - return function() { - return find.call(this.children, match); - }; - } - __name(childFind, "childFind"); - function childFirst() { - return this.firstElementChild; - } - __name(childFirst, "childFirst"); - function selectChild_default(match) { - return this.select(match == null ? childFirst : childFind(typeof match === "function" ? match : childMatcher(match))); - } - __name(selectChild_default, "default"); - - // node_modules/d3-selection/src/selection/selectChildren.js - var filter = Array.prototype.filter; - function children() { - return Array.from(this.children); - } - __name(children, "children"); - function childrenFilter(match) { - return function() { - return filter.call(this.children, match); - }; - } - __name(childrenFilter, "childrenFilter"); - function selectChildren_default(match) { - return this.selectAll(match == null ? children : childrenFilter(typeof match === "function" ? match : childMatcher(match))); - } - __name(selectChildren_default, "default"); - - // node_modules/d3-selection/src/selection/filter.js - function filter_default(match) { - if (typeof match !== "function") match = matcher_default(match); - for (var groups = this._groups, m = groups.length, subgroups = new Array(m), j = 0; j < m; ++j) { - for (var group = groups[j], n = group.length, subgroup = subgroups[j] = [], node, i = 0; i < n; ++i) { - if ((node = group[i]) && match.call(node, node.__data__, i, group)) { - subgroup.push(node); - } - } - } - return new Selection(subgroups, this._parents); - } - __name(filter_default, "default"); - - // node_modules/d3-selection/src/selection/sparse.js - function sparse_default(update) { - return new Array(update.length); - } - __name(sparse_default, "default"); - - // node_modules/d3-selection/src/selection/enter.js - function enter_default() { - return new Selection(this._enter || this._groups.map(sparse_default), this._parents); - } - __name(enter_default, "default"); - function EnterNode(parent, datum2) { - this.ownerDocument = parent.ownerDocument; - this.namespaceURI = parent.namespaceURI; - this._next = null; - this._parent = parent; - this.__data__ = datum2; - } - __name(EnterNode, "EnterNode"); - EnterNode.prototype = { - constructor: EnterNode, - appendChild: /* @__PURE__ */ __name(function(child) { - return this._parent.insertBefore(child, this._next); - }, "appendChild"), - insertBefore: /* @__PURE__ */ __name(function(child, next) { - return this._parent.insertBefore(child, next); - }, "insertBefore"), - querySelector: /* @__PURE__ */ __name(function(selector) { - return this._parent.querySelector(selector); - }, "querySelector"), - querySelectorAll: /* @__PURE__ */ __name(function(selector) { - return this._parent.querySelectorAll(selector); - }, "querySelectorAll") - }; - - // node_modules/d3-selection/src/constant.js - function constant_default2(x) { - return function() { - return x; - }; - } - __name(constant_default2, "default"); - - // node_modules/d3-selection/src/selection/data.js - function bindIndex(parent, group, enter, update, exit, data) { - var i = 0, node, groupLength = group.length, dataLength = data.length; - for (; i < dataLength; ++i) { - if (node = group[i]) { - node.__data__ = data[i]; - update[i] = node; - } else { - enter[i] = new EnterNode(parent, data[i]); - } - } - for (; i < groupLength; ++i) { - if (node = group[i]) { - exit[i] = node; - } - } - } - __name(bindIndex, "bindIndex"); - function bindKey(parent, group, enter, update, exit, data, key) { - var i, node, nodeByKeyValue = /* @__PURE__ */ new Map(), groupLength = group.length, dataLength = data.length, keyValues = new Array(groupLength), keyValue; - for (i = 0; i < groupLength; ++i) { - if (node = group[i]) { - keyValues[i] = keyValue = key.call(node, node.__data__, i, group) + ""; - if (nodeByKeyValue.has(keyValue)) { - exit[i] = node; - } else { - nodeByKeyValue.set(keyValue, node); - } - } - } - for (i = 0; i < dataLength; ++i) { - keyValue = key.call(parent, data[i], i, data) + ""; - if (node = nodeByKeyValue.get(keyValue)) { - update[i] = node; - node.__data__ = data[i]; - nodeByKeyValue.delete(keyValue); - } else { - enter[i] = new EnterNode(parent, data[i]); - } - } - for (i = 0; i < groupLength; ++i) { - if ((node = group[i]) && nodeByKeyValue.get(keyValues[i]) === node) { - exit[i] = node; - } - } - } - __name(bindKey, "bindKey"); - function datum(node) { - return node.__data__; - } - __name(datum, "datum"); - function data_default(value, key) { - if (!arguments.length) return Array.from(this, datum); - var bind = key ? bindKey : bindIndex, parents = this._parents, groups = this._groups; - if (typeof value !== "function") value = constant_default2(value); - for (var m = groups.length, update = new Array(m), enter = new Array(m), exit = new Array(m), j = 0; j < m; ++j) { - var parent = parents[j], group = groups[j], groupLength = group.length, data = arraylike(value.call(parent, parent && parent.__data__, j, parents)), dataLength = data.length, enterGroup = enter[j] = new Array(dataLength), updateGroup = update[j] = new Array(dataLength), exitGroup = exit[j] = new Array(groupLength); - bind(parent, group, enterGroup, updateGroup, exitGroup, data, key); - for (var i0 = 0, i1 = 0, previous, next; i0 < dataLength; ++i0) { - if (previous = enterGroup[i0]) { - if (i0 >= i1) i1 = i0 + 1; - while (!(next = updateGroup[i1]) && ++i1 < dataLength) ; - previous._next = next || null; - } - } - } - update = new Selection(update, parents); - update._enter = enter; - update._exit = exit; - return update; - } - __name(data_default, "default"); - function arraylike(data) { - return typeof data === "object" && "length" in data ? data : Array.from(data); - } - __name(arraylike, "arraylike"); - - // node_modules/d3-selection/src/selection/exit.js - function exit_default() { - return new Selection(this._exit || this._groups.map(sparse_default), this._parents); - } - __name(exit_default, "default"); - - // node_modules/d3-selection/src/selection/join.js - function join_default(onenter, onupdate, onexit) { - var enter = this.enter(), update = this, exit = this.exit(); - if (typeof onenter === "function") { - enter = onenter(enter); - if (enter) enter = enter.selection(); - } else { - enter = enter.append(onenter + ""); - } - if (onupdate != null) { - update = onupdate(update); - if (update) update = update.selection(); - } - if (onexit == null) exit.remove(); - else onexit(exit); - return enter && update ? enter.merge(update).order() : update; - } - __name(join_default, "default"); - - // node_modules/d3-selection/src/selection/merge.js - function merge_default(context) { - var selection2 = context.selection ? context.selection() : context; - for (var groups0 = this._groups, groups1 = selection2._groups, m0 = groups0.length, m1 = groups1.length, m = Math.min(m0, m1), merges = new Array(m0), j = 0; j < m; ++j) { - for (var group0 = groups0[j], group1 = groups1[j], n = group0.length, merge = merges[j] = new Array(n), node, i = 0; i < n; ++i) { - if (node = group0[i] || group1[i]) { - merge[i] = node; - } - } - } - for (; j < m0; ++j) { - merges[j] = groups0[j]; - } - return new Selection(merges, this._parents); - } - __name(merge_default, "default"); - - // node_modules/d3-selection/src/selection/order.js - function order_default() { - for (var groups = this._groups, j = -1, m = groups.length; ++j < m; ) { - for (var group = groups[j], i = group.length - 1, next = group[i], node; --i >= 0; ) { - if (node = group[i]) { - if (next && node.compareDocumentPosition(next) ^ 4) next.parentNode.insertBefore(node, next); - next = node; - } - } - } - return this; - } - __name(order_default, "default"); - - // node_modules/d3-selection/src/selection/sort.js - function sort_default2(compare) { - if (!compare) compare = ascending; - function compareNode(a, b) { - return a && b ? compare(a.__data__, b.__data__) : !a - !b; - } - __name(compareNode, "compareNode"); - for (var groups = this._groups, m = groups.length, sortgroups = new Array(m), j = 0; j < m; ++j) { - for (var group = groups[j], n = group.length, sortgroup = sortgroups[j] = new Array(n), node, i = 0; i < n; ++i) { - if (node = group[i]) { - sortgroup[i] = node; - } - } - sortgroup.sort(compareNode); - } - return new Selection(sortgroups, this._parents).order(); - } - __name(sort_default2, "default"); - function ascending(a, b) { - return a < b ? -1 : a > b ? 1 : a >= b ? 0 : NaN; - } - __name(ascending, "ascending"); - - // node_modules/d3-selection/src/selection/call.js - function call_default() { - var callback = arguments[0]; - arguments[0] = this; - callback.apply(null, arguments); - return this; - } - __name(call_default, "default"); - - // node_modules/d3-selection/src/selection/nodes.js - function nodes_default() { - return Array.from(this); - } - __name(nodes_default, "default"); - - // node_modules/d3-selection/src/selection/node.js - function node_default() { - for (var groups = this._groups, j = 0, m = groups.length; j < m; ++j) { - for (var group = groups[j], i = 0, n = group.length; i < n; ++i) { - var node = group[i]; - if (node) return node; - } - } - return null; - } - __name(node_default, "default"); - - // node_modules/d3-selection/src/selection/size.js - function size_default() { - let size = 0; - for (const node of this) ++size; - return size; - } - __name(size_default, "default"); - - // node_modules/d3-selection/src/selection/empty.js - function empty_default() { - return !this.node(); - } - __name(empty_default, "default"); - - // node_modules/d3-selection/src/selection/each.js - function each_default2(callback) { - for (var groups = this._groups, j = 0, m = groups.length; j < m; ++j) { - for (var group = groups[j], i = 0, n = group.length, node; i < n; ++i) { - if (node = group[i]) callback.call(node, node.__data__, i, group); - } - } - return this; - } - __name(each_default2, "default"); - - // node_modules/d3-selection/src/selection/attr.js - function attrRemove(name) { - return function() { - this.removeAttribute(name); - }; - } - __name(attrRemove, "attrRemove"); - function attrRemoveNS(fullname) { - return function() { - this.removeAttributeNS(fullname.space, fullname.local); - }; - } - __name(attrRemoveNS, "attrRemoveNS"); - function attrConstant(name, value) { - return function() { - this.setAttribute(name, value); - }; - } - __name(attrConstant, "attrConstant"); - function attrConstantNS(fullname, value) { - return function() { - this.setAttributeNS(fullname.space, fullname.local, value); - }; - } - __name(attrConstantNS, "attrConstantNS"); - function attrFunction(name, value) { - return function() { - var v = value.apply(this, arguments); - if (v == null) this.removeAttribute(name); - else this.setAttribute(name, v); - }; - } - __name(attrFunction, "attrFunction"); - function attrFunctionNS(fullname, value) { - return function() { - var v = value.apply(this, arguments); - if (v == null) this.removeAttributeNS(fullname.space, fullname.local); - else this.setAttributeNS(fullname.space, fullname.local, v); - }; - } - __name(attrFunctionNS, "attrFunctionNS"); - function attr_default(name, value) { - var fullname = namespace_default(name); - if (arguments.length < 2) { - var node = this.node(); - return fullname.local ? node.getAttributeNS(fullname.space, fullname.local) : node.getAttribute(fullname); - } - return this.each((value == null ? fullname.local ? attrRemoveNS : attrRemove : typeof value === "function" ? fullname.local ? attrFunctionNS : attrFunction : fullname.local ? attrConstantNS : attrConstant)(fullname, value)); - } - __name(attr_default, "default"); - - // node_modules/d3-selection/src/window.js - function window_default(node) { - return node.ownerDocument && node.ownerDocument.defaultView || node.document && node || node.defaultView; - } - __name(window_default, "default"); - - // node_modules/d3-selection/src/selection/style.js - function styleRemove(name) { - return function() { - this.style.removeProperty(name); - }; - } - __name(styleRemove, "styleRemove"); - function styleConstant(name, value, priority) { - return function() { - this.style.setProperty(name, value, priority); - }; - } - __name(styleConstant, "styleConstant"); - function styleFunction(name, value, priority) { - return function() { - var v = value.apply(this, arguments); - if (v == null) this.style.removeProperty(name); - else this.style.setProperty(name, v, priority); - }; - } - __name(styleFunction, "styleFunction"); - function style_default(name, value, priority) { - return arguments.length > 1 ? this.each((value == null ? styleRemove : typeof value === "function" ? styleFunction : styleConstant)(name, value, priority == null ? "" : priority)) : styleValue(this.node(), name); - } - __name(style_default, "default"); - function styleValue(node, name) { - return node.style.getPropertyValue(name) || window_default(node).getComputedStyle(node, null).getPropertyValue(name); - } - __name(styleValue, "styleValue"); - - // node_modules/d3-selection/src/selection/property.js - function propertyRemove(name) { - return function() { - delete this[name]; - }; - } - __name(propertyRemove, "propertyRemove"); - function propertyConstant(name, value) { - return function() { - this[name] = value; - }; - } - __name(propertyConstant, "propertyConstant"); - function propertyFunction(name, value) { - return function() { - var v = value.apply(this, arguments); - if (v == null) delete this[name]; - else this[name] = v; - }; - } - __name(propertyFunction, "propertyFunction"); - function property_default(name, value) { - return arguments.length > 1 ? this.each((value == null ? propertyRemove : typeof value === "function" ? propertyFunction : propertyConstant)(name, value)) : this.node()[name]; - } - __name(property_default, "default"); - - // node_modules/d3-selection/src/selection/classed.js - function classArray(string) { - return string.trim().split(/^|\s+/); - } - __name(classArray, "classArray"); - function classList(node) { - return node.classList || new ClassList(node); - } - __name(classList, "classList"); - function ClassList(node) { - this._node = node; - this._names = classArray(node.getAttribute("class") || ""); - } - __name(ClassList, "ClassList"); - ClassList.prototype = { - add: /* @__PURE__ */ __name(function(name) { - var i = this._names.indexOf(name); - if (i < 0) { - this._names.push(name); - this._node.setAttribute("class", this._names.join(" ")); - } - }, "add"), - remove: /* @__PURE__ */ __name(function(name) { - var i = this._names.indexOf(name); - if (i >= 0) { - this._names.splice(i, 1); - this._node.setAttribute("class", this._names.join(" ")); - } - }, "remove"), - contains: /* @__PURE__ */ __name(function(name) { - return this._names.indexOf(name) >= 0; - }, "contains") - }; - function classedAdd(node, names) { - var list = classList(node), i = -1, n = names.length; - while (++i < n) list.add(names[i]); - } - __name(classedAdd, "classedAdd"); - function classedRemove(node, names) { - var list = classList(node), i = -1, n = names.length; - while (++i < n) list.remove(names[i]); - } - __name(classedRemove, "classedRemove"); - function classedTrue(names) { - return function() { - classedAdd(this, names); - }; - } - __name(classedTrue, "classedTrue"); - function classedFalse(names) { - return function() { - classedRemove(this, names); - }; - } - __name(classedFalse, "classedFalse"); - function classedFunction(names, value) { - return function() { - (value.apply(this, arguments) ? classedAdd : classedRemove)(this, names); - }; - } - __name(classedFunction, "classedFunction"); - function classed_default(name, value) { - var names = classArray(name + ""); - if (arguments.length < 2) { - var list = classList(this.node()), i = -1, n = names.length; - while (++i < n) if (!list.contains(names[i])) return false; - return true; - } - return this.each((typeof value === "function" ? classedFunction : value ? classedTrue : classedFalse)(names, value)); - } - __name(classed_default, "default"); - - // node_modules/d3-selection/src/selection/text.js - function textRemove() { - this.textContent = ""; - } - __name(textRemove, "textRemove"); - function textConstant(value) { - return function() { - this.textContent = value; - }; - } - __name(textConstant, "textConstant"); - function textFunction(value) { - return function() { - var v = value.apply(this, arguments); - this.textContent = v == null ? "" : v; - }; - } - __name(textFunction, "textFunction"); - function text_default(value) { - return arguments.length ? this.each(value == null ? textRemove : (typeof value === "function" ? textFunction : textConstant)(value)) : this.node().textContent; - } - __name(text_default, "default"); - - // node_modules/d3-selection/src/selection/html.js - function htmlRemove() { - this.innerHTML = ""; - } - __name(htmlRemove, "htmlRemove"); - function htmlConstant(value) { - return function() { - this.innerHTML = value; - }; - } - __name(htmlConstant, "htmlConstant"); - function htmlFunction(value) { - return function() { - var v = value.apply(this, arguments); - this.innerHTML = v == null ? "" : v; - }; - } - __name(htmlFunction, "htmlFunction"); - function html_default(value) { - return arguments.length ? this.each(value == null ? htmlRemove : (typeof value === "function" ? htmlFunction : htmlConstant)(value)) : this.node().innerHTML; - } - __name(html_default, "default"); - - // node_modules/d3-selection/src/selection/raise.js - function raise() { - if (this.nextSibling) this.parentNode.appendChild(this); - } - __name(raise, "raise"); - function raise_default() { - return this.each(raise); - } - __name(raise_default, "default"); - - // node_modules/d3-selection/src/selection/lower.js - function lower() { - if (this.previousSibling) this.parentNode.insertBefore(this, this.parentNode.firstChild); - } - __name(lower, "lower"); - function lower_default() { - return this.each(lower); - } - __name(lower_default, "default"); - - // node_modules/d3-selection/src/selection/append.js - function append_default(name) { - var create3 = typeof name === "function" ? name : creator_default(name); - return this.select(function() { - return this.appendChild(create3.apply(this, arguments)); - }); - } - __name(append_default, "default"); - - // node_modules/d3-selection/src/selection/insert.js - function constantNull() { - return null; - } - __name(constantNull, "constantNull"); - function insert_default(name, before) { - var create3 = typeof name === "function" ? name : creator_default(name), select = before == null ? constantNull : typeof before === "function" ? before : selector_default(before); - return this.select(function() { - return this.insertBefore(create3.apply(this, arguments), select.apply(this, arguments) || null); - }); - } - __name(insert_default, "default"); - - // node_modules/d3-selection/src/selection/remove.js - function remove() { - var parent = this.parentNode; - if (parent) parent.removeChild(this); - } - __name(remove, "remove"); - function remove_default() { - return this.each(remove); - } - __name(remove_default, "default"); - - // node_modules/d3-selection/src/selection/clone.js - function selection_cloneShallow() { - var clone = this.cloneNode(false), parent = this.parentNode; - return parent ? parent.insertBefore(clone, this.nextSibling) : clone; - } - __name(selection_cloneShallow, "selection_cloneShallow"); - function selection_cloneDeep() { - var clone = this.cloneNode(true), parent = this.parentNode; - return parent ? parent.insertBefore(clone, this.nextSibling) : clone; - } - __name(selection_cloneDeep, "selection_cloneDeep"); - function clone_default(deep) { - return this.select(deep ? selection_cloneDeep : selection_cloneShallow); - } - __name(clone_default, "default"); - - // node_modules/d3-selection/src/selection/datum.js - function datum_default(value) { - return arguments.length ? this.property("__data__", value) : this.node().__data__; - } - __name(datum_default, "default"); - - // node_modules/d3-selection/src/selection/on.js - function contextListener(listener) { - return function(event) { - listener.call(this, event, this.__data__); - }; - } - __name(contextListener, "contextListener"); - function parseTypenames(typenames) { - return typenames.trim().split(/^|\s+/).map(function(t) { - var name = "", i = t.indexOf("."); - if (i >= 0) name = t.slice(i + 1), t = t.slice(0, i); - return { type: t, name }; - }); - } - __name(parseTypenames, "parseTypenames"); - function onRemove(typename) { - return function() { - var on = this.__on; - if (!on) return; - for (var j = 0, i = -1, m = on.length, o; j < m; ++j) { - if (o = on[j], (!typename.type || o.type === typename.type) && o.name === typename.name) { - this.removeEventListener(o.type, o.listener, o.options); - } else { - on[++i] = o; - } - } - if (++i) on.length = i; - else delete this.__on; - }; - } - __name(onRemove, "onRemove"); - function onAdd(typename, value, options) { - return function() { - var on = this.__on, o, listener = contextListener(value); - if (on) for (var j = 0, m = on.length; j < m; ++j) { - if ((o = on[j]).type === typename.type && o.name === typename.name) { - this.removeEventListener(o.type, o.listener, o.options); - this.addEventListener(o.type, o.listener = listener, o.options = options); - o.value = value; - return; - } - } - this.addEventListener(typename.type, listener, options); - o = { type: typename.type, name: typename.name, value, listener, options }; - if (!on) this.__on = [o]; - else on.push(o); - }; - } - __name(onAdd, "onAdd"); - function on_default(typename, value, options) { - var typenames = parseTypenames(typename + ""), i, n = typenames.length, t; - if (arguments.length < 2) { - var on = this.node().__on; - if (on) for (var j = 0, m = on.length, o; j < m; ++j) { - for (i = 0, o = on[j]; i < n; ++i) { - if ((t = typenames[i]).type === o.type && t.name === o.name) { - return o.value; - } - } - } - return; - } - on = value ? onAdd : onRemove; - for (i = 0; i < n; ++i) this.each(on(typenames[i], value, options)); - return this; - } - __name(on_default, "default"); - - // node_modules/d3-selection/src/selection/dispatch.js - function dispatchEvent(node, type, params) { - var window2 = window_default(node), event = window2.CustomEvent; - if (typeof event === "function") { - event = new event(type, params); - } else { - event = window2.document.createEvent("Event"); - if (params) event.initEvent(type, params.bubbles, params.cancelable), event.detail = params.detail; - else event.initEvent(type, false, false); - } - node.dispatchEvent(event); - } - __name(dispatchEvent, "dispatchEvent"); - function dispatchConstant(type, params) { - return function() { - return dispatchEvent(this, type, params); - }; - } - __name(dispatchConstant, "dispatchConstant"); - function dispatchFunction(type, params) { - return function() { - return dispatchEvent(this, type, params.apply(this, arguments)); - }; - } - __name(dispatchFunction, "dispatchFunction"); - function dispatch_default(type, params) { - return this.each((typeof params === "function" ? dispatchFunction : dispatchConstant)(type, params)); - } - __name(dispatch_default, "default"); - - // node_modules/d3-selection/src/selection/iterator.js - function* iterator_default2() { - for (var groups = this._groups, j = 0, m = groups.length; j < m; ++j) { - for (var group = groups[j], i = 0, n = group.length, node; i < n; ++i) { - if (node = group[i]) yield node; - } - } - } - __name(iterator_default2, "default"); - - // node_modules/d3-selection/src/selection/index.js - var root = [null]; - function Selection(groups, parents) { - this._groups = groups; - this._parents = parents; - } - __name(Selection, "Selection"); - function selection() { - return new Selection([[document.documentElement]], root); - } - __name(selection, "selection"); - function selection_selection() { - return this; - } - __name(selection_selection, "selection_selection"); - Selection.prototype = selection.prototype = { - constructor: Selection, - select: select_default, - selectAll: selectAll_default, - selectChild: selectChild_default, - selectChildren: selectChildren_default, - filter: filter_default, - data: data_default, - enter: enter_default, - exit: exit_default, - join: join_default, - merge: merge_default, - selection: selection_selection, - order: order_default, - sort: sort_default2, - call: call_default, - nodes: nodes_default, - node: node_default, - size: size_default, - empty: empty_default, - each: each_default2, - attr: attr_default, - style: style_default, - property: property_default, - classed: classed_default, - text: text_default, - html: html_default, - raise: raise_default, - lower: lower_default, - append: append_default, - insert: insert_default, - remove: remove_default, - clone: clone_default, - datum: datum_default, - on: on_default, - dispatch: dispatch_default, - [Symbol.iterator]: iterator_default2 - }; - var selection_default = selection; - - // node_modules/d3-selection/src/select.js - function select_default2(selector) { - return typeof selector === "string" ? new Selection([[document.querySelector(selector)]], [document.documentElement]) : new Selection([[selector]], root); - } - __name(select_default2, "default"); - - // node_modules/d3-selection/src/sourceEvent.js - function sourceEvent_default(event) { - let sourceEvent; - while (sourceEvent = event.sourceEvent) event = sourceEvent; - return event; - } - __name(sourceEvent_default, "default"); - - // node_modules/d3-selection/src/pointer.js - function pointer_default(event, node) { - event = sourceEvent_default(event); - if (node === void 0) node = event.currentTarget; - if (node) { - var svg = node.ownerSVGElement || node; - if (svg.createSVGPoint) { - var point = svg.createSVGPoint(); - point.x = event.clientX, point.y = event.clientY; - point = point.matrixTransform(node.getScreenCTM().inverse()); - return [point.x, point.y]; - } - if (node.getBoundingClientRect) { - var rect = node.getBoundingClientRect(); - return [event.clientX - rect.left - node.clientLeft, event.clientY - rect.top - node.clientTop]; - } - } - return [event.pageX, event.pageY]; - } - __name(pointer_default, "default"); - - // node_modules/d3-selection/src/selectAll.js - function selectAll_default2(selector) { - return typeof selector === "string" ? new Selection([document.querySelectorAll(selector)], [document.documentElement]) : new Selection([array(selector)], root); - } - __name(selectAll_default2, "default"); - - // node_modules/d3-dispatch/src/dispatch.js - var noop = { value: /* @__PURE__ */ __name(() => { - }, "value") }; - function dispatch() { - for (var i = 0, n = arguments.length, _ = {}, t; i < n; ++i) { - if (!(t = arguments[i] + "") || t in _ || /[\s.]/.test(t)) throw new Error("illegal type: " + t); - _[t] = []; - } - return new Dispatch(_); - } - __name(dispatch, "dispatch"); - function Dispatch(_) { - this._ = _; - } - __name(Dispatch, "Dispatch"); - function parseTypenames2(typenames, types) { - return typenames.trim().split(/^|\s+/).map(function(t) { - var name = "", i = t.indexOf("."); - if (i >= 0) name = t.slice(i + 1), t = t.slice(0, i); - if (t && !types.hasOwnProperty(t)) throw new Error("unknown type: " + t); - return { type: t, name }; - }); - } - __name(parseTypenames2, "parseTypenames"); - Dispatch.prototype = dispatch.prototype = { - constructor: Dispatch, - on: /* @__PURE__ */ __name(function(typename, callback) { - var _ = this._, T = parseTypenames2(typename + "", _), t, i = -1, n = T.length; - if (arguments.length < 2) { - while (++i < n) if ((t = (typename = T[i]).type) && (t = get(_[t], typename.name))) return t; - return; - } - if (callback != null && typeof callback !== "function") throw new Error("invalid callback: " + callback); - while (++i < n) { - if (t = (typename = T[i]).type) _[t] = set(_[t], typename.name, callback); - else if (callback == null) for (t in _) _[t] = set(_[t], typename.name, null); - } - return this; - }, "on"), - copy: /* @__PURE__ */ __name(function() { - var copy = {}, _ = this._; - for (var t in _) copy[t] = _[t].slice(); - return new Dispatch(copy); - }, "copy"), - call: /* @__PURE__ */ __name(function(type, that) { - if ((n = arguments.length - 2) > 0) for (var args = new Array(n), i = 0, n, t; i < n; ++i) args[i] = arguments[i + 2]; - if (!this._.hasOwnProperty(type)) throw new Error("unknown type: " + type); - for (t = this._[type], i = 0, n = t.length; i < n; ++i) t[i].value.apply(that, args); - }, "call"), - apply: /* @__PURE__ */ __name(function(type, that, args) { - if (!this._.hasOwnProperty(type)) throw new Error("unknown type: " + type); - for (var t = this._[type], i = 0, n = t.length; i < n; ++i) t[i].value.apply(that, args); - }, "apply") - }; - function get(type, name) { - for (var i = 0, n = type.length, c; i < n; ++i) { - if ((c = type[i]).name === name) { - return c.value; - } - } - } - __name(get, "get"); - function set(type, name, callback) { - for (var i = 0, n = type.length; i < n; ++i) { - if (type[i].name === name) { - type[i] = noop, type = type.slice(0, i).concat(type.slice(i + 1)); - break; - } - } - if (callback != null) type.push({ name, value: callback }); - return type; - } - __name(set, "set"); - var dispatch_default2 = dispatch; - - // node_modules/d3-drag/src/noevent.js - var nonpassivecapture = { capture: true, passive: false }; - function noevent_default(event) { - event.preventDefault(); - event.stopImmediatePropagation(); - } - __name(noevent_default, "default"); - - // node_modules/d3-drag/src/nodrag.js - function nodrag_default(view) { - var root2 = view.document.documentElement, selection2 = select_default2(view).on("dragstart.drag", noevent_default, nonpassivecapture); - if ("onselectstart" in root2) { - selection2.on("selectstart.drag", noevent_default, nonpassivecapture); - } else { - root2.__noselect = root2.style.MozUserSelect; - root2.style.MozUserSelect = "none"; - } - } - __name(nodrag_default, "default"); - function yesdrag(view, noclick) { - var root2 = view.document.documentElement, selection2 = select_default2(view).on("dragstart.drag", null); - if (noclick) { - selection2.on("click.drag", noevent_default, nonpassivecapture); - setTimeout(function() { - selection2.on("click.drag", null); - }, 0); - } - if ("onselectstart" in root2) { - selection2.on("selectstart.drag", null); - } else { - root2.style.MozUserSelect = root2.__noselect; - delete root2.__noselect; - } - } - __name(yesdrag, "yesdrag"); - - // node_modules/d3-color/src/define.js - function define_default(constructor, factory, prototype) { - constructor.prototype = factory.prototype = prototype; - prototype.constructor = constructor; - } - __name(define_default, "default"); - function extend(parent, definition) { - var prototype = Object.create(parent.prototype); - for (var key in definition) prototype[key] = definition[key]; - return prototype; - } - __name(extend, "extend"); - - // node_modules/d3-color/src/color.js - function Color() { - } - __name(Color, "Color"); - var darker = 0.7; - var brighter = 1 / darker; - var reI = "\\s*([+-]?\\d+)\\s*"; - var reN = "\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)\\s*"; - var reP = "\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)%\\s*"; - var reHex = /^#([0-9a-f]{3,8})$/; - var reRgbInteger = new RegExp(`^rgb\\(${reI},${reI},${reI}\\)$`); - var reRgbPercent = new RegExp(`^rgb\\(${reP},${reP},${reP}\\)$`); - var reRgbaInteger = new RegExp(`^rgba\\(${reI},${reI},${reI},${reN}\\)$`); - var reRgbaPercent = new RegExp(`^rgba\\(${reP},${reP},${reP},${reN}\\)$`); - var reHslPercent = new RegExp(`^hsl\\(${reN},${reP},${reP}\\)$`); - var reHslaPercent = new RegExp(`^hsla\\(${reN},${reP},${reP},${reN}\\)$`); - var named = { - aliceblue: 15792383, - antiquewhite: 16444375, - aqua: 65535, - aquamarine: 8388564, - azure: 15794175, - beige: 16119260, - bisque: 16770244, - black: 0, - blanchedalmond: 16772045, - blue: 255, - blueviolet: 9055202, - brown: 10824234, - burlywood: 14596231, - cadetblue: 6266528, - chartreuse: 8388352, - chocolate: 13789470, - coral: 16744272, - cornflowerblue: 6591981, - cornsilk: 16775388, - crimson: 14423100, - cyan: 65535, - darkblue: 139, - darkcyan: 35723, - darkgoldenrod: 12092939, - darkgray: 11119017, - darkgreen: 25600, - darkgrey: 11119017, - darkkhaki: 12433259, - darkmagenta: 9109643, - darkolivegreen: 5597999, - darkorange: 16747520, - darkorchid: 10040012, - darkred: 9109504, - darksalmon: 15308410, - darkseagreen: 9419919, - darkslateblue: 4734347, - darkslategray: 3100495, - darkslategrey: 3100495, - darkturquoise: 52945, - darkviolet: 9699539, - deeppink: 16716947, - deepskyblue: 49151, - dimgray: 6908265, - dimgrey: 6908265, - dodgerblue: 2003199, - firebrick: 11674146, - floralwhite: 16775920, - forestgreen: 2263842, - fuchsia: 16711935, - gainsboro: 14474460, - ghostwhite: 16316671, - gold: 16766720, - goldenrod: 14329120, - gray: 8421504, - green: 32768, - greenyellow: 11403055, - grey: 8421504, - honeydew: 15794160, - hotpink: 16738740, - indianred: 13458524, - indigo: 4915330, - ivory: 16777200, - khaki: 15787660, - lavender: 15132410, - lavenderblush: 16773365, - lawngreen: 8190976, - lemonchiffon: 16775885, - lightblue: 11393254, - lightcoral: 15761536, - lightcyan: 14745599, - lightgoldenrodyellow: 16448210, - lightgray: 13882323, - lightgreen: 9498256, - lightgrey: 13882323, - lightpink: 16758465, - lightsalmon: 16752762, - lightseagreen: 2142890, - lightskyblue: 8900346, - lightslategray: 7833753, - lightslategrey: 7833753, - lightsteelblue: 11584734, - lightyellow: 16777184, - lime: 65280, - limegreen: 3329330, - linen: 16445670, - magenta: 16711935, - maroon: 8388608, - mediumaquamarine: 6737322, - mediumblue: 205, - mediumorchid: 12211667, - mediumpurple: 9662683, - mediumseagreen: 3978097, - mediumslateblue: 8087790, - mediumspringgreen: 64154, - mediumturquoise: 4772300, - mediumvioletred: 13047173, - midnightblue: 1644912, - mintcream: 16121850, - mistyrose: 16770273, - moccasin: 16770229, - navajowhite: 16768685, - navy: 128, - oldlace: 16643558, - olive: 8421376, - olivedrab: 7048739, - orange: 16753920, - orangered: 16729344, - orchid: 14315734, - palegoldenrod: 15657130, - palegreen: 10025880, - paleturquoise: 11529966, - palevioletred: 14381203, - papayawhip: 16773077, - peachpuff: 16767673, - peru: 13468991, - pink: 16761035, - plum: 14524637, - powderblue: 11591910, - purple: 8388736, - rebeccapurple: 6697881, - red: 16711680, - rosybrown: 12357519, - royalblue: 4286945, - saddlebrown: 9127187, - salmon: 16416882, - sandybrown: 16032864, - seagreen: 3050327, - seashell: 16774638, - sienna: 10506797, - silver: 12632256, - skyblue: 8900331, - slateblue: 6970061, - slategray: 7372944, - slategrey: 7372944, - snow: 16775930, - springgreen: 65407, - steelblue: 4620980, - tan: 13808780, - teal: 32896, - thistle: 14204888, - tomato: 16737095, - turquoise: 4251856, - violet: 15631086, - wheat: 16113331, - white: 16777215, - whitesmoke: 16119285, - yellow: 16776960, - yellowgreen: 10145074 - }; - define_default(Color, color, { - copy(channels) { - return Object.assign(new this.constructor(), this, channels); - }, - displayable() { - return this.rgb().displayable(); - }, - hex: color_formatHex, - // Deprecated! Use color.formatHex. - formatHex: color_formatHex, - formatHex8: color_formatHex8, - formatHsl: color_formatHsl, - formatRgb: color_formatRgb, - toString: color_formatRgb - }); - function color_formatHex() { - return this.rgb().formatHex(); - } - __name(color_formatHex, "color_formatHex"); - function color_formatHex8() { - return this.rgb().formatHex8(); - } - __name(color_formatHex8, "color_formatHex8"); - function color_formatHsl() { - return hslConvert(this).formatHsl(); - } - __name(color_formatHsl, "color_formatHsl"); - function color_formatRgb() { - return this.rgb().formatRgb(); - } - __name(color_formatRgb, "color_formatRgb"); - function color(format) { - var m, l; - format = (format + "").trim().toLowerCase(); - return (m = reHex.exec(format)) ? (l = m[1].length, m = parseInt(m[1], 16), l === 6 ? rgbn(m) : l === 3 ? new Rgb(m >> 8 & 15 | m >> 4 & 240, m >> 4 & 15 | m & 240, (m & 15) << 4 | m & 15, 1) : l === 8 ? rgba(m >> 24 & 255, m >> 16 & 255, m >> 8 & 255, (m & 255) / 255) : l === 4 ? rgba(m >> 12 & 15 | m >> 8 & 240, m >> 8 & 15 | m >> 4 & 240, m >> 4 & 15 | m & 240, ((m & 15) << 4 | m & 15) / 255) : null) : (m = reRgbInteger.exec(format)) ? new Rgb(m[1], m[2], m[3], 1) : (m = reRgbPercent.exec(format)) ? new Rgb(m[1] * 255 / 100, m[2] * 255 / 100, m[3] * 255 / 100, 1) : (m = reRgbaInteger.exec(format)) ? rgba(m[1], m[2], m[3], m[4]) : (m = reRgbaPercent.exec(format)) ? rgba(m[1] * 255 / 100, m[2] * 255 / 100, m[3] * 255 / 100, m[4]) : (m = reHslPercent.exec(format)) ? hsla(m[1], m[2] / 100, m[3] / 100, 1) : (m = reHslaPercent.exec(format)) ? hsla(m[1], m[2] / 100, m[3] / 100, m[4]) : named.hasOwnProperty(format) ? rgbn(named[format]) : format === "transparent" ? new Rgb(NaN, NaN, NaN, 0) : null; - } - __name(color, "color"); - function rgbn(n) { - return new Rgb(n >> 16 & 255, n >> 8 & 255, n & 255, 1); - } - __name(rgbn, "rgbn"); - function rgba(r, g, b, a) { - if (a <= 0) r = g = b = NaN; - return new Rgb(r, g, b, a); - } - __name(rgba, "rgba"); - function rgbConvert(o) { - if (!(o instanceof Color)) o = color(o); - if (!o) return new Rgb(); - o = o.rgb(); - return new Rgb(o.r, o.g, o.b, o.opacity); - } - __name(rgbConvert, "rgbConvert"); - function rgb(r, g, b, opacity) { - return arguments.length === 1 ? rgbConvert(r) : new Rgb(r, g, b, opacity == null ? 1 : opacity); - } - __name(rgb, "rgb"); - function Rgb(r, g, b, opacity) { - this.r = +r; - this.g = +g; - this.b = +b; - this.opacity = +opacity; - } - __name(Rgb, "Rgb"); - define_default(Rgb, rgb, extend(Color, { - brighter(k) { - k = k == null ? brighter : Math.pow(brighter, k); - return new Rgb(this.r * k, this.g * k, this.b * k, this.opacity); - }, - darker(k) { - k = k == null ? darker : Math.pow(darker, k); - return new Rgb(this.r * k, this.g * k, this.b * k, this.opacity); - }, - rgb() { - return this; - }, - clamp() { - return new Rgb(clampi(this.r), clampi(this.g), clampi(this.b), clampa(this.opacity)); - }, - displayable() { - return -0.5 <= this.r && this.r < 255.5 && (-0.5 <= this.g && this.g < 255.5) && (-0.5 <= this.b && this.b < 255.5) && (0 <= this.opacity && this.opacity <= 1); - }, - hex: rgb_formatHex, - // Deprecated! Use color.formatHex. - formatHex: rgb_formatHex, - formatHex8: rgb_formatHex8, - formatRgb: rgb_formatRgb, - toString: rgb_formatRgb - })); - function rgb_formatHex() { - return `#${hex(this.r)}${hex(this.g)}${hex(this.b)}`; - } - __name(rgb_formatHex, "rgb_formatHex"); - function rgb_formatHex8() { - return `#${hex(this.r)}${hex(this.g)}${hex(this.b)}${hex((isNaN(this.opacity) ? 1 : this.opacity) * 255)}`; - } - __name(rgb_formatHex8, "rgb_formatHex8"); - function rgb_formatRgb() { - const a = clampa(this.opacity); - return `${a === 1 ? "rgb(" : "rgba("}${clampi(this.r)}, ${clampi(this.g)}, ${clampi(this.b)}${a === 1 ? ")" : `, ${a})`}`; - } - __name(rgb_formatRgb, "rgb_formatRgb"); - function clampa(opacity) { - return isNaN(opacity) ? 1 : Math.max(0, Math.min(1, opacity)); - } - __name(clampa, "clampa"); - function clampi(value) { - return Math.max(0, Math.min(255, Math.round(value) || 0)); - } - __name(clampi, "clampi"); - function hex(value) { - value = clampi(value); - return (value < 16 ? "0" : "") + value.toString(16); - } - __name(hex, "hex"); - function hsla(h, s, l, a) { - if (a <= 0) h = s = l = NaN; - else if (l <= 0 || l >= 1) h = s = NaN; - else if (s <= 0) h = NaN; - return new Hsl(h, s, l, a); - } - __name(hsla, "hsla"); - function hslConvert(o) { - if (o instanceof Hsl) return new Hsl(o.h, o.s, o.l, o.opacity); - if (!(o instanceof Color)) o = color(o); - if (!o) return new Hsl(); - if (o instanceof Hsl) return o; - o = o.rgb(); - var r = o.r / 255, g = o.g / 255, b = o.b / 255, min = Math.min(r, g, b), max = Math.max(r, g, b), h = NaN, s = max - min, l = (max + min) / 2; - if (s) { - if (r === max) h = (g - b) / s + (g < b) * 6; - else if (g === max) h = (b - r) / s + 2; - else h = (r - g) / s + 4; - s /= l < 0.5 ? max + min : 2 - max - min; - h *= 60; - } else { - s = l > 0 && l < 1 ? 0 : h; - } - return new Hsl(h, s, l, o.opacity); - } - __name(hslConvert, "hslConvert"); - function hsl(h, s, l, opacity) { - return arguments.length === 1 ? hslConvert(h) : new Hsl(h, s, l, opacity == null ? 1 : opacity); - } - __name(hsl, "hsl"); - function Hsl(h, s, l, opacity) { - this.h = +h; - this.s = +s; - this.l = +l; - this.opacity = +opacity; - } - __name(Hsl, "Hsl"); - define_default(Hsl, hsl, extend(Color, { - brighter(k) { - k = k == null ? brighter : Math.pow(brighter, k); - return new Hsl(this.h, this.s, this.l * k, this.opacity); - }, - darker(k) { - k = k == null ? darker : Math.pow(darker, k); - return new Hsl(this.h, this.s, this.l * k, this.opacity); - }, - rgb() { - var h = this.h % 360 + (this.h < 0) * 360, s = isNaN(h) || isNaN(this.s) ? 0 : this.s, l = this.l, m2 = l + (l < 0.5 ? l : 1 - l) * s, m1 = 2 * l - m2; - return new Rgb( - hsl2rgb(h >= 240 ? h - 240 : h + 120, m1, m2), - hsl2rgb(h, m1, m2), - hsl2rgb(h < 120 ? h + 240 : h - 120, m1, m2), - this.opacity - ); - }, - clamp() { - return new Hsl(clamph(this.h), clampt(this.s), clampt(this.l), clampa(this.opacity)); - }, - displayable() { - return (0 <= this.s && this.s <= 1 || isNaN(this.s)) && (0 <= this.l && this.l <= 1) && (0 <= this.opacity && this.opacity <= 1); - }, - formatHsl() { - const a = clampa(this.opacity); - return `${a === 1 ? "hsl(" : "hsla("}${clamph(this.h)}, ${clampt(this.s) * 100}%, ${clampt(this.l) * 100}%${a === 1 ? ")" : `, ${a})`}`; - } - })); - function clamph(value) { - value = (value || 0) % 360; - return value < 0 ? value + 360 : value; - } - __name(clamph, "clamph"); - function clampt(value) { - return Math.max(0, Math.min(1, value || 0)); - } - __name(clampt, "clampt"); - function hsl2rgb(h, m1, m2) { - return (h < 60 ? m1 + (m2 - m1) * h / 60 : h < 180 ? m2 : h < 240 ? m1 + (m2 - m1) * (240 - h) / 60 : m1) * 255; - } - __name(hsl2rgb, "hsl2rgb"); - - // node_modules/d3-interpolate/src/basis.js - function basis(t1, v0, v1, v2, v3) { - var t2 = t1 * t1, t3 = t2 * t1; - return ((1 - 3 * t1 + 3 * t2 - t3) * v0 + (4 - 6 * t2 + 3 * t3) * v1 + (1 + 3 * t1 + 3 * t2 - 3 * t3) * v2 + t3 * v3) / 6; - } - __name(basis, "basis"); - function basis_default(values) { - var n = values.length - 1; - return function(t) { - var i = t <= 0 ? t = 0 : t >= 1 ? (t = 1, n - 1) : Math.floor(t * n), v1 = values[i], v2 = values[i + 1], v0 = i > 0 ? values[i - 1] : 2 * v1 - v2, v3 = i < n - 1 ? values[i + 2] : 2 * v2 - v1; - return basis((t - i / n) * n, v0, v1, v2, v3); - }; - } - __name(basis_default, "default"); - - // node_modules/d3-interpolate/src/basisClosed.js - function basisClosed_default(values) { - var n = values.length; - return function(t) { - var i = Math.floor(((t %= 1) < 0 ? ++t : t) * n), v0 = values[(i + n - 1) % n], v1 = values[i % n], v2 = values[(i + 1) % n], v3 = values[(i + 2) % n]; - return basis((t - i / n) * n, v0, v1, v2, v3); - }; - } - __name(basisClosed_default, "default"); - - // node_modules/d3-interpolate/src/constant.js - var constant_default3 = /* @__PURE__ */ __name((x) => () => x, "default"); - - // node_modules/d3-interpolate/src/color.js - function linear(a, d) { - return function(t) { - return a + t * d; - }; - } - __name(linear, "linear"); - function exponential(a, b, y) { - return a = Math.pow(a, y), b = Math.pow(b, y) - a, y = 1 / y, function(t) { - return Math.pow(a + t * b, y); - }; - } - __name(exponential, "exponential"); - function gamma(y) { - return (y = +y) === 1 ? nogamma : function(a, b) { - return b - a ? exponential(a, b, y) : constant_default3(isNaN(a) ? b : a); - }; - } - __name(gamma, "gamma"); - function nogamma(a, b) { - var d = b - a; - return d ? linear(a, d) : constant_default3(isNaN(a) ? b : a); - } - __name(nogamma, "nogamma"); - - // node_modules/d3-interpolate/src/rgb.js - var rgb_default = (/* @__PURE__ */ __name(function rgbGamma(y) { - var color2 = gamma(y); - function rgb2(start2, end) { - var r = color2((start2 = rgb(start2)).r, (end = rgb(end)).r), g = color2(start2.g, end.g), b = color2(start2.b, end.b), opacity = nogamma(start2.opacity, end.opacity); - return function(t) { - start2.r = r(t); - start2.g = g(t); - start2.b = b(t); - start2.opacity = opacity(t); - return start2 + ""; - }; - } - __name(rgb2, "rgb"); - rgb2.gamma = rgbGamma; - return rgb2; - }, "rgbGamma"))(1); - function rgbSpline(spline) { - return function(colors) { - var n = colors.length, r = new Array(n), g = new Array(n), b = new Array(n), i, color2; - for (i = 0; i < n; ++i) { - color2 = rgb(colors[i]); - r[i] = color2.r || 0; - g[i] = color2.g || 0; - b[i] = color2.b || 0; - } - r = spline(r); - g = spline(g); - b = spline(b); - color2.opacity = 1; - return function(t) { - color2.r = r(t); - color2.g = g(t); - color2.b = b(t); - return color2 + ""; - }; - }; - } - __name(rgbSpline, "rgbSpline"); - var rgbBasis = rgbSpline(basis_default); - var rgbBasisClosed = rgbSpline(basisClosed_default); - - // node_modules/d3-interpolate/src/number.js - function number_default(a, b) { - return a = +a, b = +b, function(t) { - return a * (1 - t) + b * t; - }; - } - __name(number_default, "default"); - - // node_modules/d3-interpolate/src/string.js - var reA = /[-+]?(?:\d+\.?\d*|\.?\d+)(?:[eE][-+]?\d+)?/g; - var reB = new RegExp(reA.source, "g"); - function zero(b) { - return function() { - return b; - }; - } - __name(zero, "zero"); - function one(b) { - return function(t) { - return b(t) + ""; - }; - } - __name(one, "one"); - function string_default(a, b) { - var bi = reA.lastIndex = reB.lastIndex = 0, am, bm, bs, i = -1, s = [], q = []; - a = a + "", b = b + ""; - while ((am = reA.exec(a)) && (bm = reB.exec(b))) { - if ((bs = bm.index) > bi) { - bs = b.slice(bi, bs); - if (s[i]) s[i] += bs; - else s[++i] = bs; - } - if ((am = am[0]) === (bm = bm[0])) { - if (s[i]) s[i] += bm; - else s[++i] = bm; - } else { - s[++i] = null; - q.push({ i, x: number_default(am, bm) }); - } - bi = reB.lastIndex; - } - if (bi < b.length) { - bs = b.slice(bi); - if (s[i]) s[i] += bs; - else s[++i] = bs; - } - return s.length < 2 ? q[0] ? one(q[0].x) : zero(b) : (b = q.length, function(t) { - for (var i2 = 0, o; i2 < b; ++i2) s[(o = q[i2]).i] = o.x(t); - return s.join(""); - }); - } - __name(string_default, "default"); - - // node_modules/d3-interpolate/src/transform/decompose.js - var degrees = 180 / Math.PI; - var identity = { - translateX: 0, - translateY: 0, - rotate: 0, - skewX: 0, - scaleX: 1, - scaleY: 1 - }; - function decompose_default(a, b, c, d, e, f) { - var scaleX, scaleY, skewX; - if (scaleX = Math.sqrt(a * a + b * b)) a /= scaleX, b /= scaleX; - if (skewX = a * c + b * d) c -= a * skewX, d -= b * skewX; - if (scaleY = Math.sqrt(c * c + d * d)) c /= scaleY, d /= scaleY, skewX /= scaleY; - if (a * d < b * c) a = -a, b = -b, skewX = -skewX, scaleX = -scaleX; - return { - translateX: e, - translateY: f, - rotate: Math.atan2(b, a) * degrees, - skewX: Math.atan(skewX) * degrees, - scaleX, - scaleY - }; - } - __name(decompose_default, "default"); - - // node_modules/d3-interpolate/src/transform/parse.js - var svgNode; - function parseCss(value) { - const m = new (typeof DOMMatrix === "function" ? DOMMatrix : WebKitCSSMatrix)(value + ""); - return m.isIdentity ? identity : decompose_default(m.a, m.b, m.c, m.d, m.e, m.f); - } - __name(parseCss, "parseCss"); - function parseSvg(value) { - if (value == null) return identity; - if (!svgNode) svgNode = document.createElementNS("http://www.w3.org/2000/svg", "g"); - svgNode.setAttribute("transform", value); - if (!(value = svgNode.transform.baseVal.consolidate())) return identity; - value = value.matrix; - return decompose_default(value.a, value.b, value.c, value.d, value.e, value.f); - } - __name(parseSvg, "parseSvg"); - - // node_modules/d3-interpolate/src/transform/index.js - function interpolateTransform(parse, pxComma, pxParen, degParen) { - function pop(s) { - return s.length ? s.pop() + " " : ""; - } - __name(pop, "pop"); - function translate(xa, ya, xb, yb, s, q) { - if (xa !== xb || ya !== yb) { - var i = s.push("translate(", null, pxComma, null, pxParen); - q.push({ i: i - 4, x: number_default(xa, xb) }, { i: i - 2, x: number_default(ya, yb) }); - } else if (xb || yb) { - s.push("translate(" + xb + pxComma + yb + pxParen); - } - } - __name(translate, "translate"); - function rotate(a, b, s, q) { - if (a !== b) { - if (a - b > 180) b += 360; - else if (b - a > 180) a += 360; - q.push({ i: s.push(pop(s) + "rotate(", null, degParen) - 2, x: number_default(a, b) }); - } else if (b) { - s.push(pop(s) + "rotate(" + b + degParen); - } - } - __name(rotate, "rotate"); - function skewX(a, b, s, q) { - if (a !== b) { - q.push({ i: s.push(pop(s) + "skewX(", null, degParen) - 2, x: number_default(a, b) }); - } else if (b) { - s.push(pop(s) + "skewX(" + b + degParen); - } - } - __name(skewX, "skewX"); - function scale(xa, ya, xb, yb, s, q) { - if (xa !== xb || ya !== yb) { - var i = s.push(pop(s) + "scale(", null, ",", null, ")"); - q.push({ i: i - 4, x: number_default(xa, xb) }, { i: i - 2, x: number_default(ya, yb) }); - } else if (xb !== 1 || yb !== 1) { - s.push(pop(s) + "scale(" + xb + "," + yb + ")"); - } - } - __name(scale, "scale"); - return function(a, b) { - var s = [], q = []; - a = parse(a), b = parse(b); - translate(a.translateX, a.translateY, b.translateX, b.translateY, s, q); - rotate(a.rotate, b.rotate, s, q); - skewX(a.skewX, b.skewX, s, q); - scale(a.scaleX, a.scaleY, b.scaleX, b.scaleY, s, q); - a = b = null; - return function(t) { - var i = -1, n = q.length, o; - while (++i < n) s[(o = q[i]).i] = o.x(t); - return s.join(""); - }; - }; - } - __name(interpolateTransform, "interpolateTransform"); - var interpolateTransformCss = interpolateTransform(parseCss, "px, ", "px)", "deg)"); - var interpolateTransformSvg = interpolateTransform(parseSvg, ", ", ")", ")"); - - // node_modules/d3-interpolate/src/zoom.js - var epsilon2 = 1e-12; - function cosh(x) { - return ((x = Math.exp(x)) + 1 / x) / 2; - } - __name(cosh, "cosh"); - function sinh(x) { - return ((x = Math.exp(x)) - 1 / x) / 2; - } - __name(sinh, "sinh"); - function tanh(x) { - return ((x = Math.exp(2 * x)) - 1) / (x + 1); - } - __name(tanh, "tanh"); - var zoom_default = (/* @__PURE__ */ __name(function zoomRho(rho, rho2, rho4) { - function zoom(p0, p1) { - var ux0 = p0[0], uy0 = p0[1], w0 = p0[2], ux1 = p1[0], uy1 = p1[1], w1 = p1[2], dx = ux1 - ux0, dy = uy1 - uy0, d2 = dx * dx + dy * dy, i, S; - if (d2 < epsilon2) { - S = Math.log(w1 / w0) / rho; - i = /* @__PURE__ */ __name(function(t) { - return [ - ux0 + t * dx, - uy0 + t * dy, - w0 * Math.exp(rho * t * S) - ]; - }, "i"); - } else { - var d1 = Math.sqrt(d2), b0 = (w1 * w1 - w0 * w0 + rho4 * d2) / (2 * w0 * rho2 * d1), b1 = (w1 * w1 - w0 * w0 - rho4 * d2) / (2 * w1 * rho2 * d1), r0 = Math.log(Math.sqrt(b0 * b0 + 1) - b0), r1 = Math.log(Math.sqrt(b1 * b1 + 1) - b1); - S = (r1 - r0) / rho; - i = /* @__PURE__ */ __name(function(t) { - var s = t * S, coshr0 = cosh(r0), u = w0 / (rho2 * d1) * (coshr0 * tanh(rho * s + r0) - sinh(r0)); - return [ - ux0 + u * dx, - uy0 + u * dy, - w0 * coshr0 / cosh(rho * s + r0) - ]; - }, "i"); - } - i.duration = S * 1e3 * rho / Math.SQRT2; - return i; - } - __name(zoom, "zoom"); - zoom.rho = function(_) { - var _1 = Math.max(1e-3, +_), _2 = _1 * _1, _4 = _2 * _2; - return zoomRho(_1, _2, _4); - }; - return zoom; - }, "zoomRho"))(Math.SQRT2, 2, 4); - - // node_modules/d3-timer/src/timer.js - var frame = 0; - var timeout = 0; - var interval = 0; - var pokeDelay = 1e3; - var taskHead; - var taskTail; - var clockLast = 0; - var clockNow = 0; - var clockSkew = 0; - var clock = typeof performance === "object" && performance.now ? performance : Date; - var setFrame = typeof window === "object" && window.requestAnimationFrame ? window.requestAnimationFrame.bind(window) : function(f) { - setTimeout(f, 17); - }; - function now() { - return clockNow || (setFrame(clearNow), clockNow = clock.now() + clockSkew); - } - __name(now, "now"); - function clearNow() { - clockNow = 0; - } - __name(clearNow, "clearNow"); - function Timer() { - this._call = this._time = this._next = null; - } - __name(Timer, "Timer"); - Timer.prototype = timer.prototype = { - constructor: Timer, - restart: /* @__PURE__ */ __name(function(callback, delay, time) { - if (typeof callback !== "function") throw new TypeError("callback is not a function"); - time = (time == null ? now() : +time) + (delay == null ? 0 : +delay); - if (!this._next && taskTail !== this) { - if (taskTail) taskTail._next = this; - else taskHead = this; - taskTail = this; - } - this._call = callback; - this._time = time; - sleep(); - }, "restart"), - stop: /* @__PURE__ */ __name(function() { - if (this._call) { - this._call = null; - this._time = Infinity; - sleep(); - } - }, "stop") - }; - function timer(callback, delay, time) { - var t = new Timer(); - t.restart(callback, delay, time); - return t; - } - __name(timer, "timer"); - function timerFlush() { - now(); - ++frame; - var t = taskHead, e; - while (t) { - if ((e = clockNow - t._time) >= 0) t._call.call(void 0, e); - t = t._next; - } - --frame; - } - __name(timerFlush, "timerFlush"); - function wake() { - clockNow = (clockLast = clock.now()) + clockSkew; - frame = timeout = 0; - try { - timerFlush(); - } finally { - frame = 0; - nap(); - clockNow = 0; - } - } - __name(wake, "wake"); - function poke() { - var now2 = clock.now(), delay = now2 - clockLast; - if (delay > pokeDelay) clockSkew -= delay, clockLast = now2; - } - __name(poke, "poke"); - function nap() { - var t0, t1 = taskHead, t2, time = Infinity; - while (t1) { - if (t1._call) { - if (time > t1._time) time = t1._time; - t0 = t1, t1 = t1._next; - } else { - t2 = t1._next, t1._next = null; - t1 = t0 ? t0._next = t2 : taskHead = t2; - } - } - taskTail = t0; - sleep(time); - } - __name(nap, "nap"); - function sleep(time) { - if (frame) return; - if (timeout) timeout = clearTimeout(timeout); - var delay = time - clockNow; - if (delay > 24) { - if (time < Infinity) timeout = setTimeout(wake, time - clock.now() - clockSkew); - if (interval) interval = clearInterval(interval); - } else { - if (!interval) clockLast = clock.now(), interval = setInterval(poke, pokeDelay); - frame = 1, setFrame(wake); - } - } - __name(sleep, "sleep"); - - // node_modules/d3-timer/src/timeout.js - function timeout_default(callback, delay, time) { - var t = new Timer(); - delay = delay == null ? 0 : +delay; - t.restart((elapsed) => { - t.stop(); - callback(elapsed + delay); - }, delay, time); - return t; - } - __name(timeout_default, "default"); - - // node_modules/d3-transition/src/transition/schedule.js - var emptyOn = dispatch_default2("start", "end", "cancel", "interrupt"); - var emptyTween = []; - var CREATED = 0; - var SCHEDULED = 1; - var STARTING = 2; - var STARTED = 3; - var RUNNING = 4; - var ENDING = 5; - var ENDED = 6; - function schedule_default(node, name, id2, index, group, timing) { - var schedules = node.__transition; - if (!schedules) node.__transition = {}; - else if (id2 in schedules) return; - create(node, id2, { - name, - index, - // For context during callback. - group, - // For context during callback. - on: emptyOn, - tween: emptyTween, - time: timing.time, - delay: timing.delay, - duration: timing.duration, - ease: timing.ease, - timer: null, - state: CREATED - }); - } - __name(schedule_default, "default"); - function init(node, id2) { - var schedule = get2(node, id2); - if (schedule.state > CREATED) throw new Error("too late; already scheduled"); - return schedule; - } - __name(init, "init"); - function set2(node, id2) { - var schedule = get2(node, id2); - if (schedule.state > STARTED) throw new Error("too late; already running"); - return schedule; - } - __name(set2, "set"); - function get2(node, id2) { - var schedule = node.__transition; - if (!schedule || !(schedule = schedule[id2])) throw new Error("transition not found"); - return schedule; - } - __name(get2, "get"); - function create(node, id2, self) { - var schedules = node.__transition, tween; - schedules[id2] = self; - self.timer = timer(schedule, 0, self.time); - function schedule(elapsed) { - self.state = SCHEDULED; - self.timer.restart(start2, self.delay, self.time); - if (self.delay <= elapsed) start2(elapsed - self.delay); - } - __name(schedule, "schedule"); - function start2(elapsed) { - var i, j, n, o; - if (self.state !== SCHEDULED) return stop(); - for (i in schedules) { - o = schedules[i]; - if (o.name !== self.name) continue; - if (o.state === STARTED) return timeout_default(start2); - if (o.state === RUNNING) { - o.state = ENDED; - o.timer.stop(); - o.on.call("interrupt", node, node.__data__, o.index, o.group); - delete schedules[i]; - } else if (+i < id2) { - o.state = ENDED; - o.timer.stop(); - o.on.call("cancel", node, node.__data__, o.index, o.group); - delete schedules[i]; - } - } - timeout_default(function() { - if (self.state === STARTED) { - self.state = RUNNING; - self.timer.restart(tick, self.delay, self.time); - tick(elapsed); - } - }); - self.state = STARTING; - self.on.call("start", node, node.__data__, self.index, self.group); - if (self.state !== STARTING) return; - self.state = STARTED; - tween = new Array(n = self.tween.length); - for (i = 0, j = -1; i < n; ++i) { - if (o = self.tween[i].value.call(node, node.__data__, self.index, self.group)) { - tween[++j] = o; - } - } - tween.length = j + 1; - } - __name(start2, "start"); - function tick(elapsed) { - var t = elapsed < self.duration ? self.ease.call(null, elapsed / self.duration) : (self.timer.restart(stop), self.state = ENDING, 1), i = -1, n = tween.length; - while (++i < n) { - tween[i].call(node, t); - } - if (self.state === ENDING) { - self.on.call("end", node, node.__data__, self.index, self.group); - stop(); - } - } - __name(tick, "tick"); - function stop() { - self.state = ENDED; - self.timer.stop(); - delete schedules[id2]; - for (var i in schedules) return; - delete node.__transition; - } - __name(stop, "stop"); - } - __name(create, "create"); - - // node_modules/d3-transition/src/interrupt.js - function interrupt_default(node, name) { - var schedules = node.__transition, schedule, active, empty2 = true, i; - if (!schedules) return; - name = name == null ? null : name + ""; - for (i in schedules) { - if ((schedule = schedules[i]).name !== name) { - empty2 = false; - continue; - } - active = schedule.state > STARTING && schedule.state < ENDING; - schedule.state = ENDED; - schedule.timer.stop(); - schedule.on.call(active ? "interrupt" : "cancel", node, node.__data__, schedule.index, schedule.group); - delete schedules[i]; - } - if (empty2) delete node.__transition; - } - __name(interrupt_default, "default"); - - // node_modules/d3-transition/src/selection/interrupt.js - function interrupt_default2(name) { - return this.each(function() { - interrupt_default(this, name); - }); - } - __name(interrupt_default2, "default"); - - // node_modules/d3-transition/src/transition/tween.js - function tweenRemove(id2, name) { - var tween0, tween1; - return function() { - var schedule = set2(this, id2), tween = schedule.tween; - if (tween !== tween0) { - tween1 = tween0 = tween; - for (var i = 0, n = tween1.length; i < n; ++i) { - if (tween1[i].name === name) { - tween1 = tween1.slice(); - tween1.splice(i, 1); - break; - } - } - } - schedule.tween = tween1; - }; - } - __name(tweenRemove, "tweenRemove"); - function tweenFunction(id2, name, value) { - var tween0, tween1; - if (typeof value !== "function") throw new Error(); - return function() { - var schedule = set2(this, id2), tween = schedule.tween; - if (tween !== tween0) { - tween1 = (tween0 = tween).slice(); - for (var t = { name, value }, i = 0, n = tween1.length; i < n; ++i) { - if (tween1[i].name === name) { - tween1[i] = t; - break; - } - } - if (i === n) tween1.push(t); - } - schedule.tween = tween1; - }; - } - __name(tweenFunction, "tweenFunction"); - function tween_default(name, value) { - var id2 = this._id; - name += ""; - if (arguments.length < 2) { - var tween = get2(this.node(), id2).tween; - for (var i = 0, n = tween.length, t; i < n; ++i) { - if ((t = tween[i]).name === name) { - return t.value; - } - } - return null; - } - return this.each((value == null ? tweenRemove : tweenFunction)(id2, name, value)); - } - __name(tween_default, "default"); - function tweenValue(transition2, name, value) { - var id2 = transition2._id; - transition2.each(function() { - var schedule = set2(this, id2); - (schedule.value || (schedule.value = {}))[name] = value.apply(this, arguments); - }); - return function(node) { - return get2(node, id2).value[name]; - }; - } - __name(tweenValue, "tweenValue"); - - // node_modules/d3-transition/src/transition/interpolate.js - function interpolate_default(a, b) { - var c; - return (typeof b === "number" ? number_default : b instanceof color ? rgb_default : (c = color(b)) ? (b = c, rgb_default) : string_default)(a, b); - } - __name(interpolate_default, "default"); - - // node_modules/d3-transition/src/transition/attr.js - function attrRemove2(name) { - return function() { - this.removeAttribute(name); - }; - } - __name(attrRemove2, "attrRemove"); - function attrRemoveNS2(fullname) { - return function() { - this.removeAttributeNS(fullname.space, fullname.local); - }; - } - __name(attrRemoveNS2, "attrRemoveNS"); - function attrConstant2(name, interpolate, value1) { - var string00, string1 = value1 + "", interpolate0; - return function() { - var string0 = this.getAttribute(name); - return string0 === string1 ? null : string0 === string00 ? interpolate0 : interpolate0 = interpolate(string00 = string0, value1); - }; - } - __name(attrConstant2, "attrConstant"); - function attrConstantNS2(fullname, interpolate, value1) { - var string00, string1 = value1 + "", interpolate0; - return function() { - var string0 = this.getAttributeNS(fullname.space, fullname.local); - return string0 === string1 ? null : string0 === string00 ? interpolate0 : interpolate0 = interpolate(string00 = string0, value1); - }; - } - __name(attrConstantNS2, "attrConstantNS"); - function attrFunction2(name, interpolate, value) { - var string00, string10, interpolate0; - return function() { - var string0, value1 = value(this), string1; - if (value1 == null) return void this.removeAttribute(name); - string0 = this.getAttribute(name); - string1 = value1 + ""; - return string0 === string1 ? null : string0 === string00 && string1 === string10 ? interpolate0 : (string10 = string1, interpolate0 = interpolate(string00 = string0, value1)); - }; - } - __name(attrFunction2, "attrFunction"); - function attrFunctionNS2(fullname, interpolate, value) { - var string00, string10, interpolate0; - return function() { - var string0, value1 = value(this), string1; - if (value1 == null) return void this.removeAttributeNS(fullname.space, fullname.local); - string0 = this.getAttributeNS(fullname.space, fullname.local); - string1 = value1 + ""; - return string0 === string1 ? null : string0 === string00 && string1 === string10 ? interpolate0 : (string10 = string1, interpolate0 = interpolate(string00 = string0, value1)); - }; - } - __name(attrFunctionNS2, "attrFunctionNS"); - function attr_default2(name, value) { - var fullname = namespace_default(name), i = fullname === "transform" ? interpolateTransformSvg : interpolate_default; - return this.attrTween(name, typeof value === "function" ? (fullname.local ? attrFunctionNS2 : attrFunction2)(fullname, i, tweenValue(this, "attr." + name, value)) : value == null ? (fullname.local ? attrRemoveNS2 : attrRemove2)(fullname) : (fullname.local ? attrConstantNS2 : attrConstant2)(fullname, i, value)); - } - __name(attr_default2, "default"); - - // node_modules/d3-transition/src/transition/attrTween.js - function attrInterpolate(name, i) { - return function(t) { - this.setAttribute(name, i.call(this, t)); - }; - } - __name(attrInterpolate, "attrInterpolate"); - function attrInterpolateNS(fullname, i) { - return function(t) { - this.setAttributeNS(fullname.space, fullname.local, i.call(this, t)); - }; - } - __name(attrInterpolateNS, "attrInterpolateNS"); - function attrTweenNS(fullname, value) { - var t0, i0; - function tween() { - var i = value.apply(this, arguments); - if (i !== i0) t0 = (i0 = i) && attrInterpolateNS(fullname, i); - return t0; - } - __name(tween, "tween"); - tween._value = value; - return tween; - } - __name(attrTweenNS, "attrTweenNS"); - function attrTween(name, value) { - var t0, i0; - function tween() { - var i = value.apply(this, arguments); - if (i !== i0) t0 = (i0 = i) && attrInterpolate(name, i); - return t0; - } - __name(tween, "tween"); - tween._value = value; - return tween; - } - __name(attrTween, "attrTween"); - function attrTween_default(name, value) { - var key = "attr." + name; - if (arguments.length < 2) return (key = this.tween(key)) && key._value; - if (value == null) return this.tween(key, null); - if (typeof value !== "function") throw new Error(); - var fullname = namespace_default(name); - return this.tween(key, (fullname.local ? attrTweenNS : attrTween)(fullname, value)); - } - __name(attrTween_default, "default"); - - // node_modules/d3-transition/src/transition/delay.js - function delayFunction(id2, value) { - return function() { - init(this, id2).delay = +value.apply(this, arguments); - }; - } - __name(delayFunction, "delayFunction"); - function delayConstant(id2, value) { - return value = +value, function() { - init(this, id2).delay = value; - }; - } - __name(delayConstant, "delayConstant"); - function delay_default(value) { - var id2 = this._id; - return arguments.length ? this.each((typeof value === "function" ? delayFunction : delayConstant)(id2, value)) : get2(this.node(), id2).delay; - } - __name(delay_default, "default"); - - // node_modules/d3-transition/src/transition/duration.js - function durationFunction(id2, value) { - return function() { - set2(this, id2).duration = +value.apply(this, arguments); - }; - } - __name(durationFunction, "durationFunction"); - function durationConstant(id2, value) { - return value = +value, function() { - set2(this, id2).duration = value; - }; - } - __name(durationConstant, "durationConstant"); - function duration_default(value) { - var id2 = this._id; - return arguments.length ? this.each((typeof value === "function" ? durationFunction : durationConstant)(id2, value)) : get2(this.node(), id2).duration; - } - __name(duration_default, "default"); - - // node_modules/d3-transition/src/transition/ease.js - function easeConstant(id2, value) { - if (typeof value !== "function") throw new Error(); - return function() { - set2(this, id2).ease = value; - }; - } - __name(easeConstant, "easeConstant"); - function ease_default(value) { - var id2 = this._id; - return arguments.length ? this.each(easeConstant(id2, value)) : get2(this.node(), id2).ease; - } - __name(ease_default, "default"); - - // node_modules/d3-transition/src/transition/easeVarying.js - function easeVarying(id2, value) { - return function() { - var v = value.apply(this, arguments); - if (typeof v !== "function") throw new Error(); - set2(this, id2).ease = v; - }; - } - __name(easeVarying, "easeVarying"); - function easeVarying_default(value) { - if (typeof value !== "function") throw new Error(); - return this.each(easeVarying(this._id, value)); - } - __name(easeVarying_default, "default"); - - // node_modules/d3-transition/src/transition/filter.js - function filter_default2(match) { - if (typeof match !== "function") match = matcher_default(match); - for (var groups = this._groups, m = groups.length, subgroups = new Array(m), j = 0; j < m; ++j) { - for (var group = groups[j], n = group.length, subgroup = subgroups[j] = [], node, i = 0; i < n; ++i) { - if ((node = group[i]) && match.call(node, node.__data__, i, group)) { - subgroup.push(node); - } - } - } - return new Transition(subgroups, this._parents, this._name, this._id); - } - __name(filter_default2, "default"); - - // node_modules/d3-transition/src/transition/merge.js - function merge_default2(transition2) { - if (transition2._id !== this._id) throw new Error(); - for (var groups0 = this._groups, groups1 = transition2._groups, m0 = groups0.length, m1 = groups1.length, m = Math.min(m0, m1), merges = new Array(m0), j = 0; j < m; ++j) { - for (var group0 = groups0[j], group1 = groups1[j], n = group0.length, merge = merges[j] = new Array(n), node, i = 0; i < n; ++i) { - if (node = group0[i] || group1[i]) { - merge[i] = node; - } - } - } - for (; j < m0; ++j) { - merges[j] = groups0[j]; - } - return new Transition(merges, this._parents, this._name, this._id); - } - __name(merge_default2, "default"); - - // node_modules/d3-transition/src/transition/on.js - function start(name) { - return (name + "").trim().split(/^|\s+/).every(function(t) { - var i = t.indexOf("."); - if (i >= 0) t = t.slice(0, i); - return !t || t === "start"; - }); - } - __name(start, "start"); - function onFunction(id2, name, listener) { - var on0, on1, sit = start(name) ? init : set2; - return function() { - var schedule = sit(this, id2), on = schedule.on; - if (on !== on0) (on1 = (on0 = on).copy()).on(name, listener); - schedule.on = on1; - }; - } - __name(onFunction, "onFunction"); - function on_default2(name, listener) { - var id2 = this._id; - return arguments.length < 2 ? get2(this.node(), id2).on.on(name) : this.each(onFunction(id2, name, listener)); - } - __name(on_default2, "default"); - - // node_modules/d3-transition/src/transition/remove.js - function removeFunction(id2) { - return function() { - var parent = this.parentNode; - for (var i in this.__transition) if (+i !== id2) return; - if (parent) parent.removeChild(this); - }; - } - __name(removeFunction, "removeFunction"); - function remove_default2() { - return this.on("end.remove", removeFunction(this._id)); - } - __name(remove_default2, "default"); - - // node_modules/d3-transition/src/transition/select.js - function select_default3(select) { - var name = this._name, id2 = this._id; - if (typeof select !== "function") select = selector_default(select); - for (var groups = this._groups, m = groups.length, subgroups = new Array(m), j = 0; j < m; ++j) { - for (var group = groups[j], n = group.length, subgroup = subgroups[j] = new Array(n), node, subnode, i = 0; i < n; ++i) { - if ((node = group[i]) && (subnode = select.call(node, node.__data__, i, group))) { - if ("__data__" in node) subnode.__data__ = node.__data__; - subgroup[i] = subnode; - schedule_default(subgroup[i], name, id2, i, subgroup, get2(node, id2)); - } - } - } - return new Transition(subgroups, this._parents, name, id2); - } - __name(select_default3, "default"); - - // node_modules/d3-transition/src/transition/selectAll.js - function selectAll_default3(select) { - var name = this._name, id2 = this._id; - if (typeof select !== "function") select = selectorAll_default(select); - for (var groups = this._groups, m = groups.length, subgroups = [], parents = [], j = 0; j < m; ++j) { - for (var group = groups[j], n = group.length, node, i = 0; i < n; ++i) { - if (node = group[i]) { - for (var children2 = select.call(node, node.__data__, i, group), child, inherit2 = get2(node, id2), k = 0, l = children2.length; k < l; ++k) { - if (child = children2[k]) { - schedule_default(child, name, id2, k, children2, inherit2); - } - } - subgroups.push(children2); - parents.push(node); - } - } - } - return new Transition(subgroups, parents, name, id2); - } - __name(selectAll_default3, "default"); - - // node_modules/d3-transition/src/transition/selection.js - var Selection2 = selection_default.prototype.constructor; - function selection_default2() { - return new Selection2(this._groups, this._parents); - } - __name(selection_default2, "default"); - - // node_modules/d3-transition/src/transition/style.js - function styleNull(name, interpolate) { - var string00, string10, interpolate0; - return function() { - var string0 = styleValue(this, name), string1 = (this.style.removeProperty(name), styleValue(this, name)); - return string0 === string1 ? null : string0 === string00 && string1 === string10 ? interpolate0 : interpolate0 = interpolate(string00 = string0, string10 = string1); - }; - } - __name(styleNull, "styleNull"); - function styleRemove2(name) { - return function() { - this.style.removeProperty(name); - }; - } - __name(styleRemove2, "styleRemove"); - function styleConstant2(name, interpolate, value1) { - var string00, string1 = value1 + "", interpolate0; - return function() { - var string0 = styleValue(this, name); - return string0 === string1 ? null : string0 === string00 ? interpolate0 : interpolate0 = interpolate(string00 = string0, value1); - }; - } - __name(styleConstant2, "styleConstant"); - function styleFunction2(name, interpolate, value) { - var string00, string10, interpolate0; - return function() { - var string0 = styleValue(this, name), value1 = value(this), string1 = value1 + ""; - if (value1 == null) string1 = value1 = (this.style.removeProperty(name), styleValue(this, name)); - return string0 === string1 ? null : string0 === string00 && string1 === string10 ? interpolate0 : (string10 = string1, interpolate0 = interpolate(string00 = string0, value1)); - }; - } - __name(styleFunction2, "styleFunction"); - function styleMaybeRemove(id2, name) { - var on0, on1, listener0, key = "style." + name, event = "end." + key, remove2; - return function() { - var schedule = set2(this, id2), on = schedule.on, listener = schedule.value[key] == null ? remove2 || (remove2 = styleRemove2(name)) : void 0; - if (on !== on0 || listener0 !== listener) (on1 = (on0 = on).copy()).on(event, listener0 = listener); - schedule.on = on1; - }; - } - __name(styleMaybeRemove, "styleMaybeRemove"); - function style_default2(name, value, priority) { - var i = (name += "") === "transform" ? interpolateTransformCss : interpolate_default; - return value == null ? this.styleTween(name, styleNull(name, i)).on("end.style." + name, styleRemove2(name)) : typeof value === "function" ? this.styleTween(name, styleFunction2(name, i, tweenValue(this, "style." + name, value))).each(styleMaybeRemove(this._id, name)) : this.styleTween(name, styleConstant2(name, i, value), priority).on("end.style." + name, null); - } - __name(style_default2, "default"); - - // node_modules/d3-transition/src/transition/styleTween.js - function styleInterpolate(name, i, priority) { - return function(t) { - this.style.setProperty(name, i.call(this, t), priority); - }; - } - __name(styleInterpolate, "styleInterpolate"); - function styleTween(name, value, priority) { - var t, i0; - function tween() { - var i = value.apply(this, arguments); - if (i !== i0) t = (i0 = i) && styleInterpolate(name, i, priority); - return t; - } - __name(tween, "tween"); - tween._value = value; - return tween; - } - __name(styleTween, "styleTween"); - function styleTween_default(name, value, priority) { - var key = "style." + (name += ""); - if (arguments.length < 2) return (key = this.tween(key)) && key._value; - if (value == null) return this.tween(key, null); - if (typeof value !== "function") throw new Error(); - return this.tween(key, styleTween(name, value, priority == null ? "" : priority)); - } - __name(styleTween_default, "default"); - - // node_modules/d3-transition/src/transition/text.js - function textConstant2(value) { - return function() { - this.textContent = value; - }; - } - __name(textConstant2, "textConstant"); - function textFunction2(value) { - return function() { - var value1 = value(this); - this.textContent = value1 == null ? "" : value1; - }; - } - __name(textFunction2, "textFunction"); - function text_default2(value) { - return this.tween("text", typeof value === "function" ? textFunction2(tweenValue(this, "text", value)) : textConstant2(value == null ? "" : value + "")); - } - __name(text_default2, "default"); - - // node_modules/d3-transition/src/transition/textTween.js - function textInterpolate(i) { - return function(t) { - this.textContent = i.call(this, t); - }; - } - __name(textInterpolate, "textInterpolate"); - function textTween(value) { - var t0, i0; - function tween() { - var i = value.apply(this, arguments); - if (i !== i0) t0 = (i0 = i) && textInterpolate(i); - return t0; - } - __name(tween, "tween"); - tween._value = value; - return tween; - } - __name(textTween, "textTween"); - function textTween_default(value) { - var key = "text"; - if (arguments.length < 1) return (key = this.tween(key)) && key._value; - if (value == null) return this.tween(key, null); - if (typeof value !== "function") throw new Error(); - return this.tween(key, textTween(value)); - } - __name(textTween_default, "default"); - - // node_modules/d3-transition/src/transition/transition.js - function transition_default() { - var name = this._name, id0 = this._id, id1 = newId(); - for (var groups = this._groups, m = groups.length, j = 0; j < m; ++j) { - for (var group = groups[j], n = group.length, node, i = 0; i < n; ++i) { - if (node = group[i]) { - var inherit2 = get2(node, id0); - schedule_default(node, name, id1, i, group, { - time: inherit2.time + inherit2.delay + inherit2.duration, - delay: 0, - duration: inherit2.duration, - ease: inherit2.ease - }); - } - } - } - return new Transition(groups, this._parents, name, id1); - } - __name(transition_default, "default"); - - // node_modules/d3-transition/src/transition/end.js - function end_default() { - var on0, on1, that = this, id2 = that._id, size = that.size(); - return new Promise(function(resolve, reject) { - var cancel = { value: reject }, end = { value: /* @__PURE__ */ __name(function() { - if (--size === 0) resolve(); - }, "value") }; - that.each(function() { - var schedule = set2(this, id2), on = schedule.on; - if (on !== on0) { - on1 = (on0 = on).copy(); - on1._.cancel.push(cancel); - on1._.interrupt.push(cancel); - on1._.end.push(end); - } - schedule.on = on1; - }); - if (size === 0) resolve(); - }); - } - __name(end_default, "default"); - - // node_modules/d3-transition/src/transition/index.js - var id = 0; - function Transition(groups, parents, name, id2) { - this._groups = groups; - this._parents = parents; - this._name = name; - this._id = id2; - } - __name(Transition, "Transition"); - function transition(name) { - return selection_default().transition(name); - } - __name(transition, "transition"); - function newId() { - return ++id; - } - __name(newId, "newId"); - var selection_prototype = selection_default.prototype; - Transition.prototype = transition.prototype = { - constructor: Transition, - select: select_default3, - selectAll: selectAll_default3, - selectChild: selection_prototype.selectChild, - selectChildren: selection_prototype.selectChildren, - filter: filter_default2, - merge: merge_default2, - selection: selection_default2, - transition: transition_default, - call: selection_prototype.call, - nodes: selection_prototype.nodes, - node: selection_prototype.node, - size: selection_prototype.size, - empty: selection_prototype.empty, - each: selection_prototype.each, - on: on_default2, - attr: attr_default2, - attrTween: attrTween_default, - style: style_default2, - styleTween: styleTween_default, - text: text_default2, - textTween: textTween_default, - remove: remove_default2, - tween: tween_default, - delay: delay_default, - duration: duration_default, - ease: ease_default, - easeVarying: easeVarying_default, - end: end_default, - [Symbol.iterator]: selection_prototype[Symbol.iterator] - }; - - // node_modules/d3-ease/src/cubic.js - function cubicInOut(t) { - return ((t *= 2) <= 1 ? t * t * t : (t -= 2) * t * t + 2) / 2; - } - __name(cubicInOut, "cubicInOut"); - - // node_modules/d3-transition/src/selection/transition.js - var defaultTiming = { - time: null, - // Set on use. - delay: 0, - duration: 250, - ease: cubicInOut - }; - function inherit(node, id2) { - var timing; - while (!(timing = node.__transition) || !(timing = timing[id2])) { - if (!(node = node.parentNode)) { - throw new Error(`transition ${id2} not found`); - } - } - return timing; - } - __name(inherit, "inherit"); - function transition_default2(name) { - var id2, timing; - if (name instanceof Transition) { - id2 = name._id, name = name._name; - } else { - id2 = newId(), (timing = defaultTiming).time = now(), name = name == null ? null : name + ""; - } - for (var groups = this._groups, m = groups.length, j = 0; j < m; ++j) { - for (var group = groups[j], n = group.length, node, i = 0; i < n; ++i) { - if (node = group[i]) { - schedule_default(node, name, id2, i, group, timing || inherit(node, id2)); - } - } - } - return new Transition(groups, this._parents, name, id2); - } - __name(transition_default2, "default"); - - // node_modules/d3-transition/src/selection/index.js - selection_default.prototype.interrupt = interrupt_default2; - selection_default.prototype.transition = transition_default2; - - // node_modules/d3-zoom/src/constant.js - var constant_default4 = /* @__PURE__ */ __name((x) => () => x, "default"); - - // node_modules/d3-zoom/src/event.js - function ZoomEvent(type, { - sourceEvent, - target, - transform: transform2, - dispatch: dispatch2 - }) { - Object.defineProperties(this, { - type: { value: type, enumerable: true, configurable: true }, - sourceEvent: { value: sourceEvent, enumerable: true, configurable: true }, - target: { value: target, enumerable: true, configurable: true }, - transform: { value: transform2, enumerable: true, configurable: true }, - _: { value: dispatch2 } - }); - } - __name(ZoomEvent, "ZoomEvent"); - - // node_modules/d3-zoom/src/transform.js - function Transform(k, x, y) { - this.k = k; - this.x = x; - this.y = y; - } - __name(Transform, "Transform"); - Transform.prototype = { - constructor: Transform, - scale: /* @__PURE__ */ __name(function(k) { - return k === 1 ? this : new Transform(this.k * k, this.x, this.y); - }, "scale"), - translate: /* @__PURE__ */ __name(function(x, y) { - return x === 0 & y === 0 ? this : new Transform(this.k, this.x + this.k * x, this.y + this.k * y); - }, "translate"), - apply: /* @__PURE__ */ __name(function(point) { - return [point[0] * this.k + this.x, point[1] * this.k + this.y]; - }, "apply"), - applyX: /* @__PURE__ */ __name(function(x) { - return x * this.k + this.x; - }, "applyX"), - applyY: /* @__PURE__ */ __name(function(y) { - return y * this.k + this.y; - }, "applyY"), - invert: /* @__PURE__ */ __name(function(location) { - return [(location[0] - this.x) / this.k, (location[1] - this.y) / this.k]; - }, "invert"), - invertX: /* @__PURE__ */ __name(function(x) { - return (x - this.x) / this.k; - }, "invertX"), - invertY: /* @__PURE__ */ __name(function(y) { - return (y - this.y) / this.k; - }, "invertY"), - rescaleX: /* @__PURE__ */ __name(function(x) { - return x.copy().domain(x.range().map(this.invertX, this).map(x.invert, x)); - }, "rescaleX"), - rescaleY: /* @__PURE__ */ __name(function(y) { - return y.copy().domain(y.range().map(this.invertY, this).map(y.invert, y)); - }, "rescaleY"), - toString: /* @__PURE__ */ __name(function() { - return "translate(" + this.x + "," + this.y + ") scale(" + this.k + ")"; - }, "toString") - }; - var identity2 = new Transform(1, 0, 0); - transform.prototype = Transform.prototype; - function transform(node) { - while (!node.__zoom) if (!(node = node.parentNode)) return identity2; - return node.__zoom; - } - __name(transform, "transform"); - - // node_modules/d3-zoom/src/noevent.js - function nopropagation(event) { - event.stopImmediatePropagation(); - } - __name(nopropagation, "nopropagation"); - function noevent_default2(event) { - event.preventDefault(); - event.stopImmediatePropagation(); - } - __name(noevent_default2, "default"); - - // node_modules/d3-zoom/src/zoom.js - function defaultFilter(event) { - return (!event.ctrlKey || event.type === "wheel") && !event.button; - } - __name(defaultFilter, "defaultFilter"); - function defaultExtent() { - var e = this; - if (e instanceof SVGElement) { - e = e.ownerSVGElement || e; - if (e.hasAttribute("viewBox")) { - e = e.viewBox.baseVal; - return [[e.x, e.y], [e.x + e.width, e.y + e.height]]; - } - return [[0, 0], [e.width.baseVal.value, e.height.baseVal.value]]; - } - return [[0, 0], [e.clientWidth, e.clientHeight]]; - } - __name(defaultExtent, "defaultExtent"); - function defaultTransform() { - return this.__zoom || identity2; - } - __name(defaultTransform, "defaultTransform"); - function defaultWheelDelta(event) { - return -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 2e-3) * (event.ctrlKey ? 10 : 1); - } - __name(defaultWheelDelta, "defaultWheelDelta"); - function defaultTouchable() { - return navigator.maxTouchPoints || "ontouchstart" in this; - } - __name(defaultTouchable, "defaultTouchable"); - function defaultConstrain(transform2, extent, translateExtent) { - var dx0 = transform2.invertX(extent[0][0]) - translateExtent[0][0], dx1 = transform2.invertX(extent[1][0]) - translateExtent[1][0], dy0 = transform2.invertY(extent[0][1]) - translateExtent[0][1], dy1 = transform2.invertY(extent[1][1]) - translateExtent[1][1]; - return transform2.translate( - dx1 > dx0 ? (dx0 + dx1) / 2 : Math.min(0, dx0) || Math.max(0, dx1), - dy1 > dy0 ? (dy0 + dy1) / 2 : Math.min(0, dy0) || Math.max(0, dy1) - ); - } - __name(defaultConstrain, "defaultConstrain"); - function zoom_default2() { - var filter2 = defaultFilter, extent = defaultExtent, constrain = defaultConstrain, wheelDelta = defaultWheelDelta, touchable = defaultTouchable, scaleExtent = [0, Infinity], translateExtent = [[-Infinity, -Infinity], [Infinity, Infinity]], duration = 250, interpolate = zoom_default, listeners = dispatch_default2("start", "zoom", "end"), touchstarting, touchfirst, touchending, touchDelay = 500, wheelDelay = 150, clickDistance2 = 0, tapDistance = 10; - function zoom(selection2) { - selection2.property("__zoom", defaultTransform).on("wheel.zoom", wheeled, { passive: false }).on("mousedown.zoom", mousedowned).on("dblclick.zoom", dblclicked).filter(touchable).on("touchstart.zoom", touchstarted).on("touchmove.zoom", touchmoved).on("touchend.zoom touchcancel.zoom", touchended).style("-webkit-tap-highlight-color", "rgba(0,0,0,0)"); - } - __name(zoom, "zoom"); - zoom.transform = function(collection, transform2, point, event) { - var selection2 = collection.selection ? collection.selection() : collection; - selection2.property("__zoom", defaultTransform); - if (collection !== selection2) { - schedule(collection, transform2, point, event); - } else { - selection2.interrupt().each(function() { - gesture(this, arguments).event(event).start().zoom(null, typeof transform2 === "function" ? transform2.apply(this, arguments) : transform2).end(); - }); - } - }; - zoom.scaleBy = function(selection2, k, p, event) { - zoom.scaleTo(selection2, function() { - var k0 = this.__zoom.k, k1 = typeof k === "function" ? k.apply(this, arguments) : k; - return k0 * k1; - }, p, event); - }; - zoom.scaleTo = function(selection2, k, p, event) { - zoom.transform(selection2, function() { - var e = extent.apply(this, arguments), t0 = this.__zoom, p0 = p == null ? centroid(e) : typeof p === "function" ? p.apply(this, arguments) : p, p1 = t0.invert(p0), k1 = typeof k === "function" ? k.apply(this, arguments) : k; - return constrain(translate(scale(t0, k1), p0, p1), e, translateExtent); - }, p, event); - }; - zoom.translateBy = function(selection2, x, y, event) { - zoom.transform(selection2, function() { - return constrain(this.__zoom.translate( - typeof x === "function" ? x.apply(this, arguments) : x, - typeof y === "function" ? y.apply(this, arguments) : y - ), extent.apply(this, arguments), translateExtent); - }, null, event); - }; - zoom.translateTo = function(selection2, x, y, p, event) { - zoom.transform(selection2, function() { - var e = extent.apply(this, arguments), t = this.__zoom, p0 = p == null ? centroid(e) : typeof p === "function" ? p.apply(this, arguments) : p; - return constrain(identity2.translate(p0[0], p0[1]).scale(t.k).translate( - typeof x === "function" ? -x.apply(this, arguments) : -x, - typeof y === "function" ? -y.apply(this, arguments) : -y - ), e, translateExtent); - }, p, event); - }; - function scale(transform2, k) { - k = Math.max(scaleExtent[0], Math.min(scaleExtent[1], k)); - return k === transform2.k ? transform2 : new Transform(k, transform2.x, transform2.y); - } - __name(scale, "scale"); - function translate(transform2, p0, p1) { - var x = p0[0] - p1[0] * transform2.k, y = p0[1] - p1[1] * transform2.k; - return x === transform2.x && y === transform2.y ? transform2 : new Transform(transform2.k, x, y); - } - __name(translate, "translate"); - function centroid(extent2) { - return [(+extent2[0][0] + +extent2[1][0]) / 2, (+extent2[0][1] + +extent2[1][1]) / 2]; - } - __name(centroid, "centroid"); - function schedule(transition2, transform2, point, event) { - transition2.on("start.zoom", function() { - gesture(this, arguments).event(event).start(); - }).on("interrupt.zoom end.zoom", function() { - gesture(this, arguments).event(event).end(); - }).tween("zoom", function() { - var that = this, args = arguments, g = gesture(that, args).event(event), e = extent.apply(that, args), p = point == null ? centroid(e) : typeof point === "function" ? point.apply(that, args) : point, w = Math.max(e[1][0] - e[0][0], e[1][1] - e[0][1]), a = that.__zoom, b = typeof transform2 === "function" ? transform2.apply(that, args) : transform2, i = interpolate(a.invert(p).concat(w / a.k), b.invert(p).concat(w / b.k)); - return function(t) { - if (t === 1) t = b; - else { - var l = i(t), k = w / l[2]; - t = new Transform(k, p[0] - l[0] * k, p[1] - l[1] * k); - } - g.zoom(null, t); - }; - }); - } - __name(schedule, "schedule"); - function gesture(that, args, clean) { - return !clean && that.__zooming || new Gesture(that, args); - } - __name(gesture, "gesture"); - function Gesture(that, args) { - this.that = that; - this.args = args; - this.active = 0; - this.sourceEvent = null; - this.extent = extent.apply(that, args); - this.taps = 0; - } - __name(Gesture, "Gesture"); - Gesture.prototype = { - event: /* @__PURE__ */ __name(function(event) { - if (event) this.sourceEvent = event; - return this; - }, "event"), - start: /* @__PURE__ */ __name(function() { - if (++this.active === 1) { - this.that.__zooming = this; - this.emit("start"); - } - return this; - }, "start"), - zoom: /* @__PURE__ */ __name(function(key, transform2) { - if (this.mouse && key !== "mouse") this.mouse[1] = transform2.invert(this.mouse[0]); - if (this.touch0 && key !== "touch") this.touch0[1] = transform2.invert(this.touch0[0]); - if (this.touch1 && key !== "touch") this.touch1[1] = transform2.invert(this.touch1[0]); - this.that.__zoom = transform2; - this.emit("zoom"); - return this; - }, "zoom"), - end: /* @__PURE__ */ __name(function() { - if (--this.active === 0) { - delete this.that.__zooming; - this.emit("end"); - } - return this; - }, "end"), - emit: /* @__PURE__ */ __name(function(type) { - var d = select_default2(this.that).datum(); - listeners.call( - type, - this.that, - new ZoomEvent(type, { - sourceEvent: this.sourceEvent, - target: zoom, - type, - transform: this.that.__zoom, - dispatch: listeners - }), - d - ); - }, "emit") - }; - function wheeled(event, ...args) { - if (!filter2.apply(this, arguments)) return; - var g = gesture(this, args).event(event), t = this.__zoom, k = Math.max(scaleExtent[0], Math.min(scaleExtent[1], t.k * Math.pow(2, wheelDelta.apply(this, arguments)))), p = pointer_default(event); - if (g.wheel) { - if (g.mouse[0][0] !== p[0] || g.mouse[0][1] !== p[1]) { - g.mouse[1] = t.invert(g.mouse[0] = p); - } - clearTimeout(g.wheel); - } else if (t.k === k) return; - else { - g.mouse = [p, t.invert(p)]; - interrupt_default(this); - g.start(); - } - noevent_default2(event); - g.wheel = setTimeout(wheelidled, wheelDelay); - g.zoom("mouse", constrain(translate(scale(t, k), g.mouse[0], g.mouse[1]), g.extent, translateExtent)); - function wheelidled() { - g.wheel = null; - g.end(); - } - __name(wheelidled, "wheelidled"); - } - __name(wheeled, "wheeled"); - function mousedowned(event, ...args) { - if (touchending || !filter2.apply(this, arguments)) return; - var currentTarget = event.currentTarget, g = gesture(this, args, true).event(event), v = select_default2(event.view).on("mousemove.zoom", mousemoved, true).on("mouseup.zoom", mouseupped, true), p = pointer_default(event, currentTarget), x0 = event.clientX, y0 = event.clientY; - nodrag_default(event.view); - nopropagation(event); - g.mouse = [p, this.__zoom.invert(p)]; - interrupt_default(this); - g.start(); - function mousemoved(event2) { - noevent_default2(event2); - if (!g.moved) { - var dx = event2.clientX - x0, dy = event2.clientY - y0; - g.moved = dx * dx + dy * dy > clickDistance2; - } - g.event(event2).zoom("mouse", constrain(translate(g.that.__zoom, g.mouse[0] = pointer_default(event2, currentTarget), g.mouse[1]), g.extent, translateExtent)); - } - __name(mousemoved, "mousemoved"); - function mouseupped(event2) { - v.on("mousemove.zoom mouseup.zoom", null); - yesdrag(event2.view, g.moved); - noevent_default2(event2); - g.event(event2).end(); - } - __name(mouseupped, "mouseupped"); - } - __name(mousedowned, "mousedowned"); - function dblclicked(event, ...args) { - if (!filter2.apply(this, arguments)) return; - var t0 = this.__zoom, p0 = pointer_default(event.changedTouches ? event.changedTouches[0] : event, this), p1 = t0.invert(p0), k1 = t0.k * (event.shiftKey ? 0.5 : 2), t1 = constrain(translate(scale(t0, k1), p0, p1), extent.apply(this, args), translateExtent); - noevent_default2(event); - if (duration > 0) select_default2(this).transition().duration(duration).call(schedule, t1, p0, event); - else select_default2(this).call(zoom.transform, t1, p0, event); - } - __name(dblclicked, "dblclicked"); - function touchstarted(event, ...args) { - if (!filter2.apply(this, arguments)) return; - var touches = event.touches, n = touches.length, g = gesture(this, args, event.changedTouches.length === n).event(event), started, i, t, p; - nopropagation(event); - for (i = 0; i < n; ++i) { - t = touches[i], p = pointer_default(t, this); - p = [p, this.__zoom.invert(p), t.identifier]; - if (!g.touch0) g.touch0 = p, started = true, g.taps = 1 + !!touchstarting; - else if (!g.touch1 && g.touch0[2] !== p[2]) g.touch1 = p, g.taps = 0; - } - if (touchstarting) touchstarting = clearTimeout(touchstarting); - if (started) { - if (g.taps < 2) touchfirst = p[0], touchstarting = setTimeout(function() { - touchstarting = null; - }, touchDelay); - interrupt_default(this); - g.start(); - } - } - __name(touchstarted, "touchstarted"); - function touchmoved(event, ...args) { - if (!this.__zooming) return; - var g = gesture(this, args).event(event), touches = event.changedTouches, n = touches.length, i, t, p, l; - noevent_default2(event); - for (i = 0; i < n; ++i) { - t = touches[i], p = pointer_default(t, this); - if (g.touch0 && g.touch0[2] === t.identifier) g.touch0[0] = p; - else if (g.touch1 && g.touch1[2] === t.identifier) g.touch1[0] = p; - } - t = g.that.__zoom; - if (g.touch1) { - var p0 = g.touch0[0], l0 = g.touch0[1], p1 = g.touch1[0], l1 = g.touch1[1], dp = (dp = p1[0] - p0[0]) * dp + (dp = p1[1] - p0[1]) * dp, dl = (dl = l1[0] - l0[0]) * dl + (dl = l1[1] - l0[1]) * dl; - t = scale(t, Math.sqrt(dp / dl)); - p = [(p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2]; - l = [(l0[0] + l1[0]) / 2, (l0[1] + l1[1]) / 2]; - } else if (g.touch0) p = g.touch0[0], l = g.touch0[1]; - else return; - g.zoom("touch", constrain(translate(t, p, l), g.extent, translateExtent)); - } - __name(touchmoved, "touchmoved"); - function touchended(event, ...args) { - if (!this.__zooming) return; - var g = gesture(this, args).event(event), touches = event.changedTouches, n = touches.length, i, t; - nopropagation(event); - if (touchending) clearTimeout(touchending); - touchending = setTimeout(function() { - touchending = null; - }, touchDelay); - for (i = 0; i < n; ++i) { - t = touches[i]; - if (g.touch0 && g.touch0[2] === t.identifier) delete g.touch0; - else if (g.touch1 && g.touch1[2] === t.identifier) delete g.touch1; - } - if (g.touch1 && !g.touch0) g.touch0 = g.touch1, delete g.touch1; - if (g.touch0) g.touch0[1] = this.__zoom.invert(g.touch0[0]); - else { - g.end(); - if (g.taps === 2) { - t = pointer_default(t, this); - if (Math.hypot(touchfirst[0] - t[0], touchfirst[1] - t[1]) < tapDistance) { - var p = select_default2(this).on("dblclick.zoom"); - if (p) p.apply(this, arguments); - } - } - } - } - __name(touchended, "touchended"); - zoom.wheelDelta = function(_) { - return arguments.length ? (wheelDelta = typeof _ === "function" ? _ : constant_default4(+_), zoom) : wheelDelta; - }; - zoom.filter = function(_) { - return arguments.length ? (filter2 = typeof _ === "function" ? _ : constant_default4(!!_), zoom) : filter2; - }; - zoom.touchable = function(_) { - return arguments.length ? (touchable = typeof _ === "function" ? _ : constant_default4(!!_), zoom) : touchable; - }; - zoom.extent = function(_) { - return arguments.length ? (extent = typeof _ === "function" ? _ : constant_default4([[+_[0][0], +_[0][1]], [+_[1][0], +_[1][1]]]), zoom) : extent; - }; - zoom.scaleExtent = function(_) { - return arguments.length ? (scaleExtent[0] = +_[0], scaleExtent[1] = +_[1], zoom) : [scaleExtent[0], scaleExtent[1]]; - }; - zoom.translateExtent = function(_) { - return arguments.length ? (translateExtent[0][0] = +_[0][0], translateExtent[1][0] = +_[1][0], translateExtent[0][1] = +_[0][1], translateExtent[1][1] = +_[1][1], zoom) : [[translateExtent[0][0], translateExtent[0][1]], [translateExtent[1][0], translateExtent[1][1]]]; - }; - zoom.constrain = function(_) { - return arguments.length ? (constrain = _, zoom) : constrain; - }; - zoom.duration = function(_) { - return arguments.length ? (duration = +_, zoom) : duration; - }; - zoom.interpolate = function(_) { - return arguments.length ? (interpolate = _, zoom) : interpolate; - }; - zoom.on = function() { - var value = listeners.on.apply(listeners, arguments); - return value === listeners ? zoom : value; - }; - zoom.clickDistance = function(_) { - return arguments.length ? (clickDistance2 = (_ = +_) * _, zoom) : Math.sqrt(clickDistance2); - }; - zoom.tapDistance = function(_) { - return arguments.length ? (tapDistance = +_, zoom) : tapDistance; - }; - return zoom; - } - __name(zoom_default2, "default"); - - // src/d3.ts - var d3_default = { - hierarchy, - stratify: stratify_default, - tree: tree_default, - treemap: treemap_default, - select: select_default2, - selectAll: selectAll_default2, - zoom: zoom_default2 - }; - - // src/utils.ts - var getAreaSize = /* @__PURE__ */ __name((htmlId) => { - const SVGContainer = document.querySelector(`#${htmlId}`); - if (SVGContainer === null) { - throw new Error(`Cannot find dom element with id:${htmlId}`); - } - const areaWidth = SVGContainer.clientWidth; - const areaHeight = SVGContainer.clientHeight; - if (areaHeight === 0 || areaWidth === 0) { - throw new Error( - "The tree can't be display because the svg height or width of the container is null" - ); - } - return { areaWidth, areaHeight }; - }, "getAreaSize"); - var getFirstDisplayedAncestor = /* @__PURE__ */ __name((ghostNodes, viewableNodes, id2) => { - try { - const parentNode = ghostNodes.find((node) => node.id === id2); - const parentNodeId = parentNode.ancestors()[1].id; - const isPresentInOldNodes = viewableNodes.some( - (oldNode) => oldNode.id === parentNodeId - ); - if (isPresentInOldNodes) { - return parentNode.ancestors()[1]; - } else { - return getFirstDisplayedAncestor(ghostNodes, viewableNodes, parentNodeId); - } - } catch (e) { - return ghostNodes.find((node) => node.id === id2); - } - }, "getFirstDisplayedAncestor"); - var setNodeLocation = /* @__PURE__ */ __name((xPosition, yPosition, settings) => { - if (settings.isHorizontal) { - return "translate(" + yPosition + "," + xPosition + ")"; - } else { - return "translate(" + xPosition + "," + yPosition + ")"; - } - }, "setNodeLocation"); - var RefreshQueue = class { - static { - __name(this, "RefreshQueue"); - } - // The queue is an array that contains objects. Each object represents an - // refresh action and only they have 2 properties: - // { - // callback: triggers when it's the first of queue and then it - // becomes null to prevent that callback executes more - // than once. - // delayNextCallback: when callback is executed, queue will subtracts - // milliseconds from it. When it becomes 0, the entire - // object is destroyed (shifted) from the array and then - // the next item (if exists) will be executed similary - // to this. - // } - static queue = []; - // Contains setInterval ID - static runner; - // Milliseconds of each iteration - static runnerSpeed = 100; - // Developer internal magic number. Time added at end of refresh transition to - // let DOM and d3 rest before another refresh. - // 0 creates console and visual errors because getFirstDisplayedAncestor never - // found the needed id and setNodeLocation receives undefined parameters. - // Between 50 and 100 milliseconds seems enough for 10 nodes (demo example) - static extraDelayBetweenCallbacks = 100; - // Developer internal for debugging RefreshQueue class. Set true to see - // console "real time" queue of tasks. - // If there is a cleaner method, remove it! - static showQueueLog = false; - // Adds one refresh action to the queue. When safe callback will be - // triggered - static add(duration, callback) { - this.queue.push({ - delayNextCallback: duration + this.extraDelayBetweenCallbacks, - callback - }); - this.log( - this.queue.map((_) => _.delayNextCallback), - "<-- New task !!!" - ); - if (!this.runner) { - this.runnerFunction(); - this.runner = setInterval(() => this.runnerFunction(), this.runnerSpeed); - } - } - // Each this.runnerSpeed milliseconds it's executed. It stops when finish. - static runnerFunction() { - if (this.queue[0]) { - if (this.queue[0].callback) { - this.log("Executing task, delaying next task..."); - try { - this.queue[0].callback(); - } catch (e) { - console.error(e); - } finally { - this.queue[0].callback = null; - } - } - this.queue[0].delayNextCallback -= this.runnerSpeed; - this.log(this.queue.map((_) => _.delayNextCallback)); - if (this.queue[0].delayNextCallback <= 0) { - this.queue.shift(); - } - } else { - this.log("No task found"); - clearInterval(this.runner); - this.runner = 0; - } - } - // Print to console debug data if this.showQueueLog = true - static log(...msg) { - if (this.showQueueLog) console.log(...msg); - } - }; - - // src/initializeSVG.ts - var initiliazeSVG = /* @__PURE__ */ __name((treeConfig) => { - const { - htmlId, - isHorizontal, - hasPan, - hasZoom, - mainAxisNodeSpacing, - nodeHeight, - nodeWidth, - marginBottom, - marginLeft, - marginRight, - marginTop - } = treeConfig; - const margin = { - top: marginTop, - right: marginRight, - bottom: marginBottom, - left: marginLeft - }; - const { areaHeight, areaWidth } = getAreaSize(treeConfig.htmlId); - const width = areaWidth - margin.left - margin.right; - const height = areaHeight - margin.top - margin.bottom; - const svg = d3_default.select("#" + htmlId).append("svg").attr("width", areaWidth).attr("height", areaHeight); - const ZoomContainer = svg.append("g"); - const zoom = d3_default.zoom().on("zoom", (e) => { - ZoomContainer.attr("transform", () => e.transform); - }); - svg.call(zoom); - if (!hasPan) { - svg.on("mousedown.zoom", null).on("touchstart.zoom", null).on("touchmove.zoom", null).on("touchend.zoom", null); - } - if (!hasZoom) { - svg.on("wheel.zoom", null).on("mousewheel.zoom", null).on("mousemove.zoom", null).on("DOMMouseScroll.zoom", null).on("dblclick.zoom", null); - } - const MainG = ZoomContainer.append("g").attr( - "transform", - mainAxisNodeSpacing === "auto" ? "translate(0,0)" : isHorizontal ? "translate(" + margin.left + "," + (margin.top + height / 2 - nodeHeight / 2) + ")" : "translate(" + (margin.left + width / 2 - nodeWidth / 2) + "," + margin.top + ")" - ); - return MainG; - }, "initiliazeSVG"); - - // src/links/draw-links.ts - var generateLinkLayout = /* @__PURE__ */ __name((s, d, treeConfig) => { - const { isHorizontal, nodeHeight, nodeWidth, linkShape } = treeConfig; - if (linkShape === "orthogonal") { - if (isHorizontal) { - return `M ${s.y} ${s.x + nodeHeight / 2} - L ${(s.y + d.y + nodeWidth) / 2} ${s.x + nodeHeight / 2} - L ${(s.y + d.y + nodeWidth) / 2} ${d.x + nodeHeight / 2} - ${d.y + nodeWidth} ${d.x + nodeHeight / 2}`; - } else { - return `M ${s.x + nodeWidth / 2} ${s.y} - L ${s.x + nodeWidth / 2} ${(s.y + d.y + nodeHeight) / 2} - L ${d.x + nodeWidth / 2} ${(s.y + d.y + nodeHeight) / 2} - ${d.x + nodeWidth / 2} ${d.y + nodeHeight} `; - } - } else if (linkShape === "curve") { - if (isHorizontal) { - return `M ${s.y} ${s.x + nodeHeight / 2} - L ${s.y - (s.y - d.y - nodeWidth) / 2 + 15} ${s.x + nodeHeight / 2} - Q${s.y - (s.y - d.y - nodeWidth) / 2} ${s.x + nodeHeight / 2} - ${s.y - (s.y - d.y - nodeWidth) / 2} ${s.x + nodeHeight / 2 - offsetPosOrNeg(s.x, d.x, 15)} - L ${s.y - (s.y - d.y - nodeWidth) / 2} ${d.x + nodeHeight / 2} - L ${d.y + nodeWidth} ${d.x + nodeHeight / 2}`; - } else { - return `M ${s.x + nodeWidth / 2} ${s.y} - L ${s.x + nodeWidth / 2} ${s.y - (s.y - d.y - nodeHeight) / 2 + 15} - Q${s.x + nodeWidth / 2} ${s.y - (s.y - d.y - nodeHeight) / 2} - ${s.x + nodeWidth / 2 - offsetPosOrNeg(s.x, d.x, 15)} ${s.y - (s.y - d.y - nodeHeight) / 2} - L ${d.x + nodeWidth / 2} ${s.y - (s.y - d.y - nodeHeight) / 2} - L ${d.x + nodeWidth / 2} ${d.y + nodeHeight} `; - } - } else { - if (isHorizontal) { - return `M ${s.y} ${s.x + nodeHeight / 2} - C ${(s.y + d.y + nodeWidth) / 2} ${s.x + nodeHeight / 2} - ${(s.y + d.y + nodeWidth) / 2} ${d.x + nodeHeight / 2} - ${d.y + nodeWidth} ${d.x + nodeHeight / 2}`; - } else { - return `M ${s.x + nodeWidth / 2} ${s.y} - C ${s.x + nodeWidth / 2} ${(s.y + d.y + nodeHeight) / 2} - ${d.x + nodeWidth / 2} ${(s.y + d.y + nodeHeight) / 2} - ${d.x + nodeWidth / 2} ${d.y + nodeHeight} `; - } - } - }, "generateLinkLayout"); - var offsetPosOrNeg = /* @__PURE__ */ __name((val1, val2, offset) => val1 > val2 ? offset : val1 < val2 ? -offset : 0, "offsetPosOrNeg"); - - // src/links/link-enter.ts - var drawLinkEnter = /* @__PURE__ */ __name((link, settings, nodes, oldNodes) => link.enter().insert("path", "g").attr("class", "link").attr("d", (d) => { - const firstDisplayedParentNode = getFirstDisplayedAncestor( - nodes, - oldNodes, - d.id - ); - const o = { - x: firstDisplayedParentNode.x0, - y: firstDisplayedParentNode.y0 - }; - return generateLinkLayout(o, o, settings); - }).attr("fill", "none").attr( - "stroke-width", - (d) => settings.linkWidth(d) - // Pass the correct `d` object to linkWidth - ).attr( - "stroke", - (d) => settings.linkColor(d) - // Pass the correct `d` object to linkColor - ), "drawLinkEnter"); - - // src/links/link-exit.ts - var drawLinkExit = /* @__PURE__ */ __name((link, settings, nodes, oldNodes) => { - link.exit().transition().duration(settings.duration).style("opacity", 0).attr("d", (d) => { - const firstDisplayedParentNode = getFirstDisplayedAncestor( - oldNodes, - nodes, - d.id - ); - const o = { - x: firstDisplayedParentNode.x0, - y: firstDisplayedParentNode.y0 - }; - return generateLinkLayout(o, o, settings); - }).remove(); - }, "drawLinkExit"); - - // src/links/link-update.ts - var drawLinkUpdate = /* @__PURE__ */ __name((linkEnter, link, settings) => { - const linkUpdate = linkEnter.merge(link); - linkUpdate.transition().duration(settings.duration).attr("d", (d) => { - return generateLinkLayout(d, d.parent, settings); - }).attr("fill", "none").attr("stroke-width", (d) => { - return settings.linkWidth(d); - }).attr("stroke", (d) => { - return settings.linkColor(d); - }); - }, "drawLinkUpdate"); - - // src/nodes/node-enter.ts - var drawNodeEnter = /* @__PURE__ */ __name((node, settings, nodes, oldNodes) => { - const nodeEnter = node.enter().append("g").attr("class", "node").attr("id", (d) => d?.id).attr("transform", (d) => { - const firstDisplayedParentNode = getFirstDisplayedAncestor( - nodes, - oldNodes, - d.id - ); - return setNodeLocation( - firstDisplayedParentNode.x0, - firstDisplayedParentNode.y0, - settings - ); - }); - nodeEnter.append("foreignObject").attr("width", settings.nodeWidth).attr("height", settings.nodeHeight); - return nodeEnter; - }, "drawNodeEnter"); - - // src/nodes/node-exit.ts - var drawNodeExit = /* @__PURE__ */ __name((node, settings, nodes, oldNodes) => { - const nodeExit = node.exit().transition().duration(settings.duration).style("opacity", 0).attr("transform", (d) => { - const firstDisplayedParentNode = getFirstDisplayedAncestor( - oldNodes, - nodes, - d.id - ); - return setNodeLocation( - firstDisplayedParentNode.x0, - firstDisplayedParentNode.y0, - settings - ); - }).remove(); - nodeExit.select("rect").style("fill-opacity", 1e-6); - nodeExit.select("circle").attr("r", 1e-6); - nodeExit.select("text").style("fill-opacity", 1e-6); - }, "drawNodeExit"); - - // src/nodes/node-update.ts - var drawNodeUpdate = /* @__PURE__ */ __name((nodeEnter, node, settings) => { - const nodeUpdate = nodeEnter.merge(node); - nodeUpdate.transition().duration(settings.duration).attr("transform", (d) => { - return settings.isHorizontal ? "translate(" + d.y + "," + d.x + ")" : "translate(" + d.x + "," + d.y + ")"; - }); - nodeUpdate.select("foreignObject").attr("width", settings.nodeWidth).attr("height", settings.nodeHeight).style("overflow", "visible").on("click", (_, d) => settings.onNodeClick({ ...d, settings })).on("mouseenter", (_, d) => settings.onNodeMouseEnter({ ...d, settings })).on("mouseleave", (_, d) => settings.onNodeMouseLeave({ ...d, settings })).html((d) => settings.renderNode({ ...d, settings })); - }, "drawNodeUpdate"); - - // src/prepare-data.ts - var generateNestedData = /* @__PURE__ */ __name((data, treeConfig) => { - const { idKey, relationnalField, hasFlatData } = treeConfig; - return hasFlatData ? d3_default.stratify().id((d) => d[idKey]).parentId((d) => d[relationnalField])(data) : d3_default.hierarchy(data, (d) => d[relationnalField]); - }, "generateNestedData"); - var generateBasicTreemap = /* @__PURE__ */ __name((treeConfig) => { - const { areaHeight, areaWidth } = getAreaSize(treeConfig.htmlId); - return treeConfig.mainAxisNodeSpacing === "auto" && treeConfig.isHorizontal ? d3_default.tree().size([ - areaHeight - treeConfig.nodeHeight, - areaWidth - treeConfig.nodeWidth - ]) : treeConfig.mainAxisNodeSpacing === "auto" && !treeConfig.isHorizontal ? d3_default.tree().size([ - areaWidth - treeConfig.nodeWidth, - areaHeight - treeConfig.nodeHeight - ]) : treeConfig.isHorizontal === true ? d3_default.tree().nodeSize([ - treeConfig.nodeHeight * treeConfig.secondaryAxisNodeSpacing, - treeConfig.nodeWidth - ]) : d3_default.tree().nodeSize([ - treeConfig.nodeWidth * treeConfig.secondaryAxisNodeSpacing, - treeConfig.nodeHeight - ]); - }, "generateBasicTreemap"); - - // src/index.ts - var Treeviz = { - create: create2 - }; - if (typeof window !== "undefined") { - window.Treeviz = Treeviz; - } - function create2(userSettings) { - const defaultSettings = { - data: [], - htmlId: "", - idKey: "id", - relationnalField: "father", - hasFlatData: true, - nodeWidth: 160, - nodeHeight: 100, - mainAxisNodeSpacing: 300, - renderNode: /* @__PURE__ */ __name(() => "Node", "renderNode"), - linkColor: /* @__PURE__ */ __name(() => "#ffcc80", "linkColor"), - linkWidth: /* @__PURE__ */ __name(() => 10, "linkWidth"), - linkShape: "quadraticBeziers", - isHorizontal: true, - hasPan: false, - hasZoom: false, - duration: 600, - onNodeClick: /* @__PURE__ */ __name(() => void 0, "onNodeClick"), - onNodeMouseEnter: /* @__PURE__ */ __name(() => void 0, "onNodeMouseEnter"), - onNodeMouseLeave: /* @__PURE__ */ __name(() => void 0, "onNodeMouseLeave"), - marginBottom: 0, - marginLeft: 0, - marginRight: 0, - marginTop: 0, - secondaryAxisNodeSpacing: 1.25 - }; - let settings = { - ...defaultSettings, - ...userSettings - }; - let oldNodes = []; - function draw(svg2, computedTree) { - const nodes = computedTree.descendants(); - const links = computedTree.descendants().slice(1); - const { mainAxisNodeSpacing } = settings; - if (mainAxisNodeSpacing !== "auto") { - nodes.forEach((d) => { - d.y = d.depth * settings.nodeWidth * mainAxisNodeSpacing; - }); - } - nodes.forEach((currentNode) => { - const currentNodeOldPosition = oldNodes.find( - (node2) => node2.id === currentNode.id - ); - currentNode.x0 = currentNodeOldPosition ? currentNodeOldPosition.x0 : currentNode.x; - currentNode.y0 = currentNodeOldPosition ? currentNodeOldPosition.y0 : currentNode.y; - }); - const node = svg2.selectAll("g.node").data(nodes, (d) => { - return d[settings.idKey]; - }); - const nodeEnter = drawNodeEnter(node, settings, nodes, oldNodes); - drawNodeUpdate(nodeEnter, node, settings); - drawNodeExit(node, settings, nodes, oldNodes); - const link = svg2.selectAll("path.link").data(links, (d) => { - return d.id; - }); - const linkEnter = drawLinkEnter(link, settings, nodes, oldNodes); - drawLinkUpdate(linkEnter, link, settings); - drawLinkExit(link, settings, nodes, oldNodes); - oldNodes = [...nodes]; - } - __name(draw, "draw"); - let nodeMap = /* @__PURE__ */ new Map(); - function refresh(data, newSettings) { - RefreshQueue.add(settings.duration, () => { - if (newSettings) { - settings = { ...settings, ...newSettings }; - } - const nestedData = generateNestedData(data, settings); - const treemap = generateBasicTreemap(settings); - const computedTree = treemap(nestedData); - const nodes = computedTree.descendants(); - const updatedNodes = []; - nodes.forEach((node) => { - if (node.id != void 0) { - const existing = nodeMap.get(node.id); - if (!existing || existing.x !== node.x || existing.y !== node.y) { - updatedNodes.push(node); - } - nodeMap.set(node.id, node); - } - }); - if (svg) { - draw(svg, computedTree); - } - }); - } - __name(refresh, "refresh"); - function clean(keepConfig) { - const myNode = keepConfig ? document.querySelector(`#${settings.htmlId} svg g`) : document.querySelector(`#${settings.htmlId}`); - if (myNode) { - while (myNode.firstChild) { - myNode.removeChild(myNode.firstChild); - } - } - oldNodes = []; - } - __name(clean, "clean"); - const treeObject = { refresh, clean }; - const svg = initiliazeSVG(settings); - return treeObject; - } - __name(create2, "create"); - return __toCommonJS(index_exports); -})(); diff --git a/front/network.php b/front/network.php index 7cf2dfeb3..bf9df4681 100755 --- a/front/network.php +++ b/front/network.php @@ -63,7 +63,6 @@ require 'php/templates/footer.php'; ?> - From a014140e50e29b5b8b5603a8d1265ad83c667166 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Mon, 31 Aug 2026 14:57:40 +1000 Subject: [PATCH 7/8] FE: dashed links in network view for non-ethernet connections #1763 --- front/js/network-tree.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/front/js/network-tree.js b/front/js/network-tree.js index b9d9600de..8005639e1 100644 --- a/front/js/network-tree.js +++ b/front/js/network-tree.js @@ -329,9 +329,13 @@ function initTree(myHierarchy) linkStyle: (nodeData) => { // Return "solid", "dashed", "dotted", or "dashdot" // Can vary per link based on node data: - console.log(nodeData.data.devIsEthernet); - - return nodeData.data.devIsEthernet ? "solid" : "dashed"; + if(nodeData.data.devParentRelType == "virtual") + { + return "dotted"; + } else + { + return nodeData.data.devIsEthernet ? "solid" : "dashed"; + } }, linkLabel: { render: (parent, child) => { From 7bf62567888709fbab1569284b1a2ecdc5ba5c15 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Mon, 31 Aug 2026 15:20:54 +1000 Subject: [PATCH 8/8] better scaffolding, robustness --- .claude/skills/pr-analysis/SKILL.md | 2 +- .gemini/skills/pr-analysis/SKILL.md | 2 +- .github/skills/devcontainer-setup/SKILL.md | 6 +++--- .github/skills/pr-analysis/SKILL.md | 2 +- CLAUDE.md | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.claude/skills/pr-analysis/SKILL.md b/.claude/skills/pr-analysis/SKILL.md index a411eae8d..657c30d0d 100644 --- a/.claude/skills/pr-analysis/SKILL.md +++ b/.claude/skills/pr-analysis/SKILL.md @@ -11,7 +11,7 @@ Run through this before creating or editing any file under `test/`: 1. **Helpers first:** Check `test/db_test_helpers.py` for existing factories (`make_db`, `make_device_dict`, `insert_device_from_dict`, `DummyDB`). Use them. If what you need doesn't exist, add it there — never define it locally in the test file. 2. **MAC literals must be lowercase:** Every MAC string in fixtures, parametrize, assertions, docstrings, and comments must be lowercase hex (e.g. `aa:bb:cc:dd:ee:01`). No exceptions. -3. **Test file location:** Place tests under a subdirectory of `test/` that mirrors the source path (e.g. `test/scan/` for `server/scan/`). Never put test files directly in `test/`. +3. **Test file location:** Place *new* tests under a subdirectory of `test/` that mirrors the source path (e.g. `test/scan/` for `server/scan/`). Don't add new files directly in `test/` root - a handful of existing ones there (e.g. `test_plugin_helper.py`, `test_wol_validation.py`) predate this convention; that's not license to add more, but don't migrate them unprompted either. 4. **No inline imports:** All imports at the top of the file. ## Before Acting on Any PR Comment diff --git a/.gemini/skills/pr-analysis/SKILL.md b/.gemini/skills/pr-analysis/SKILL.md index 1f90e2741..ea12b2cc3 100644 --- a/.gemini/skills/pr-analysis/SKILL.md +++ b/.gemini/skills/pr-analysis/SKILL.md @@ -11,7 +11,7 @@ Run through this before creating or editing any file under `test/`: 1. **Helpers first:** Check `test/db_test_helpers.py` for existing factories (`make_db`, `make_device_dict`, `insert_device_from_dict`, `DummyDB`). Use them. If what you need doesn't exist, add it there — never define it locally in the test file. 2. **MAC literals must be lowercase:** Every MAC string in fixtures, parametrize, assertions, docstrings, and comments must be lowercase hex (e.g. `aa:bb:cc:dd:ee:01`). No exceptions. -3. **Test file location:** Place tests under a subdirectory of `test/` that mirrors the source path (e.g. `test/scan/` for `server/scan/`). Never put test files directly in `test/`. +3. **Test file location:** Place *new* tests under a subdirectory of `test/` that mirrors the source path (e.g. `test/scan/` for `server/scan/`). Don't add new files directly in `test/` root - a handful of existing ones there (e.g. `test_plugin_helper.py`, `test_wol_validation.py`) predate this convention; that's not license to add more, but don't migrate them unprompted either. 4. **No inline imports:** All imports at the top of the file. ## Before Acting on Any PR Comment diff --git a/.github/skills/devcontainer-setup/SKILL.md b/.github/skills/devcontainer-setup/SKILL.md index ac6c661a1..2f77de882 100644 --- a/.github/skills/devcontainer-setup/SKILL.md +++ b/.github/skills/devcontainer-setup/SKILL.md @@ -5,7 +5,7 @@ description: Reprovision and reset the devcontainer environment. Use this when a # Devcontainer Setup -The setup script forcefully resets all runtime state. It is idempotent—every run wipes and recreates all relevant folders, symlinks, and files. +The setup script forcefully resets all *runtime* state (services, tmpfs ramdisks, symlinks, log files) unconditionally on every run. Persistent DB/config content under `/data` is the one exception - it's preserved by default; see step 7 below. ## Command @@ -29,8 +29,8 @@ The setup script forcefully resets all runtime state. It is idempotent—every r - After modifying setup scripts - After container rebuild - When environment is in broken state -- After database reset +- To pick up a database/config reset done another way (setup.sh itself won't reset them - see step 7) ## Philosophy -No conditional logic. Everything is recreated unconditionally. If something doesn't work, run setup again. +Runtime state (services, tmpfs, symlinks, logs) has no conditional logic - everything is recreated unconditionally, every run. DB/config are the one deliberate exception, gated behind `ALWAYS_FRESH_INSTALL`. If something in runtime state doesn't work, run setup again. diff --git a/.github/skills/pr-analysis/SKILL.md b/.github/skills/pr-analysis/SKILL.md index 904d9f12a..384872107 100644 --- a/.github/skills/pr-analysis/SKILL.md +++ b/.github/skills/pr-analysis/SKILL.md @@ -11,7 +11,7 @@ Run through this before creating or editing any file under `test/`: 1. **Helpers first:** Check `test/db_test_helpers.py` for existing factories (`make_db`, `make_device_dict`, `insert_device_from_dict`, `DummyDB`). Use them. If what you need doesn't exist, add it there — never define it locally in the test file. 2. **MAC literals must be lowercase:** Every MAC string in fixtures, `parametrize`, assertions, docstrings, and comments must be lowercase hex (e.g. `aa:bb:cc:dd:ee:01`). No exceptions. -3. **Test file location:** Place tests under a subdirectory of `test/` that mirrors the source path (e.g. `test/scan/` for `server/scan/`). Never put test files directly in `test/`. +3. **Test file location:** Place *new* tests under a subdirectory of `test/` that mirrors the source path (e.g. `test/scan/` for `server/scan/`). Don't add new files directly in `test/` root - a handful of existing ones there (e.g. `test_plugin_helper.py`, `test_wol_validation.py`) predate this convention; that's not license to add more, but don't migrate them unprompted either. 4. **No inline imports:** All imports at the top of the file. ## Before Acting on Any PR Comment diff --git a/CLAUDE.md b/CLAUDE.md index ee3c3d724..4dd198f10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ Three distinct roots, each with a different persistence contract — get this wr - `configPath` (`/data/config`) — durable, user-facing. `app.conf` lives here; config-like plugin artifacts (exports, backups) belong here too. - `logPath` (`/tmp/log`, plus `/tmp/api`, `/tmp/db_is_locked`, nginx state) — **ephemeral tmpfs**, wiped on every container restart. Never put anything here you need to survive a restart. (`server/plugins/adguard_export`, `unifi_import` were both fixed this way after shipping with state files rooted in `logPath` — check any plugin that opens a file outside its `RESULT_FILE` against this before assuming it's fine.) -All three are exported from `server/const.py` (`dbFolderPath`, `configPath`, `dataPath`, `logPath`) and importable by any plugin. +All three, plus `dataPath` (`/data`, the bare parent of `dbFolderPath`/`configPath` - avoid writing loose files directly under it; pick one of the two subpaths above instead), are exported from `server/const.py` and importable by any plugin. ### Plugin system (`server/plugins/*/`)