ttl3d 0.2.2__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.
- ttl3d/__init__.py +2 -0
- ttl3d/__main__.py +3 -0
- ttl3d/cli.py +92 -0
- ttl3d/graph.py +196 -0
- ttl3d/layout.py +108 -0
- ttl3d/load.py +70 -0
- ttl3d/render.py +170 -0
- ttl3d/vendor/LICENSES.md +119 -0
- ttl3d/vendor/VENDOR.md +70 -0
- ttl3d/vendor/fg-bundle.min.js +5320 -0
- ttl3d/viewer-2d.js +79 -0
- ttl3d/viewer-3d.js +102 -0
- ttl3d/viewer.css +55 -0
- ttl3d/viewer.js +269 -0
- ttl3d-0.2.2.dist-info/METADATA +177 -0
- ttl3d-0.2.2.dist-info/RECORD +20 -0
- ttl3d-0.2.2.dist-info/WHEEL +5 -0
- ttl3d-0.2.2.dist-info/entry_points.txt +2 -0
- ttl3d-0.2.2.dist-info/licenses/LICENSE +21 -0
- ttl3d-0.2.2.dist-info/top_level.txt +1 -0
ttl3d/__init__.py
ADDED
ttl3d/__main__.py
ADDED
ttl3d/cli.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Command line entry point: ttl3d FILE [FILE ...] [-o OUT] [--view 3d|2d] [--color-by KEY] ..."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__, graph, layout, load, render
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
12
|
+
p = argparse.ArgumentParser(
|
|
13
|
+
prog="ttl3d",
|
|
14
|
+
description="Render Turtle / RDF files as one self-contained 2D/3D HTML viewer.")
|
|
15
|
+
p.add_argument("files", nargs="+", help="RDF files; the format is guessed from the extension")
|
|
16
|
+
p.add_argument("-o", "--out",
|
|
17
|
+
help="output HTML path (default: <first file stem>-<view>.html in the current directory)")
|
|
18
|
+
p.add_argument("--color-by", choices=graph.COLOR_KEYS, default="file",
|
|
19
|
+
help="what the node colors and legend mean (default: file)")
|
|
20
|
+
p.add_argument("--layout", choices=layout.LAYOUT_MODES, default="auto",
|
|
21
|
+
help=f"stress = pinned Kamada-Kawai (auto up to {layout.STRESS_MAX_NODES} nodes); "
|
|
22
|
+
"force = live simulation")
|
|
23
|
+
p.add_argument("--labels", choices=layout.LABEL_MODES, default="auto",
|
|
24
|
+
help=f"permanent labels (auto: nodes up to {layout.LABEL_MAX_NODES}, "
|
|
25
|
+
f"edges up to {layout.LABEL_MAX_LINKS}); hover = tooltips only")
|
|
26
|
+
p.add_argument("--view", choices=render.VIEWS, default="3d",
|
|
27
|
+
help="starting view; the page can switch between 3d and 2d (default: 3d)")
|
|
28
|
+
p.add_argument("--title", help="page title (default: first file stem)")
|
|
29
|
+
p.add_argument("--lang", default="en",
|
|
30
|
+
help="preferred language tag for labels and definitions (default: en); "
|
|
31
|
+
"untagged literals rank next, other languages become synonyms"
|
|
32
|
+
"; matched exactly (en does not select en-GB)")
|
|
33
|
+
p.add_argument("--type-links", action="store_true",
|
|
34
|
+
help="draw rdf:type as an edge from each instance to its class (default: card only)")
|
|
35
|
+
p.add_argument("--attribute-preds", action="append", default=[], metavar="PRED[,PRED...]",
|
|
36
|
+
help="predicates to show on the card instead of drawing, as prefix:local, full IRIs, "
|
|
37
|
+
"or <urn:...> in angle brackets (e.g. foaf:homepage,rdfs:seeAlso); repeatable")
|
|
38
|
+
p.add_argument("--format", metavar="NAME",
|
|
39
|
+
help="rdflib parser name for every input (turtle, xml, nt, n3, json-ld, trig, nquads); "
|
|
40
|
+
"default: guess from the extension, then try turtle")
|
|
41
|
+
p.add_argument("--version", action="version", version=f"ttl3d {__version__}")
|
|
42
|
+
return p
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _fail(problem) -> int:
|
|
46
|
+
"""Print one line to stderr and return the exit code; `problem` is an exception or a string."""
|
|
47
|
+
print(f"ttl3d: error: {problem}", file=sys.stderr)
|
|
48
|
+
return 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main(argv: list[str] | None = None) -> int:
|
|
52
|
+
# stderr already replaces characters it cannot encode; stdout raises instead, and a console
|
|
53
|
+
# that cannot show the output path (Windows with stdout redirected, an ASCII locale) must
|
|
54
|
+
# still get the summary line, not a UnicodeEncodeError traceback
|
|
55
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
56
|
+
sys.stdout.reconfigure(errors="backslashreplace")
|
|
57
|
+
args = build_parser().parse_args(argv)
|
|
58
|
+
first = Path(args.files[0]).stem
|
|
59
|
+
out = Path(args.out) if args.out else Path.cwd() / f"{first}-{args.view}.html"
|
|
60
|
+
# by identity, not by spelling: on case-insensitive filesystems Graph.ttl is graph.ttl
|
|
61
|
+
if out.exists() and any(Path(f).exists() and out.samefile(f) for f in args.files):
|
|
62
|
+
return _fail(f"output {out} is also an input file; pick another -o path")
|
|
63
|
+
try:
|
|
64
|
+
ds = load.load_files(args.files, fmt=args.format)
|
|
65
|
+
extra = graph.resolve_terms([t for arg in args.attribute_preds for t in arg.split(",") if t],
|
|
66
|
+
ds)
|
|
67
|
+
except (OSError, ValueError) as e: # LoadError is a ValueError
|
|
68
|
+
return _fail(e)
|
|
69
|
+
data = graph.build(ds, color_by=args.color_by, lang=args.lang,
|
|
70
|
+
type_links=args.type_links, attribute_preds=extra)
|
|
71
|
+
mode = layout.choose_layout(len(data["nodes"]), args.layout)
|
|
72
|
+
if mode == "stress":
|
|
73
|
+
notice = layout.stress_notice(len(data["nodes"]))
|
|
74
|
+
if notice:
|
|
75
|
+
print(notice, file=sys.stderr)
|
|
76
|
+
ids = [n["id"] for n in data["nodes"]]
|
|
77
|
+
pos3 = layout.stress_positions(ids, data["links"], dim=3)
|
|
78
|
+
pos2 = layout.stress_positions(ids, data["links"], dim=2)
|
|
79
|
+
for n in data["nodes"]:
|
|
80
|
+
n["x"], n["y"], n["z"] = pos3[n["id"]]
|
|
81
|
+
n["x2"], n["y2"] = pos2[n["id"]]
|
|
82
|
+
labels = layout.choose_labels(len(data["nodes"]), len(data["links"]), args.labels)
|
|
83
|
+
html = render.render_html(data, title=args.title or first,
|
|
84
|
+
pinned=(mode == "stress"), labels=labels, view=args.view)
|
|
85
|
+
try:
|
|
86
|
+
render.write_html(html, out)
|
|
87
|
+
except OSError as e:
|
|
88
|
+
return _fail(e)
|
|
89
|
+
shown = "+".join(k for k, v in labels.items() if v) or "hover only"
|
|
90
|
+
print(f'{len(data["nodes"])} nodes, {len(data["links"])} links -> {out} '
|
|
91
|
+
f'(view: {args.view}, layout: {mode}, labels: {shown})')
|
|
92
|
+
return 0
|
ttl3d/graph.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Build the node/link model of a Dataset.
|
|
2
|
+
|
|
3
|
+
Nodes are every IRI that is a subject of some triple or the object of a link,
|
|
4
|
+
except IRIs of the RDF/RDFS/OWL/XSD vocabularies and ontology headers.
|
|
5
|
+
Links are IRI-to-IRI triples whose predicate describes a relation rather than
|
|
6
|
+
an attribute (rdf:type, imports and provenance go to the node card instead).
|
|
7
|
+
Parallel edges collapse into one link listing every predicate; an edge
|
|
8
|
+
asserted in both directions is one link whose `reverse` list holds the
|
|
9
|
+
predicates pointing back. Each node remembers the file that first declares
|
|
10
|
+
it; each link remembers the file(s) asserting it, so a file that only adds
|
|
11
|
+
edges between other files' nodes still owns something visible.
|
|
12
|
+
|
|
13
|
+
A node's label is chosen by preference tier: the requested `--lang` language
|
|
14
|
+
first, then untagged literals, then any other language, alphabetical within
|
|
15
|
+
a tier; the same order picks the definition among skos:definition,
|
|
16
|
+
rdfs:comment and dcterms:description. Label and altLabel values that lose
|
|
17
|
+
that pick become synonyms in `alt`, and a definition-slot literal that loses
|
|
18
|
+
its pick stays visible in the property table instead of vanishing. Literal
|
|
19
|
+
`dcterms:source` / `prov:wasDerivedFrom` values land in the property table
|
|
20
|
+
too; only IRI sources appear in the node's `sources` list.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from collections import defaultdict
|
|
26
|
+
|
|
27
|
+
from rdflib import Literal, URIRef
|
|
28
|
+
from rdflib.namespace import DCTERMS, OWL, PROV, RDF, RDFS, SKOS, XSD
|
|
29
|
+
|
|
30
|
+
from .load import Dataset
|
|
31
|
+
|
|
32
|
+
COLOR_KEYS = ("file", "type", "namespace")
|
|
33
|
+
RESERVED = tuple(str(ns) for ns in (RDF, RDFS, OWL, XSD))
|
|
34
|
+
ATTRIBUTE_PREDS = {RDF.type, OWL.imports, OWL.versionIRI, OWL.priorVersion,
|
|
35
|
+
RDFS.isDefinedBy, PROV.wasDerivedFrom, DCTERMS.source}
|
|
36
|
+
LABEL_PREDS = (RDFS.label, SKOS.prefLabel)
|
|
37
|
+
DEFINITION_PREDS = (SKOS.definition, RDFS.comment, DCTERMS.description)
|
|
38
|
+
SOURCE_PREDS = (PROV.wasDerivedFrom, DCTERMS.source)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def local(u) -> str:
|
|
42
|
+
return re.split(r"[/#]", str(u))[-1] or str(u)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def namespace_of(u) -> str:
|
|
46
|
+
s = str(u)
|
|
47
|
+
return s[:max(s.rfind("#"), s.rfind("/")) + 1]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _literals(g, s, p, lang: str | None) -> list:
|
|
51
|
+
"""Literal objects of (s, p): the requested language first, then untagged,
|
|
52
|
+
then any other language; alphabetical within a tier."""
|
|
53
|
+
def rank(o):
|
|
54
|
+
tag = (o.language or "").lower()
|
|
55
|
+
tier = 0 if lang and tag == lang.lower() else 1 if not tag else 2
|
|
56
|
+
return (tier, str(o))
|
|
57
|
+
return sorted((o for o in g.objects(s, p) if isinstance(o, Literal)), key=rank)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _first(g, s, preds, lang: str | None = None):
|
|
61
|
+
for p in preds:
|
|
62
|
+
vals = _literals(g, s, p, lang)
|
|
63
|
+
if vals:
|
|
64
|
+
return str(vals[0])
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _prefix(ds: Dataset, ns: str) -> str:
|
|
69
|
+
"""Prefix bound to a namespace; the empty default prefix shows as ':'.
|
|
70
|
+
Unbound namespaces fall back to the IRI base itself."""
|
|
71
|
+
if ns in ds.prefixes:
|
|
72
|
+
return ds.prefixes[ns] or ":"
|
|
73
|
+
return ns
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_terms(terms, ds: Dataset) -> set:
|
|
77
|
+
"""Turn 'prefix:local', full IRIs, or '<...>'-wrapped IRIs into URIRefs using the
|
|
78
|
+
dataset's bindings. Angle brackets are the Turtle convention for "this is a full
|
|
79
|
+
IRI, not a CURIE" -- needed for schemes like urn: or mailto: that have no "://"."""
|
|
80
|
+
by_prefix: dict = {}
|
|
81
|
+
for ns, prefix in ds.prefixes.items():
|
|
82
|
+
by_prefix.setdefault(prefix, ns)
|
|
83
|
+
out = set()
|
|
84
|
+
for term in terms:
|
|
85
|
+
if term.startswith("<") and term.endswith(">"):
|
|
86
|
+
out.add(URIRef(term[1:-1]))
|
|
87
|
+
continue
|
|
88
|
+
if "://" in term:
|
|
89
|
+
out.add(URIRef(term))
|
|
90
|
+
continue
|
|
91
|
+
prefix, _, name = term.partition(":")
|
|
92
|
+
if prefix not in by_prefix:
|
|
93
|
+
raise ValueError(f"unknown prefix in {term!r}; bind it in an input file, "
|
|
94
|
+
f"or write the full IRI as <{term}>")
|
|
95
|
+
out.add(URIRef(by_prefix[prefix] + name))
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build(ds: Dataset, color_by: str = "file", lang: str | None = None,
|
|
100
|
+
type_links: bool = False, attribute_preds=()) -> dict:
|
|
101
|
+
if color_by not in COLOR_KEYS:
|
|
102
|
+
raise ValueError(f"color_by must be one of {COLOR_KEYS}, got {color_by!r}")
|
|
103
|
+
g = ds.merged
|
|
104
|
+
headers = set(g.subjects(RDF.type, OWL.Ontology))
|
|
105
|
+
|
|
106
|
+
def eligible(t) -> bool:
|
|
107
|
+
return (isinstance(t, URIRef) and t not in headers
|
|
108
|
+
and not str(t).startswith(RESERVED))
|
|
109
|
+
|
|
110
|
+
attribute = (set(ATTRIBUTE_PREDS) | set(attribute_preds)) - ({RDF.type} if type_links else set())
|
|
111
|
+
|
|
112
|
+
def is_link(s, p, o) -> bool:
|
|
113
|
+
return eligible(s) and eligible(o) and p not in attribute
|
|
114
|
+
|
|
115
|
+
node_file: dict = {} # node -> first file declaring it as a subject
|
|
116
|
+
mention_file: dict = {} # node -> first file asserting a link touching it
|
|
117
|
+
link_files: dict = defaultdict(list) # unordered pair -> files asserting any predicate
|
|
118
|
+
for key, fg in ds.graphs.items():
|
|
119
|
+
for s, p, o in fg:
|
|
120
|
+
if eligible(s):
|
|
121
|
+
node_file.setdefault(s, key)
|
|
122
|
+
if is_link(s, p, o):
|
|
123
|
+
mention_file.setdefault(s, key)
|
|
124
|
+
mention_file.setdefault(o, key)
|
|
125
|
+
pair = tuple(sorted((s, o), key=str))
|
|
126
|
+
if key not in link_files[pair]:
|
|
127
|
+
link_files[pair].append(key)
|
|
128
|
+
|
|
129
|
+
merged_links: dict = defaultdict(lambda: {"fwd": set(), "rev": set()}) # pair -> predicates per direction
|
|
130
|
+
node_ids = set()
|
|
131
|
+
for s, p, o in g:
|
|
132
|
+
if eligible(s):
|
|
133
|
+
node_ids.add(s)
|
|
134
|
+
if is_link(s, p, o):
|
|
135
|
+
node_ids.add(o)
|
|
136
|
+
a, b = sorted((s, o), key=str)
|
|
137
|
+
merged_links[(a, b)]["fwd" if s == a else "rev"].add(local(p))
|
|
138
|
+
|
|
139
|
+
labels = {}
|
|
140
|
+
for s in node_ids:
|
|
141
|
+
labels[s] = _first(g, s, LABEL_PREDS, lang) or local(s)
|
|
142
|
+
src_url = {s: str(o) for s, _, o in g.triples((None, DCTERMS.identifier, None))
|
|
143
|
+
if str(o).startswith("http")}
|
|
144
|
+
|
|
145
|
+
def group_of(n, types) -> str:
|
|
146
|
+
if color_by == "file":
|
|
147
|
+
return node_file.get(n) or mention_file.get(n) or "?"
|
|
148
|
+
if color_by == "type":
|
|
149
|
+
return types[0] if types else "?"
|
|
150
|
+
return _prefix(ds, namespace_of(n))
|
|
151
|
+
|
|
152
|
+
nodes = []
|
|
153
|
+
for n in sorted(node_ids, key=str):
|
|
154
|
+
types = sorted(local(t) for t in g.objects(n, RDF.type) if t != OWL.NamedIndividual)
|
|
155
|
+
label_vals = [str(o) for p in LABEL_PREDS for o in _literals(g, n, p, lang)]
|
|
156
|
+
definition = _first(g, n, DEFINITION_PREDS, lang)
|
|
157
|
+
def_pred = next((p for p in DEFINITION_PREDS if _literals(g, n, p, lang)), None)
|
|
158
|
+
props = defaultdict(list)
|
|
159
|
+
for _, p, o in g.triples((n, None, None)):
|
|
160
|
+
if isinstance(o, Literal):
|
|
161
|
+
if p in LABEL_PREDS or p == SKOS.altLabel:
|
|
162
|
+
continue
|
|
163
|
+
if p == def_pred and str(o) == definition:
|
|
164
|
+
continue
|
|
165
|
+
props[local(p)].append(str(o))
|
|
166
|
+
elif isinstance(o, URIRef) and p in attribute and p != RDF.type and p not in SOURCE_PREDS:
|
|
167
|
+
props[local(p)].append(labels.get(o) or _first(g, o, LABEL_PREDS, lang) or str(o))
|
|
168
|
+
alt = sorted(({str(o) for o in g.objects(n, SKOS.altLabel)} | set(label_vals)) - {labels[n]})
|
|
169
|
+
sources = [{"label": label, "url": src_url.get(s)}
|
|
170
|
+
for label, s in sorted(
|
|
171
|
+
(labels.get(s) or _first(g, s, LABEL_PREDS, lang) or local(s), s)
|
|
172
|
+
for p in SOURCE_PREDS for s in g.objects(n, p) if isinstance(s, URIRef))]
|
|
173
|
+
ns = namespace_of(n)
|
|
174
|
+
nodes.append({
|
|
175
|
+
"id": str(n), "label": labels[n], "types": types,
|
|
176
|
+
"file": node_file.get(n) or mention_file.get(n) or "?",
|
|
177
|
+
"ns": _prefix(ds, ns),
|
|
178
|
+
"group": group_of(n, types),
|
|
179
|
+
"definition": definition,
|
|
180
|
+
"alt": alt,
|
|
181
|
+
"props": {k: sorted(v) for k, v in sorted(props.items())},
|
|
182
|
+
"sources": sources,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
links = []
|
|
186
|
+
for (a, b), d in sorted(merged_links.items(), key=lambda kv: (str(kv[0][0]), str(kv[0][1]))):
|
|
187
|
+
src, tgt, fwd, rev = (a, b, d["fwd"], d["rev"]) if d["fwd"] else (b, a, d["rev"], d["fwd"])
|
|
188
|
+
files = link_files.get((a, b), [])
|
|
189
|
+
links.append({"source": str(src), "target": str(tgt), "predicates": sorted(fwd),
|
|
190
|
+
"reverse": sorted(rev), "files": files,
|
|
191
|
+
"group": (files[0] if files else "?") if color_by == "file" else None})
|
|
192
|
+
|
|
193
|
+
groups = {n["group"] for n in nodes}
|
|
194
|
+
if color_by == "file":
|
|
195
|
+
groups |= {l["group"] for l in links}
|
|
196
|
+
return {"nodes": nodes, "links": links, "groups": sorted(groups), "color_by": color_by}
|
ttl3d/layout.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Scale rules and the pinned stress layout (one per view: 3D and 2D).
|
|
2
|
+
|
|
3
|
+
Kamada-Kawai is quadratic in nodes and the label sprites die past a few
|
|
4
|
+
thousand scene objects, so the automatic modes degrade: above
|
|
5
|
+
STRESS_MAX_NODES the page runs the live force simulation instead of a
|
|
6
|
+
precomputed layout, and past the label thresholds the sprites are not
|
|
7
|
+
created (hover tooltips still work).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
|
|
13
|
+
STRESS_MAX_NODES = 1000
|
|
14
|
+
LABEL_MAX_NODES = 800
|
|
15
|
+
LABEL_MAX_LINKS = 800
|
|
16
|
+
LAYOUT_MODES = ("auto", "stress", "force")
|
|
17
|
+
LABEL_MODES = ("auto", "always", "hover")
|
|
18
|
+
|
|
19
|
+
SEED = 0 # networkx seeds 3-D Kamada-Kawai from a random layout; fix it so a rebuild keeps the picture
|
|
20
|
+
ISLAND_GAP = 60.0 # clearance between components, in scene units (about one rest length)
|
|
21
|
+
ISLANDS_PER_ROW = 10 # islands are shelved left to right, then a new row starts
|
|
22
|
+
PROGRESS_MIN_NODES = 200 # below this the stress layout is instant; say nothing
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def choose_layout(n_nodes: int, mode: str = "auto") -> str:
|
|
26
|
+
if mode not in LAYOUT_MODES:
|
|
27
|
+
raise ValueError(f"layout must be one of {LAYOUT_MODES}, got {mode!r}")
|
|
28
|
+
if mode == "auto":
|
|
29
|
+
return "stress" if n_nodes <= STRESS_MAX_NODES else "force"
|
|
30
|
+
return mode
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def choose_labels(n_nodes: int, n_links: int, mode: str = "auto") -> dict:
|
|
34
|
+
if mode not in LABEL_MODES:
|
|
35
|
+
raise ValueError(f"labels must be one of {LABEL_MODES}, got {mode!r}")
|
|
36
|
+
if mode == "always":
|
|
37
|
+
return {"node": True, "edge": True}
|
|
38
|
+
if mode == "hover":
|
|
39
|
+
return {"node": False, "edge": False}
|
|
40
|
+
return {"node": n_nodes <= LABEL_MAX_NODES, "edge": n_links <= LABEL_MAX_LINKS}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def stress_notice(n_nodes: int) -> str | None:
|
|
44
|
+
"""One stderr line before the stress layouts that will take a while (None when they won't).
|
|
45
|
+
Kamada-Kawai is quadratic and runs twice (3D and 2D): ~1.2 s at 200 nodes, ~5 s at 500,
|
|
46
|
+
~18 s at 1000."""
|
|
47
|
+
if n_nodes <= PROGRESS_MIN_NODES:
|
|
48
|
+
return None
|
|
49
|
+
msg = f"computing stress layouts (3D and 2D) for {n_nodes} nodes..."
|
|
50
|
+
if n_nodes > STRESS_MAX_NODES:
|
|
51
|
+
msg += (f" (more than {STRESS_MAX_NODES}: this is quadratic and may take minutes;"
|
|
52
|
+
" --layout force skips it)")
|
|
53
|
+
return msg
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _kk(G, dim: int = 3) -> dict:
|
|
57
|
+
"""`dim`-D Kamada-Kawai positions for one connected graph, identical in every
|
|
58
|
+
process for the same input: a subgraph view iterates in set order (which
|
|
59
|
+
follows Python's per-process hash seed), so the nodes and edges are handed
|
|
60
|
+
to networkx in sorted order and the start positions are seeded. A single
|
|
61
|
+
node sits at the origin."""
|
|
62
|
+
import networkx as nx
|
|
63
|
+
nodes = sorted(G, key=str)
|
|
64
|
+
if len(nodes) == 1:
|
|
65
|
+
return {nodes[0]: [0.0] * dim}
|
|
66
|
+
S = nx.Graph()
|
|
67
|
+
S.add_nodes_from(nodes)
|
|
68
|
+
S.add_edges_from(sorted((min(u, v, key=str), max(u, v, key=str)) for u, v in G.edges()))
|
|
69
|
+
return nx.kamada_kawai_layout(S, dim=dim, pos=nx.random_layout(S, dim=dim, seed=SEED))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _scaled(p: dict, links: list[dict]) -> dict[str, list[float]]:
|
|
73
|
+
"""Scale raw positions so the mean edge length is about 60 (the live rest length)."""
|
|
74
|
+
lens = [d for d in (math.dist(p[l["source"]], p[l["target"]])
|
|
75
|
+
for l in links if l["source"] in p and l["target"] in p) if d > 0]
|
|
76
|
+
scale = 60 / (sum(lens) / len(lens)) if lens else 60
|
|
77
|
+
return {n: [round(float(c) * scale, 2) for c in xyz] for n, xyz in p.items()}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def stress_positions(node_ids: list[str], links: list[dict], dim: int = 3) -> dict[str, list[float]]:
|
|
81
|
+
"""Kamada-Kawai positions per connected component, in `dim` (3 or 2) dimensions.
|
|
82
|
+
The largest component sits at the origin; every other component gets its own
|
|
83
|
+
layout and is shelved to the right of the main body, ISLANDS_PER_ROW per row,
|
|
84
|
+
with ISLAND_GAP of clearance between components. Only coordinates 0 and 1 are
|
|
85
|
+
shifted by the packing, so the same code serves both views."""
|
|
86
|
+
import networkx as nx
|
|
87
|
+
if not node_ids:
|
|
88
|
+
return {}
|
|
89
|
+
H = nx.Graph()
|
|
90
|
+
H.add_nodes_from(node_ids)
|
|
91
|
+
H.add_edges_from((l["source"], l["target"]) for l in links if l["source"] != l["target"])
|
|
92
|
+
comps = sorted(nx.connected_components(H), key=lambda c: (-len(c), min(c)))
|
|
93
|
+
pos = _scaled(_kk(H.subgraph(comps[0]), dim), links)
|
|
94
|
+
reach = max((abs(c) for xyz in pos.values() for c in xyz), default=0.0)
|
|
95
|
+
x_start = reach + ISLAND_GAP
|
|
96
|
+
x, row_y, row_h, col = x_start, 0.0, 0.0, 0
|
|
97
|
+
for comp in comps[1:]:
|
|
98
|
+
island = _scaled(_kk(H.subgraph(comp), dim), links)
|
|
99
|
+
xs = [q[0] for q in island.values()]
|
|
100
|
+
ys = [q[1] for q in island.values()]
|
|
101
|
+
if col == ISLANDS_PER_ROW:
|
|
102
|
+
x, row_y, row_h, col = x_start, row_y + row_h + ISLAND_GAP, 0.0, 0
|
|
103
|
+
for n, p in island.items():
|
|
104
|
+
pos[n] = [round(p[0] - min(xs) + x, 2), round(p[1] - min(ys) + row_y, 2), *p[2:]]
|
|
105
|
+
x += max(xs) - min(xs) + ISLAND_GAP
|
|
106
|
+
row_h = max(row_h, max(ys) - min(ys))
|
|
107
|
+
col += 1
|
|
108
|
+
return pos
|
ttl3d/load.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Parse one or more RDF files into a merged graph that remembers which file said what."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from rdflib import Graph
|
|
9
|
+
from rdflib.util import guess_format
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LoadError(ValueError):
|
|
13
|
+
"""An input file could not be parsed; the message names the file."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Dataset:
|
|
18
|
+
files: list[Path]
|
|
19
|
+
graphs: dict[str, Graph] # file key -> that file's own triples, in input order
|
|
20
|
+
merged: Graph # union of all files
|
|
21
|
+
prefixes: dict[str, str] # namespace IRI -> prefix, first binding wins
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def stems(self) -> list[str]:
|
|
25
|
+
return list(self.graphs)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _unique_key(stem: str, taken: dict) -> str:
|
|
29
|
+
key, i = stem, 2
|
|
30
|
+
while key in taken:
|
|
31
|
+
key = f"{stem}~{i}"
|
|
32
|
+
i += 1
|
|
33
|
+
return key
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse(f: Path, fmt: str | None = None) -> Graph:
|
|
37
|
+
"""Parse one file. Without an explicit format the extension decides; if that
|
|
38
|
+
parser rejects the file, try Turtle once (Turtle saved as .owl is common)."""
|
|
39
|
+
g = Graph()
|
|
40
|
+
guessed = fmt or guess_format(str(f)) or "turtle"
|
|
41
|
+
try:
|
|
42
|
+
g.parse(f, format=guessed)
|
|
43
|
+
except Exception as e: # every rdflib parser plugin raises its own class
|
|
44
|
+
if fmt is None and guessed != "turtle":
|
|
45
|
+
try:
|
|
46
|
+
return _parse(f, "turtle")
|
|
47
|
+
except LoadError:
|
|
48
|
+
pass
|
|
49
|
+
# collapse whitespace: rdflib's BadSyntax (and others) embed literal
|
|
50
|
+
# newlines, and the message must stay on one stderr line
|
|
51
|
+
detail = " ".join(str(e).split())
|
|
52
|
+
raise LoadError(f"{f}: cannot parse as {guessed}: {detail}") from e
|
|
53
|
+
return g
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_files(paths: Iterable[str | Path], fmt: str | None = None) -> Dataset:
|
|
57
|
+
files = [Path(p) for p in paths]
|
|
58
|
+
graphs: dict[str, Graph] = {}
|
|
59
|
+
merged = Graph()
|
|
60
|
+
prefixes: dict[str, str] = {}
|
|
61
|
+
for f in files:
|
|
62
|
+
if not f.is_file():
|
|
63
|
+
raise FileNotFoundError(f)
|
|
64
|
+
g = _parse(f, fmt)
|
|
65
|
+
graphs[_unique_key(f.stem, graphs)] = g
|
|
66
|
+
for triple in g:
|
|
67
|
+
merged.add(triple)
|
|
68
|
+
for prefix, ns in g.namespaces():
|
|
69
|
+
prefixes.setdefault(str(ns), prefix)
|
|
70
|
+
return Dataset(files, graphs, merged, prefixes)
|
ttl3d/render.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Render the node/link model as one self-contained HTML page.
|
|
2
|
+
|
|
3
|
+
The vendored bundle (3d-force-graph + force-graph + three-spritetext over one
|
|
4
|
+
shared three.js) is inlined, so the page works offline with zero installs and
|
|
5
|
+
never fetches from a CDN. The viewer's CSS and JS live next to this file
|
|
6
|
+
(viewer.css; viewer.js shared by both views plus one renderer file per view)
|
|
7
|
+
and are inlined at render time. Nodes are shaded spheres with optional
|
|
8
|
+
permanent labels, links are colored by the file asserting them (file mode)
|
|
9
|
+
with predicate labels at their midpoints, the legend filters inclusively, and
|
|
10
|
+
a card opens on click with the node's definition, synonyms, literal
|
|
11
|
+
properties, relations and sources.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import html as _html
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from collections import Counter
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
VENDOR_JS = Path(__file__).parent / "vendor" / "fg-bundle.min.js"
|
|
22
|
+
VIEWER_CSS = Path(__file__).parent / "viewer.css"
|
|
23
|
+
VIEWER_JS = Path(__file__).parent / "viewer.js" # shared app
|
|
24
|
+
VIEWER_3D_JS = Path(__file__).parent / "viewer-3d.js" # one renderer file per view, appended after it
|
|
25
|
+
VIEWER_2D_JS = Path(__file__).parent / "viewer-2d.js"
|
|
26
|
+
# saturated-on-white categorical palette
|
|
27
|
+
PALETTE = ["#ca8a04", "#dc2626", "#2563eb", "#7c3aed", "#16a34a", "#0891b2",
|
|
28
|
+
"#ea580c", "#db2777", "#4d7c0f", "#b45309", "#0f766e", "#111827"]
|
|
29
|
+
UNKNOWN_COLOR = "#9ca3af"
|
|
30
|
+
OTHER_COLOR = "#94a3b8"
|
|
31
|
+
MAX_COLORED_GROUPS = 11 # past len(PALETTE) groups, only the largest keep a colour
|
|
32
|
+
VIEWS = ("3d", "2d") # the starting view; the page switches between them
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def group_counts(data: dict) -> dict:
|
|
36
|
+
"""Nodes per group; in file mode a file also counts the links it asserts."""
|
|
37
|
+
counts = Counter(n["group"] for n in data["nodes"])
|
|
38
|
+
if data.get("color_by") == "file":
|
|
39
|
+
counts.update(l["group"] for l in data["links"] if l["group"])
|
|
40
|
+
return dict(counts)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def rank_groups(groups, counts=None) -> tuple[set, list]:
|
|
44
|
+
"""(groups that get their own colour, groups bucketed as "other")."""
|
|
45
|
+
counts = counts or {}
|
|
46
|
+
named = [g for g in groups if g != "?"]
|
|
47
|
+
if len(named) <= len(PALETTE):
|
|
48
|
+
return set(named), []
|
|
49
|
+
ranked = sorted(named, key=lambda g: (-counts.get(g, 0), g))
|
|
50
|
+
return set(ranked[:MAX_COLORED_GROUPS]), ranked[MAX_COLORED_GROUPS:]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def assign_colors(groups, counts=None) -> dict:
|
|
54
|
+
top, rest = rank_groups(groups, counts)
|
|
55
|
+
colors = {g: PALETTE[i] for i, g in enumerate(sorted(top))}
|
|
56
|
+
colors.update({g: OTHER_COLOR for g in rest})
|
|
57
|
+
colors["?"] = UNKNOWN_COLOR
|
|
58
|
+
return colors
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _legend(groups, colors, counts) -> str:
|
|
62
|
+
esc = lambda s: _html.escape(str(s), quote=True)
|
|
63
|
+
top, rest = rank_groups(groups, counts)
|
|
64
|
+
rows = [f'<div class="row grp" data-group="{esc(g)}"><span class="dot" '
|
|
65
|
+
f'style="background:{colors.get(g, UNKNOWN_COLOR)}"></span>{esc(g)}</div>'
|
|
66
|
+
for g in groups if g in top or g == "?"]
|
|
67
|
+
if rest:
|
|
68
|
+
rows.append(f'<div class="row grp" data-groups="{esc(json.dumps(rest))}"><span class="dot" '
|
|
69
|
+
f'style="background:{OTHER_COLOR}"></span>other ({len(rest)} groups)</div>')
|
|
70
|
+
return "".join(rows)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _bundle() -> str:
|
|
74
|
+
lib = _read(VENDOR_JS)
|
|
75
|
+
if "</script" in lib: # would break the inline embedding
|
|
76
|
+
raise ValueError("vendored bundle contains a closing script tag")
|
|
77
|
+
return "\n".join(l for l in lib.splitlines() if not l.startswith("//# sourceMappingURL"))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _read(path: Path) -> str:
|
|
81
|
+
return path.read_text(encoding="utf-8")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_PLACEHOLDER = re.compile(r"__[A-Z]+__")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def script_safe(value) -> str:
|
|
88
|
+
"""JSON that is safe inside a <script> block: every '<', '>' and '&' becomes a
|
|
89
|
+
JS unicode escape, so no label, title or IRI can open or close a tag or an
|
|
90
|
+
HTML comment (the HTML5 tokenizer treats "<!--" + "<script" specially)."""
|
|
91
|
+
return (json.dumps(value).replace("<", "\\u003c").replace(">", "\\u003e")
|
|
92
|
+
.replace("&", "\\u0026"))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def fill(template: str, values: dict[str, str]) -> str:
|
|
96
|
+
"""Substitute every known __KEY__ in one pass. Unknown tokens (the vendor bundle
|
|
97
|
+
contains __THREE__) are left alone, and substituted text is never rescanned,
|
|
98
|
+
so data containing a placeholder name cannot be substituted a second time."""
|
|
99
|
+
return _PLACEHOLDER.sub(lambda m: values.get(m.group(), m.group()), template)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def viewer_source() -> str:
|
|
103
|
+
"""The app script with its placeholders intact: the shared viewer followed by one
|
|
104
|
+
renderer file per view. The renderer files hold function declarations only, so the
|
|
105
|
+
shared top-level code can call them through hoisting; the page and the JavaScript
|
|
106
|
+
syntax test both go through here so they never disagree on the order."""
|
|
107
|
+
return "\n".join(_read(p) for p in (VIEWER_JS, VIEWER_3D_JS, VIEWER_2D_JS))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def render_html(data: dict, *, title: str, pinned: bool, labels: dict, view: str = "3d") -> str:
|
|
111
|
+
if view not in VIEWS:
|
|
112
|
+
raise ValueError(f"view must be one of {VIEWS}, got {view!r}")
|
|
113
|
+
esc = lambda s: _html.escape(str(s), quote=True)
|
|
114
|
+
counts = group_counts(data)
|
|
115
|
+
colors = assign_colors(data["groups"], counts)
|
|
116
|
+
legend = _legend(data["groups"], colors, counts)
|
|
117
|
+
config = {"title": title, "colorBy": data.get("color_by", "file"),
|
|
118
|
+
"pinned": bool(pinned), "labels": {"node": bool(labels["node"]),
|
|
119
|
+
"edge": bool(labels["edge"])},
|
|
120
|
+
"view": view}
|
|
121
|
+
app = fill(viewer_source(), {"__DATA__": script_safe(data),
|
|
122
|
+
"__COLORS__": script_safe(colors),
|
|
123
|
+
"__CONFIG__": script_safe(config)})
|
|
124
|
+
return fill(HTML_TEMPLATE, {
|
|
125
|
+
"__CSS__": _read(VIEWER_CSS), "__LIB__": _bundle(), "__APP__": app,
|
|
126
|
+
"__LEGEND__": legend, "__TITLE__": esc(title), "__COLORBY__": esc(config["colorBy"]),
|
|
127
|
+
"__COUNTS__": f'{len(data["nodes"])} nodes / {len(data["links"])} links'})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def write_html(html: str, out: Path | str) -> Path:
|
|
131
|
+
out = Path(out)
|
|
132
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
out.write_text(html, encoding="utf-8", newline="\n") # LF on Windows too: same bytes everywhere
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
HTML_TEMPLATE = """<!DOCTYPE html>
|
|
138
|
+
<html lang="en">
|
|
139
|
+
<head>
|
|
140
|
+
<meta charset="utf-8">
|
|
141
|
+
<title>__TITLE__</title>
|
|
142
|
+
<style>
|
|
143
|
+
__CSS__</style>
|
|
144
|
+
</head>
|
|
145
|
+
<body>
|
|
146
|
+
<div id="panel">
|
|
147
|
+
<h1>__TITLE__</h1>
|
|
148
|
+
<div class="sub">__COUNTS__ · colored by __COLORBY__</div>
|
|
149
|
+
__LEGEND__
|
|
150
|
+
<input id="q" placeholder="search labels…" autocomplete="off">
|
|
151
|
+
<div class="row views">view
|
|
152
|
+
<label><input type="radio" name="view" value="3d"> 3D</label>
|
|
153
|
+
<label><input type="radio" name="view" value="2d"> 2D</label></div>
|
|
154
|
+
<label class="tog"><input type="checkbox" id="nodelabels"> node labels</label>
|
|
155
|
+
<label class="tog"><input type="checkbox" id="edgelabels"> edge relation labels</label>
|
|
156
|
+
<label class="tog"><input type="checkbox" id="physics"> free-float physics
|
|
157
|
+
<span title="off = pinned stress-minimized layout (Kamada-Kawai); on = live force simulation" style="color:#9ca3af">?</span></label>
|
|
158
|
+
<button id="clear">clear filters</button>
|
|
159
|
+
<div id="hint">click legend rows to light a group's nodes, their neighbors, and the edges it asserts ·
|
|
160
|
+
edges wear the color of the file asserting them when coloring by file ·
|
|
161
|
+
<span id="nav"></span> · click a node for details</div>
|
|
162
|
+
</div>
|
|
163
|
+
<div id="detail"><button id="close">×</button><div id="detail-body"></div></div>
|
|
164
|
+
<div id="graph"></div>
|
|
165
|
+
<script>__LIB__</script>
|
|
166
|
+
<script>
|
|
167
|
+
__APP__</script>
|
|
168
|
+
</body>
|
|
169
|
+
</html>
|
|
170
|
+
"""
|