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 ADDED
@@ -0,0 +1,2 @@
1
+ """ttl3d: render any Turtle / RDF graph as a self-contained 2D/3D HTML viewer."""
2
+ __version__ = "0.2.2"
ttl3d/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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
+ """