diff --git a/graphify/exporters/html.py b/graphify/exporters/html.py index 59c0e52e3..bb884b3b7 100644 --- a/graphify/exporters/html.py +++ b/graphify/exporters/html.py @@ -73,6 +73,27 @@ def _hyperedge_script(hyperedges_json: str) -> str: const hyperedges = {hyperedges_json}; // afterDrawing passes ctx already transformed to network coordinate space. // Draw node positions raw — no manual pan/zoom/DPR math needed. + +// Andrew's monotone chain. Returns the hull in counter-clockwise order, which +// is what the perimeter must be traced in. Collinear and duplicate points +// collapse to the extremes, so degenerate member sets render as a segment +// rather than a zero-area crossed path. +function convexHull(pts) {{ + const p = pts.slice().sort((a, b) => (a.x - b.x) || (a.y - b.y)); + if (p.length < 3) return p; + const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); + const build = seq => {{ + const out = []; + for (const q of seq) {{ + while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], q) <= 0) out.pop(); + out.push(q); + }} + out.pop(); + return out; + }}; + const hull = build(p).concat(build(p.slice().reverse())); + return hull.length >= 3 ? hull : p; +}} network.on('afterDrawing', function(ctx) {{ hyperedges.forEach(h => {{ const positions = h.nodes @@ -85,10 +106,14 @@ def _hyperedge_script(hyperedges_json: str) -> str: ctx.strokeStyle = '#6366f1'; ctx.lineWidth = 2; ctx.beginPath(); - // Centroid and expanded hull in network coordinates + // Centroid and expanded hull in network coordinates. + // The perimeter must follow hull order, not h.nodes order: tracing the + // raw member order self-intersects whenever the layout does not happen + // to place members in angular order, filling as crossed wedges. const cx = positions.reduce((s, p) => s + p.x, 0) / positions.length; const cy = positions.reduce((s, p) => s + p.y, 0) / positions.length; - const expanded = positions.map(p => ({{ + const hull = convexHull(positions); + const expanded = hull.map(p => ({{ x: cx + (p.x - cx) * 1.15, y: cy + (p.y - cy) * 1.15 }})); diff --git a/tests/test_export.py b/tests/test_export.py index 7b55780ea..d7abbc5b7 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -831,3 +831,81 @@ def test_existing_graph_node_count(tmp_path): assert existing_graph_node_count(p) is MALFORMED_GRAPH # structurally wrong -> fail closed p.write_text('{"nodes": [{"id": "a"}, {"id": "b"}], "links": []}', encoding="utf-8") assert existing_graph_node_count(p) == 2 # valid + + +def test_hyperedge_perimeter_uses_convex_hull_not_member_order(): + """The hyperedge polygon must be traced in hull order. Tracing `h.nodes` + array order self-intersects whenever the layout does not place members in + angular order, so `fill()` paints crossed wedges instead of one region.""" + from graphify.exporters.html import _hyperedge_script + script = _hyperedge_script("[]") + assert "function convexHull(pts)" in script + assert "const hull = convexHull(positions);" in script + # the traced ring must derive from the hull, never from raw member order + assert "const expanded = hull.map(" in script + assert "const expanded = positions.map(" not in script + + +def test_hyperedge_convex_hull_js_is_geometrically_sound(): + """Execute the emitted convexHull in node: the perimeter must be simple + (no self-intersection), convex, and contain every member point.""" + import shutil + import subprocess + node = shutil.which("node") + if node is None: + import pytest + pytest.skip("node not available") + from graphify.exporters.html import _hyperedge_script + m = re.search(r"function convexHull\(pts\) \{.*?\n\}", _hyperedge_script("[]"), re.S) + assert m, "convexHull not found in emitted script" + harness = m.group(0) + r""" +const cross = (p,q,r) => (q.x-p.x)*(r.y-p.y) - (q.y-p.y)*(r.x-p.x); +const proper = (a,b,c,d) => { + const s = (p,q,r) => Math.sign(cross(p,q,r)); + return s(a,b,c)*s(a,b,d) < 0 && s(c,d,a)*s(c,d,b) < 0; +}; +function selfIntersects(poly){ + const n = poly.length; + if (n < 4) return false; + for (let i=0;i (rng = (rng*1103515245+12345) & 0x7fffffff) / 0x7fffffff; +let bad = 0; +for (let t=0;t<2000;t++){ + const n = 4 + Math.floor(rnd()*4); // real hyperedges carry 4-7 members + const pts = Array.from({length:n}, () => ({x: rnd()*1000-500, y: rnd()*1000-500})); + const h = convexHull(pts); + if (selfIntersects(h)) bad++; + for (let i=0;i Number.isFinite(p.x) && Number.isFinite(p.y))) bad++; +} +// the bow-tie ordering this fix exists for +if (!selfIntersects([{x:-1,y:-1},{x:1,y:1},{x:-1,y:1},{x:1,y:-1}])) bad++; +if (selfIntersects(convexHull([{x:-1,y:-1},{x:1,y:1},{x:-1,y:1},{x:1,y:-1}]))) bad++; +console.log(bad); +""" + with tempfile.TemporaryDirectory() as tmp: + js = Path(tmp) / "hull_check.js" + js.write_text(harness, encoding="utf-8") + proc = subprocess.run([node, str(js)], capture_output=True, text=True, timeout=60) + assert proc.returncode == 0, proc.stderr + assert proc.stdout.strip() == "0", f"geometry violations: {proc.stdout.strip()}"