Add outer_face and tutte_embedding for improved graph visualization
Adds outer face detection via face traversal and signed area, Tutte embedding for clean planar layouts, and moves g/g_prime/coloring_prime into base Operation type. Pluck and squish now carry positions and recompute embeddings via is_planar. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,9 @@ class Operation(TypedDict):
|
||||
meta: Any
|
||||
before: ColoredGraphId
|
||||
after: ColoredGraphId
|
||||
g: Graph
|
||||
g_prime: Graph
|
||||
coloring_prime: VertexColoring
|
||||
|
||||
class CanonicalColoredGraph(TypedDict):
|
||||
"""Canonical representation of a colored graph"""
|
||||
@@ -81,6 +84,77 @@ def save_colored_graph(g: Graph, coloring: VertexColoring) -> tuple[Graph, Verte
|
||||
save(g_canon, str(out_dir / 'graph'))
|
||||
return g_canon, canonical_coloring, cid
|
||||
|
||||
def outer_face(g: Graph) -> list[Any]:
|
||||
"""Return the vertices of the outer (unbounded) face of g using its planar embedding and positions."""
|
||||
pos = g.get_pos()
|
||||
embedding = g._embedding
|
||||
|
||||
visited: set[tuple[Any, Any]] = set()
|
||||
faces: list[list[Any]] = []
|
||||
|
||||
for u in g.vertices():
|
||||
for v in embedding[u]:
|
||||
if (u, v) not in visited:
|
||||
face: list[Any] = []
|
||||
cu, cv = u, v
|
||||
while (cu, cv) not in visited:
|
||||
visited.add((cu, cv))
|
||||
face.append(cu)
|
||||
neighbors = embedding[cv]
|
||||
cw = neighbors[(neighbors.index(cu) + 1) % len(neighbors)]
|
||||
cu, cv = cv, cw
|
||||
faces.append(face)
|
||||
|
||||
def signed_area(face: list[Any]) -> float:
|
||||
coords = [pos[v] for v in face]
|
||||
n = len(coords)
|
||||
return sum(
|
||||
coords[i][0] * coords[(i + 1) % n][1] - coords[(i + 1) % n][0] * coords[i][1]
|
||||
for i in range(n)
|
||||
) / 2
|
||||
|
||||
return min(faces, key=signed_area)
|
||||
|
||||
def tutte_embedding(g: Graph, outer: list[Any]) -> dict[Any, tuple[float, float]]:
|
||||
"""Compute a Tutte embedding fixing outer on a convex polygon, solving for inner vertices."""
|
||||
import math
|
||||
import numpy as np
|
||||
|
||||
outer_set = set(outer)
|
||||
inner = [v for v in g.vertices() if v not in outer_set]
|
||||
|
||||
pos: dict[Any, tuple[float, float]] = {}
|
||||
for i, v in enumerate(outer):
|
||||
angle = 2 * math.pi * i / len(outer)
|
||||
pos[v] = (math.cos(angle), math.sin(angle))
|
||||
|
||||
if not inner:
|
||||
return pos
|
||||
|
||||
inner_idx = {v: i for i, v in enumerate(inner)}
|
||||
n = len(inner)
|
||||
A = np.zeros((n, n))
|
||||
bx = np.zeros(n)
|
||||
by = np.zeros(n)
|
||||
|
||||
for i, v in enumerate(inner):
|
||||
neighbors = g.neighbors(v)
|
||||
deg = len(neighbors)
|
||||
A[i, i] = 1.0
|
||||
for w in neighbors:
|
||||
if w in inner_idx:
|
||||
A[i, inner_idx[w]] = -1.0 / deg
|
||||
else:
|
||||
bx[i] += pos[w][0] / deg
|
||||
by[i] += pos[w][1] / deg
|
||||
|
||||
x = np.linalg.solve(A, bx)
|
||||
y = np.linalg.solve(A, by)
|
||||
for i, v in enumerate(inner):
|
||||
pos[v] = (float(x[i]), float(y[i]))
|
||||
|
||||
return pos
|
||||
|
||||
def _neighbors_form_cycle(g: Graph, v: Any) -> bool:
|
||||
"""Return True if the neighbors of v induce a cycle in g."""
|
||||
return bool(cast(Graph, g.subgraph(g.neighbors(v))).is_cycle())
|
||||
@@ -98,6 +172,9 @@ def pluck(g: Graph, coloring: VertexColoring, v0: Any) -> tuple[Graph, VertexCol
|
||||
"""Delete v0 and all its incident edges from g"""
|
||||
g_prime = g.copy()
|
||||
g_prime.delete_vertex(v0)
|
||||
if (pos := g.get_pos()) is not None:
|
||||
g_prime.set_pos({v: p for v, p in pos.items() if v != v0})
|
||||
g_prime.is_planar(set_embedding=True)
|
||||
coloring_prime = coloring.copy()
|
||||
del coloring_prime[v0]
|
||||
return g_prime, coloring_prime
|
||||
@@ -132,6 +209,9 @@ def squish(g: Graph, coloring: VertexColoring, v0: Any) -> tuple[Graph, VertexCo
|
||||
|
||||
g_prime = g.copy()
|
||||
g_prime.merge_vertices([v0, v1, v2])
|
||||
if (pos := g.get_pos()) is not None:
|
||||
g_prime.set_pos({v: p for v, p in pos.items() if v not in (v1, v2)})
|
||||
g_prime.is_planar(set_embedding=True)
|
||||
coloring_prime = {v: c for v, c in coloring.items() if v not in (v1, v2)}
|
||||
coloring_prime[v0] = coloring[v1]
|
||||
return g_prime, coloring_prime, v1, v2
|
||||
@@ -159,7 +239,7 @@ def reduce(
|
||||
g_prime, coloring_prime = pluck(g, coloring, v)
|
||||
print(f"\nG' (after pluck v0={v}): {g_prime.order()} vertices, {g_prime.size()} edges")
|
||||
_, _, after_cid = save_colored_graph(g_prime, coloring_prime)
|
||||
steps.append(PluckOperation(name='pluck', meta=PluckMeta(v0=v), before=before_cid, after=after_cid))
|
||||
steps.append(PluckOperation(name='pluck', meta=PluckMeta(v0=v), g=g, g_prime=g_prime, coloring_prime=coloring_prime, before=before_cid, after=after_cid))
|
||||
return reduce(g_prime, coloring_prime, after_cid, steps)
|
||||
if g.degree(v) == 4 and _neighbors_form_cycle(g, v):
|
||||
degree_4_candidates.append(v)
|
||||
@@ -172,7 +252,7 @@ def reduce(
|
||||
print(f"Shared-color neighbors: v1={v1}, v2={v2} (color {coloring[v1]})")
|
||||
print(f"\nG' (after squish v0={v0}): {g_prime.order()} vertices, {g_prime.size()} edges")
|
||||
_, _, after_cid = save_colored_graph(g_prime, coloring_prime)
|
||||
steps.append(SquishOperation(name='squish', meta=SquishMeta(v0=v0, v_merged=[v1, v2]), before=before_cid, after=after_cid))
|
||||
steps.append(SquishOperation(name='squish', meta=SquishMeta(v0=v0, v_merged=[v1, v2]), g=g, g_prime=g_prime, coloring_prime=coloring_prime, before=before_cid, after=after_cid))
|
||||
return reduce(g_prime, coloring_prime, after_cid, steps)
|
||||
|
||||
if degree_5_candidates:
|
||||
@@ -181,7 +261,7 @@ def reduce(
|
||||
print(f"Shared-color neighbors: v1={v1}, v2={v2} (color {coloring[v1]})")
|
||||
print(f"\nG' (after squish v0={v0}): {g_prime.order()} vertices, {g_prime.size()} edges")
|
||||
_, _, after_cid = save_colored_graph(g_prime, coloring_prime)
|
||||
steps.append(SquishOperation(name='squish', meta=SquishMeta(v0=v0, v_merged=[v1, v2]), before=before_cid, after=after_cid))
|
||||
steps.append(SquishOperation(name='squish', meta=SquishMeta(v0=v0, v_merged=[v1, v2]), g=g, g_prime=g_prime, coloring_prime=coloring_prime, before=before_cid, after=after_cid))
|
||||
return reduce(g_prime, coloring_prime, after_cid, steps)
|
||||
|
||||
print("DONE")
|
||||
@@ -194,24 +274,39 @@ print(f"Degree sequence: {sorted(G.degree_sequence(), reverse=True)}")
|
||||
starting_coloring_classes = G.coloring()
|
||||
starting_coloring = {v: i for i, cls in enumerate(starting_coloring_classes) for v in cls}
|
||||
_, _, initial_cid = save_colored_graph(G, starting_coloring)
|
||||
G.is_planar(set_embedding=True, set_pos=True)
|
||||
|
||||
steps = reduce(G, starting_coloring, initial_cid)
|
||||
def strip_graphs(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return {k: strip_graphs(v) for k, v in obj.items() if not isinstance(v, Graph)}
|
||||
if isinstance(obj, list):
|
||||
return [strip_graphs(v) for v in obj]
|
||||
return obj
|
||||
|
||||
print("\nSteps:")
|
||||
print(json.dumps(steps, indent=2))
|
||||
print(json.dumps(strip_graphs(steps), indent=2))
|
||||
|
||||
op_seq_id = operation_sequence_id(steps)
|
||||
op_dir = DIR / "data" / "operations" / op_seq_id
|
||||
op_dir.mkdir(parents=True, exist_ok=True)
|
||||
(op_dir / "colored_pentagon_contractions.json").write_text(json.dumps(steps, indent=2))
|
||||
(op_dir / "colored_pentagon_contractions.json").write_text(json.dumps(strip_graphs(steps), indent=2))
|
||||
|
||||
def img_data_uri(cid: ColoredGraphId) -> str:
|
||||
png_bytes = (DIR / "data" / "graphs" / cid['graph_id'] / cid['coloring_id'] / "graph.png").read_bytes()
|
||||
def plot_to_data_uri(g: Graph, coloring: VertexColoring) -> str:
|
||||
import tempfile
|
||||
vertex_colors: defaultdict[str, list[Any]] = defaultdict(list)
|
||||
for v, c in coloring.items():
|
||||
vertex_colors[PALETTE[c]].append(v)
|
||||
if g.get_pos() is None:
|
||||
g.is_planar(set_embedding=True, set_pos=True)
|
||||
g.set_pos(tutte_embedding(g, outer_face(g)))
|
||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=True) as f:
|
||||
g.plot(vertex_colors=dict(vertex_colors)).save(f.name)
|
||||
png_bytes = Path(f.name).read_bytes()
|
||||
return f"data:image/png;base64,{base64.b64encode(png_bytes).decode()}"
|
||||
|
||||
md_lines = [f"## start\n\n})"]
|
||||
md_lines = [f"## start\n\n})"]
|
||||
for step in steps:
|
||||
b = step['before']
|
||||
a = step['after']
|
||||
meta_json = json.dumps(step['meta'])
|
||||
md_lines.append(f"## {step['name']} {meta_json}\n\n})")
|
||||
md_lines.append(f"## {step['name']} {meta_json}\n\n})")
|
||||
(op_dir / "colored_pentagon_contractions.md").write_text("\n".join(md_lines) + "\n")
|
||||
|
||||
Reference in New Issue
Block a user