fix(html): trace hyperedge perimeter in convex-hull order (#2449)

This commit is contained in:
JAESOL SHIN
2026-08-13 14:17:42 +01:00
committed by safishamsi
parent 613c45cc7e
commit a4e8446479
2 changed files with 105 additions and 2 deletions
+27 -2
View File
@@ -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 @@ network.on('afterDrawing', function(ctx) {{
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
}}));
+78
View File
@@ -931,3 +931,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()}"