Updated on 2026-08-14
This commit is contained in:
parent
5b27f5191e
commit
67e847a672
7 changed files with 2454 additions and 1 deletions
718
.claude/docs/module-connectivity.html
Normal file
718
.claude/docs/module-connectivity.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
59
.claude/skills/navigation-graph/SKILL.md
Normal file
59
.claude/skills/navigation-graph/SKILL.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
---
|
||||
name: navigation-graph
|
||||
description: Refresh the app's navigation/dependency graph from live code and rebuild the interactive visualization. Scans features/domain Gradle project dependencies and every AppRoute usage, updates the generated regions of .claude/docs/navigation-graph.md (preserving hand-written prose and the curated config), then renders .claude/docs/module-connectivity.html (Area / Module / Screens views with team overlays). Use when the user asks to update/regenerate/refresh the navigation graph, screen map, module connectivity diagram or module-connectivity.html, after adding/removing AppRoute screens or feature/domain modules, or to add/recolor a team. Triggers: "update the navigation graph", "regenerate module-connectivity.html", "refresh the screen map", "rebuild the module connectivity diagram", "add a team to the graph".
|
||||
allowed-tools: Bash, Read, Edit, Grep, Glob
|
||||
---
|
||||
|
||||
Refresh and rebuild the app's connectivity visualization. The data flows **code → doc → HTML**:
|
||||
|
||||
```
|
||||
features/domain/data build.gradle.kts deps ─┐
|
||||
AppRoute.kt + every AppRoute.X usage ─┴─▶ build_graph.py ─▶ .claude/docs/navigation-graph.md
|
||||
│ (data + curated config blocks)
|
||||
▼
|
||||
render_html.py ─▶ .claude/docs/module-connectivity.html
|
||||
```
|
||||
|
||||
`navigation-graph.md` is the source of truth. Its hand-written prose (sections 1–4: route tables, edges-with-triggers, nested routes, deep links) is **owned by humans and never overwritten**. The skill manages only the region between `<!-- NAVGRAPH:BEGIN -->` and `<!-- NAVGRAPH:END -->`, which holds:
|
||||
- an **editable CONFIG json block** — functional groups (label/color/grid anchor), per-screen group + owner, and teams. Preserved across refreshes (human edits win).
|
||||
- **auto data blocks** — `areaGraph`, `moduleGraph`, `screensGraph`, `screensMeta`. Overwritten from code every run.
|
||||
|
||||
## To refresh after a code change (the common case)
|
||||
|
||||
Run both scripts from anywhere in the repo (they locate the root via `settings.gradle.kts`). The scan walks the whole tree — expect ~10–20s.
|
||||
|
||||
```bash
|
||||
python3 .claude/skills/navigation-graph/scripts/build_graph.py
|
||||
python3 .claude/skills/navigation-graph/scripts/render_html.py
|
||||
```
|
||||
|
||||
Then report to the user: the printed counts, **any `⚠ NEW screen` warnings**, and that `.claude/docs/module-connectivity.html` is a self-contained file they can open in a browser.
|
||||
|
||||
**If `build_graph.py` prints `⚠ NEW screen …` warnings:** a route was added to `AppRoute.kt` but isn't classified. The new screen was defaulted to group `misc` / owner `app`. Edit the CONFIG block in `.claude/docs/navigation-graph.md` to set its real `screenGroups[Name]` and `screenOwners[Name]`, then re-run both scripts. Pick the group/owner by reading where the route lives and is pushed from. Removed routes are reported as "no longer in code" and are harmless (kept in config for when they return).
|
||||
|
||||
## To change grouping, owners, colors, or teams
|
||||
|
||||
Edit the **CONFIG** json block inside `.claude/docs/navigation-graph.md` (between `<!-- NAVGRAPH:config:BEGIN -->` and `:END`), then run `render_html.py` (no need to re-scan code unless code changed):
|
||||
- **`groups`** — add/rename a functional group or change its `color` / `label` / grid `anchor` (`x`,`y` are 0–1 fractions of the canvas).
|
||||
- **`screenGroups` / `screenOwners`** — reassign a screen.
|
||||
- **`teams`** — add a team object `{ "id", "name", "color", "roots": [...module path prefixes...], "screens": [...AppRoute names...] }`. `roots` drive the overlay in Area/Module views (e.g. `"features:onramp"`, `"domain:staking"`, `"data:swap"`); `screens` drive it in the Screens view (e.g. `"Send"`). A module/screen may be matched by either. The overlay (hull + rings, optional cluster force) and per-view member counts wire up automatically.
|
||||
|
||||
After editing CONFIG, re-run `render_html.py`. If you changed code-derived facts, run `build_graph.py` first.
|
||||
|
||||
## What gets extracted (and what's inferred)
|
||||
|
||||
- **Dependency edges** (Area/Module): only project (`projects.*`) deps among `features/*`, `domain/*` and `data/*` — external libraries excluded. Area view merges each area's `api`/`impl`/`models`. The Data-layer toggle in the HTML appears only when data modules are present.
|
||||
- **Screen edges** (Screens): the **target** is exact — the `AppRoute.X` argument of a `push`/`replaceCurrent`/`replaceAll`/`popTo` call. The **source** is the navigating file's feature module collapsed to that feature's main screen (eponymous screen if one exists, else most-referenced), so intra-feature hops are merged. Calls from shared UI / app root attach to a synthetic **App shell** node. Group/owner come from the curated CONFIG.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
.claude/skills/navigation-graph/
|
||||
SKILL.md
|
||||
assets/template.html # parameterized HTML (8 inject points: 4 data blocks + teams + group colors/labels/anchors)
|
||||
assets/config.seed.json # initial curation; used only when the doc has no CONFIG block yet
|
||||
scripts/build_graph.py # code -> navigation-graph.md (managed region)
|
||||
scripts/render_html.py # navigation-graph.md -> module-connectivity.html
|
||||
```
|
||||
|
||||
Outputs live in `.claude/docs/`. The scripts are idempotent — re-running never duplicates the managed region. Do not hand-edit the auto data blocks (they're regenerated); edit CONFIG instead. After regenerating, sanity-check the HTML by extracting its `<script>` and running `node --check` if Node is available.
|
||||
280
.claude/skills/navigation-graph/assets/config.seed.json
Normal file
280
.claude/skills/navigation-graph/assets/config.seed.json
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
{
|
||||
"hubs": [
|
||||
"domain:models",
|
||||
"domain:core",
|
||||
"domain:app-currency",
|
||||
"domain:settings",
|
||||
"domain:balance-hiding",
|
||||
"domain:feedback",
|
||||
"domain:demo"
|
||||
],
|
||||
"groups": {
|
||||
"shell": {
|
||||
"label": "app shell",
|
||||
"color": "#5A6677",
|
||||
"anchor": {
|
||||
"x": 0.5,
|
||||
"y": 0.07
|
||||
}
|
||||
},
|
||||
"entry": {
|
||||
"label": "entry",
|
||||
"color": "#7DA2FF",
|
||||
"anchor": {
|
||||
"x": 0.18,
|
||||
"y": 0.24
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"label": "onboarding",
|
||||
"color": "#34D8C4",
|
||||
"anchor": {
|
||||
"x": 0.5,
|
||||
"y": 0.24
|
||||
}
|
||||
},
|
||||
"wallet": {
|
||||
"label": "wallet",
|
||||
"color": "#E8A33D",
|
||||
"anchor": {
|
||||
"x": 0.82,
|
||||
"y": 0.24
|
||||
}
|
||||
},
|
||||
"portfolio": {
|
||||
"label": "portfolio",
|
||||
"color": "#B69CFF",
|
||||
"anchor": {
|
||||
"x": 0.18,
|
||||
"y": 0.52
|
||||
}
|
||||
},
|
||||
"tokenaction": {
|
||||
"label": "token actions",
|
||||
"color": "#FF8F6B",
|
||||
"anchor": {
|
||||
"x": 0.5,
|
||||
"y": 0.52
|
||||
}
|
||||
},
|
||||
"markets": {
|
||||
"label": "markets / news",
|
||||
"color": "#5BD08A",
|
||||
"anchor": {
|
||||
"x": 0.82,
|
||||
"y": 0.52
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"label": "settings",
|
||||
"color": "#9AA7B8",
|
||||
"anchor": {
|
||||
"x": 0.18,
|
||||
"y": 0.8
|
||||
}
|
||||
},
|
||||
"tangempay": {
|
||||
"label": "tangem pay",
|
||||
"color": "#F2C14E",
|
||||
"anchor": {
|
||||
"x": 0.5,
|
||||
"y": 0.8
|
||||
}
|
||||
},
|
||||
"misc": {
|
||||
"label": "misc",
|
||||
"color": "#C7CFDA",
|
||||
"anchor": {
|
||||
"x": 0.82,
|
||||
"y": 0.8
|
||||
}
|
||||
}
|
||||
},
|
||||
"groupOrder": [
|
||||
"shell",
|
||||
"entry",
|
||||
"onboarding",
|
||||
"wallet",
|
||||
"portfolio",
|
||||
"tokenaction",
|
||||
"markets",
|
||||
"settings",
|
||||
"tangempay",
|
||||
"misc"
|
||||
],
|
||||
"defaultGroup": "misc",
|
||||
"defaultOwner": "app",
|
||||
"screenGroups": {
|
||||
"Initial": "entry",
|
||||
"Home": "entry",
|
||||
"Welcome": "entry",
|
||||
"Disclaimer": "entry",
|
||||
"CreateWalletSelection": "onboarding",
|
||||
"CreateWalletStart": "onboarding",
|
||||
"CreateHardwareWallet": "onboarding",
|
||||
"CreateMobileWallet": "onboarding",
|
||||
"AddExistingWallet": "onboarding",
|
||||
"Onboarding": "onboarding",
|
||||
"CreateWalletBackup": "onboarding",
|
||||
"WalletBackup": "onboarding",
|
||||
"WalletHardwareBackup": "onboarding",
|
||||
"UpgradeWallet": "onboarding",
|
||||
"WalletActivation": "onboarding",
|
||||
"AccessCodeRecovery": "onboarding",
|
||||
"UpdateAccessCode": "onboarding",
|
||||
"ViewPhrase": "onboarding",
|
||||
"Wallet": "wallet",
|
||||
"Stories": "wallet",
|
||||
"NFT": "wallet",
|
||||
"NFTSend": "wallet",
|
||||
"PushNotification": "wallet",
|
||||
"PushNotificationSettings": "wallet",
|
||||
"CurrencyDetails": "portfolio",
|
||||
"ManageTokens": "portfolio",
|
||||
"ChooseManagedTokens": "portfolio",
|
||||
"CreateAccount": "portfolio",
|
||||
"EditAccount": "portfolio",
|
||||
"AccountDetails": "portfolio",
|
||||
"ArchivedAccountList": "portfolio",
|
||||
"Send": "tokenaction",
|
||||
"SendEntryPoint": "tokenaction",
|
||||
"Swap": "tokenaction",
|
||||
"SwapCrypto": "tokenaction",
|
||||
"Onramp": "tokenaction",
|
||||
"OnrampSuccess": "tokenaction",
|
||||
"BuyCrypto": "tokenaction",
|
||||
"SellCrypto": "tokenaction",
|
||||
"Staking": "tokenaction",
|
||||
"YieldSupplyEntry": "tokenaction",
|
||||
"Earn": "tokenaction",
|
||||
"Markets": "markets",
|
||||
"MarketsTokenDetails": "markets",
|
||||
"News": "markets",
|
||||
"NewsDetails": "markets",
|
||||
"Details": "settings",
|
||||
"DetailsSecurity": "settings",
|
||||
"CardSettings": "settings",
|
||||
"AppSettings": "settings",
|
||||
"ResetToFactory": "settings",
|
||||
"WalletSettings": "settings",
|
||||
"ForgetWallet": "settings",
|
||||
"AppCurrencySelector": "settings",
|
||||
"ReferralProgram": "settings",
|
||||
"AddressBook": "settings",
|
||||
"WalletConnectSessions": "settings",
|
||||
"TangemPayDetails": "tangempay",
|
||||
"TangemPayHotWalletOnboarding": "tangempay",
|
||||
"TangemPayOnboarding": "tangempay",
|
||||
"Kyc": "tangempay",
|
||||
"QrScanning": "misc",
|
||||
"Usedesk": "misc",
|
||||
"Survey": "misc"
|
||||
},
|
||||
"screenOwners": {
|
||||
"Initial": "app",
|
||||
"Home": "features:home",
|
||||
"Welcome": "features:welcome",
|
||||
"Disclaimer": "features:disclaimer",
|
||||
"Wallet": "features:wallet",
|
||||
"CurrencyDetails": "features:tokendetails",
|
||||
"Send": "features:send",
|
||||
"Details": "features:details",
|
||||
"DetailsSecurity": "features:details",
|
||||
"Usedesk": "features:usedesk",
|
||||
"CardSettings": "features:details",
|
||||
"AppSettings": "features:details",
|
||||
"ResetToFactory": "features:details",
|
||||
"AccessCodeRecovery": "features:onboarding-v2",
|
||||
"ManageTokens": "features:manage-tokens",
|
||||
"ChooseManagedTokens": "features:manage-tokens",
|
||||
"WalletConnectSessions": "features:walletconnect",
|
||||
"AddressBook": "features:address-book",
|
||||
"QrScanning": "features:qr-scanning",
|
||||
"ReferralProgram": "features:referral",
|
||||
"Swap": "features:swap",
|
||||
"AppCurrencySelector": "features:wallet-settings",
|
||||
"Staking": "features:staking",
|
||||
"PushNotification": "features:push-notifications",
|
||||
"WalletSettings": "features:wallet-settings",
|
||||
"PushNotificationSettings": "features:push-notification-settings",
|
||||
"WalletBackup": "features:onboarding-v2",
|
||||
"WalletHardwareBackup": "features:onboarding-v2",
|
||||
"Markets": "features:markets",
|
||||
"MarketsTokenDetails": "features:markets",
|
||||
"Onramp": "features:onramp",
|
||||
"OnrampSuccess": "features:onramp",
|
||||
"BuyCrypto": "features:onramp",
|
||||
"SellCrypto": "features:onramp",
|
||||
"SwapCrypto": "features:swap-v2",
|
||||
"Onboarding": "features:onboarding-v2",
|
||||
"Stories": "features:stories",
|
||||
"NFT": "features:nft",
|
||||
"NFTSend": "features:nft",
|
||||
"CreateWalletSelection": "features:create-wallet-selection",
|
||||
"CreateWalletStart": "features:create-wallet-start",
|
||||
"CreateHardwareWallet": "features:onboarding-v2",
|
||||
"CreateMobileWallet": "features:hot-wallet",
|
||||
"UpgradeWallet": "features:hot-wallet",
|
||||
"AddExistingWallet": "features:onboarding-v2",
|
||||
"WalletActivation": "features:tangempay",
|
||||
"CreateWalletBackup": "features:onboarding-v2",
|
||||
"UpdateAccessCode": "features:onboarding-v2",
|
||||
"ViewPhrase": "features:onboarding-v2",
|
||||
"ForgetWallet": "features:wallet-settings",
|
||||
"SendEntryPoint": "features:send",
|
||||
"CreateAccount": "features:account",
|
||||
"EditAccount": "features:account",
|
||||
"AccountDetails": "features:account",
|
||||
"ArchivedAccountList": "features:account",
|
||||
"TangemPayDetails": "features:tangempay",
|
||||
"TangemPayHotWalletOnboarding": "features:tangempay",
|
||||
"TangemPayOnboarding": "features:tangempay",
|
||||
"Kyc": "features:kyc",
|
||||
"Survey": "features:survey",
|
||||
"YieldSupplyEntry": "features:yield-supply",
|
||||
"NewsDetails": "features:feed",
|
||||
"News": "features:feed",
|
||||
"Earn": "features:feed"
|
||||
},
|
||||
"teams": [
|
||||
{
|
||||
"id": "grow",
|
||||
"name": "Grow",
|
||||
"color": "#FF6FD8",
|
||||
"roots": [
|
||||
"features:approval",
|
||||
"features:onramp",
|
||||
"features:send",
|
||||
"features:staking",
|
||||
"features:swap",
|
||||
"features:swap-v2",
|
||||
"features:yield-supply",
|
||||
"domain:express",
|
||||
"domain:offramp",
|
||||
"domain:onramp",
|
||||
"domain:staking",
|
||||
"domain:swap",
|
||||
"domain:yield-supply",
|
||||
"domain:transaction",
|
||||
"data:express",
|
||||
"data:onramp",
|
||||
"data:staking",
|
||||
"data:swap",
|
||||
"data:yield-supply"
|
||||
],
|
||||
"screens": [
|
||||
"Send",
|
||||
"Swap",
|
||||
"Staking",
|
||||
"Onramp",
|
||||
"OnrampSuccess",
|
||||
"BuyCrypto",
|
||||
"SellCrypto",
|
||||
"SwapCrypto",
|
||||
"NFTSend",
|
||||
"SendEntryPoint",
|
||||
"YieldSupplyEntry"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
718
.claude/skills/navigation-graph/assets/template.html
Normal file
718
.claude/skills/navigation-graph/assets/template.html
Normal file
|
|
@ -0,0 +1,718 @@
|
|||
<title>Tangem — features ↔ domain module connectivity</title>
|
||||
<style>
|
||||
:root{
|
||||
--ground:#0E1116;--panel:#161B22;--panel-2:#1B222B;--line:#222B36;
|
||||
--text:#C9D4E0;--muted:#74859A;--faint:#3A4757;
|
||||
--features:#E8A33D;--domain:#34D8C4;--data:#B69CFF;--inverted:#FF5C6C;
|
||||
--mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;
|
||||
--ui:"Helvetica Neue",Inter,system-ui,-apple-system,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{margin:0;background:var(--ground);color:var(--text);font-family:var(--ui);
|
||||
overflow:hidden;-webkit-font-smoothing:antialiased}
|
||||
.app{display:flex;flex-direction:column;height:100vh;width:100vw}
|
||||
|
||||
header{flex:0 0 auto;border-bottom:1px solid var(--line);padding:14px 22px 12px;
|
||||
display:flex;align-items:flex-end;justify-content:space-between;gap:24px;flex-wrap:wrap;
|
||||
background:linear-gradient(180deg,#11161D,#0E1116)}
|
||||
.titleblock{min-width:0}
|
||||
.eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.34em;text-transform:uppercase;
|
||||
color:var(--muted);margin:0 0 6px}
|
||||
.eyebrow b{color:var(--domain);font-weight:600}
|
||||
h1{margin:0;font-size:clamp(20px,3.2vw,30px);font-weight:800;letter-spacing:-.022em;line-height:1}
|
||||
h1 .arrow{color:var(--muted);font-weight:400;padding:0 .15em}
|
||||
.features-ink{color:var(--features)}.domain-ink{color:var(--domain)}
|
||||
.stats{display:flex;gap:0;font-family:var(--mono)}
|
||||
.stat{padding:0 18px;border-left:1px solid var(--line);text-align:right}
|
||||
.stat:first-child{border-left:0}
|
||||
.stat .num{font-size:22px;font-weight:700;line-height:1.05}
|
||||
.stat .lbl{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);margin-top:3px}
|
||||
.stat.warn .num{color:var(--inverted)}
|
||||
|
||||
.body{flex:1 1 auto;display:flex;min-height:0}
|
||||
.rail{flex:0 0 270px;border-right:1px solid var(--line);background:var(--panel);
|
||||
overflow-y:auto;padding:16px 16px 28px}
|
||||
.stage{flex:1 1 auto;position:relative;min-width:0;
|
||||
background:radial-gradient(120% 120% at 30% 0%,#12171F 0%,#0E1116 60%)}
|
||||
svg{width:100%;height:100%;display:block;cursor:grab;touch-action:none}
|
||||
svg.grabbing{cursor:grabbing}
|
||||
|
||||
/* view switch */
|
||||
.viewswitch{display:flex;background:var(--ground);border:1px solid var(--line);
|
||||
border-radius:9px;padding:3px;gap:3px;margin-bottom:20px}
|
||||
.viewswitch button{flex:1;border:0;background:transparent;color:var(--muted);
|
||||
font-family:var(--mono);font-size:12px;font-weight:600;padding:8px 6px;border-radius:6px;
|
||||
cursor:pointer;letter-spacing:.04em;transition:.13s;display:flex;flex-direction:column;gap:2px;align-items:center}
|
||||
.viewswitch button .sub{font-size:9.5px;font-weight:500;letter-spacing:.02em;opacity:.7}
|
||||
.viewswitch button:hover{color:var(--text)}
|
||||
.viewswitch button.on{background:var(--panel-2);color:var(--text);box-shadow:inset 0 0 0 1px var(--line)}
|
||||
|
||||
.grp{margin-bottom:20px}
|
||||
.grp h2{font-family:var(--mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;
|
||||
color:var(--muted);margin:0 0 9px;font-weight:600}
|
||||
.search{width:100%;background:var(--ground);border:1px solid var(--line);color:var(--text);
|
||||
font-family:var(--mono);font-size:13px;padding:9px 10px;border-radius:7px;outline:none}
|
||||
.search:focus{border-color:var(--domain);box-shadow:0 0 0 2px rgba(52,216,196,.16)}
|
||||
.search::placeholder{color:var(--faint)}
|
||||
|
||||
.toggles{display:flex;flex-direction:column;gap:2px}
|
||||
.tog{display:flex;align-items:center;gap:9px;cursor:pointer;padding:7px 8px;border-radius:7px;
|
||||
font-size:13px;user-select:none}
|
||||
.tog:hover{background:var(--panel-2)}
|
||||
.tog .box{width:15px;height:15px;border-radius:4px;border:1.5px solid var(--faint);flex:0 0 auto;position:relative;transition:.12s}
|
||||
.tog.on .box{background:var(--domain);border-color:var(--domain)}
|
||||
.tog.on .box::after{content:"";position:absolute;left:4px;top:1px;width:4px;height:8px;
|
||||
border:solid var(--ground);border-width:0 2px 2px 0;transform:rotate(45deg)}
|
||||
.tog .dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto}
|
||||
.tog .dot.f{background:var(--features)}.tog .dot.d{background:var(--domain)}.tog .dot.da{background:var(--data)}
|
||||
.tog .meta{margin-left:auto;font-family:var(--mono);font-size:11px;color:var(--muted)}
|
||||
|
||||
.btn{width:100%;text-align:left;background:var(--ground);border:1px solid var(--line);color:var(--text);
|
||||
font-family:var(--mono);font-size:12px;padding:9px 11px;border-radius:7px;cursor:pointer;
|
||||
display:flex;align-items:center;gap:8px;transition:.12s}
|
||||
.btn:hover{border-color:var(--inverted);color:#fff}
|
||||
.btn .swatch{width:18px;height:0;border-top:2px solid var(--inverted);flex:0 0 auto}
|
||||
.btn.active{border-color:var(--inverted);background:rgba(255,92,108,.08)}
|
||||
.hubtext{font-size:11px;color:var(--muted);margin:8px 2px 0;line-height:1.5}
|
||||
.hubtext span{font-family:var(--mono);color:var(--text)}
|
||||
|
||||
.insp{border-top:1px solid var(--line);padding-top:16px}
|
||||
.insp .hint{color:var(--muted);font-size:12.5px;line-height:1.55}
|
||||
.insp-id{font-family:var(--mono);font-size:14.5px;font-weight:700;word-break:break-all;line-height:1.25}
|
||||
.chip{display:inline-block;font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;
|
||||
padding:3px 8px;border-radius:20px;margin-top:8px;font-weight:600}
|
||||
.chip.f{background:rgba(232,163,61,.15);color:var(--features)}
|
||||
.chip.d{background:rgba(52,216,196,.15);color:var(--domain)}
|
||||
.chip.da{background:rgba(182,156,255,.15);color:var(--data)}
|
||||
.degline{display:flex;gap:14px;font-family:var(--mono);font-size:12px;margin:12px 0 4px}
|
||||
.degline b{color:var(--text);font-weight:700}.degline .k{color:var(--muted)}
|
||||
.deplist h3{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;
|
||||
color:var(--muted);margin:14px 0 7px;font-weight:600;display:flex;justify-content:space-between}
|
||||
.deplist ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:1px}
|
||||
.deplist li{font-family:var(--mono);font-size:12px;padding:4px 7px;border-radius:5px;cursor:pointer;
|
||||
display:flex;justify-content:space-between;gap:8px;color:var(--text)}
|
||||
.deplist li:hover{background:var(--panel-2)}
|
||||
.deplist li .w{color:var(--muted);font-size:11px;white-space:nowrap}
|
||||
.deplist li .dot{width:7px;height:7px;border-radius:50%;margin-right:7px;flex:0 0 auto;align-self:center}
|
||||
.tog.act{background:var(--panel-2);box-shadow:inset 0 0 0 1px var(--line)}
|
||||
.deplist li span.nm{display:flex;align-items:center;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.deplist li.f span.nm::before,.deplist li.d span.nm::before,.deplist li.da span.nm::before{content:"";display:inline-block;width:7px;height:7px;
|
||||
border-radius:50%;margin-right:7px;flex:0 0 auto}
|
||||
.deplist li.f span.nm::before{background:var(--features)}
|
||||
.deplist li.d span.nm::before{background:var(--domain)}
|
||||
.deplist li.da span.nm::before{background:var(--data)}
|
||||
|
||||
.edge{stroke:var(--faint);stroke-opacity:.10;fill:none;transition:stroke-opacity .15s}
|
||||
.edge.inv{stroke:var(--inverted);stroke-opacity:.22}
|
||||
.node-label{font-family:var(--mono);font-size:9.5px;fill:var(--text);pointer-events:none;opacity:0;
|
||||
transition:opacity .15s;paint-order:stroke;stroke:var(--ground);stroke-width:2.4px;stroke-linejoin:round}
|
||||
.node-label.show{opacity:.92}
|
||||
.node-dot{cursor:pointer}
|
||||
.node-dot circle{transition:fill-opacity .15s,stroke .12s}
|
||||
.dim{opacity:.12 !important}
|
||||
.halo{fill:none;stroke-width:2.6;opacity:0;transition:opacity .15s;pointer-events:none}
|
||||
.hull{stroke-width:1.6;fill-opacity:.08;stroke-opacity:.6;pointer-events:none}
|
||||
.hull-label{font-family:var(--mono);font-size:12px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;
|
||||
pointer-events:none;paint-order:stroke;stroke:var(--ground);stroke-width:3.2px;stroke-linejoin:round;opacity:.92}
|
||||
|
||||
.legend{position:absolute;left:16px;bottom:14px;display:flex;gap:18px;align-items:center;
|
||||
font-family:var(--mono);font-size:11px;color:var(--muted);background:rgba(14,17,22,.72);
|
||||
backdrop-filter:blur(4px);border:1px solid var(--line);border-radius:9px;padding:8px 13px;
|
||||
flex-wrap:wrap;max-width:calc(100% - 32px)}
|
||||
.legend .it{display:flex;align-items:center;gap:7px}
|
||||
.legend .sw{width:11px;height:11px;border-radius:50%}
|
||||
.legend .sw.f{background:var(--features)}.legend .sw.d{background:var(--domain)}.legend .sw.da{background:var(--data)}
|
||||
.legend .ln{width:18px;border-top:2px solid var(--inverted)}
|
||||
.legend .sz{display:flex;align-items:center;gap:4px}
|
||||
.legend .sz i{display:inline-block;border-radius:50%;background:var(--muted)}
|
||||
|
||||
.hud{position:absolute;right:16px;top:14px;font-family:var(--mono);font-size:11px;color:var(--muted);
|
||||
text-align:right;line-height:1.6;pointer-events:none}
|
||||
.hud b{color:var(--text);font-weight:600}
|
||||
.reset{position:absolute;right:16px;bottom:14px;font-family:var(--mono);font-size:11px;
|
||||
background:rgba(14,17,22,.72);backdrop-filter:blur(4px);border:1px solid var(--line);
|
||||
color:var(--muted);border-radius:8px;padding:7px 11px;cursor:pointer}
|
||||
.reset:hover{color:var(--text);border-color:var(--faint)}
|
||||
|
||||
@media (max-width:880px){
|
||||
body{overflow:auto}.app{height:auto}.body{flex-direction:column}
|
||||
.rail{flex-basis:auto;border-right:0;border-bottom:1px solid var(--line)}
|
||||
.stage{height:72vh}.stats{flex-wrap:wrap}
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce){.edge,.node-label,.node-dot circle{transition:none}}
|
||||
</style>
|
||||
|
||||
<div class="app">
|
||||
<header>
|
||||
<div class="titleblock">
|
||||
<p class="eyebrow"><b>Tangem</b> · android · <span id="eyebrowKind">gradle dependency graph</span></p>
|
||||
<h1><span class="features-ink">features</span><span class="arrow">→</span><span class="domain-ink">domain</span> connectivity</h1>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="num" id="statNodes">88</div><div class="lbl" id="statNodesLbl">areas</div></div>
|
||||
<div class="stat"><div class="num" id="statEdges">716</div><div class="lbl" id="statEdgesLbl">dep links</div></div>
|
||||
<div class="stat warn"><div class="num" id="invCount">6</div><div class="lbl">inverted</div></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<aside class="rail">
|
||||
<div class="viewswitch" id="viewswitch">
|
||||
<button data-view="area" class="on">Area<span class="sub">api+impl merged</span></button>
|
||||
<button data-view="module">Module<span class="sub">every module</span></button>
|
||||
<button data-view="screens">Screens<span class="sub">AppRoute map</span></button>
|
||||
</div>
|
||||
|
||||
<div class="grp">
|
||||
<h2>Find a module</h2>
|
||||
<input class="search" id="search" placeholder="e.g. staking, wallet, tokens" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
|
||||
<div class="grp" id="grpLayers">
|
||||
<h2>Layers</h2>
|
||||
<div class="toggles">
|
||||
<label class="tog on" data-layer="features"><span class="box"></span><span class="dot f"></span>features<span class="meta" id="cntF"></span></label>
|
||||
<label class="tog on" data-layer="domain"><span class="box"></span><span class="dot d"></span>domain<span class="meta" id="cntD"></span></label>
|
||||
<label class="tog on" data-layer="data" id="togDataRow" style="display:none"><span class="box"></span><span class="dot da"></span>data<span class="meta" id="cntData"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grp" id="grpTeams">
|
||||
<h2>Teams</h2>
|
||||
<div class="toggles" id="teamList"></div>
|
||||
<label class="tog" id="togCluster"><span class="box"></span>Cluster enabled teams</label>
|
||||
<p class="hubtext">Outlines & rings mark a team's owned modules (in Area / Module) or screens (in Screens). <span>More teams can be added in the config.</span></p>
|
||||
</div>
|
||||
|
||||
<div class="grp" id="grpDeclutter">
|
||||
<h2>Declutter</h2>
|
||||
<div class="toggles">
|
||||
<label class="tog" id="togHubs"><span class="box"></span>Hide utility hubs<span class="meta" id="hubCount"></span></label>
|
||||
<label class="tog" id="togApi"><span class="box"></span>Only <code>api</code> links</label>
|
||||
</div>
|
||||
<p class="hubtext">Hubs hidden: <span id="hubList"></span> — depended on near-universally.</p>
|
||||
</div>
|
||||
|
||||
<div class="grp" id="grpLayering">
|
||||
<h2>Layering check</h2>
|
||||
<button class="btn" id="btnInv"><span class="swatch"></span>Show inverted deps</button>
|
||||
<p class="hubtext">A lower-layer <span style="color:var(--inverted)">domain / data</span> module importing a <span style="color:var(--features)">feature</span> <code>api</code> — reaching up across the layer boundary.</p>
|
||||
</div>
|
||||
|
||||
<div class="grp" id="grpGroups" style="display:none">
|
||||
<h2>Functional groups</h2>
|
||||
<div class="toggles" id="groupList"></div>
|
||||
<p class="hubtext">Click a group to isolate its screens. Node size = navigation links. Arrow = navigates to.</p>
|
||||
</div>
|
||||
|
||||
<div class="insp" id="insp">
|
||||
<p class="hint">Hover a node to trace its links. <b style="color:var(--text)">Click</b> to pin it and list its dependencies. Drag nodes to rearrange · scroll to zoom · drag canvas to pan.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="stage">
|
||||
<svg id="svg" viewBox="0 0 1200 820" preserveAspectRatio="xMidYMid meet" aria-label="Module dependency graph"></svg>
|
||||
<div class="hud" id="hud">consumers <span style="color:var(--features)">left</span> · foundations <span style="color:var(--domain)">right</span><br>arrow points to the dependency<br><b>scroll</b> zoom · <b>drag</b> pan</div>
|
||||
<button class="reset" id="resetView">reset view</button>
|
||||
<div class="legend" id="legendBox">
|
||||
<div class="it"><span class="sw f"></span>features</div>
|
||||
<div class="it"><span class="sw d"></span>domain</div>
|
||||
<div class="it" id="legendData" style="display:none"><span class="sw da"></span>data</div>
|
||||
<div class="it"><span class="ln"></span>inverted dep</div>
|
||||
<div class="it sz">size <i style="width:6px;height:6px"></i><i style="width:11px;height:11px"></i><i style="width:16px;height:16px"></i> = total degree</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const RAW = { area: /*__AREA_DATA__*/null, module: /*__MODULE_DATA__*/null, screens: /*__SCREENS_DATA__*/null };
|
||||
const SCREENMETA = /*__SCREENS_META__*/null;
|
||||
const BANDS2={features:0.28,domain:0.72};
|
||||
const VIEWCFG = {
|
||||
area: {W:1200,H:820, labelFloor:13,rScale:1.85,rep:14000,ky:0.006, prewarm:340,kind:'areas', bandX:BANDS2},
|
||||
module: {W:1640,H:1120,labelFloor:20,rScale:1.45,rep:23000,ky:0.0035,prewarm:480,kind:'modules',bandX:BANDS2},
|
||||
screens:{W:1500,H:1040,labelFloor:0, rScale:1.7, rep:20000,ky:0.02, prewarm:460,kind:'screens',
|
||||
anchors:/*__GROUP_ANCHORS__*/{}}
|
||||
};
|
||||
const GROUP_COLORS=/*__GROUP_COLORS__*/{};
|
||||
const GROUP_LABELS=/*__GROUP_LABELS__*/{};
|
||||
const SVGNS="http://www.w3.org/2000/svg";
|
||||
const css=getComputedStyle(document.documentElement);
|
||||
const FEAT=css.getPropertyValue('--features').trim();
|
||||
const DOM=css.getPropertyValue('--domain').trim();
|
||||
const DATAC=css.getPropertyValue('--data').trim();
|
||||
const INV=css.getPropertyValue('--inverted').trim();
|
||||
function colorFor(l){if(GROUP_COLORS[l])return GROUP_COLORS[l];return l==='features'?FEAT:l==='data'?DATAC:DOM;}
|
||||
function layerCls(l){return l==='features'?'f':l==='data'?'da':'d';}
|
||||
function hexA(h,a){const n=parseInt(h.slice(1),16);return `rgba(${(n>>16)&255},${(n>>8)&255},${n&255},${a})`;}
|
||||
|
||||
// ---- team ownership (extensible: add one entry per team) ----
|
||||
const TEAMS=/*__TEAMS__*/[];
|
||||
function normId(id){return id.charAt(0)===':'?id.slice(1):id;}
|
||||
function teamsOf(id){const p=normId(id);return TEAMS.filter(t=>
|
||||
(t.roots&&t.roots.some(r=>p===r||p.startsWith(r+':'))) || (t.screens&&t.screens.includes(id)));}
|
||||
const enabledTeams=new Set();
|
||||
let clusterOn=false;
|
||||
const reduced=matchMedia('(prefers-reduced-motion:reduce)').matches;
|
||||
|
||||
// ---- persistent SVG scaffold ----
|
||||
const svg=document.getElementById('svg');
|
||||
const defs=document.createElementNS(SVGNS,'defs');
|
||||
defs.innerHTML='<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse"><path d="M40 0H0V40" fill="none" stroke="#19212B" stroke-width="1"/></pattern>';
|
||||
svg.appendChild(defs);
|
||||
const gRoot=document.createElementNS(SVGNS,'g'); svg.appendChild(gRoot);
|
||||
const bg=document.createElementNS(SVGNS,'rect');
|
||||
bg.setAttribute('x',-2000);bg.setAttribute('y',-2000);bg.setAttribute('fill','url(#grid)');bg.setAttribute('opacity','0.5');
|
||||
const gHulls=document.createElementNS(SVGNS,'g');
|
||||
const gEdges=document.createElementNS(SVGNS,'g');
|
||||
const gNodes=document.createElementNS(SVGNS,'g');
|
||||
gRoot.appendChild(bg);gRoot.appendChild(gHulls);gRoot.appendChild(gEdges);gRoot.appendChild(gNodes);
|
||||
|
||||
// ---- mutable graph state ----
|
||||
let nodes,byId,edges,outAdj,inAdj,HUBS,W,H,labelFloor;
|
||||
let CURRENT='area',selected=null,alpha=1,raf=null,REP,KY;
|
||||
|
||||
// ---- model ----
|
||||
function buildModel(name){
|
||||
const d=RAW[name],cfg=VIEWCFG[name];
|
||||
W=cfg.W;H=cfg.H;labelFloor=cfg.labelFloor;REP=cfg.rep;KY=cfg.ky;
|
||||
nodes=d.n.map(([id,label,layer,inD,outD])=>({id,label,layer,inD,outD,deg:inD+outD,
|
||||
r:3.8+Math.sqrt(inD+outD+1)*cfg.rScale, color:colorFor(layer), teams:teamsOf(id)}));
|
||||
byId=new Map(nodes.map(n=>[n.id,n]));
|
||||
edges=d.e.map(([s,t,w,api])=>{const sn=byId.get(s),tn=byId.get(t);
|
||||
return {s:sn,t:tn,w,api:!!api,inv:tn.layer==='features'&&sn.layer!=='features'};});
|
||||
outAdj=new Map(nodes.map(n=>[n.id,[]]));
|
||||
inAdj=new Map(nodes.map(n=>[n.id,[]]));
|
||||
edges.forEach(e=>{outAdj.get(e.s.id).push(e);inAdj.get(e.t.id).push(e);});
|
||||
HUBS=new Set(nodes.filter(n=>n.inD>=12&&n.outD<=3).map(n=>n.id));
|
||||
}
|
||||
|
||||
// ---- layout ----
|
||||
let seed=1337;
|
||||
function rnd(){seed=(seed*1664525+1013904223)&0x7fffffff;return seed/0x7fffffff;}
|
||||
function initLayout(){
|
||||
seed=1337;
|
||||
const cfg=VIEWCFG[CURRENT];
|
||||
for(const n of nodes){
|
||||
if(cfg.anchors&&cfg.anchors[n.layer]){
|
||||
n.tx=W*cfg.anchors[n.layer].x;n.ty=H*cfg.anchors[n.layer].y;
|
||||
n.x=n.tx+(rnd()-0.5)*220;n.y=n.ty+(rnd()-0.5)*220;
|
||||
}else{
|
||||
const bx=cfg.bandX?cfg.bandX[n.layer]:undefined;n.tx=W*(bx===undefined?0.5:bx);n.ty=H*0.5;
|
||||
n.x=n.tx+(rnd()-0.5)*240;n.y=H*0.5+(rnd()-0.5)*H*0.86;
|
||||
}
|
||||
n.vx=0;n.vy=0;n.fixed=false;
|
||||
}
|
||||
let a=1;
|
||||
for(let i=0;i<VIEWCFG[CURRENT].prewarm;i++){tick(a);a*=0.992;}
|
||||
}
|
||||
function tick(a){
|
||||
const n=nodes.length;
|
||||
for(let i=0;i<n;i++){nodes[i].fx=0;nodes[i].fy=0;}
|
||||
for(let i=0;i<n;i++){const A=nodes[i];
|
||||
for(let j=i+1;j<n;j++){const B=nodes[j];
|
||||
let dx=A.x-B.x,dy=A.y-B.y,d2=dx*dx+dy*dy;if(d2<0.01)d2=0.01;
|
||||
const d=Math.sqrt(d2);let f=REP/d2;if(f>42)f=42;
|
||||
const ux=dx/d,uy=dy/d;A.fx+=ux*f;A.fy+=uy*f;B.fx-=ux*f;B.fy-=uy*f;}}
|
||||
for(const e of edges){const A=e.s,B=e.t;
|
||||
let dx=B.x-A.x,dy=B.y-A.y,d=Math.sqrt(dx*dx+dy*dy)||0.01;
|
||||
const f=(d-96)*0.018,ux=dx/d,uy=dy/d;
|
||||
A.fx+=ux*f;A.fy+=uy*f;B.fx-=ux*f;B.fy-=uy*f;}
|
||||
if(clusterOn&&enabledTeams.size)computeTeamCentroids();
|
||||
for(const A of nodes){
|
||||
const ct=A._cteam;
|
||||
if(clusterOn&&ct&&ct.cx!==undefined){
|
||||
A.fx+=(ct.cx-A.x)*0.04;A.fy+=(ct.cy-A.y)*0.04;
|
||||
A.fx+=(A.tx-A.x)*0.005;A.fy+=(A.ty-A.y)*KY;
|
||||
}else{A.fx+=(A.tx-A.x)*0.018;A.fy+=(A.ty-A.y)*KY;}
|
||||
}
|
||||
for(const A of nodes){if(A.fixed)continue;
|
||||
A.vx=(A.vx+A.fx)*0.84;A.vy=(A.vy+A.fy)*0.84;A.x+=A.vx*a;A.y+=A.vy*a;
|
||||
A.x=Math.max(30,Math.min(W-30,A.x));A.y=Math.max(30,Math.min(H-30,A.y));}
|
||||
}
|
||||
|
||||
// ---- DOM build ----
|
||||
function buildDOM(){
|
||||
svg.setAttribute('viewBox',`0 0 ${W} ${H}`);
|
||||
bg.setAttribute('width',W+4000);bg.setAttribute('height',H+4000);
|
||||
gEdges.textContent='';gNodes.textContent='';
|
||||
edges.forEach(e=>{const ln=document.createElementNS(SVGNS,'line');
|
||||
ln.setAttribute('class',e.inv?'edge inv':'edge');e.el=ln;gEdges.appendChild(ln);});
|
||||
nodes.forEach(n=>{
|
||||
const g=document.createElementNS(SVGNS,'g');g.setAttribute('class','node-dot');
|
||||
const c=document.createElementNS(SVGNS,'circle');
|
||||
c.setAttribute('r',n.r);c.setAttribute('fill',n.color);c.setAttribute('fill-opacity','0.92');
|
||||
c.setAttribute('stroke','#0E1116');c.setAttribute('stroke-width','1.5');
|
||||
const halo=document.createElementNS(SVGNS,'circle');halo.setAttribute('class','halo');halo.setAttribute('r',n.r+5);
|
||||
const t=document.createElementNS(SVGNS,'text');t.setAttribute('class','node-label');
|
||||
t.setAttribute('x',n.r+4);t.setAttribute('y',3.2);t.textContent=n.label;
|
||||
g.appendChild(halo);g.appendChild(c);g.appendChild(t);n.gEl=g;n.cEl=c;n.tEl=t;n.haloEl=halo;gNodes.appendChild(g);
|
||||
g.addEventListener('pointerenter',()=>{if(!selected)focusNode(n.id);});
|
||||
g.addEventListener('pointerleave',()=>{if(!selected)clearFocus();});
|
||||
g.addEventListener('click',ev=>{ev.stopPropagation();selectNode(n.id);});
|
||||
g.addEventListener('pointerdown',startDrag);
|
||||
});
|
||||
}
|
||||
function render(){
|
||||
for(const e of edges){const A=e.s,B=e.t;
|
||||
let dx=B.x-A.x,dy=B.y-A.y,d=Math.sqrt(dx*dx+dy*dy)||1;const ux=dx/d,uy=dy/d;
|
||||
e.el.setAttribute('x1',(A.x+ux*A.r).toFixed(1));e.el.setAttribute('y1',(A.y+uy*A.r).toFixed(1));
|
||||
e.el.setAttribute('x2',(B.x-ux*(B.r+2.5)).toFixed(1));e.el.setAttribute('y2',(B.y-uy*(B.r+2.5)).toFixed(1));}
|
||||
for(const n of nodes)n.gEl.setAttribute('transform',`translate(${n.x.toFixed(1)},${n.y.toFixed(1)})`);
|
||||
renderTeamOverlay();
|
||||
}
|
||||
function applyBaseLabels(){
|
||||
for(const n of nodes){
|
||||
if(n.deg>=labelFloor&&!n.hidden)n.tEl.classList.add('show');
|
||||
else n.tEl.classList.remove('show');}
|
||||
}
|
||||
|
||||
// ---- animation ----
|
||||
function animate(){tick(alpha);alpha*=0.99;render();
|
||||
if(alpha>0.015||dragging)raf=requestAnimationFrame(animate);else raf=null;}
|
||||
function reheat(v){alpha=Math.max(alpha,v);if(!raf&&!reduced)raf=requestAnimationFrame(animate);}
|
||||
|
||||
// ---- highlight ----
|
||||
function setEdge(e,on){
|
||||
if(on){e.el.style.stroke=e.inv?INV:e.s.color;e.el.style.strokeOpacity='0.85';
|
||||
e.el.style.strokeWidth=Math.min(0.7+e.w*0.55,4.2);}
|
||||
else{e.el.style.stroke='';e.el.style.strokeOpacity='';e.el.style.strokeWidth='';}
|
||||
}
|
||||
function dimAll(bright){
|
||||
for(const n of nodes){if(n.hidden)continue;
|
||||
if(bright.has(n.id)){n.gEl.classList.remove('dim');n.tEl.classList.add('show');}
|
||||
else{n.gEl.classList.add('dim');if(n.deg<labelFloor)n.tEl.classList.remove('show');}}
|
||||
}
|
||||
function focusNode(id){
|
||||
const bright=new Set([id]),incident=[];
|
||||
for(const e of outAdj.get(id)){if(e.s.hidden||e.t.hidden)continue;bright.add(e.t.id);incident.push(e);}
|
||||
for(const e of inAdj.get(id)){if(e.s.hidden||e.t.hidden)continue;bright.add(e.s.id);incident.push(e);}
|
||||
edges.forEach(e=>{setEdge(e,false);if(!e.s.hidden&&!e.t.hidden)e.el.style.strokeOpacity='0.03';});
|
||||
incident.forEach(e=>setEdge(e,true));
|
||||
dimAll(bright);
|
||||
const n=byId.get(id);n.cEl.setAttribute('stroke','#fff');n.cEl.setAttribute('stroke-width','2');
|
||||
}
|
||||
function clearFocus(){
|
||||
edges.forEach(e=>{setEdge(e,false);e.el.style.strokeOpacity='';});
|
||||
for(const n of nodes){if(n.hidden)continue;n.gEl.classList.remove('dim');
|
||||
n.cEl.setAttribute('stroke','#0E1116');n.cEl.setAttribute('stroke-width','1.5');}
|
||||
applyBaseLabels();
|
||||
}
|
||||
function selectNode(id){
|
||||
if(btnInv.classList.contains('active'))toggleInverted(false);
|
||||
selected=id;focusNode(id);renderInspector(byId.get(id));
|
||||
}
|
||||
|
||||
// ---- inspector ----
|
||||
const insp=document.getElementById('insp');
|
||||
function renderInspectorHint(){
|
||||
insp.innerHTML='<p class="hint">Hover a node to trace its links. <b style="color:var(--text)">Click</b> to pin it and list its dependencies. Drag nodes to rearrange · scroll to zoom · drag canvas to pan.</p>';
|
||||
}
|
||||
function depItem(node,e){
|
||||
const li=document.createElement('li');
|
||||
const nm=document.createElement('span');nm.className='nm';
|
||||
const dot=document.createElement('span');dot.className='dot';dot.style.background=node.color;
|
||||
nm.appendChild(dot);nm.appendChild(document.createTextNode(node.id));
|
||||
const w=document.createElement('span');w.className='w';w.textContent=(e.w>1?e.w+'× ':'')+(e.api?'api':'');
|
||||
li.appendChild(nm);li.appendChild(w);
|
||||
li.addEventListener('click',ev=>{ev.stopPropagation();selectNode(node.id);});
|
||||
return li;
|
||||
}
|
||||
function renderInspector(n){
|
||||
const navView=CURRENT==='screens';
|
||||
const outs=outAdj.get(n.id).slice().sort((a,b)=>b.w-a.w);
|
||||
const ins=inAdj.get(n.id).slice().sort((a,b)=>b.w-a.w);
|
||||
insp.innerHTML='';
|
||||
const id=document.createElement('div');id.className='insp-id';id.textContent=n.id;insp.appendChild(id);
|
||||
const chip=document.createElement('span');chip.className='chip';chip.style.color=n.color;chip.style.background=hexA(n.color,0.16);
|
||||
chip.textContent=navView?(GROUP_LABELS[n.layer]||n.layer):n.layer;insp.appendChild(chip);
|
||||
if(navView){
|
||||
const m=SCREENMETA[n.id];
|
||||
const info=document.createElement('div');info.className='hubtext';info.style.margin='10px 0 2px';
|
||||
if(m){info.innerHTML=`<div style="font-family:var(--mono);color:var(--text);word-break:break-all">${m.path||'—'}</div>`+
|
||||
`<div style="margin-top:6px">owner <span style="font-family:var(--mono);color:var(--text)">${m.owner}</span> · ${m.total} refs in ${m.refs.length} module(s)</div>`;}
|
||||
else info.textContent='Synthetic origin for navigation from the app root / shared UI (no AppRoute of its own).';
|
||||
insp.appendChild(info);
|
||||
}
|
||||
const deg=document.createElement('div');deg.className='degline';
|
||||
deg.innerHTML=`<span><span class="k">${navView?'navigates to':'depends on'}</span> <b>${n.outD}</b></span><span><span class="k">${navView?'reached from':'used by'}</span> <b>${n.inD}</b></span>`;
|
||||
insp.appendChild(deg);
|
||||
const mk=(title,arr,pick)=>{
|
||||
const wrap=document.createElement('div');wrap.className='deplist';
|
||||
const h=document.createElement('h3');h.innerHTML=`${title}<span>${arr.length}</span>`;wrap.appendChild(h);
|
||||
if(!arr.length){const p=document.createElement('p');p.style.cssText='color:var(--faint);font-family:var(--mono);font-size:11px;margin:0 0 0 2px';p.textContent='— none —';wrap.appendChild(p);}
|
||||
else{const ul=document.createElement('ul');arr.forEach(e=>ul.appendChild(depItem(pick(e),e)));wrap.appendChild(ul);}
|
||||
insp.appendChild(wrap);
|
||||
};
|
||||
mk(navView?'Navigates to →':'Depends on →',outs,e=>e.t);
|
||||
mk(navView?'← Reached from':'← Used by',ins,e=>e.s);
|
||||
if(navView&&SCREENMETA[n.id]){
|
||||
const wrap=document.createElement('div');wrap.className='deplist';
|
||||
const h=document.createElement('h3');h.innerHTML=`Referenced in<span>${SCREENMETA[n.id].refs.length}</span>`;wrap.appendChild(h);
|
||||
const ul=document.createElement('ul');
|
||||
SCREENMETA[n.id].refs.forEach(([a,c])=>{const li=document.createElement('li');
|
||||
const nm=document.createElement('span');nm.className='nm';nm.textContent=a;
|
||||
const w=document.createElement('span');w.className='w';w.textContent=c+'×';
|
||||
li.appendChild(nm);li.appendChild(w);ul.appendChild(li);});
|
||||
wrap.appendChild(ul);insp.appendChild(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- zoom / pan ----
|
||||
let view={k:1,x:0,y:0};
|
||||
function applyView(){gRoot.setAttribute('transform',`translate(${view.x} ${view.y}) scale(${view.k})`);}
|
||||
function screenToVB(ev){const p=svg.createSVGPoint();p.x=ev.clientX;p.y=ev.clientY;return p.matrixTransform(svg.getScreenCTM().inverse());}
|
||||
function vbToWorld(v){return {x:(v.x-view.x)/view.k,y:(v.y-view.y)/view.k};}
|
||||
svg.addEventListener('wheel',ev=>{ev.preventDefault();
|
||||
const vb=screenToVB(ev),w=vbToWorld(vb),f=Math.pow(1.0016,-ev.deltaY);
|
||||
view.k=Math.max(0.25,Math.min(5,view.k*f));
|
||||
view.x=vb.x-w.x*view.k;view.y=vb.y-w.y*view.k;applyView();
|
||||
},{passive:false});
|
||||
|
||||
let panning=null;
|
||||
svg.addEventListener('pointerdown',ev=>{
|
||||
if(ev.target.closest('.node-dot'))return; // node handles its own
|
||||
panning={sx:ev.clientX,sy:ev.clientY,vx:view.x,vy:view.y,moved:false};
|
||||
svg.classList.add('grabbing');
|
||||
window.addEventListener('pointermove',onPan);window.addEventListener('pointerup',endPan);
|
||||
});
|
||||
function onPan(ev){if(!panning)return;const ctm=svg.getScreenCTM();
|
||||
const dx=(ev.clientX-panning.sx)/ctm.a,dy=(ev.clientY-panning.sy)/ctm.d;
|
||||
if(Math.abs(ev.clientX-panning.sx)+Math.abs(ev.clientY-panning.sy)>3)panning.moved=true;
|
||||
view.x=panning.vx+dx;view.y=panning.vy+dy;applyView();}
|
||||
function endPan(){const moved=panning&&panning.moved;panning=null;svg.classList.remove('grabbing');
|
||||
window.removeEventListener('pointermove',onPan);window.removeEventListener('pointerup',endPan);
|
||||
if(!moved){selected=null;clearFocus();renderInspectorHint();}}
|
||||
document.getElementById('resetView').addEventListener('click',()=>{view={k:1,x:0,y:0};applyView();});
|
||||
|
||||
// ---- node drag ----
|
||||
let dragging=null;
|
||||
function startDrag(ev){
|
||||
const n=nodes.find(x=>x.gEl===ev.currentTarget);if(!n)return;
|
||||
ev.preventDefault();ev.stopPropagation();dragging=n;n.fixed=true;
|
||||
ev.currentTarget.setPointerCapture(ev.pointerId);
|
||||
window.addEventListener('pointermove',onDrag);window.addEventListener('pointerup',endDrag);
|
||||
}
|
||||
function onDrag(ev){if(!dragging)return;const w=vbToWorld(screenToVB(ev));
|
||||
dragging.x=Math.max(30,Math.min(W-30,w.x));dragging.y=Math.max(30,Math.min(H-30,w.y));
|
||||
dragging.vx=0;dragging.vy=0;reheat(0.25);render();}
|
||||
function endDrag(){if(dragging)dragging.fixed=false;dragging=null;
|
||||
window.removeEventListener('pointermove',onDrag);window.removeEventListener('pointerup',endDrag);}
|
||||
|
||||
// ---- team overlay ----
|
||||
function setClusterMembership(){
|
||||
for(const n of nodes){n._cteam=null;
|
||||
if(!clusterOn)continue;
|
||||
for(const t of n.teams){if(enabledTeams.has(t.id)){n._cteam=t;break;}}}
|
||||
}
|
||||
function computeTeamCentroids(){
|
||||
for(const t of TEAMS){if(!enabledTeams.has(t.id))continue;
|
||||
let sx=0,sy=0,c=0;
|
||||
for(const n of nodes){if(!n.hidden&&n._cteam===t){sx+=n.x;sy+=n.y;c++;}}
|
||||
if(c){t.cx=sx/c;t.cy=sy/c;}}
|
||||
}
|
||||
function updateHalos(){
|
||||
for(const n of nodes){
|
||||
const t=n.teams.find(tt=>enabledTeams.has(tt.id));
|
||||
if(t){n.haloEl.setAttribute('stroke',t.color);n.haloEl.style.opacity='0.9';}
|
||||
else n.haloEl.style.opacity='0';}
|
||||
}
|
||||
function convexHull(pts){
|
||||
pts=pts.slice().sort((a,b)=>a.x-b.x||a.y-b.y);
|
||||
const n=pts.length;if(n<3)return pts;
|
||||
const cross=(o,a,b)=>(a.x-o.x)*(b.y-o.y)-(a.y-o.y)*(b.x-o.x);
|
||||
const lo=[];for(const p of pts){while(lo.length>=2&&cross(lo[lo.length-2],lo[lo.length-1],p)<=0)lo.pop();lo.push(p);}
|
||||
const up=[];for(let i=n-1;i>=0;i--){const p=pts[i];while(up.length>=2&&cross(up[up.length-2],up[up.length-1],p)<=0)up.pop();up.push(p);}
|
||||
lo.pop();up.pop();return lo.concat(up);
|
||||
}
|
||||
function smoothPath(p){
|
||||
const n=p.length;if(n<3)return '';
|
||||
const mid=(a,b)=>({x:(a.x+b.x)/2,y:(a.y+b.y)/2});const s=mid(p[n-1],p[0]);
|
||||
let d=`M ${s.x.toFixed(1)} ${s.y.toFixed(1)}`;
|
||||
for(let i=0;i<n;i++){const cur=p[i],nx=p[(i+1)%n],m=mid(cur,nx);
|
||||
d+=` Q ${cur.x.toFixed(1)} ${cur.y.toFixed(1)} ${m.x.toFixed(1)} ${m.y.toFixed(1)}`;}
|
||||
return d+' Z';
|
||||
}
|
||||
function renderTeamOverlay(){
|
||||
for(const t of TEAMS){
|
||||
if(!t._hullEl){
|
||||
t._hullEl=document.createElementNS(SVGNS,'path');t._hullEl.setAttribute('class','hull');
|
||||
t._hullEl.setAttribute('fill',t.color);t._hullEl.setAttribute('stroke',t.color);gHulls.appendChild(t._hullEl);
|
||||
t._labelEl=document.createElementNS(SVGNS,'text');t._labelEl.setAttribute('class','hull-label');
|
||||
t._labelEl.setAttribute('fill',t.color);t._labelEl.setAttribute('text-anchor','middle');gHulls.appendChild(t._labelEl);
|
||||
}
|
||||
if(!enabledTeams.has(t.id)){t._hullEl.setAttribute('d','');t._labelEl.textContent='';continue;}
|
||||
const mem=nodes.filter(n=>!n.hidden&&n.teams.includes(t));
|
||||
if(!mem.length){t._hullEl.setAttribute('d','');t._labelEl.textContent='';continue;}
|
||||
if(mem.length<3){
|
||||
let sx=0,sy=0,my=Infinity;mem.forEach(n=>{sx+=n.x;sy+=n.y;if(n.y<my)my=n.y;});
|
||||
t._hullEl.setAttribute('d','');t._labelEl.setAttribute('x',sx/mem.length);t._labelEl.setAttribute('y',my-14);t._labelEl.textContent=t.name;continue;}
|
||||
const hull=convexHull(mem.map(n=>({x:n.x,y:n.y})));
|
||||
let cx=0,cy=0;hull.forEach(p=>{cx+=p.x;cy+=p.y;});cx/=hull.length;cy/=hull.length;
|
||||
const ex=hull.map(p=>{const dx=p.x-cx,dy=p.y-cy,d=Math.hypot(dx,dy)||1;const pad=32;return {x:p.x+dx/d*pad,y:p.y+dy/d*pad};});
|
||||
t._hullEl.setAttribute('d',smoothPath(ex));
|
||||
let miny=Infinity;ex.forEach(p=>{if(p.y<miny)miny=p.y;});
|
||||
t._labelEl.setAttribute('x',cx);t._labelEl.setAttribute('y',miny-8);t._labelEl.textContent=t.name;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- controls ----
|
||||
const search=document.getElementById('search');
|
||||
search.addEventListener('input',runSearch);
|
||||
function runSearch(){
|
||||
const q=search.value.trim().toLowerCase();
|
||||
if(btnInv.classList.contains('active'))toggleInverted(false);
|
||||
selected=null;
|
||||
if(!q){clearFocus();renderInspectorHint();return;}
|
||||
const matches=new Set(nodes.filter(n=>!n.hidden&&n.id.toLowerCase().includes(q)).map(n=>n.id));
|
||||
edges.forEach(e=>{setEdge(e,false);
|
||||
if(!e.s.hidden&&!e.t.hidden)e.el.style.strokeOpacity=(matches.has(e.s.id)&&matches.has(e.t.id))?'0.7':'0.03';});
|
||||
edges.forEach(e=>{if(matches.has(e.s.id)&&matches.has(e.t.id))setEdge(e,true);});
|
||||
if(matches.size)dimAll(matches);else clearFocus();
|
||||
}
|
||||
function recomputeHidden(){
|
||||
const showF=togF.classList.contains('on'),showD=togD.classList.contains('on'),showData=togData.classList.contains('on');
|
||||
const hideHubs=togHubs.classList.contains('on'),apiOnly=togApi.classList.contains('on');
|
||||
for(const n of nodes){let h=false;
|
||||
if(n.layer==='features'&&!showF)h=true;
|
||||
if(n.layer==='domain'&&!showD)h=true;
|
||||
if(n.layer==='data'&&!showData)h=true;
|
||||
if(hideHubs&&HUBS.has(n.id))h=true;
|
||||
n.hidden=h;n.gEl.style.display=h?'none':'';}
|
||||
for(const e of edges){let h=e.s.hidden||e.t.hidden;if(apiOnly&&!e.api)h=true;
|
||||
e.el.style.display=h?'none':'';}
|
||||
if(selected&&!byId.get(selected).hidden)focusNode(selected);
|
||||
else{selected=null;if(search.value.trim())runSearch();else clearFocus();}
|
||||
reheat(0.15);
|
||||
}
|
||||
function bindTog(el){el.addEventListener('click',()=>{el.classList.toggle('on');recomputeHidden();});}
|
||||
const togF=document.querySelector('.tog[data-layer="features"]');
|
||||
const togD=document.querySelector('.tog[data-layer="domain"]');
|
||||
const togData=document.querySelector('.tog[data-layer="data"]');
|
||||
const togHubs=document.getElementById('togHubs');
|
||||
const togApi=document.getElementById('togApi');
|
||||
[togF,togD,togData,togHubs,togApi].forEach(bindTog);
|
||||
|
||||
// team toggles (built from TEAMS config)
|
||||
const teamList=document.getElementById('teamList');
|
||||
const togCluster=document.getElementById('togCluster');
|
||||
TEAMS.forEach(t=>{
|
||||
const el=document.createElement('label');el.className='tog';el.dataset.team=t.id;
|
||||
el.innerHTML=`<span class="box"></span><span class="dot" style="background:${t.color}"></span>${t.name}<span class="meta" id="teamCnt_${t.id}"></span>`;
|
||||
el.addEventListener('click',()=>{el.classList.toggle('on');syncTeams();});
|
||||
teamList.appendChild(el);
|
||||
});
|
||||
function syncTeams(){
|
||||
enabledTeams.clear();
|
||||
teamList.querySelectorAll('.tog.on').forEach(el=>enabledTeams.add(el.dataset.team));
|
||||
setClusterMembership();updateHalos();reheat(0.35);render();
|
||||
}
|
||||
togCluster.addEventListener('click',()=>{
|
||||
togCluster.classList.toggle('on');clusterOn=togCluster.classList.contains('on');
|
||||
setClusterMembership();reheat(0.45);render();
|
||||
});
|
||||
|
||||
const btnInv=document.getElementById('btnInv');
|
||||
function toggleInverted(force){
|
||||
const on=force!==undefined?force:!btnInv.classList.contains('active');
|
||||
btnInv.classList.toggle('active',on);
|
||||
if(!on){selected=null;clearFocus();renderInspectorHint();return;}
|
||||
selected=null;search.value='';
|
||||
const invE=edges.filter(e=>e.inv&&!e.s.hidden&&!e.t.hidden);
|
||||
const bright=new Set();invE.forEach(e=>{bright.add(e.s.id);bright.add(e.t.id);});
|
||||
edges.forEach(e=>{setEdge(e,false);if(!e.s.hidden&&!e.t.hidden)e.el.style.strokeOpacity='0.025';});
|
||||
invE.forEach(e=>setEdge(e,true));dimAll(bright);
|
||||
insp.innerHTML='<div class="insp-id" style="color:var(--inverted)">inverted deps</div>'+
|
||||
'<p class="hint" style="margin-top:10px">Edges where a lower-layer <b style="color:var(--domain)">domain</b> or <b style="color:var(--data)">data</b> module imports a <b style="color:var(--features)">feature</b> <code>api</code> — the dependency arrow points the "wrong" way across the layer boundary.</p>';
|
||||
const ul=document.createElement('ul');ul.style.cssText='list-style:none;padding:0;margin:12px 0 0;display:flex;flex-direction:column;gap:1px';
|
||||
invE.sort((a,b)=>a.s.id.localeCompare(b.s.id)).forEach(e=>{
|
||||
const li=document.createElement('li');
|
||||
li.style.cssText='font-family:var(--mono);font-size:11px;padding:5px 7px;border-radius:5px;color:var(--inverted);cursor:pointer;word-break:break-all';
|
||||
li.textContent=`${e.s.id} → ${e.t.id}`;
|
||||
li.addEventListener('click',ev=>{ev.stopPropagation();toggleInverted(false);selectNode(e.s.id);});
|
||||
li.addEventListener('mouseenter',()=>li.style.background='var(--panel-2)');
|
||||
li.addEventListener('mouseleave',()=>li.style.background='');
|
||||
ul.appendChild(li);});
|
||||
insp.appendChild(ul);
|
||||
}
|
||||
btnInv.addEventListener('click',()=>toggleInverted());
|
||||
|
||||
// ---- header / hub text ----
|
||||
function updateChrome(){
|
||||
document.getElementById('statNodes').textContent=nodes.length;
|
||||
document.getElementById('statNodesLbl').textContent=VIEWCFG[CURRENT].kind;
|
||||
document.getElementById('statEdges').textContent=edges.length;
|
||||
document.getElementById('invCount').textContent=edges.filter(e=>e.inv).length;
|
||||
document.getElementById('cntF').textContent=nodes.filter(n=>n.layer==='features').length;
|
||||
document.getElementById('cntD').textContent=nodes.filter(n=>n.layer==='domain').length;
|
||||
const nData=nodes.filter(n=>n.layer==='data').length, hasData=nData>0;
|
||||
document.getElementById('cntData').textContent=nData;
|
||||
document.getElementById('togDataRow').style.display=hasData?'':'none';
|
||||
document.getElementById('legendData').style.display=hasData?'':'none';
|
||||
document.getElementById('hubCount').textContent=HUBS.size;
|
||||
document.getElementById('hubList').textContent=[...HUBS].map(id=>byId.get(id).label).join(', ');
|
||||
TEAMS.forEach(t=>{const el=document.getElementById('teamCnt_'+t.id);
|
||||
if(el)el.textContent=nodes.filter(n=>n.teams.includes(t)).length;});
|
||||
document.getElementById('statEdgesLbl').textContent=CURRENT==='screens'?'nav links':'dep links';
|
||||
if(CURRENT==='screens'&&groupListBuilt)Object.keys(GROUP_LABELS).forEach(g=>{
|
||||
const el=document.getElementById('grpCnt_'+g);if(el)el.textContent=nodes.filter(n=>n.layer===g).length;});
|
||||
}
|
||||
|
||||
// ---- screens-view chrome ----
|
||||
let groupListBuilt=false;
|
||||
function buildGroupList(){
|
||||
groupListBuilt=true;const gl=document.getElementById('groupList');gl.innerHTML='';
|
||||
Object.keys(GROUP_LABELS).forEach(g=>{
|
||||
const el=document.createElement('label');el.className='tog';el.dataset.group=g;
|
||||
el.innerHTML=`<span style="background:${GROUP_COLORS[g]};width:10px;height:10px;border-radius:50%;flex:0 0 auto;display:inline-block"></span>${GROUP_LABELS[g]}<span class="meta" id="grpCnt_${g}"></span>`;
|
||||
el.addEventListener('click',()=>{
|
||||
const was=el.classList.contains('act');
|
||||
gl.querySelectorAll('.tog').forEach(x=>x.classList.remove('act'));
|
||||
if(was)clearFocus();else{el.classList.add('act');isolateGroup(g);}
|
||||
});
|
||||
gl.appendChild(el);
|
||||
});
|
||||
}
|
||||
function isolateGroup(g){
|
||||
selected=null;
|
||||
const set=new Set(nodes.filter(n=>!n.hidden&&n.layer===g).map(n=>n.id));
|
||||
const bright=new Set(set);
|
||||
edges.forEach(e=>{if(set.has(e.s.id))bright.add(e.t.id);if(set.has(e.t.id))bright.add(e.s.id);});
|
||||
edges.forEach(e=>{setEdge(e,false);if(!e.s.hidden&&!e.t.hidden)e.el.style.strokeOpacity='0.02';});
|
||||
edges.forEach(e=>{if(set.has(e.s.id)||set.has(e.t.id))setEdge(e,true);});
|
||||
dimAll(bright);
|
||||
}
|
||||
function applyViewChrome(name){
|
||||
const isS=name==='screens';
|
||||
['grpLayers','grpDeclutter','grpLayering'].forEach(id=>document.getElementById(id).style.display=isS?'none':'');
|
||||
document.getElementById('grpGroups').style.display=isS?'':'none';
|
||||
document.getElementById('legendBox').style.display=isS?'none':'';
|
||||
document.querySelector('.stat.warn').style.display=isS?'none':'';
|
||||
document.getElementById('eyebrowKind').textContent=isS?'AppRoute navigation map':'gradle dependency graph';
|
||||
document.getElementById('hud').innerHTML=isS
|
||||
?'screens grouped by function<br>arrow = navigates to<br><b>scroll</b> zoom · <b>drag</b> pan'
|
||||
:'consumers <span style="color:var(--features)">left</span> · foundations <span style="color:var(--domain)">right</span><br>arrow points to the dependency<br><b>scroll</b> zoom · <b>drag</b> pan';
|
||||
if(isS&&!groupListBuilt)buildGroupList();
|
||||
if(groupListBuilt)document.getElementById('groupList').querySelectorAll('.tog').forEach(x=>x.classList.remove('act'));
|
||||
}
|
||||
|
||||
// ---- view switch ----
|
||||
function loadView(name){
|
||||
if(raf){cancelAnimationFrame(raf);raf=null;}
|
||||
CURRENT=name;selected=null;search.value='';
|
||||
btnInv.classList.remove('active');
|
||||
applyViewChrome(name);
|
||||
view={k:1,x:0,y:0};applyView();
|
||||
buildModel(name);setClusterMembership();initLayout();buildDOM();updateHalos();updateChrome();
|
||||
recomputeHidden();render();applyBaseLabels();
|
||||
if(!reduced){alpha=0.16;raf=requestAnimationFrame(animate);}
|
||||
}
|
||||
const vs=document.getElementById('viewswitch');
|
||||
vs.addEventListener('click',ev=>{const b=ev.target.closest('button');if(!b)return;
|
||||
[...vs.children].forEach(x=>x.classList.toggle('on',x===b));
|
||||
loadView(b.dataset.view);});
|
||||
|
||||
loadView('area');
|
||||
</script>
|
||||
277
.claude/skills/navigation-graph/scripts/build_graph.py
Normal file
277
.claude/skills/navigation-graph/scripts/build_graph.py
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
build_graph.py — scan the live codebase and refresh the managed regions of
|
||||
.claude/docs/navigation-graph.md (the source of truth for module-connectivity.html).
|
||||
|
||||
What it extracts from code:
|
||||
- features/domain/data Gradle project dependencies -> area graph + module graph
|
||||
- AppRoute screens + every `AppRoute.X` reference -> screen navigation graph
|
||||
|
||||
What it preserves:
|
||||
- all hand-written prose ABOVE the <!-- NAVGRAPH:BEGIN --> marker
|
||||
- the curated CONFIG block (groups / owners / teams) inside the managed region
|
||||
|
||||
Run from anywhere inside the repo: python3 build_graph.py
|
||||
Then render the HTML: python3 render_html.py
|
||||
"""
|
||||
import os, re, json, sys
|
||||
from collections import defaultdict
|
||||
|
||||
# ---------------------------------------------------------------- paths
|
||||
def find_root(start):
|
||||
d = os.path.abspath(start)
|
||||
while d != os.path.dirname(d):
|
||||
if os.path.exists(os.path.join(d, "settings.gradle.kts")):
|
||||
return d
|
||||
d = os.path.dirname(d)
|
||||
sys.exit("ERROR: could not locate repo root (settings.gradle.kts not found).")
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = find_root(os.getcwd())
|
||||
SKILL = os.path.dirname(SCRIPT_DIR)
|
||||
DOCS = os.path.join(ROOT, ".claude", "docs")
|
||||
MD = os.path.join(DOCS, "navigation-graph.md")
|
||||
SEED = os.path.join(SKILL, "assets", "config.seed.json")
|
||||
APPROUTE = os.path.join(ROOT, "common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt")
|
||||
|
||||
# ---------------------------------------------------------------- managed-region helpers
|
||||
BEGIN = "<!-- NAVGRAPH:BEGIN — managed by the navigation-graph skill. Edit only the CONFIG json; the rest is generated from code. -->"
|
||||
END = "<!-- NAVGRAPH:END -->"
|
||||
|
||||
def block(text, key):
|
||||
"""Return the JSON string inside the <!-- NAVGRAPH:key:BEGIN/END --> markers, or None."""
|
||||
m = re.search(r"<!-- NAVGRAPH:%s:BEGIN -->\s*```json\s*(.*?)\s*```\s*<!-- NAVGRAPH:%s:END -->"
|
||||
% (re.escape(key), re.escape(key)), text, re.S)
|
||||
return m.group(1) if m else None
|
||||
|
||||
def wrap(key, payload):
|
||||
return f"<!-- NAVGRAPH:{key}:BEGIN -->\n```json\n{payload}\n```\n<!-- NAVGRAPH:{key}:END -->"
|
||||
|
||||
# ---------------------------------------------------------------- gradle dependency scan
|
||||
def camel_to_kebab(s): return re.sub(r'(?<!^)(?=[A-Z])', '-', s).lower()
|
||||
def accessor_to_path(acc): return ':' + ':'.join(camel_to_kebab(p) for p in acc.split('.'))
|
||||
def dir_to_path(d): return ':' + os.path.relpath(d, ROOT).replace(os.sep, ':')
|
||||
|
||||
PROJ_RE = re.compile(r'(api|implementation|testImplementation|androidTestImplementation|'
|
||||
r'compileOnly|kapt|ksp|debugImplementation)\s*\(\s*projects\.([A-Za-z0-9_.]+)\s*\)')
|
||||
|
||||
def scan_modules():
|
||||
modules = {}
|
||||
edges = []
|
||||
for dp, _, fns in os.walk(ROOT):
|
||||
if '/build/' in dp or '/.git' in dp or '/.gradle' in dp: continue
|
||||
if 'build.gradle.kts' not in fns: continue
|
||||
if dp == ROOT: continue
|
||||
modules[dir_to_path(dp)] = dp
|
||||
for path, dp in modules.items():
|
||||
txt = open(os.path.join(dp, 'build.gradle.kts'), encoding='utf-8', errors='ignore').read()
|
||||
for m in PROJ_RE.finditer(txt):
|
||||
edges.append((path, accessor_to_path(m.group(2)), m.group(1)))
|
||||
return modules, edges
|
||||
|
||||
SCOPED_LAYERS = ('features', 'domain', 'data')
|
||||
def is_scoped(m): return any(m.startswith(':' + layer) for layer in SCOPED_LAYERS)
|
||||
def area_of(m):
|
||||
p = m.split(':')[1:]
|
||||
return f"{p[0]}:{p[1]}" if len(p) > 1 and p[0] in SCOPED_LAYERS else None
|
||||
|
||||
def build_area_graph(modules, edges):
|
||||
agg = defaultdict(lambda: {"w": 0, "api": False})
|
||||
for s, d, c in edges:
|
||||
if not (is_scoped(s) and is_scoped(d)): continue
|
||||
sa, da = area_of(s), area_of(d)
|
||||
if not sa or not da or sa == da: continue
|
||||
agg[(sa, da)]["w"] += 1
|
||||
if c == 'api': agg[(sa, da)]["api"] = True
|
||||
indeg, outdeg = defaultdict(int), defaultdict(int)
|
||||
for (s, d) in agg: outdeg[s] += 1; indeg[d] += 1
|
||||
nodes = sorted({n for e in agg for n in e})
|
||||
N = [[n, n.split(':')[1], n.split(':')[0], indeg[n], outdeg[n]] for n in nodes]
|
||||
E = [[s, d, v["w"], 1 if v["api"] else 0] for (s, d), v in agg.items()]
|
||||
return {"n": N, "e": E}
|
||||
|
||||
def build_module_graph(modules, edges):
|
||||
agg = defaultdict(lambda: {"w": 0, "api": False})
|
||||
for s, d, c in edges:
|
||||
if not (is_scoped(s) and is_scoped(d)) or s == d: continue
|
||||
agg[(s, d)]["w"] += 1
|
||||
if c == 'api': agg[(s, d)]["api"] = True
|
||||
indeg, outdeg = defaultdict(int), defaultdict(int)
|
||||
for (s, d) in agg: outdeg[s] += 1; indeg[d] += 1
|
||||
conn = {n for e in agg for n in e}
|
||||
N = [[m, ':'.join(m.split(':')[2:]), m.split(':')[1], indeg[m], outdeg[m]]
|
||||
for m in sorted(conn)]
|
||||
E = [[s, d, v["w"], 1 if v["api"] else 0] for (s, d), v in agg.items()]
|
||||
return {"n": N, "e": E}
|
||||
|
||||
# ---------------------------------------------------------------- AppRoute scan
|
||||
REF_RE = re.compile(r'AppRoute\.([A-Z][A-Za-z0-9]+)')
|
||||
DECL_RE = re.compile(r'^ (?:data )?(?:object|class) (\w+)', re.M)
|
||||
|
||||
def read_kotlin_string(text, i):
|
||||
"""text[i] must be '"'. Return (content, index_after_closing_quote), handling \\"
|
||||
escapes and ${...} template expressions (nested braces/strings copied verbatim) so a
|
||||
path with string templates isn't truncated at the first inner quote."""
|
||||
out, j = [], i + 1
|
||||
while j < len(text):
|
||||
c = text[j]
|
||||
if c == '\\':
|
||||
out.append(text[j:j + 2]); j += 2; continue
|
||||
if c == '"':
|
||||
return ''.join(out), j + 1
|
||||
if c == '$' and j + 1 < len(text) and text[j + 1] == '{':
|
||||
out.append('${'); j += 2; depth = 1
|
||||
while j < len(text) and depth > 0:
|
||||
ck = text[j]
|
||||
if ck == '"':
|
||||
s, j = read_kotlin_string(text, j); out.append('"' + s + '"'); continue
|
||||
if ck == '{': depth += 1
|
||||
elif ck == '}': depth -= 1
|
||||
if depth > 0: out.append(ck)
|
||||
j += 1
|
||||
out.append('}'); continue
|
||||
out.append(c); j += 1
|
||||
return ''.join(out), j
|
||||
|
||||
def extract_path(block):
|
||||
"""First string literal of the `path = …` argument within one screen's source block.
|
||||
Block-scoped (so multi-line `AppRoute(` blocks resolve) and template-aware (so paths
|
||||
aren't truncated); for a non-literal RHS (e.g. `path = when {…}`) it takes the first
|
||||
branch literal. Returns None when the block has no `path =`."""
|
||||
m = re.search(r'\bpath\s*=\s*', block)
|
||||
if not m: return None
|
||||
i = m.end()
|
||||
while i < len(block) and block[i] in ' \t\r\n': i += 1
|
||||
if i < len(block) and block[i] == '"':
|
||||
return read_kotlin_string(block, i)[0]
|
||||
q = block.find('"', i)
|
||||
return read_kotlin_string(block, q)[0] if q != -1 else None
|
||||
|
||||
def scan_routes():
|
||||
src = open(APPROUTE, encoding='utf-8').read()
|
||||
decls = [(m.group(1), m.start()) for m in DECL_RE.finditer(src)]
|
||||
screens = {name for name, _ in decls}
|
||||
paths = {}
|
||||
for idx, (name, start) in enumerate(decls):
|
||||
end = decls[idx + 1][1] if idx + 1 < len(decls) else len(src)
|
||||
p = extract_path(src[start:end])
|
||||
if p is not None: paths.setdefault(name, p)
|
||||
usage = defaultdict(lambda: defaultdict(int))
|
||||
nav = defaultdict(int)
|
||||
def area(fp):
|
||||
parts = os.path.relpath(fp, ROOT).split(os.sep)
|
||||
top = parts[0]
|
||||
return f"{top}:{parts[1]}" if top in ('features','domain','data','core','common','libs') and len(parts) > 1 else top
|
||||
for dp, _, fns in os.walk(ROOT):
|
||||
if '/build/' in dp or '/.git' in dp or '/.gradle' in dp: continue
|
||||
for fn in fns:
|
||||
if not fn.endswith('.kt'): continue
|
||||
fp = os.path.join(dp, fn)
|
||||
if os.path.samefile(fp, APPROUTE) if os.path.exists(APPROUTE) else False: continue
|
||||
try: txt = open(fp, encoding='utf-8', errors='ignore').read()
|
||||
except Exception: continue
|
||||
if 'AppRoute.' not in txt: continue
|
||||
a = area(fp)
|
||||
for m in REF_RE.finditer(txt):
|
||||
if m.group(1) in screens: usage[m.group(1)][a] += 1
|
||||
for nm in re.finditer(r'\b(push|replaceCurrent|replaceAll|popTo)\s*\(', txt):
|
||||
r2 = REF_RE.search(txt[nm.end():nm.end()+160])
|
||||
if r2 and r2.group(1) in screens: nav[(a, r2.group(1))] += 1
|
||||
return screens, paths, usage, nav
|
||||
|
||||
def build_screen_graph(screens, paths, usage, nav, cfg):
|
||||
total = {s: sum(usage[s].values()) for s in screens}
|
||||
sg, so = cfg["screenGroups"], cfg["screenOwners"]
|
||||
owned = defaultdict(list)
|
||||
for s in sorted(screens): owned[so.get(s, cfg["defaultOwner"])].append(s)
|
||||
def fnorm(a): return a.split(':')[-1].replace('-', '').lower()
|
||||
def main_of(a, ss):
|
||||
epon = [s for s in ss if s.lower() == fnorm(a)]
|
||||
return epon[0] if epon else max(ss, key=lambda x: (total[x], x)) # deterministic tie-break
|
||||
main = {a: main_of(a, ss) for a, ss in owned.items()}
|
||||
APPSHELL = 'AppShell'
|
||||
edge = defaultdict(int)
|
||||
for (a, t), c in nav.items():
|
||||
src = main.get(a, APPSHELL)
|
||||
if src == t: continue
|
||||
edge[(src, t)] += c
|
||||
use_shell = any(s == APPSHELL for s, _ in edge)
|
||||
indeg, outdeg = defaultdict(int), defaultdict(int)
|
||||
for (s, t) in edge: outdeg[s] += 1; indeg[t] += 1
|
||||
N = [[s, s, sg.get(s, cfg["defaultGroup"]), indeg[s], outdeg[s]] for s in sorted(screens)]
|
||||
if use_shell: N.append([APPSHELL, 'App shell', 'shell', indeg[APPSHELL], outdeg[APPSHELL]])
|
||||
E = [[s, t, w, 0] for (s, t), w in edge.items()]
|
||||
meta = {}
|
||||
for s in sorted(screens):
|
||||
meta[s] = {"path": paths.get(s, ""), "owner": so.get(s, cfg["defaultOwner"]),
|
||||
"group": sg.get(s, cfg["defaultGroup"]), "total": total[s],
|
||||
"refs": sorted(usage[s].items(), key=lambda kv: -kv[1])}
|
||||
return {"n": N, "e": E}, meta
|
||||
|
||||
# ---------------------------------------------------------------- main
|
||||
def main():
|
||||
if not os.path.exists(APPROUTE):
|
||||
sys.exit(f"ERROR: AppRoute.kt not found at {APPROUTE}")
|
||||
old = open(MD, encoding='utf-8').read() if os.path.exists(MD) else ""
|
||||
|
||||
# config: prefer the one already in the doc (human edits win), else seed
|
||||
cfg_str = block(old, "config")
|
||||
cfg = json.loads(cfg_str) if cfg_str else json.load(open(SEED))
|
||||
|
||||
modules, dep_edges = scan_modules()
|
||||
area_g = build_area_graph(modules, dep_edges)
|
||||
mod_g = build_module_graph(modules, dep_edges)
|
||||
screens, paths, usage, nav = scan_routes()
|
||||
|
||||
# reconcile config with the screens actually present in code
|
||||
warns = []
|
||||
for s in sorted(screens):
|
||||
if s not in cfg["screenGroups"]:
|
||||
cfg["screenGroups"][s] = cfg["defaultGroup"]; warns.append(f"NEW screen '{s}': group defaulted to '{cfg['defaultGroup']}' — set it in CONFIG")
|
||||
if s not in cfg["screenOwners"]:
|
||||
cfg["screenOwners"][s] = cfg["defaultOwner"]; warns.append(f"NEW screen '{s}': owner defaulted to '{cfg['defaultOwner']}' — set it in CONFIG")
|
||||
stale = [s for s in cfg["screenGroups"] if s not in screens]
|
||||
|
||||
screen_g, screen_meta = build_screen_graph(screens, paths, usage, nav, cfg)
|
||||
|
||||
inv_area = sum(1 for s, t, w, a in area_g["e"] if t.startswith('features:') and not s.startswith('features:'))
|
||||
|
||||
cj = lambda o: json.dumps(o, separators=(',', ':'))
|
||||
summary = (
|
||||
f"_Auto-generated from code by the `navigation-graph` skill. Edit only the CONFIG block below._\n\n"
|
||||
f"- **Screens (AppRoute):** {len(screens)} · screen-nav edges {len(screen_g['e'])}\n"
|
||||
f"- **Area graph:** {len(area_g['n'])} feature/domain/data areas · {len(area_g['e'])} dependency edges · {inv_area} inverted (domain/data→features)\n"
|
||||
f"- **Module graph:** {len(mod_g['n'])} modules · {len(mod_g['e'])} edges\n"
|
||||
)
|
||||
if warns: summary += "\n**Action needed:**\n" + "\n".join(f"- {w}" for w in warns) + "\n"
|
||||
if stale: summary += f"\n_Config has {len(stale)} screen(s) no longer in code (kept, harmless): {', '.join(stale)}_\n"
|
||||
|
||||
managed = "\n\n".join([
|
||||
BEGIN,
|
||||
"## Connectivity data (generated)\n\n" + summary,
|
||||
"### Curated config (editable — preserved across refreshes)\n\n" + wrap("config", json.dumps(cfg, indent=2)),
|
||||
"### Graph data (auto — overwritten every refresh; do not hand-edit)\n\n" +
|
||||
wrap("areaGraph", cj(area_g)) + "\n\n" + wrap("moduleGraph", cj(mod_g)) + "\n\n" +
|
||||
wrap("screensGraph", cj(screen_g)) + "\n\n" + wrap("screensMeta", cj(screen_meta)),
|
||||
END,
|
||||
])
|
||||
|
||||
if BEGIN in old and END in old:
|
||||
head = old[:old.index(BEGIN)].rstrip() + "\n\n"
|
||||
tail = old[old.index(END) + len(END):]
|
||||
new = head + managed + tail
|
||||
elif old.strip():
|
||||
new = old.rstrip() + "\n\n" + managed + "\n"
|
||||
else:
|
||||
new = "# Navigation Graph\n\n" + managed + "\n"
|
||||
|
||||
os.makedirs(DOCS, exist_ok=True)
|
||||
open(MD, "w", encoding='utf-8').write(new)
|
||||
print(f"✓ updated {os.path.relpath(MD, ROOT)}")
|
||||
print(f" screens={len(screens)} screen-edges={len(screen_g['e'])} | "
|
||||
f"areas={len(area_g['n'])}/{len(area_g['e'])} | modules={len(mod_g['n'])}/{len(mod_g['e'])}")
|
||||
for w in warns: print(" ⚠ " + w)
|
||||
print("Next: python3 " + os.path.join(SCRIPT_DIR, "render_html.py"))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
73
.claude/skills/navigation-graph/scripts/render_html.py
Normal file
73
.claude/skills/navigation-graph/scripts/render_html.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
render_html.py — build .claude/docs/module-connectivity.html from the data blocks
|
||||
in .claude/docs/navigation-graph.md (which build_graph.py refreshes from code).
|
||||
|
||||
Run AFTER build_graph.py: python3 render_html.py
|
||||
"""
|
||||
import os, re, json, sys
|
||||
|
||||
def find_root(start):
|
||||
d = os.path.abspath(start)
|
||||
while d != os.path.dirname(d):
|
||||
if os.path.exists(os.path.join(d, "settings.gradle.kts")):
|
||||
return d
|
||||
d = os.path.dirname(d)
|
||||
sys.exit("ERROR: could not locate repo root (settings.gradle.kts not found).")
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = find_root(os.getcwd())
|
||||
SKILL = os.path.dirname(SCRIPT_DIR)
|
||||
MD = os.path.join(ROOT, ".claude", "docs", "navigation-graph.md")
|
||||
TEMPLATE = os.path.join(SKILL, "assets", "template.html")
|
||||
OUT = os.path.join(ROOT, ".claude", "docs", "module-connectivity.html")
|
||||
|
||||
def block(text, key):
|
||||
m = re.search(r"<!-- NAVGRAPH:%s:BEGIN -->\s*```json\s*(.*?)\s*```\s*<!-- NAVGRAPH:%s:END -->"
|
||||
% (re.escape(key), re.escape(key)), text, re.S)
|
||||
if not m:
|
||||
sys.exit(f"ERROR: data block '{key}' not found in {MD}. Run build_graph.py first.")
|
||||
return json.loads(m.group(1))
|
||||
|
||||
def main():
|
||||
if not os.path.exists(MD): sys.exit(f"ERROR: {MD} not found. Run build_graph.py first.")
|
||||
if not os.path.exists(TEMPLATE): sys.exit(f"ERROR: template missing at {TEMPLATE}")
|
||||
text = open(MD, encoding='utf-8').read()
|
||||
cfg = block(text, "config")
|
||||
area = block(text, "areaGraph")
|
||||
mod = block(text, "moduleGraph")
|
||||
screens = block(text, "screensGraph")
|
||||
smeta = block(text, "screensMeta")
|
||||
|
||||
groups = cfg["groups"]
|
||||
group_colors = {g: groups[g]["color"] for g in groups}
|
||||
group_labels = {g: groups[g]["label"] for g in groups}
|
||||
group_anchors = {g: groups[g]["anchor"] for g in groups}
|
||||
|
||||
cj = lambda o: json.dumps(o, separators=(',', ':'))
|
||||
tpl = open(TEMPLATE, encoding='utf-8').read()
|
||||
repl = {
|
||||
"/*__AREA_DATA__*/null": cj(area),
|
||||
"/*__MODULE_DATA__*/null": cj(mod),
|
||||
"/*__SCREENS_DATA__*/null": cj(screens),
|
||||
"/*__SCREENS_META__*/null": cj(smeta),
|
||||
"/*__TEAMS__*/[]": cj(cfg["teams"]),
|
||||
"/*__GROUP_COLORS__*/{}": cj(group_colors),
|
||||
"/*__GROUP_LABELS__*/{}": cj(group_labels),
|
||||
"/*__GROUP_ANCHORS__*/{}": cj(group_anchors),
|
||||
}
|
||||
out = tpl
|
||||
for k, v in repl.items():
|
||||
if k not in out: sys.exit(f"ERROR: placeholder '{k}' missing in template — template/skill version mismatch.")
|
||||
out = out.replace(k, v)
|
||||
for ph in ("__AREA_DATA__", "__MODULE_DATA__", "__SCREENS_DATA__", "__SCREENS_META__",
|
||||
"__TEAMS__", "__GROUP_COLORS__", "__GROUP_LABELS__", "__GROUP_ANCHORS__"):
|
||||
if "/*" + ph + "*/" in out: sys.exit(f"ERROR: placeholder {ph} left unreplaced.")
|
||||
open(OUT, "w", encoding='utf-8').write(out)
|
||||
print(f"✓ wrote {os.path.relpath(OUT, ROOT)} ({len(out)//1024} KB, self-contained)")
|
||||
print(f" area {len(area['n'])}n/{len(area['e'])}e · module {len(mod['n'])}n/{len(mod['e'])}e · "
|
||||
f"screens {len(screens['n'])}n/{len(screens['e'])}e · teams {len(cfg['teams'])}")
|
||||
print(f" open: {OUT}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue