Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions graphify/exporters/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}}));
Expand Down
78 changes: 78 additions & 0 deletions tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<n;i++) for (let j=i+1;j<n;j++){
if ((i+1)%n===j || (j+1)%n===i) continue;
if (proper(poly[i],poly[(i+1)%n],poly[j],poly[(j+1)%n])) return true;
}
return false;
}
let rng = 12345;
const rnd = () => (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<h.length;i++) // convex + counter-clockwise
if (cross(h[i], h[(i+1)%h.length], h[(i+2)%h.length]) < -1e-9) bad++;
for (const p of pts) // every member enclosed
for (let i=0;i<h.length;i++)
if (cross(h[i], h[(i+1)%h.length], p) < -1e-6) { bad++; break; }
}
// degenerate member sets must not throw or produce a crossed ring
for (const pts of [
[{x:-2,y:0},{x:-1,y:0},{x:1,y:0},{x:2,y:0}],
[{x:0,y:0},{x:0,y:0},{x:5,y:0},{x:0,y:5}],
[{x:3,y:3},{x:3,y:3},{x:3,y:3},{x:3,y:3}],
[{x:0,y:0},{x:1,y:1}],
]) {
const h = convexHull(pts);
if (!Array.isArray(h) || h.length < 1 || selfIntersects(h)) bad++;
if (!h.every(p => 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()}"