graphitect 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graphify/__init__.py +30 -0
- graphify/__main__.py +757 -0
- graphify/_minhash.py +107 -0
- graphify/affected.py +318 -0
- graphify/always_on/agents-md.md +12 -0
- graphify/always_on/antigravity-rules.md +14 -0
- graphify/always_on/claude-md.md +9 -0
- graphify/always_on/gemini-md.md +9 -0
- graphify/always_on/kiro-steering.md +5 -0
- graphify/always_on/vscode-instructions.md +17 -0
- graphify/analyze.py +769 -0
- graphify/benchmark.py +152 -0
- graphify/build.py +2300 -0
- graphify/cache.py +1746 -0
- graphify/callflow_html.py +2051 -0
- graphify/cargo_introspect.py +109 -0
- graphify/cli.py +4745 -0
- graphify/cluster.py +409 -0
- graphify/command-kilo.md +15 -0
- graphify/cross_repo_calls.py +216 -0
- graphify/cross_repo_types.py +75 -0
- graphify/csharp_dispatch.py +154 -0
- graphify/dedup.py +1213 -0
- graphify/detect.py +2566 -0
- graphify/diagnostics.py +406 -0
- graphify/export.py +1349 -0
- graphify/exporters/__init__.py +1 -0
- graphify/exporters/base.py +14 -0
- graphify/exporters/graphdb.py +173 -0
- graphify/exporters/html.py +637 -0
- graphify/extract.py +7856 -0
- graphify/extractors/MIGRATION.md +107 -0
- graphify/extractors/__init__.py +66 -0
- graphify/extractors/apex.py +215 -0
- graphify/extractors/base.py +85 -0
- graphify/extractors/bash.py +579 -0
- graphify/extractors/blade.py +53 -0
- graphify/extractors/commonlisp.py +540 -0
- graphify/extractors/csharp.py +448 -0
- graphify/extractors/dart.py +564 -0
- graphify/extractors/dm.py +494 -0
- graphify/extractors/elixir.py +241 -0
- graphify/extractors/engine.py +6509 -0
- graphify/extractors/fortran.py +311 -0
- graphify/extractors/go.py +527 -0
- graphify/extractors/json_config.py +240 -0
- graphify/extractors/julia.py +289 -0
- graphify/extractors/markdown.py +408 -0
- graphify/extractors/models.py +131 -0
- graphify/extractors/objc.py +566 -0
- graphify/extractors/ocaml.py +289 -0
- graphify/extractors/pascal.py +688 -0
- graphify/extractors/pascal_forms.py +196 -0
- graphify/extractors/powershell.py +522 -0
- graphify/extractors/razor.py +192 -0
- graphify/extractors/resolution.py +3584 -0
- graphify/extractors/robot.py +296 -0
- graphify/extractors/rust.py +470 -0
- graphify/extractors/sln.py +92 -0
- graphify/extractors/sql.py +720 -0
- graphify/extractors/terraform.py +181 -0
- graphify/extractors/verilog.py +329 -0
- graphify/extractors/zig.py +181 -0
- graphify/file_slice.py +246 -0
- graphify/global_graph.py +194 -0
- graphify/google_workspace.py +237 -0
- graphify/hooks.py +933 -0
- graphify/ids.py +93 -0
- graphify/ingest.py +358 -0
- graphify/install.py +2366 -0
- graphify/llm.py +3544 -0
- graphify/manifest.py +4 -0
- graphify/manifest_ingest.py +311 -0
- graphify/mcp_ingest.py +386 -0
- graphify/multigraph_compat.py +212 -0
- graphify/pascal_resolution.py +129 -0
- graphify/paths.py +436 -0
- graphify/pg_introspect.py +165 -0
- graphify/prs.py +770 -0
- graphify/querylog.py +80 -0
- graphify/reflect.py +882 -0
- graphify/report.py +346 -0
- graphify/resolver_registry.py +85 -0
- graphify/ruby_resolution.py +242 -0
- graphify/scip_ingest.py +363 -0
- graphify/security.py +460 -0
- graphify/semantic_cleanup.py +336 -0
- graphify/serve.py +2608 -0
- graphify/skill-agents.md +710 -0
- graphify/skill-aider.md +1283 -0
- graphify/skill-amp.md +710 -0
- graphify/skill-claw.md +713 -0
- graphify/skill-codex.md +710 -0
- graphify/skill-copilot.md +713 -0
- graphify/skill-devin.md +1410 -0
- graphify/skill-droid.md +710 -0
- graphify/skill-kilo.md +722 -0
- graphify/skill-kiro.md +713 -0
- graphify/skill-opencode.md +705 -0
- graphify/skill-pi.md +713 -0
- graphify/skill-trae.md +711 -0
- graphify/skill-vscode.md +709 -0
- graphify/skill-windows.md +755 -0
- graphify/skill.md +713 -0
- graphify/skills/agents/references/add-watch.md +56 -0
- graphify/skills/agents/references/exports.md +87 -0
- graphify/skills/agents/references/extraction-spec.md +70 -0
- graphify/skills/agents/references/github-and-merge.md +46 -0
- graphify/skills/agents/references/hooks.md +33 -0
- graphify/skills/agents/references/query.md +311 -0
- graphify/skills/agents/references/transcribe.md +52 -0
- graphify/skills/agents/references/update.md +210 -0
- graphify/skills/amp/references/add-watch.md +56 -0
- graphify/skills/amp/references/exports.md +87 -0
- graphify/skills/amp/references/extraction-spec.md +70 -0
- graphify/skills/amp/references/github-and-merge.md +46 -0
- graphify/skills/amp/references/hooks.md +33 -0
- graphify/skills/amp/references/query.md +311 -0
- graphify/skills/amp/references/transcribe.md +52 -0
- graphify/skills/amp/references/update.md +210 -0
- graphify/skills/claude/references/add-watch.md +56 -0
- graphify/skills/claude/references/exports.md +87 -0
- graphify/skills/claude/references/extraction-spec.md +70 -0
- graphify/skills/claude/references/github-and-merge.md +46 -0
- graphify/skills/claude/references/hooks.md +33 -0
- graphify/skills/claude/references/query.md +311 -0
- graphify/skills/claude/references/transcribe.md +52 -0
- graphify/skills/claude/references/update.md +210 -0
- graphify/skills/claw/references/add-watch.md +56 -0
- graphify/skills/claw/references/exports.md +87 -0
- graphify/skills/claw/references/extraction-spec.md +31 -0
- graphify/skills/claw/references/github-and-merge.md +46 -0
- graphify/skills/claw/references/hooks.md +33 -0
- graphify/skills/claw/references/query.md +311 -0
- graphify/skills/claw/references/transcribe.md +52 -0
- graphify/skills/claw/references/update.md +210 -0
- graphify/skills/codex/references/add-watch.md +56 -0
- graphify/skills/codex/references/exports.md +87 -0
- graphify/skills/codex/references/extraction-spec.md +31 -0
- graphify/skills/codex/references/github-and-merge.md +46 -0
- graphify/skills/codex/references/hooks.md +33 -0
- graphify/skills/codex/references/query.md +311 -0
- graphify/skills/codex/references/transcribe.md +52 -0
- graphify/skills/codex/references/update.md +210 -0
- graphify/skills/copilot/references/add-watch.md +56 -0
- graphify/skills/copilot/references/exports.md +87 -0
- graphify/skills/copilot/references/extraction-spec.md +70 -0
- graphify/skills/copilot/references/github-and-merge.md +46 -0
- graphify/skills/copilot/references/hooks.md +33 -0
- graphify/skills/copilot/references/query.md +311 -0
- graphify/skills/copilot/references/transcribe.md +52 -0
- graphify/skills/copilot/references/update.md +210 -0
- graphify/skills/droid/references/add-watch.md +56 -0
- graphify/skills/droid/references/exports.md +87 -0
- graphify/skills/droid/references/extraction-spec.md +70 -0
- graphify/skills/droid/references/github-and-merge.md +46 -0
- graphify/skills/droid/references/hooks.md +33 -0
- graphify/skills/droid/references/query.md +311 -0
- graphify/skills/droid/references/transcribe.md +52 -0
- graphify/skills/droid/references/update.md +210 -0
- graphify/skills/kilo/references/add-watch.md +56 -0
- graphify/skills/kilo/references/exports.md +87 -0
- graphify/skills/kilo/references/extraction-spec.md +70 -0
- graphify/skills/kilo/references/github-and-merge.md +46 -0
- graphify/skills/kilo/references/hooks.md +33 -0
- graphify/skills/kilo/references/query.md +311 -0
- graphify/skills/kilo/references/transcribe.md +52 -0
- graphify/skills/kilo/references/update.md +210 -0
- graphify/skills/kiro/references/add-watch.md +56 -0
- graphify/skills/kiro/references/exports.md +87 -0
- graphify/skills/kiro/references/extraction-spec.md +31 -0
- graphify/skills/kiro/references/github-and-merge.md +46 -0
- graphify/skills/kiro/references/hooks.md +33 -0
- graphify/skills/kiro/references/query.md +311 -0
- graphify/skills/kiro/references/transcribe.md +52 -0
- graphify/skills/kiro/references/update.md +210 -0
- graphify/skills/opencode/references/add-watch.md +56 -0
- graphify/skills/opencode/references/exports.md +87 -0
- graphify/skills/opencode/references/extraction-spec.md +70 -0
- graphify/skills/opencode/references/github-and-merge.md +46 -0
- graphify/skills/opencode/references/hooks.md +33 -0
- graphify/skills/opencode/references/query.md +311 -0
- graphify/skills/opencode/references/transcribe.md +52 -0
- graphify/skills/opencode/references/update.md +210 -0
- graphify/skills/pi/references/add-watch.md +56 -0
- graphify/skills/pi/references/exports.md +87 -0
- graphify/skills/pi/references/extraction-spec.md +31 -0
- graphify/skills/pi/references/github-and-merge.md +46 -0
- graphify/skills/pi/references/hooks.md +33 -0
- graphify/skills/pi/references/query.md +311 -0
- graphify/skills/pi/references/transcribe.md +52 -0
- graphify/skills/pi/references/update.md +210 -0
- graphify/skills/trae/references/add-watch.md +56 -0
- graphify/skills/trae/references/exports.md +87 -0
- graphify/skills/trae/references/extraction-spec.md +70 -0
- graphify/skills/trae/references/github-and-merge.md +46 -0
- graphify/skills/trae/references/hooks.md +35 -0
- graphify/skills/trae/references/query.md +311 -0
- graphify/skills/trae/references/transcribe.md +52 -0
- graphify/skills/trae/references/update.md +210 -0
- graphify/skills/vscode/references/add-watch.md +56 -0
- graphify/skills/vscode/references/exports.md +87 -0
- graphify/skills/vscode/references/extraction-spec.md +70 -0
- graphify/skills/vscode/references/github-and-merge.md +46 -0
- graphify/skills/vscode/references/hooks.md +33 -0
- graphify/skills/vscode/references/query.md +311 -0
- graphify/skills/vscode/references/transcribe.md +52 -0
- graphify/skills/vscode/references/update.md +210 -0
- graphify/skills/windows/references/add-watch.md +56 -0
- graphify/skills/windows/references/exports.md +87 -0
- graphify/skills/windows/references/extraction-spec.md +70 -0
- graphify/skills/windows/references/github-and-merge.md +46 -0
- graphify/skills/windows/references/hooks.md +33 -0
- graphify/skills/windows/references/query.md +311 -0
- graphify/skills/windows/references/transcribe.md +52 -0
- graphify/skills/windows/references/update.md +210 -0
- graphify/symbol_resolution.py +556 -0
- graphify/transcribe.py +186 -0
- graphify/tree_html.py +603 -0
- graphify/validate.py +95 -0
- graphify/watch.py +2280 -0
- graphify/wiki.py +405 -0
- graphitect/__init__.py +28 -0
- graphitect/__main__.py +4 -0
- graphitect/_vendor/__init__.py +2 -0
- graphitect/_vendor/archify/LICENSE +22 -0
- graphitect/_vendor/archify/SKILL.md +137 -0
- graphitect/_vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
- graphitect/_vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
- graphitect/_vendor/archify/assets/template.html +14935 -0
- graphitect/_vendor/archify/bin/archify.mjs +2091 -0
- graphitect/_vendor/archify/bin/open-artifact.mjs +86 -0
- graphitect/_vendor/archify/bin/preview.mjs +653 -0
- graphitect/_vendor/archify/bin/visual-check.mjs +829 -0
- graphitect/_vendor/archify/brand-marks/README.md +31 -0
- graphitect/_vendor/archify/brand-marks/catalog.json +131 -0
- graphitect/_vendor/archify/delta/architecture-delta.mjs +1221 -0
- graphitect/_vendor/archify/examples/agent-run.lifecycle.json +60 -0
- graphitect/_vendor/archify/examples/agent-tool-call.workflow.json +94 -0
- graphitect/_vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
- graphitect/_vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
- graphitect/_vendor/archify/examples/cache-miss-request.sequence.json +82 -0
- graphitect/_vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
- graphitect/_vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
- graphitect/_vendor/archify/examples/dataflow-product-analytics.html +15045 -0
- graphitect/_vendor/archify/examples/deployment-release.lifecycle.json +49 -0
- graphitect/_vendor/archify/examples/event-stream.dataflow.json +57 -0
- graphitect/_vendor/archify/examples/incident-response.workflow.json +64 -0
- graphitect/_vendor/archify/examples/lifecycle-agent-run.html +14980 -0
- graphitect/_vendor/archify/examples/product-analytics.dataflow.json +76 -0
- graphitect/_vendor/archify/examples/production-deployment.architecture.json +71 -0
- graphitect/_vendor/archify/examples/release-delivery.workflow.json +62 -0
- graphitect/_vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
- graphitect/_vendor/archify/examples/web-app-rendered.html +15009 -0
- graphitect/_vendor/archify/examples/web-app.architecture.json +46 -0
- graphitect/_vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
- graphitect/_vendor/archify/migrations/workflow-v2.mjs +279 -0
- graphitect/_vendor/archify/package-lock.json +149 -0
- graphitect/_vendor/archify/package.json +39 -0
- graphitect/_vendor/archify/recipes/scenarios.mjs +391 -0
- graphitect/_vendor/archify/references/authoring-contract.md +243 -0
- graphitect/_vendor/archify/references/brand-marks.md +65 -0
- graphitect/_vendor/archify/references/delivery-contract.md +120 -0
- graphitect/_vendor/archify/references/viewer-runtime.md +45 -0
- graphitect/_vendor/archify/renderers/architecture/grid.mjs +62 -0
- graphitect/_vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
- graphitect/_vendor/archify/renderers/dataflow/README.md +104 -0
- graphitect/_vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
- graphitect/_vendor/archify/renderers/lifecycle/README.md +115 -0
- graphitect/_vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
- graphitect/_vendor/archify/renderers/sequence/README.md +114 -0
- graphitect/_vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
- graphitect/_vendor/archify/renderers/shared/brand-marks.mjs +563 -0
- graphitect/_vendor/archify/renderers/shared/cli.mjs +218 -0
- graphitect/_vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
- graphitect/_vendor/archify/renderers/shared/diagnostics.mjs +127 -0
- graphitect/_vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
- graphitect/_vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- graphitect/_vendor/archify/renderers/shared/generated-validators.mjs +13 -0
- graphitect/_vendor/archify/renderers/shared/geometry.mjs +1423 -0
- graphitect/_vendor/archify/renderers/shared/i18n.mjs +595 -0
- graphitect/_vendor/archify/renderers/shared/layout-report.mjs +40 -0
- graphitect/_vendor/archify/renderers/shared/legend.mjs +217 -0
- graphitect/_vendor/archify/renderers/shared/output-path.mjs +340 -0
- graphitect/_vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
- graphitect/_vendor/archify/renderers/shared/repository-location.mjs +58 -0
- graphitect/_vendor/archify/renderers/shared/text-fit.mjs +49 -0
- graphitect/_vendor/archify/renderers/shared/utils.mjs +232 -0
- graphitect/_vendor/archify/renderers/shared/validator.mjs +86 -0
- graphitect/_vendor/archify/renderers/workflow/README.md +223 -0
- graphitect/_vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
- graphitect/_vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
- graphitect/_vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
- graphitect/_vendor/archify/schemas/README.md +211 -0
- graphitect/_vendor/archify/schemas/architecture.schema.json +178 -0
- graphitect/_vendor/archify/schemas/common.schema.json +115 -0
- graphitect/_vendor/archify/schemas/dataflow.schema.json +243 -0
- graphitect/_vendor/archify/schemas/lifecycle.schema.json +266 -0
- graphitect/_vendor/archify/schemas/sequence.schema.json +223 -0
- graphitect/_vendor/archify/schemas/workflow.schema.json +428 -0
- graphitect/_vendor/archify/scripts/check-render-output.mjs +836 -0
- graphitect/_vendor/archify/scripts/check-update.mjs +1667 -0
- graphitect/_vendor/archify/scripts/generate-brand-marks.mjs +141 -0
- graphitect/_vendor/archify/scripts/generate-validators.mjs +66 -0
- graphitect/_vendor/archify/scripts/render-examples.mjs +26 -0
- graphitect/_vendor/archify/scripts/update-contract.mjs +182 -0
- graphitect/_vendor/archify/skill-release.json +10 -0
- graphitect/cli.py +981 -0
- graphitect/deliver/__init__.py +5 -0
- graphitect/deliver/archify_adapter.py +1877 -0
- graphitect/deliver/archify_ir.py +160 -0
- graphitect/deliver/archify_repair.py +135 -0
- graphitect/deliver/doc_compiler.py +916 -0
- graphitect/ground/__init__.py +5 -0
- graphitect/ground/describe_source.py +27 -0
- graphitect/ground/fullread_source.py +56 -0
- graphitect/ground/graphify_source.py +107 -0
- graphitect/models.py +118 -0
- graphitect/skill/SKILL.md +80 -0
- graphitect/skill/agents/openai.yaml +4 -0
- graphitect/synthesize/__init__.py +5 -0
- graphitect/synthesize/engine.py +281 -0
- graphitect/synthesize/llm_backend.py +331 -0
- graphitect/synthesize/questions.py +139 -0
- graphitect/synthesize/rubric.py +104 -0
- graphitect-0.2.0.dist-info/METADATA +284 -0
- graphitect-0.2.0.dist-info/RECORD +336 -0
- graphitect-0.2.0.dist-info/WHEEL +5 -0
- graphitect-0.2.0.dist-info/entry_points.txt +2 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE +21 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-ARCHIFY-MIT +22 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-APACHE-2.0 +202 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-MIT +21 -0
- graphitect-0.2.0.dist-info/licenses/NOTICE-ARCHIFY-THIRD-PARTY.md +69 -0
- graphitect-0.2.0.dist-info/licenses/NOTICE-GRAPHIFY +8 -0
- graphitect-0.2.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
"""html — moved verbatim from graphify/export.py."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from graphify.exporters.base import COMMUNITY_COLORS # noqa: E402,F401
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import html as _html
|
|
7
|
+
from graphify.analyze import _node_community_map
|
|
8
|
+
from graphify.paths import write_text_atomic
|
|
9
|
+
import json
|
|
10
|
+
import networkx as nx
|
|
11
|
+
from graphify.security import sanitize_label
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
MAX_NODES_FOR_VIZ = 5_000
|
|
15
|
+
_HTML_STALE_MARKER = ".graph.html.stale"
|
|
16
|
+
|
|
17
|
+
def _viz_node_limit() -> int:
|
|
18
|
+
"""Return the effective viz node limit, honoring GRAPHIFY_VIZ_NODE_LIMIT env var.
|
|
19
|
+
|
|
20
|
+
Falls back to MAX_NODES_FOR_VIZ when the env var is unset, empty, or non-integer.
|
|
21
|
+
Set to 0 to disable HTML viz unconditionally (useful for CI runners).
|
|
22
|
+
"""
|
|
23
|
+
import os
|
|
24
|
+
raw = os.environ.get("GRAPHIFY_VIZ_NODE_LIMIT")
|
|
25
|
+
if raw is None or not raw.strip():
|
|
26
|
+
return MAX_NODES_FOR_VIZ
|
|
27
|
+
try:
|
|
28
|
+
return int(raw)
|
|
29
|
+
except ValueError:
|
|
30
|
+
return MAX_NODES_FOR_VIZ
|
|
31
|
+
|
|
32
|
+
def _html_styles() -> str:
|
|
33
|
+
return """<style>
|
|
34
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
35
|
+
body { background: #0f0f1a; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: flex; height: 100vh; overflow: hidden; }
|
|
36
|
+
#graph { flex: 1; }
|
|
37
|
+
#sidebar { width: 280px; background: #1a1a2e; border-left: 1px solid #2a2a4e; display: flex; flex-direction: column; overflow: hidden; }
|
|
38
|
+
#search-wrap { padding: 12px; border-bottom: 1px solid #2a2a4e; }
|
|
39
|
+
#search { width: 100%; background: #0f0f1a; border: 1px solid #3a3a5e; color: #e0e0e0; padding: 7px 10px; border-radius: 6px; font-size: 13px; outline: none; }
|
|
40
|
+
#search:focus { border-color: #4E79A7; }
|
|
41
|
+
#search-results { max-height: 140px; overflow-y: auto; padding: 4px 12px; border-bottom: 1px solid #2a2a4e; display: none; }
|
|
42
|
+
.search-item { padding: 4px 6px; cursor: pointer; border-radius: 4px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
43
|
+
.search-item:hover { background: #2a2a4e; }
|
|
44
|
+
#info-panel { padding: 14px; border-bottom: 1px solid #2a2a4e; min-height: 140px; }
|
|
45
|
+
#info-panel h3 { font-size: 13px; color: #aaa; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
46
|
+
#info-content { font-size: 13px; color: #ccc; line-height: 1.6; }
|
|
47
|
+
#info-content .field { margin-bottom: 5px; }
|
|
48
|
+
#info-content .field b { color: #e0e0e0; }
|
|
49
|
+
#info-content .empty { color: #555; font-style: italic; }
|
|
50
|
+
.neighbor-link { display: block; padding: 2px 6px; margin: 2px 0; border-radius: 3px; cursor: pointer; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 3px solid #333; }
|
|
51
|
+
.neighbor-link:hover { background: #2a2a4e; }
|
|
52
|
+
#neighbors-list { max-height: 160px; overflow-y: auto; margin-top: 4px; }
|
|
53
|
+
#legend-wrap { flex: 1; overflow-y: auto; padding: 12px; }
|
|
54
|
+
#legend-wrap h3 { font-size: 13px; color: #aaa; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
55
|
+
.legend-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; border-radius: 4px; font-size: 12px; }
|
|
56
|
+
.legend-item:hover { background: #2a2a4e; padding-left: 4px; }
|
|
57
|
+
.legend-item.dimmed { opacity: 0.35; }
|
|
58
|
+
.legend-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
|
|
59
|
+
.legend-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
60
|
+
.legend-count { color: #666; font-size: 11px; }
|
|
61
|
+
#stats { padding: 10px 14px; border-top: 1px solid #2a2a4e; font-size: 11px; color: #555; }
|
|
62
|
+
#legend-controls { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; padding: 4px 0; }
|
|
63
|
+
#legend-controls label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px; color: #aaa; user-select: none; }
|
|
64
|
+
#legend-controls label:hover { color: #e0e0e0; }
|
|
65
|
+
.legend-cb, #select-all-cb { appearance: none; -webkit-appearance: none; width: 14px; height: 14px; border: 1.5px solid #3a3a5e; border-radius: 3px; background: #0f0f1a; cursor: pointer; position: relative; flex-shrink: 0; }
|
|
66
|
+
.legend-cb:checked, #select-all-cb:checked { background: #4E79A7; border-color: #4E79A7; }
|
|
67
|
+
.legend-cb:checked::after, #select-all-cb:checked::after { content: ''; position: absolute; left: 3.5px; top: 1px; width: 4px; height: 7px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); }
|
|
68
|
+
#select-all-cb:indeterminate { background: #4E79A7; border-color: #4E79A7; }
|
|
69
|
+
#select-all-cb:indeterminate::after { content: ''; position: absolute; left: 2px; top: 5px; width: 8px; height: 2px; background: #fff; border: none; transform: none; }
|
|
70
|
+
</style>"""
|
|
71
|
+
|
|
72
|
+
def _hyperedge_script(hyperedges_json: str) -> str:
|
|
73
|
+
return f"""<script>
|
|
74
|
+
// Render hyperedges as shaded regions
|
|
75
|
+
const hyperedges = {hyperedges_json};
|
|
76
|
+
// afterDrawing passes ctx already transformed to network coordinate space.
|
|
77
|
+
// Draw node positions raw — no manual pan/zoom/DPR math needed.
|
|
78
|
+
|
|
79
|
+
// Andrew's monotone chain. Returns the hull in counter-clockwise order, which
|
|
80
|
+
// is what the perimeter must be traced in. Collinear and duplicate points
|
|
81
|
+
// collapse to the extremes, so degenerate member sets render as a segment
|
|
82
|
+
// rather than a zero-area crossed path.
|
|
83
|
+
function convexHull(pts) {{
|
|
84
|
+
const p = pts.slice().sort((a, b) => (a.x - b.x) || (a.y - b.y));
|
|
85
|
+
if (p.length < 3) return p;
|
|
86
|
+
const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
|
87
|
+
const build = seq => {{
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const q of seq) {{
|
|
90
|
+
while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], q) <= 0) out.pop();
|
|
91
|
+
out.push(q);
|
|
92
|
+
}}
|
|
93
|
+
out.pop();
|
|
94
|
+
return out;
|
|
95
|
+
}};
|
|
96
|
+
const hull = build(p).concat(build(p.slice().reverse()));
|
|
97
|
+
return hull.length >= 3 ? hull : p;
|
|
98
|
+
}}
|
|
99
|
+
network.on('afterDrawing', function(ctx) {{
|
|
100
|
+
hyperedges.forEach(h => {{
|
|
101
|
+
const positions = h.nodes
|
|
102
|
+
.map(nid => network.getPositions([nid])[nid])
|
|
103
|
+
.filter(p => p !== undefined);
|
|
104
|
+
if (positions.length < 2) return;
|
|
105
|
+
ctx.save();
|
|
106
|
+
ctx.globalAlpha = 0.12;
|
|
107
|
+
ctx.fillStyle = '#6366f1';
|
|
108
|
+
ctx.strokeStyle = '#6366f1';
|
|
109
|
+
ctx.lineWidth = 2;
|
|
110
|
+
ctx.beginPath();
|
|
111
|
+
// Centroid and expanded hull in network coordinates.
|
|
112
|
+
// The perimeter must follow hull order, not h.nodes order: tracing the
|
|
113
|
+
// raw member order self-intersects whenever the layout does not happen
|
|
114
|
+
// to place members in angular order, filling as crossed wedges.
|
|
115
|
+
const cx = positions.reduce((s, p) => s + p.x, 0) / positions.length;
|
|
116
|
+
const cy = positions.reduce((s, p) => s + p.y, 0) / positions.length;
|
|
117
|
+
const hull = convexHull(positions);
|
|
118
|
+
const expanded = hull.map(p => ({{
|
|
119
|
+
x: cx + (p.x - cx) * 1.15,
|
|
120
|
+
y: cy + (p.y - cy) * 1.15
|
|
121
|
+
}}));
|
|
122
|
+
ctx.moveTo(expanded[0].x, expanded[0].y);
|
|
123
|
+
expanded.slice(1).forEach(p => ctx.lineTo(p.x, p.y));
|
|
124
|
+
ctx.closePath();
|
|
125
|
+
ctx.fill();
|
|
126
|
+
ctx.globalAlpha = 0.4;
|
|
127
|
+
ctx.stroke();
|
|
128
|
+
// Label
|
|
129
|
+
ctx.globalAlpha = 0.8;
|
|
130
|
+
ctx.fillStyle = '#4f46e5';
|
|
131
|
+
ctx.font = 'bold 11px sans-serif';
|
|
132
|
+
ctx.textAlign = 'center';
|
|
133
|
+
ctx.fillText(h.label, cx, cy - 5);
|
|
134
|
+
ctx.restore();
|
|
135
|
+
}});
|
|
136
|
+
}});
|
|
137
|
+
</script>"""
|
|
138
|
+
|
|
139
|
+
def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str:
|
|
140
|
+
return f"""<script>
|
|
141
|
+
const RAW_NODES = {nodes_json};
|
|
142
|
+
const RAW_EDGES = {edges_json};
|
|
143
|
+
const LEGEND = {legend_json};
|
|
144
|
+
|
|
145
|
+
// HTML-escape helper — prevents XSS when injecting graph data into innerHTML
|
|
146
|
+
function esc(s) {{
|
|
147
|
+
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
148
|
+
}}
|
|
149
|
+
|
|
150
|
+
// Build vis datasets
|
|
151
|
+
const nodesDS = new vis.DataSet(RAW_NODES.map(n => ({{
|
|
152
|
+
id: n.id, label: n.label, color: n.color, size: n.size,
|
|
153
|
+
font: n.font, title: n.title,
|
|
154
|
+
_community: n.community, _community_name: n.community_name,
|
|
155
|
+
_source_file: n.source_file, _file_type: n.file_type, _degree: n.degree,
|
|
156
|
+
}})));
|
|
157
|
+
|
|
158
|
+
const edgesDS = new vis.DataSet(RAW_EDGES.map((e, i) => ({{
|
|
159
|
+
id: i, from: e.from, to: e.to,
|
|
160
|
+
label: '',
|
|
161
|
+
title: e.title,
|
|
162
|
+
dashes: e.dashes,
|
|
163
|
+
width: e.width,
|
|
164
|
+
color: e.color,
|
|
165
|
+
arrows: {{ to: {{ enabled: true, scaleFactor: 0.5 }} }},
|
|
166
|
+
}})));
|
|
167
|
+
|
|
168
|
+
const container = document.getElementById('graph');
|
|
169
|
+
const network = new vis.Network(container, {{ nodes: nodesDS, edges: edgesDS }}, {{
|
|
170
|
+
physics: {{
|
|
171
|
+
enabled: true,
|
|
172
|
+
solver: 'forceAtlas2Based',
|
|
173
|
+
forceAtlas2Based: {{
|
|
174
|
+
gravitationalConstant: -60,
|
|
175
|
+
centralGravity: 0.005,
|
|
176
|
+
springLength: 120,
|
|
177
|
+
springConstant: 0.08,
|
|
178
|
+
damping: 0.4,
|
|
179
|
+
avoidOverlap: 0.8,
|
|
180
|
+
}},
|
|
181
|
+
stabilization: {{ iterations: 200, fit: true }},
|
|
182
|
+
}},
|
|
183
|
+
interaction: {{
|
|
184
|
+
hover: true,
|
|
185
|
+
tooltipDelay: 100,
|
|
186
|
+
hideEdgesOnDrag: true,
|
|
187
|
+
navigationButtons: false,
|
|
188
|
+
keyboard: false,
|
|
189
|
+
}},
|
|
190
|
+
nodes: {{ shape: 'dot', borderWidth: 1.5 }},
|
|
191
|
+
edges: {{ smooth: {{ type: 'continuous', roundness: 0.2 }}, selectionWidth: 3 }},
|
|
192
|
+
}});
|
|
193
|
+
|
|
194
|
+
network.once('stabilizationIterationsDone', () => {{
|
|
195
|
+
network.setOptions({{ physics: {{ enabled: false }} }});
|
|
196
|
+
}});
|
|
197
|
+
|
|
198
|
+
function showInfo(nodeId) {{
|
|
199
|
+
const n = nodesDS.get(nodeId);
|
|
200
|
+
if (!n) return;
|
|
201
|
+
const neighborIds = network.getConnectedNodes(nodeId);
|
|
202
|
+
const neighborItems = neighborIds.map(nid => {{
|
|
203
|
+
const nb = nodesDS.get(nid);
|
|
204
|
+
const color = nb ? nb.color.background : '#555';
|
|
205
|
+
return `<span class="neighbor-link" style="border-left-color:${{esc(color)}}" data-nid="${{esc(nid)}}">${{esc(nb ? nb.label : nid)}}</span>`;
|
|
206
|
+
}}).join('');
|
|
207
|
+
document.getElementById('info-content').innerHTML = `
|
|
208
|
+
<div class="field"><b>${{esc(n.label)}}</b></div>
|
|
209
|
+
<div class="field">Type: ${{esc(n._file_type || 'unknown')}}</div>
|
|
210
|
+
<div class="field">Community: ${{esc(n._community_name)}}</div>
|
|
211
|
+
<div class="field">Source: ${{esc(n._source_file || '-')}}</div>
|
|
212
|
+
<div class="field">Degree: ${{n._degree}}</div>
|
|
213
|
+
${{neighborIds.length ? `<div class="field" style="margin-top:8px;color:#aaa;font-size:11px">Neighbors (${{neighborIds.length}})</div><div id="neighbors-list">${{neighborItems}}</div>` : ''}}
|
|
214
|
+
`;
|
|
215
|
+
}}
|
|
216
|
+
|
|
217
|
+
function focusNode(nodeId) {{
|
|
218
|
+
network.focus(nodeId, {{ scale: 1.4, animation: true }});
|
|
219
|
+
network.selectNodes([nodeId]);
|
|
220
|
+
showInfo(nodeId);
|
|
221
|
+
}}
|
|
222
|
+
|
|
223
|
+
// Neighbor links use a data attribute + one delegated listener rather than an
|
|
224
|
+
// inline onclick. A node id/label sourced from a document or a scraped URL
|
|
225
|
+
// (graphify add) can contain a double-quote; dropping the stringified id
|
|
226
|
+
// unescaped into a quoted onclick both broke every link and allowed a hostile
|
|
227
|
+
// source to inject an event handler into the local report (stored XSS, #1838).
|
|
228
|
+
// esc() on data-nid keeps the value inside the attribute; the listener reads it
|
|
229
|
+
// back verbatim. Bound to document so it survives the innerHTML rebuild that
|
|
230
|
+
// recreates #neighbors-list on each showInfo().
|
|
231
|
+
document.addEventListener('click', e => {{
|
|
232
|
+
const el = e.target.closest('.neighbor-link');
|
|
233
|
+
if (el && el.dataset.nid !== undefined) focusNode(el.dataset.nid);
|
|
234
|
+
}});
|
|
235
|
+
|
|
236
|
+
// Track hovered node — hover detection is more reliable than click params
|
|
237
|
+
let hoveredNodeId = null;
|
|
238
|
+
network.on('hoverNode', params => {{
|
|
239
|
+
hoveredNodeId = params.node;
|
|
240
|
+
container.style.cursor = 'pointer';
|
|
241
|
+
}});
|
|
242
|
+
network.on('blurNode', () => {{
|
|
243
|
+
hoveredNodeId = null;
|
|
244
|
+
container.style.cursor = 'default';
|
|
245
|
+
}});
|
|
246
|
+
container.addEventListener('click', () => {{
|
|
247
|
+
if (hoveredNodeId !== null) {{
|
|
248
|
+
showInfo(hoveredNodeId);
|
|
249
|
+
network.selectNodes([hoveredNodeId]);
|
|
250
|
+
}}
|
|
251
|
+
}});
|
|
252
|
+
network.on('click', params => {{
|
|
253
|
+
if (params.nodes.length > 0) {{
|
|
254
|
+
showInfo(params.nodes[0]);
|
|
255
|
+
}} else if (hoveredNodeId === null) {{
|
|
256
|
+
document.getElementById('info-content').innerHTML = '<span class="empty">Click a node to inspect it</span>';
|
|
257
|
+
}}
|
|
258
|
+
}});
|
|
259
|
+
|
|
260
|
+
const searchInput = document.getElementById('search');
|
|
261
|
+
const searchResults = document.getElementById('search-results');
|
|
262
|
+
searchInput.addEventListener('input', () => {{
|
|
263
|
+
const q = searchInput.value.toLowerCase().trim();
|
|
264
|
+
searchResults.innerHTML = '';
|
|
265
|
+
if (!q) {{ searchResults.style.display = 'none'; return; }}
|
|
266
|
+
const matches = RAW_NODES.filter(n => n.label.toLowerCase().includes(q)).slice(0, 20);
|
|
267
|
+
if (!matches.length) {{ searchResults.style.display = 'none'; return; }}
|
|
268
|
+
searchResults.style.display = 'block';
|
|
269
|
+
matches.forEach(n => {{
|
|
270
|
+
const el = document.createElement('div');
|
|
271
|
+
el.className = 'search-item';
|
|
272
|
+
el.textContent = n.label;
|
|
273
|
+
el.style.borderLeft = `3px solid ${{n.color.background}}`;
|
|
274
|
+
el.style.paddingLeft = '8px';
|
|
275
|
+
el.onclick = () => {{
|
|
276
|
+
network.focus(n.id, {{ scale: 1.5, animation: true }});
|
|
277
|
+
network.selectNodes([n.id]);
|
|
278
|
+
showInfo(n.id);
|
|
279
|
+
searchResults.style.display = 'none';
|
|
280
|
+
searchInput.value = '';
|
|
281
|
+
}};
|
|
282
|
+
searchResults.appendChild(el);
|
|
283
|
+
}});
|
|
284
|
+
}});
|
|
285
|
+
document.addEventListener('click', e => {{
|
|
286
|
+
if (!searchResults.contains(e.target) && e.target !== searchInput)
|
|
287
|
+
searchResults.style.display = 'none';
|
|
288
|
+
}});
|
|
289
|
+
|
|
290
|
+
const hiddenCommunities = new Set();
|
|
291
|
+
|
|
292
|
+
const selectAllCb = document.getElementById('select-all-cb');
|
|
293
|
+
|
|
294
|
+
function updateSelectAllState() {{
|
|
295
|
+
const total = LEGEND.length;
|
|
296
|
+
const hidden = hiddenCommunities.size;
|
|
297
|
+
selectAllCb.checked = hidden === 0;
|
|
298
|
+
selectAllCb.indeterminate = hidden > 0 && hidden < total;
|
|
299
|
+
}}
|
|
300
|
+
|
|
301
|
+
function toggleAllCommunities(hide) {{
|
|
302
|
+
document.querySelectorAll('.legend-item').forEach(item => {{
|
|
303
|
+
hide ? item.classList.add('dimmed') : item.classList.remove('dimmed');
|
|
304
|
+
}});
|
|
305
|
+
document.querySelectorAll('.legend-cb').forEach(cb => {{
|
|
306
|
+
cb.checked = !hide;
|
|
307
|
+
}});
|
|
308
|
+
LEGEND.forEach(c => {{
|
|
309
|
+
if (hide) hiddenCommunities.add(c.cid); else hiddenCommunities.delete(c.cid);
|
|
310
|
+
}});
|
|
311
|
+
const updates = RAW_NODES.map(n => ({{ id: n.id, hidden: hide }}));
|
|
312
|
+
nodesDS.update(updates);
|
|
313
|
+
updateSelectAllState();
|
|
314
|
+
}}
|
|
315
|
+
|
|
316
|
+
const legendEl = document.getElementById('legend');
|
|
317
|
+
LEGEND.forEach(c => {{
|
|
318
|
+
const item = document.createElement('div');
|
|
319
|
+
item.className = 'legend-item';
|
|
320
|
+
const cb = document.createElement('input');
|
|
321
|
+
cb.type = 'checkbox';
|
|
322
|
+
cb.className = 'legend-cb';
|
|
323
|
+
cb.checked = true;
|
|
324
|
+
cb.addEventListener('change', (e) => {{
|
|
325
|
+
e.stopPropagation();
|
|
326
|
+
if (cb.checked) {{
|
|
327
|
+
hiddenCommunities.delete(c.cid);
|
|
328
|
+
item.classList.remove('dimmed');
|
|
329
|
+
}} else {{
|
|
330
|
+
hiddenCommunities.add(c.cid);
|
|
331
|
+
item.classList.add('dimmed');
|
|
332
|
+
}}
|
|
333
|
+
const updates = RAW_NODES
|
|
334
|
+
.filter(n => n.community === c.cid)
|
|
335
|
+
.map(n => ({{ id: n.id, hidden: !cb.checked }}));
|
|
336
|
+
nodesDS.update(updates);
|
|
337
|
+
updateSelectAllState();
|
|
338
|
+
}});
|
|
339
|
+
item.innerHTML = `<div class="legend-dot" style="background:${{c.color}}"></div>
|
|
340
|
+
<span class="legend-label">${{c.label}}</span>
|
|
341
|
+
<span class="legend-count">${{c.count}}</span>`;
|
|
342
|
+
item.prepend(cb);
|
|
343
|
+
item.onclick = (e) => {{
|
|
344
|
+
if (e.target === cb) return;
|
|
345
|
+
cb.checked = !cb.checked;
|
|
346
|
+
cb.dispatchEvent(new Event('change'));
|
|
347
|
+
}};
|
|
348
|
+
legendEl.appendChild(item);
|
|
349
|
+
}});
|
|
350
|
+
</script>"""
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _html_document_title(output_path: str) -> str:
|
|
354
|
+
"""Return a portable label for the graph.html <title>.
|
|
355
|
+
|
|
356
|
+
Tracked artifacts must not embed the generator host absolute path
|
|
357
|
+
(regression of #433; reported again as #2598 on Windows). Keep from the
|
|
358
|
+
configured output-dir bare name (``graphify-out`` / ``GRAPHIFY_OUT``
|
|
359
|
+
basename) onward — portable in every case; otherwise fall back to a
|
|
360
|
+
cwd-relative label, and finally the filename only.
|
|
361
|
+
"""
|
|
362
|
+
from graphify.paths import GRAPHIFY_OUT_NAME
|
|
363
|
+
|
|
364
|
+
raw = str(output_path).replace("\\", "/")
|
|
365
|
+
# Drop Windows drive prefix so Path parts are comparable on any OS.
|
|
366
|
+
if len(raw) >= 3 and raw[1] == ":" and raw[0].isalpha() and raw[2] == "/":
|
|
367
|
+
raw = raw[2:] # "/Users/..." style after drive strip
|
|
368
|
+
p = Path(raw)
|
|
369
|
+
|
|
370
|
+
parts = list(Path(raw).parts)
|
|
371
|
+
# Path("C:/Users/..") on POSIX may keep "C:" as first part — strip it.
|
|
372
|
+
if parts and len(parts[0]) == 2 and parts[0][1] == ":" and parts[0][0].isalpha():
|
|
373
|
+
parts = parts[1:]
|
|
374
|
+
# Prefer keeping from the output-dir marker onward: portable in every
|
|
375
|
+
# case, whereas a cwd-relative path still leaks host/user segments when
|
|
376
|
+
# the graph is built from a directory ABOVE the project (#2598 follow-up).
|
|
377
|
+
marker = GRAPHIFY_OUT_NAME
|
|
378
|
+
for i, part in enumerate(parts):
|
|
379
|
+
if part == marker or part.startswith("graphify-out"):
|
|
380
|
+
return "/".join(parts[i:])
|
|
381
|
+
|
|
382
|
+
# No standard out-dir marker (fully custom output path): fall back to a
|
|
383
|
+
# cwd-relative label when the target is under cwd, else the bare filename.
|
|
384
|
+
try:
|
|
385
|
+
resolved = p if p.is_absolute() else (Path.cwd() / p)
|
|
386
|
+
rel = resolved.resolve().relative_to(Path.cwd().resolve())
|
|
387
|
+
label = rel.as_posix()
|
|
388
|
+
if label and label != ".":
|
|
389
|
+
return label
|
|
390
|
+
except (ValueError, OSError, RuntimeError):
|
|
391
|
+
pass
|
|
392
|
+
|
|
393
|
+
name = p.name
|
|
394
|
+
return name if name else "graph.html"
|
|
395
|
+
|
|
396
|
+
def to_html(
|
|
397
|
+
G: nx.Graph,
|
|
398
|
+
communities: dict[int, list[str]],
|
|
399
|
+
output_path: str,
|
|
400
|
+
community_labels: dict[int, str] | None = None,
|
|
401
|
+
member_counts: dict[int, int] | None = None,
|
|
402
|
+
node_limit: int | None = None,
|
|
403
|
+
learning_overlay: dict | None = None,
|
|
404
|
+
) -> bool:
|
|
405
|
+
"""Generate an interactive vis.js HTML visualization of the graph.
|
|
406
|
+
|
|
407
|
+
Features: node size by degree, click-to-inspect panel, search box,
|
|
408
|
+
community filter, physics clustering by community, confidence-styled edges.
|
|
409
|
+
Raises ValueError if graph exceeds MAX_NODES_FOR_VIZ.
|
|
410
|
+
|
|
411
|
+
If member_counts is provided (aggregated community view), node sizes are
|
|
412
|
+
based on community member counts rather than graph degree.
|
|
413
|
+
|
|
414
|
+
If node_limit is set and the graph exceeds it, automatically builds an
|
|
415
|
+
aggregated community-level meta-graph instead of raising ValueError.
|
|
416
|
+
|
|
417
|
+
Returns True when the output was written. Returns False when an aggregated
|
|
418
|
+
view would contain fewer than two communities and is intentionally skipped.
|
|
419
|
+
"""
|
|
420
|
+
limit = node_limit if node_limit is not None else _viz_node_limit()
|
|
421
|
+
if G.number_of_nodes() > limit:
|
|
422
|
+
if node_limit is not None:
|
|
423
|
+
# Build aggregated community meta-graph
|
|
424
|
+
from collections import Counter as _Counter
|
|
425
|
+
import networkx as _nx
|
|
426
|
+
print(f"Graph has {G.number_of_nodes()} nodes (above {limit} limit). Building aggregated community view...")
|
|
427
|
+
node_to_community = {nid: cid for cid, members in communities.items() for nid in members}
|
|
428
|
+
meta = _nx.Graph()
|
|
429
|
+
for cid, members in communities.items():
|
|
430
|
+
meta.add_node(str(cid), label=(community_labels or {}).get(cid, f"Community {cid}"))
|
|
431
|
+
edge_counts = _Counter()
|
|
432
|
+
for u, v in G.edges():
|
|
433
|
+
cu, cv = node_to_community.get(u), node_to_community.get(v)
|
|
434
|
+
if cu is not None and cv is not None and cu != cv:
|
|
435
|
+
edge_counts[(min(cu, cv), max(cu, cv))] += 1
|
|
436
|
+
for (cu, cv), w in edge_counts.items():
|
|
437
|
+
meta.add_edge(str(cu), str(cv), weight=w,
|
|
438
|
+
relation=f"{w} cross-community edges", confidence="AGGREGATED")
|
|
439
|
+
if meta.number_of_nodes() <= 1:
|
|
440
|
+
print("Single community - aggregated view not useful. Skipping graph.html.")
|
|
441
|
+
return False
|
|
442
|
+
meta_communities = {cid: [str(cid)] for cid in communities}
|
|
443
|
+
mc = {cid: len(members) for cid, members in communities.items()}
|
|
444
|
+
# Remap hyperedges from semantic node IDs to community IDs
|
|
445
|
+
raw_hyperedges = G.graph.get("hyperedges", [])
|
|
446
|
+
if raw_hyperedges:
|
|
447
|
+
remapped = []
|
|
448
|
+
for he in raw_hyperedges:
|
|
449
|
+
he_members = he.get("nodes", [])
|
|
450
|
+
comm_ids, seen = [], set()
|
|
451
|
+
for nid in he_members:
|
|
452
|
+
c = node_to_community.get(nid)
|
|
453
|
+
if c is None:
|
|
454
|
+
continue
|
|
455
|
+
s = str(c)
|
|
456
|
+
if s in seen:
|
|
457
|
+
continue
|
|
458
|
+
seen.add(s)
|
|
459
|
+
comm_ids.append(s)
|
|
460
|
+
if len(comm_ids) < 2:
|
|
461
|
+
continue
|
|
462
|
+
remapped.append({
|
|
463
|
+
"id": he.get("id", ""),
|
|
464
|
+
"label": he.get("label") or he.get("relation", "").replace("_", " "),
|
|
465
|
+
"nodes": comm_ids,
|
|
466
|
+
})
|
|
467
|
+
meta.graph["hyperedges"] = remapped
|
|
468
|
+
written = to_html(meta, meta_communities, output_path,
|
|
469
|
+
community_labels=community_labels, member_counts=mc)
|
|
470
|
+
if not written:
|
|
471
|
+
return False
|
|
472
|
+
print(f"graph.html written (aggregated: {meta.number_of_nodes()} community nodes, {meta.number_of_edges()} cross-community edges)")
|
|
473
|
+
print("Tip: run with --obsidian for full node-level detail.")
|
|
474
|
+
return True
|
|
475
|
+
raise ValueError(
|
|
476
|
+
f"Graph has {G.number_of_nodes()} nodes - too large for HTML viz "
|
|
477
|
+
f"(limit: {limit}). Use --no-viz, raise GRAPHIFY_VIZ_NODE_LIMIT, "
|
|
478
|
+
f"or reduce input size."
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
node_community = _node_community_map(communities)
|
|
482
|
+
degree = dict(G.degree())
|
|
483
|
+
max_deg = max(degree.values(), default=1) or 1
|
|
484
|
+
max_mc = (max(member_counts.values(), default=1) or 1) if member_counts else 1
|
|
485
|
+
|
|
486
|
+
# Work-memory overlay (derived sidecar). When not passed explicitly, load it
|
|
487
|
+
# best-effort from the sibling .graphify_learning.json next to the output
|
|
488
|
+
# graph.html (which lives beside graph.json). Empty/missing => no learning
|
|
489
|
+
# fields, so the un-annotated render is byte-identical to pre-feature.
|
|
490
|
+
if learning_overlay is None:
|
|
491
|
+
learning_overlay = {}
|
|
492
|
+
try:
|
|
493
|
+
from graphify.reflect import load_learning_overlay as _llo
|
|
494
|
+
learning_overlay = _llo(Path(output_path))
|
|
495
|
+
except Exception:
|
|
496
|
+
learning_overlay = {}
|
|
497
|
+
# Status -> ring color. preferred=green, contested=amber. Tentative gets no
|
|
498
|
+
# ring (it's not yet trustworthy enough to highlight in the map).
|
|
499
|
+
_RING = {"preferred": "#22c55e", "contested": "#f59e0b"}
|
|
500
|
+
|
|
501
|
+
# Build nodes list for vis.js
|
|
502
|
+
vis_nodes = []
|
|
503
|
+
for node_id, data in G.nodes(data=True):
|
|
504
|
+
cid = node_community.get(node_id, 0)
|
|
505
|
+
color = COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)]
|
|
506
|
+
label = sanitize_label(data.get("label", node_id))
|
|
507
|
+
deg = degree.get(node_id, 1)
|
|
508
|
+
if member_counts:
|
|
509
|
+
mc = member_counts.get(cid, 1)
|
|
510
|
+
size = 10 + 30 * (mc / max_mc)
|
|
511
|
+
font_size = 12
|
|
512
|
+
else:
|
|
513
|
+
size = 10 + 30 * (deg / max_deg)
|
|
514
|
+
# Only show label for high-degree nodes by default; others show on hover
|
|
515
|
+
font_size = 12 if deg >= max_deg * 0.15 else 0
|
|
516
|
+
node = {
|
|
517
|
+
"id": node_id,
|
|
518
|
+
"label": label,
|
|
519
|
+
"color": {"background": color, "border": color, "highlight": {"background": "#ffffff", "border": color}},
|
|
520
|
+
"size": round(size, 1),
|
|
521
|
+
"font": {"size": font_size, "color": "#ffffff"},
|
|
522
|
+
"title": _html.escape(label),
|
|
523
|
+
"community": cid,
|
|
524
|
+
"community_name": sanitize_label((community_labels or {}).get(cid, f"Community {cid}")),
|
|
525
|
+
"source_file": sanitize_label(str(data.get("source_file") or "")),
|
|
526
|
+
"file_type": data.get("file_type", ""),
|
|
527
|
+
"degree": deg,
|
|
528
|
+
}
|
|
529
|
+
# Conditional learning fields — only present for annotated nodes, so
|
|
530
|
+
# un-annotated output keeps the exact pre-feature node dict shape.
|
|
531
|
+
entry = learning_overlay.get(str(node_id)) if learning_overlay else None
|
|
532
|
+
if entry:
|
|
533
|
+
status = sanitize_label(str(entry.get("status", "")))
|
|
534
|
+
stale = bool(entry.get("stale"))
|
|
535
|
+
node["learning_status"] = status
|
|
536
|
+
node["learning_stale"] = stale
|
|
537
|
+
ring = _RING.get(status)
|
|
538
|
+
if ring:
|
|
539
|
+
# Status-colored ring via the border; stale => desaturated +
|
|
540
|
+
# dashed (vis.js supports per-node `shapeProperties.borderDashes`).
|
|
541
|
+
if stale:
|
|
542
|
+
ring = "#9ca3af"
|
|
543
|
+
node["shapeProperties"] = {"borderDashes": [4, 4]}
|
|
544
|
+
node["borderWidth"] = 3
|
|
545
|
+
node["color"] = {
|
|
546
|
+
"background": color, "border": ring,
|
|
547
|
+
"highlight": {"background": "#ffffff", "border": ring},
|
|
548
|
+
}
|
|
549
|
+
# Lesson line appended to the hover title.
|
|
550
|
+
if status == "contested":
|
|
551
|
+
lesson = f"Lesson: contested (useful {entry.get('uses', 0)} / dead-end {entry.get('neg', 0)})"
|
|
552
|
+
elif status == "preferred":
|
|
553
|
+
lesson = f"Lesson: preferred source ({entry.get('uses', 0)} useful, score={entry.get('score', 0)})"
|
|
554
|
+
else:
|
|
555
|
+
lesson = f"Lesson: {status} ({entry.get('uses', 0)} useful)"
|
|
556
|
+
if stale:
|
|
557
|
+
lesson += " [code changed — re-verify]"
|
|
558
|
+
node["title"] = _html.escape(label) + "\n" + _html.escape(sanitize_label(lesson))
|
|
559
|
+
vis_nodes.append(node)
|
|
560
|
+
|
|
561
|
+
# Build edges list. Restore original edge direction from _src/_tgt
|
|
562
|
+
# (stashed by build.py for exactly this reason): undirected NetworkX
|
|
563
|
+
# canonicalizes endpoint order, which would otherwise flip the arrow
|
|
564
|
+
# for `calls` and `rationale_for` in the rendered graph (#563).
|
|
565
|
+
vis_edges = []
|
|
566
|
+
for u, v, data in G.edges(data=True):
|
|
567
|
+
confidence = data.get("confidence", "EXTRACTED")
|
|
568
|
+
relation = data.get("relation", "")
|
|
569
|
+
true_src = data.get("_src", u)
|
|
570
|
+
true_tgt = data.get("_tgt", v)
|
|
571
|
+
vis_edges.append({
|
|
572
|
+
"from": true_src,
|
|
573
|
+
"to": true_tgt,
|
|
574
|
+
"label": relation,
|
|
575
|
+
"title": _html.escape(f"{relation} [{confidence}]"),
|
|
576
|
+
"dashes": confidence != "EXTRACTED",
|
|
577
|
+
"width": 2 if confidence == "EXTRACTED" else 1,
|
|
578
|
+
"color": {"opacity": 0.7 if confidence == "EXTRACTED" else 0.35},
|
|
579
|
+
"confidence": confidence,
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
# Build community legend data
|
|
583
|
+
legend_data = []
|
|
584
|
+
for cid in sorted((community_labels or {}).keys()):
|
|
585
|
+
color = COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)]
|
|
586
|
+
lbl = _html.escape(sanitize_label((community_labels or {}).get(cid, f"Community {cid}")))
|
|
587
|
+
n = member_counts.get(cid, len(communities.get(cid, []))) if member_counts else len(communities.get(cid, []))
|
|
588
|
+
legend_data.append({"cid": cid, "color": color, "label": lbl, "count": n})
|
|
589
|
+
|
|
590
|
+
# Escape </script> sequences so embedded JSON cannot break out of the script tag
|
|
591
|
+
def _js_safe(obj) -> str:
|
|
592
|
+
return json.dumps(obj).replace("</", "<\\/")
|
|
593
|
+
|
|
594
|
+
nodes_json = _js_safe(vis_nodes)
|
|
595
|
+
edges_json = _js_safe(vis_edges)
|
|
596
|
+
legend_json = _js_safe(legend_data)
|
|
597
|
+
hyperedges_json = _js_safe(getattr(G, "graph", {}).get("hyperedges", []))
|
|
598
|
+
title = _html.escape(sanitize_label(_html_document_title(output_path)))
|
|
599
|
+
stats = f"{G.number_of_nodes()} nodes · {G.number_of_edges()} edges · {len(communities)} communities"
|
|
600
|
+
|
|
601
|
+
html = f"""<!DOCTYPE html>
|
|
602
|
+
<html lang="en">
|
|
603
|
+
<head>
|
|
604
|
+
<meta charset="UTF-8">
|
|
605
|
+
<title>graphify - {title}</title>
|
|
606
|
+
<script src="https://unpkg.com/vis-network@9.1.6/standalone/umd/vis-network.min.js"
|
|
607
|
+
integrity="sha384-Ux6phic9PEHJ38YtrijhkzyJ8yQlH8i/+buBR8s3mAZOJrP1gwyvAcIYl3GWtpX1"
|
|
608
|
+
crossorigin="anonymous"></script>
|
|
609
|
+
{_html_styles()}
|
|
610
|
+
</head>
|
|
611
|
+
<body>
|
|
612
|
+
<div id="graph"></div>
|
|
613
|
+
<div id="sidebar">
|
|
614
|
+
<div id="search-wrap">
|
|
615
|
+
<input id="search" type="text" placeholder="Search nodes..." autocomplete="off">
|
|
616
|
+
<div id="search-results"></div>
|
|
617
|
+
</div>
|
|
618
|
+
<div id="info-panel">
|
|
619
|
+
<h3>Node Info</h3>
|
|
620
|
+
<div id="info-content"><span class="empty">Click a node to inspect it</span></div>
|
|
621
|
+
</div>
|
|
622
|
+
<div id="legend-wrap">
|
|
623
|
+
<h3>Communities</h3>
|
|
624
|
+
<div id="legend-controls">
|
|
625
|
+
<label><input type="checkbox" id="select-all-cb" checked onchange="toggleAllCommunities(!this.checked)">Select All</label>
|
|
626
|
+
</div>
|
|
627
|
+
<div id="legend"></div>
|
|
628
|
+
</div>
|
|
629
|
+
<div id="stats">{stats}</div>
|
|
630
|
+
</div>
|
|
631
|
+
{_html_script(nodes_json, edges_json, legend_json)}
|
|
632
|
+
{_hyperedge_script(hyperedges_json)}
|
|
633
|
+
</body>
|
|
634
|
+
</html>"""
|
|
635
|
+
|
|
636
|
+
write_text_atomic(output_path, html)
|
|
637
|
+
return True
|