mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-26 16:26:42 +00:00
fix(html): trace hyperedge perimeter in convex-hull order (#2449)
This commit is contained in:
@@ -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
|
||||
}}));
|
||||
|
||||
@@ -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()}"
|
||||
|
||||
Reference in New Issue
Block a user