sidegraph 0.1.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.
- sidegraph/__init__.py +37 -0
- sidegraph/anchoring.py +246 -0
- sidegraph/bootstrap/__init__.py +49 -0
- sidegraph/bootstrap/apply.py +603 -0
- sidegraph/bootstrap/catalog.py +92 -0
- sidegraph/bootstrap/cli.py +827 -0
- sidegraph/bootstrap/integrations.py +184 -0
- sidegraph/bootstrap/model.py +277 -0
- sidegraph/bootstrap/planner.py +400 -0
- sidegraph/bootstrap/proof.py +103 -0
- sidegraph/bootstrap/review.py +331 -0
- sidegraph/bootstrap/scan.py +289 -0
- sidegraph/capture.py +1794 -0
- sidegraph/cli.py +1902 -0
- sidegraph/config.py +148 -0
- sidegraph/doc_import.py +2099 -0
- sidegraph/doctor.py +1429 -0
- sidegraph/domains.py +902 -0
- sidegraph/engine/__init__.py +7 -0
- sidegraph/engine/reader.py +353 -0
- sidegraph/gitio.py +572 -0
- sidegraph/host/__init__.py +7 -0
- sidegraph/host/hooks.py +770 -0
- sidegraph/importer.py +239 -0
- sidegraph/okf.py +471 -0
- sidegraph/profiles.py +459 -0
- sidegraph/retrieval.py +1657 -0
- sidegraph/schema.py +386 -0
- sidegraph/server.py +2608 -0
- sidegraph/store.py +3363 -0
- sidegraph/sync.py +885 -0
- sidegraph/verify.py +1040 -0
- sidegraph/viz/__init__.py +4 -0
- sidegraph/viz/assets/vis-network.min.js +33 -0
- sidegraph/viz/model.py +248 -0
- sidegraph/viz/render.py +110 -0
- sidegraph/viz/template.html +131 -0
- sidegraph-0.1.0.dist-info/METADATA +392 -0
- sidegraph-0.1.0.dist-info/RECORD +42 -0
- sidegraph-0.1.0.dist-info/WHEEL +4 -0
- sidegraph-0.1.0.dist-info/entry_points.txt +18 -0
- sidegraph-0.1.0.dist-info/licenses/LICENSE +201 -0
sidegraph/viz/model.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Assemble a semantic graph over the owned Decision/Fact records and their anchored
|
|
2
|
+
entities. Pure and portable: reads only :class:`~sidegraph.store.Store` — no engine reader,
|
|
3
|
+
no host specifics, no writes. :mod:`sidegraph.viz.render` turns this into HTML/JSON.
|
|
4
|
+
|
|
5
|
+
# see design/superpowers/specs/2026-07-12-decision-graph-viz-design.md
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from ..store import Store
|
|
13
|
+
|
|
14
|
+
_TERMINAL_STATUSES = frozenset({"superseded", "rejected", "deprecated"})
|
|
15
|
+
_PROBLEM_BINDING_STATUSES = frozenset({"degraded", "orphaned"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class VizNode:
|
|
20
|
+
id: str
|
|
21
|
+
type: str # "decision" | "fact" | "entity"
|
|
22
|
+
label: str
|
|
23
|
+
detail: dict[str, object] # full record fields for the click-to-inspect sidebar
|
|
24
|
+
kind: str | None = None # decision kind (adr/lesson/constraint/gotcha); else None
|
|
25
|
+
status: str | None = None # record status; None for entities
|
|
26
|
+
tier: int | None = None # entity primary tier (min over incident anchors); None for records
|
|
27
|
+
community: str | None = None # entity last_seen_community; None for records
|
|
28
|
+
degree: int = 0
|
|
29
|
+
dangling: bool = False # record node with no anchor binding at all (any status)
|
|
30
|
+
problem: bool = False # dangling OR incident to a degraded/orphaned binding
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class VizEdge:
|
|
35
|
+
source: str
|
|
36
|
+
target: str
|
|
37
|
+
kind: str # "anchor" | "supersedes" | "supports" | "domain-parent"
|
|
38
|
+
status: str | None = None # binding status for anchor edges
|
|
39
|
+
relation: str | None = None # binding relation for anchor edges
|
|
40
|
+
weight: float = 1.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class VizStats:
|
|
45
|
+
decisions: int = 0
|
|
46
|
+
facts: int = 0
|
|
47
|
+
entities: int = 0
|
|
48
|
+
orphaned_bindings: int = 0
|
|
49
|
+
degraded_bindings: int = 0
|
|
50
|
+
dangling_records: int = 0
|
|
51
|
+
supersede_links: int = 0
|
|
52
|
+
truncated: int = 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class VizGraph:
|
|
57
|
+
nodes: list[VizNode] = field(default_factory=list)
|
|
58
|
+
edges: list[VizEdge] = field(default_factory=list)
|
|
59
|
+
stats: VizStats = field(default_factory=VizStats)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _short(text: str, limit: int = 60) -> str:
|
|
63
|
+
stripped = text.strip()
|
|
64
|
+
line = stripped.splitlines()[0] if stripped else ""
|
|
65
|
+
return line if len(line) <= limit else line[: limit - 1] + "…"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _recompute_degree(nodes: dict[str, VizNode], edges: list[VizEdge]) -> None:
|
|
69
|
+
for n in nodes.values():
|
|
70
|
+
n.degree = 0
|
|
71
|
+
for e in edges:
|
|
72
|
+
nodes[e.source].degree += 1
|
|
73
|
+
nodes[e.target].degree += 1
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_graph(
|
|
77
|
+
store: Store,
|
|
78
|
+
*,
|
|
79
|
+
include_superseded: bool = True,
|
|
80
|
+
only_problems: bool = False,
|
|
81
|
+
max_nodes: int = 800,
|
|
82
|
+
) -> VizGraph:
|
|
83
|
+
"""Build a :class:`VizGraph` from the store. See module docstring."""
|
|
84
|
+
nodes: dict[str, VizNode] = {}
|
|
85
|
+
edges: list[VizEdge] = []
|
|
86
|
+
referenced_entities: dict[str, int] = {} # entity_id -> min tier seen
|
|
87
|
+
|
|
88
|
+
def _keep(status: str) -> bool:
|
|
89
|
+
return include_superseded or status not in _TERMINAL_STATUSES
|
|
90
|
+
|
|
91
|
+
def _anchor_edges(record_id: str) -> None:
|
|
92
|
+
for b in store.bindings_for_record(record_id):
|
|
93
|
+
edges.append(
|
|
94
|
+
VizEdge(
|
|
95
|
+
source=record_id,
|
|
96
|
+
target=b.entity_id,
|
|
97
|
+
kind="anchor",
|
|
98
|
+
status=b.status,
|
|
99
|
+
relation=b.relation,
|
|
100
|
+
weight=b.weight,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
prev = referenced_entities.get(b.entity_id)
|
|
104
|
+
referenced_entities[b.entity_id] = b.tier if prev is None else min(prev, b.tier)
|
|
105
|
+
|
|
106
|
+
# --- decision & fact record nodes + their anchor / structural edges ---
|
|
107
|
+
for d in store.iter_decisions():
|
|
108
|
+
if not _keep(d.status.value):
|
|
109
|
+
continue
|
|
110
|
+
nodes[d.id] = VizNode(
|
|
111
|
+
id=d.id,
|
|
112
|
+
type="decision",
|
|
113
|
+
label=_short(d.title),
|
|
114
|
+
kind=d.kind.value,
|
|
115
|
+
status=d.status.value,
|
|
116
|
+
detail={
|
|
117
|
+
"title": d.title,
|
|
118
|
+
"kind": d.kind.value,
|
|
119
|
+
"status": d.status.value,
|
|
120
|
+
"context": d.context,
|
|
121
|
+
"choice": d.choice,
|
|
122
|
+
"rejected": d.rejected,
|
|
123
|
+
"consequences": d.consequences,
|
|
124
|
+
"scope": d.scope.value,
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
_anchor_edges(d.id)
|
|
128
|
+
if d.supersedes:
|
|
129
|
+
edges.append(VizEdge(source=d.id, target=d.supersedes, kind="supersedes"))
|
|
130
|
+
|
|
131
|
+
for f in store.iter_facts():
|
|
132
|
+
if not _keep(f.status.value):
|
|
133
|
+
continue
|
|
134
|
+
nodes[f.id] = VizNode(
|
|
135
|
+
id=f.id,
|
|
136
|
+
type="fact",
|
|
137
|
+
label=_short(f.statement),
|
|
138
|
+
status=f.status.value,
|
|
139
|
+
detail={
|
|
140
|
+
"statement": f.statement,
|
|
141
|
+
"source": f.source,
|
|
142
|
+
"supports": list(f.supports),
|
|
143
|
+
"status": f.status.value,
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
_anchor_edges(f.id)
|
|
147
|
+
if f.supersedes:
|
|
148
|
+
edges.append(VizEdge(source=f.id, target=f.supersedes, kind="supersedes"))
|
|
149
|
+
for dec_id in f.supports:
|
|
150
|
+
edges.append(VizEdge(source=f.id, target=dec_id, kind="supports"))
|
|
151
|
+
|
|
152
|
+
# --- entity nodes (only those referenced by a binding) ---
|
|
153
|
+
for entity_id, tier in referenced_entities.items():
|
|
154
|
+
ent = store.get_entity(entity_id)
|
|
155
|
+
if ent is None:
|
|
156
|
+
continue
|
|
157
|
+
label = ent.descriptor.name if ent.descriptor else ent.canonical_name
|
|
158
|
+
nodes[entity_id] = VizNode(
|
|
159
|
+
id=entity_id,
|
|
160
|
+
type="entity",
|
|
161
|
+
label=label,
|
|
162
|
+
tier=tier,
|
|
163
|
+
community=ent.last_seen_community,
|
|
164
|
+
detail={
|
|
165
|
+
"canonical_name": ent.canonical_name,
|
|
166
|
+
"kind": ent.kind.value,
|
|
167
|
+
"file_path": ent.descriptor.file_path if ent.descriptor else None,
|
|
168
|
+
"last_seen_community": ent.last_seen_community,
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# --- domain parent hierarchy (between the domain: entities already drawn) ---
|
|
173
|
+
domains = list(store.iter_domains())
|
|
174
|
+
slug_by_domain_id = {dm.domain_id: dm.slug for dm in domains}
|
|
175
|
+
entity_by_domain_slug: dict[str, str] = {}
|
|
176
|
+
for nid, n in nodes.items():
|
|
177
|
+
cname = n.detail.get("canonical_name")
|
|
178
|
+
if n.type == "entity" and isinstance(cname, str) and cname.startswith("domain:"):
|
|
179
|
+
entity_by_domain_slug[cname[len("domain:") :]] = nid
|
|
180
|
+
for dm in domains:
|
|
181
|
+
if not dm.parent_id or dm.parent_id not in slug_by_domain_id:
|
|
182
|
+
continue
|
|
183
|
+
child = entity_by_domain_slug.get(dm.slug)
|
|
184
|
+
parent = entity_by_domain_slug.get(slug_by_domain_id[dm.parent_id])
|
|
185
|
+
if child and parent:
|
|
186
|
+
edges.append(VizEdge(source=child, target=parent, kind="domain-parent"))
|
|
187
|
+
|
|
188
|
+
# --- prune edges to surviving endpoints ---
|
|
189
|
+
edges = [e for e in edges if e.source in nodes and e.target in nodes]
|
|
190
|
+
|
|
191
|
+
# --- dangling + problem flags ---
|
|
192
|
+
# "dangling" = zero anchor bindings at all; a degraded/orphaned binding is a separate
|
|
193
|
+
# "problem" signal (an anchored record whose binding decayed is not unanchored).
|
|
194
|
+
has_binding: set[str] = set()
|
|
195
|
+
incident_problem: set[str] = set()
|
|
196
|
+
for e in edges:
|
|
197
|
+
if e.kind == "anchor":
|
|
198
|
+
has_binding.add(e.source)
|
|
199
|
+
if e.status in _PROBLEM_BINDING_STATUSES:
|
|
200
|
+
incident_problem.add(e.source)
|
|
201
|
+
incident_problem.add(e.target)
|
|
202
|
+
for nid, n in nodes.items():
|
|
203
|
+
if n.type in ("decision", "fact"):
|
|
204
|
+
n.dangling = nid not in has_binding
|
|
205
|
+
n.problem = n.dangling or nid in incident_problem
|
|
206
|
+
|
|
207
|
+
# --- only-problems filter (problem nodes + 1-hop context) ---
|
|
208
|
+
if only_problems:
|
|
209
|
+
problem_nodes = {nid for nid, n in nodes.items() if n.problem}
|
|
210
|
+
keep = set(problem_nodes)
|
|
211
|
+
for e in edges:
|
|
212
|
+
if e.source in problem_nodes or e.target in problem_nodes:
|
|
213
|
+
keep.add(e.source)
|
|
214
|
+
keep.add(e.target)
|
|
215
|
+
nodes = {nid: n for nid, n in nodes.items() if nid in keep}
|
|
216
|
+
edges = [e for e in edges if e.source in nodes and e.target in nodes]
|
|
217
|
+
|
|
218
|
+
_recompute_degree(nodes, edges)
|
|
219
|
+
|
|
220
|
+
# --- max-nodes truncation (deterministic priority; never silent) ---
|
|
221
|
+
stats = VizStats()
|
|
222
|
+
if len(nodes) > max_nodes:
|
|
223
|
+
|
|
224
|
+
def _priority(n: VizNode) -> tuple[int, int, int, int, str]:
|
|
225
|
+
terminal = 1 if (n.status in _TERMINAL_STATUSES) else 0
|
|
226
|
+
type_rank = 2 if n.type == "entity" else 0
|
|
227
|
+
# `n.id` as the final tiebreak makes truncation deterministic across index
|
|
228
|
+
# rebuilds -- otherwise ties fall back to dict/iteration order, which is not
|
|
229
|
+
# reproducible.
|
|
230
|
+
return (0 if n.problem else 1, type_rank, terminal, -n.degree, n.id)
|
|
231
|
+
|
|
232
|
+
ranked = sorted(nodes.values(), key=_priority)
|
|
233
|
+
keep_ids = {n.id for n in ranked[:max_nodes]}
|
|
234
|
+
stats.truncated = len(nodes) - len(keep_ids)
|
|
235
|
+
nodes = {nid: n for nid, n in nodes.items() if nid in keep_ids}
|
|
236
|
+
edges = [e for e in edges if e.source in nodes and e.target in nodes]
|
|
237
|
+
_recompute_degree(nodes, edges)
|
|
238
|
+
|
|
239
|
+
# --- final stats over the surviving graph ---
|
|
240
|
+
stats.decisions = sum(1 for n in nodes.values() if n.type == "decision")
|
|
241
|
+
stats.facts = sum(1 for n in nodes.values() if n.type == "fact")
|
|
242
|
+
stats.entities = sum(1 for n in nodes.values() if n.type == "entity")
|
|
243
|
+
stats.orphaned_bindings = sum(1 for e in edges if e.kind == "anchor" and e.status == "orphaned")
|
|
244
|
+
stats.degraded_bindings = sum(1 for e in edges if e.kind == "anchor" and e.status == "degraded")
|
|
245
|
+
stats.dangling_records = sum(1 for n in nodes.values() if n.dangling)
|
|
246
|
+
stats.supersede_links = sum(1 for e in edges if e.kind == "supersedes")
|
|
247
|
+
|
|
248
|
+
return VizGraph(nodes=list(nodes.values()), edges=edges, stats=stats)
|
sidegraph/viz/render.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Turn a :class:`~sidegraph.viz.model.VizGraph` into a vis-network-ready JSON document
|
|
2
|
+
(``to_json``) and a self-contained offline HTML file (``to_html``, added in Task 4).
|
|
3
|
+
|
|
4
|
+
# see design/superpowers/specs/2026-07-12-decision-graph-viz-design.md
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib.resources as resources
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import asdict
|
|
12
|
+
|
|
13
|
+
from .model import VizEdge, VizGraph, VizNode
|
|
14
|
+
|
|
15
|
+
_KIND_COLOR = {
|
|
16
|
+
"adr": "#4f8ef7", # blue
|
|
17
|
+
"gotcha": "#e0533d", # warm red — mistakes
|
|
18
|
+
"constraint": "#e0913d", # amber
|
|
19
|
+
"lesson": "#c76fe0", # purple
|
|
20
|
+
}
|
|
21
|
+
_TIER_COLOR = {2: "#8a94a6", 1: "#6a5acd", 0: "#557a55"}
|
|
22
|
+
_FACT_COLOR = "#3dae9c"
|
|
23
|
+
_EDGE_STATUS_COLOR = {"live": "#8a94a6", "degraded": "#e0913d", "orphaned": "#e0533d"}
|
|
24
|
+
_TERMINAL_STATUSES = frozenset({"superseded", "rejected", "deprecated"})
|
|
25
|
+
_PROBLEM_BORDER = "#e0533d"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _node_json(n: VizNode) -> dict[str, object]:
|
|
29
|
+
if n.type == "decision":
|
|
30
|
+
background = _KIND_COLOR.get(n.kind or "", "#4f8ef7")
|
|
31
|
+
shape = "box"
|
|
32
|
+
elif n.type == "fact":
|
|
33
|
+
background = _FACT_COLOR
|
|
34
|
+
shape = "diamond"
|
|
35
|
+
else:
|
|
36
|
+
background = _TIER_COLOR.get(n.tier if n.tier is not None else 2, "#8a94a6")
|
|
37
|
+
shape = "dot"
|
|
38
|
+
border = _PROBLEM_BORDER if n.problem else background
|
|
39
|
+
title = n.type
|
|
40
|
+
if n.status:
|
|
41
|
+
title += f" · {n.status}"
|
|
42
|
+
if n.tier is not None:
|
|
43
|
+
title += f" · tier {n.tier}"
|
|
44
|
+
return {
|
|
45
|
+
"id": n.id,
|
|
46
|
+
"type": n.type,
|
|
47
|
+
"label": n.label,
|
|
48
|
+
"title": title,
|
|
49
|
+
"color": {
|
|
50
|
+
"background": background,
|
|
51
|
+
"border": border,
|
|
52
|
+
"highlight": {"background": background, "border": "#ffffff"},
|
|
53
|
+
},
|
|
54
|
+
"shape": shape,
|
|
55
|
+
"size": 10 + min(n.degree, 20) * 1.5,
|
|
56
|
+
"opacity": 0.4 if (n.status in _TERMINAL_STATUSES) else 1.0,
|
|
57
|
+
"problem": n.problem,
|
|
58
|
+
"dangling": n.dangling,
|
|
59
|
+
"detail": n.detail,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _edge_json(e: VizEdge) -> dict[str, object]:
|
|
64
|
+
if e.kind == "anchor":
|
|
65
|
+
color = _EDGE_STATUS_COLOR.get(e.status or "live", "#8a94a6")
|
|
66
|
+
width = 1.0 + 3.0 * e.weight
|
|
67
|
+
label = "" if e.relation in (None, "affects") else e.relation
|
|
68
|
+
dashes: object = False
|
|
69
|
+
elif e.kind == "supersedes":
|
|
70
|
+
color, width, label, dashes = "#b0b0b0", 1.5, "supersedes", True
|
|
71
|
+
elif e.kind == "supports":
|
|
72
|
+
color, width, label, dashes = "#3dae9c", 1.5, "supports", False
|
|
73
|
+
else: # domain-parent
|
|
74
|
+
color, width, label, dashes = "#6a5acd", 1.5, "parent", [2, 4]
|
|
75
|
+
return {
|
|
76
|
+
"from": e.source,
|
|
77
|
+
"to": e.target,
|
|
78
|
+
"kind": e.kind,
|
|
79
|
+
"color": {"color": color, "opacity": 0.7},
|
|
80
|
+
"width": width,
|
|
81
|
+
"label": label,
|
|
82
|
+
"dashes": dashes,
|
|
83
|
+
"arrows": "to",
|
|
84
|
+
"status": e.status,
|
|
85
|
+
"relation": e.relation,
|
|
86
|
+
"weight": e.weight,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def to_json(graph: VizGraph) -> dict[str, object]:
|
|
91
|
+
"""vis-network-ready ``{nodes, edges, stats}`` — embedded in the HTML and emitted by
|
|
92
|
+
``sidegraph-viz --json``."""
|
|
93
|
+
return {
|
|
94
|
+
"nodes": [_node_json(n) for n in graph.nodes],
|
|
95
|
+
"edges": [_edge_json(e) for e in graph.edges],
|
|
96
|
+
"stats": asdict(graph.stats),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def to_html(graph: VizGraph) -> str:
|
|
101
|
+
"""Render a self-contained offline HTML: the vendored vis-network library and the graph
|
|
102
|
+
JSON are inlined into ``template.html``. No external resource is ever loaded."""
|
|
103
|
+
pkg = resources.files("sidegraph.viz")
|
|
104
|
+
template = pkg.joinpath("template.html").read_text(encoding="utf-8")
|
|
105
|
+
library = pkg.joinpath("assets", "vis-network.min.js").read_text(encoding="utf-8")
|
|
106
|
+
# `</` -> `<\/` so no string value inside the JSON can close the <script> element early.
|
|
107
|
+
data = json.dumps(to_json(graph)).replace("</", "<\\/")
|
|
108
|
+
html = template.replace("__SIDEGRAPH_VIS_JS__", library)
|
|
109
|
+
html = html.replace("__SIDEGRAPH_GRAPH_JSON__", data)
|
|
110
|
+
return html
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Sidegraph — decision/fact graph</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: dark; }
|
|
9
|
+
html, body { margin: 0; height: 100%; background: #0f0f1a; color: #e6e6ef;
|
|
10
|
+
font: 13px/1.4 -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
11
|
+
#app { display: flex; height: 100vh; }
|
|
12
|
+
#net { flex: 1; height: 100%; }
|
|
13
|
+
#side { width: 340px; box-sizing: border-box; padding: 14px; overflow-y: auto;
|
|
14
|
+
background: #16162a; border-left: 1px solid #262640; }
|
|
15
|
+
#side h1 { font-size: 15px; margin: 0 0 8px; }
|
|
16
|
+
#search { width: 100%; box-sizing: border-box; padding: 6px 8px; margin-bottom: 10px;
|
|
17
|
+
background: #0f0f1a; color: #e6e6ef; border: 1px solid #33334d; border-radius: 4px; }
|
|
18
|
+
.stat { display: flex; justify-content: space-between; padding: 2px 0; }
|
|
19
|
+
.stat b { color: #fff; }
|
|
20
|
+
.warn b, .warn span { color: #e0533d; }
|
|
21
|
+
#legend div { padding: 2px 0; }
|
|
22
|
+
.sw { display: inline-block; width: 10px; height: 10px; margin-right: 6px;
|
|
23
|
+
border-radius: 2px; vertical-align: middle; }
|
|
24
|
+
#info { margin-top: 12px; border-top: 1px solid #262640; padding-top: 10px; }
|
|
25
|
+
#info h2 { font-size: 13px; margin: 0 0 6px; word-break: break-word; }
|
|
26
|
+
#info .field { margin: 4px 0; }
|
|
27
|
+
#info .k { color: #9a9ab0; display: block; font-size: 11px; text-transform: uppercase;
|
|
28
|
+
letter-spacing: .04em; }
|
|
29
|
+
pre { white-space: pre-wrap; word-break: break-word; margin: 2px 0; }
|
|
30
|
+
h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .05em;
|
|
31
|
+
color: #9a9ab0; margin: 14px 0 4px; }
|
|
32
|
+
</style>
|
|
33
|
+
</head>
|
|
34
|
+
<body>
|
|
35
|
+
<div id="app">
|
|
36
|
+
<div id="net"></div>
|
|
37
|
+
<div id="side">
|
|
38
|
+
<h1>Sidegraph graph</h1>
|
|
39
|
+
<input id="search" placeholder="search nodes…" autocomplete="off">
|
|
40
|
+
<div id="stats"></div>
|
|
41
|
+
<h3>Legend</h3>
|
|
42
|
+
<div id="legend"></div>
|
|
43
|
+
<div id="info"><em>click a node to inspect</em></div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<script>__SIDEGRAPH_VIS_JS__</script>
|
|
47
|
+
<script>
|
|
48
|
+
const GRAPH = __SIDEGRAPH_GRAPH_JSON__;
|
|
49
|
+
(function () {
|
|
50
|
+
const nodes = new vis.DataSet(GRAPH.nodes);
|
|
51
|
+
const edges = new vis.DataSet(GRAPH.edges);
|
|
52
|
+
const network = new vis.Network(document.getElementById("net"), { nodes, edges }, {
|
|
53
|
+
physics: {
|
|
54
|
+
stabilization: false, // no hidden pre-settle — let the layout play out on screen
|
|
55
|
+
minVelocity: 2, // auto-stop once motion drops below this (~1-2s settle)
|
|
56
|
+
barnesHut: {
|
|
57
|
+
theta: 0.6,
|
|
58
|
+
gravitationalConstant: -16000, // repulsion — spreads dense hubs apart
|
|
59
|
+
centralGravity: 0.35, // pulls loose 2-3 node clusters back toward center
|
|
60
|
+
springLength: 180, // rest length between linked nodes
|
|
61
|
+
springConstant: 0.05,
|
|
62
|
+
damping: 0.3, // higher -> the visible settle stops quickly
|
|
63
|
+
avoidOverlap: 0.3, // keep node bodies from overlapping
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
interaction: { hover: true, tooltipDelay: 120 },
|
|
67
|
+
nodes: { font: { color: "#e6e6ef" } },
|
|
68
|
+
edges: { smooth: { type: "continuous" } },
|
|
69
|
+
});
|
|
70
|
+
const byId = {};
|
|
71
|
+
GRAPH.nodes.forEach(function (n) { byId[n.id] = n; });
|
|
72
|
+
|
|
73
|
+
const s = GRAPH.stats || {};
|
|
74
|
+
const rows = [
|
|
75
|
+
["decisions", s.decisions], ["facts", s.facts], ["entities", s.entities],
|
|
76
|
+
["orphaned bindings", s.orphaned_bindings], ["degraded bindings", s.degraded_bindings],
|
|
77
|
+
["dangling records", s.dangling_records], ["supersede links", s.supersede_links],
|
|
78
|
+
];
|
|
79
|
+
let statHtml = rows.map(function (r) {
|
|
80
|
+
return '<div class="stat"><span>' + r[0] + "</span><b>" + (r[1] || 0) + "</b></div>";
|
|
81
|
+
}).join("");
|
|
82
|
+
if (s.truncated) {
|
|
83
|
+
statHtml += '<div class="stat warn"><span>truncated</span><b>' + s.truncated + "</b></div>";
|
|
84
|
+
}
|
|
85
|
+
document.getElementById("stats").innerHTML = statHtml;
|
|
86
|
+
|
|
87
|
+
const legend = [
|
|
88
|
+
["ADR", "#4f8ef7"], ["gotcha", "#e0533d"], ["constraint", "#e0913d"],
|
|
89
|
+
["lesson", "#c76fe0"], ["fact", "#3dae9c"], ["entity", "#8a94a6"],
|
|
90
|
+
["binding live", "#8a94a6"], ["binding degraded", "#e0913d"],
|
|
91
|
+
["binding orphaned", "#e0533d"],
|
|
92
|
+
];
|
|
93
|
+
document.getElementById("legend").innerHTML = legend.map(function (l) {
|
|
94
|
+
return '<div><span class="sw" style="background:' + l[1] + '"></span>' + l[0] + "</div>";
|
|
95
|
+
}).join("");
|
|
96
|
+
|
|
97
|
+
function escapeHtml(str) {
|
|
98
|
+
return String(str).replace(/[&<>"]/g, function (c) {
|
|
99
|
+
return { "&": "&", "<": "<", ">": ">", '"': """ }[c];
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
network.on("click", function (params) {
|
|
104
|
+
const info = document.getElementById("info");
|
|
105
|
+
if (!params.nodes.length) { info.innerHTML = "<em>click a node to inspect</em>"; return; }
|
|
106
|
+
const n = byId[params.nodes[0]];
|
|
107
|
+
const d = n.detail || {};
|
|
108
|
+
let html = "<h2>" + escapeHtml(n.label) + "</h2>";
|
|
109
|
+
html += '<div class="field"><span class="k">type</span>' + escapeHtml(n.type) + "</div>";
|
|
110
|
+
Object.keys(d).forEach(function (k) {
|
|
111
|
+
const v = d[k];
|
|
112
|
+
if (v === null || v === undefined || v === "") return;
|
|
113
|
+
const val = Array.isArray(v) ? v.join(", ") : String(v);
|
|
114
|
+
html += '<div class="field"><span class="k">' + escapeHtml(k) + '</span><pre>'
|
|
115
|
+
+ escapeHtml(val) + "</pre></div>";
|
|
116
|
+
});
|
|
117
|
+
info.innerHTML = html;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
document.getElementById("search").addEventListener("input", function (e) {
|
|
121
|
+
const q = e.target.value.toLowerCase().trim();
|
|
122
|
+
if (!q) return;
|
|
123
|
+
const hit = GRAPH.nodes.find(function (n) {
|
|
124
|
+
return (n.label || "").toLowerCase().indexOf(q) !== -1;
|
|
125
|
+
});
|
|
126
|
+
if (hit) { network.selectNodes([hit.id]); network.focus(hit.id, { scale: 1.1 }); }
|
|
127
|
+
});
|
|
128
|
+
})();
|
|
129
|
+
</script>
|
|
130
|
+
</body>
|
|
131
|
+
</html>
|