westland commited on
Commit
ee2a899
·
0 Parent(s):

Initial commit of B&W Flowchart Sketcher web app

Browse files
Files changed (5) hide show
  1. .gitignore +6 -0
  2. README.md +23 -0
  3. app.py +755 -0
  4. flowchart_app.py +1257 -0
  5. requirements.txt +2 -0
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .gemini/
4
+ temp_project.json
5
+ verify_flowchart.py
6
+ generate_screenshot.py
README.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: B&W Flowchart Sketcher
3
+ emoji: 📊
4
+ colorFrom: gray
5
+ colorTo: white
6
+ sdk: streamlit
7
+ sdk_version: 1.35.0
8
+ app_file: app.py
9
+ pinned: false
10
+ license: mit
11
+ ---
12
+
13
+ # B&W Flowchart Sketcher
14
+
15
+ A web-based tool to sketch clean, professional black-and-white flowcharts.
16
+
17
+ ## Features
18
+ - **Diverse Node Shapes**: Circle, Oval, Square, Diamond, and Inverted Triangle.
19
+ - **Auto-Layout**: Layer nodes hierarchically automatically.
20
+ - **Manual Positioning**: Fine-tune node coordinates using sliders.
21
+ - **Custom Line Styles**: Connect nodes using straight, curved, or dotted lines.
22
+ - **JSON Save/Load**: Export and load project files to resume work.
23
+ - **High-Quality Export**: Export cropped PNG diagrams, perfect for documents and presentations.
app.py ADDED
@@ -0,0 +1,755 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import math
3
+ import json
4
+ import os
5
+ import io
6
+ from PIL import Image, ImageDraw, ImageFont
7
+
8
+ # Set page config
9
+ st.set_page_config(page_title="B&W Flowchart Sketcher", layout="wide")
10
+
11
+ # ==========================================
12
+ # Geometry & Math Helpers
13
+ # ==========================================
14
+
15
+ def intersect_segments(p1, p2, q1, q2):
16
+ x1, y1 = p1
17
+ x2, y2 = p2
18
+ x3, y3 = q1
19
+ x4, y4 = q2
20
+
21
+ denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1)
22
+ if denom == 0:
23
+ return None # Parallel
24
+
25
+ ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denom
26
+ ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denom
27
+
28
+ if 0 <= ua <= 1 and 0 <= ub <= 1:
29
+ return (x1 + ua * (x2 - x1), y1 + ua * (y2 - y1))
30
+ return None
31
+
32
+ def get_boundary_intersection(node, target):
33
+ cx, cy = node.x, node.y
34
+ tx, ty = target
35
+ dx = tx - cx
36
+ dy = ty - cy
37
+ dist = math.hypot(dx, dy)
38
+ if dist == 0:
39
+ return (cx, cy)
40
+
41
+ if node.shape == "circle":
42
+ r = min(node.w, node.h) / 2
43
+ return (cx + r * dx / dist, cy + r * dy / dist)
44
+
45
+ elif node.shape == "oval":
46
+ a = node.w / 2
47
+ b = node.h / 2
48
+ t = 1.0 / math.sqrt((dx/a)**2 + (dy/b)**2 + 1e-9)
49
+ return (cx + t * dx, cy + t * dy)
50
+
51
+ else:
52
+ if node.shape == "square":
53
+ vertices = [
54
+ (cx - node.w/2, cy - node.h/2),
55
+ (cx + node.w/2, cy - node.h/2),
56
+ (cx + node.w/2, cy + node.h/2),
57
+ (cx - node.w/2, cy + node.h/2)
58
+ ]
59
+ elif node.shape == "diamond":
60
+ vertices = [
61
+ (cx, cy - node.h/2),
62
+ (cx + node.w/2, cy),
63
+ (cx, cy + node.h/2),
64
+ (cx - node.w/2, cy)
65
+ ]
66
+ elif node.shape == "inverted triangle":
67
+ vertices = [
68
+ (cx - node.w/2, cy - node.h/2),
69
+ (cx + node.w/2, cy - node.h/2),
70
+ (cx, cy + node.h/2)
71
+ ]
72
+ else:
73
+ return (cx, cy)
74
+
75
+ ray_end = (cx + 10000 * dx / dist, cy + 10000 * dy / dist)
76
+
77
+ for i in range(len(vertices)):
78
+ v1 = vertices[i]
79
+ v2 = vertices[(i + 1) % len(vertices)]
80
+ pt = intersect_segments((cx, cy), ray_end, v1, v2)
81
+ if pt:
82
+ return pt
83
+ return (cx, cy)
84
+
85
+ def get_quadratic_bezier_points(p0, p1, p2, num_steps=20):
86
+ points = []
87
+ for i in range(num_steps + 1):
88
+ t = i / num_steps
89
+ x = (1-t)**2 * p0[0] + 2*(1-t)*t * p1[0] + t**2 * p2[0]
90
+ y = (1-t)**2 * p0[1] + 2*(1-t)*t * p1[1] + t**2 * p2[1]
91
+ points.append((x, y))
92
+ return points
93
+
94
+ # ==========================================
95
+ # Word Wrapping Helper
96
+ # ==========================================
97
+
98
+ def wrap_text_by_width(text, max_width, measure_fn):
99
+ if not text:
100
+ return ""
101
+ words = text.split()
102
+ lines = []
103
+ current_line = []
104
+ for word in words:
105
+ test_line = " ".join(current_line + [word])
106
+ if measure_fn(test_line) <= max_width:
107
+ current_line.append(word)
108
+ else:
109
+ if current_line:
110
+ lines.append(" ".join(current_line))
111
+ current_line = [word]
112
+ else:
113
+ lines.append(word)
114
+ current_line = []
115
+ if current_line:
116
+ lines.append(" ".join(current_line))
117
+ return "\n".join(lines)
118
+
119
+ # ==========================================
120
+ # PIL Font Loader
121
+ # ==========================================
122
+
123
+ def get_pil_font(font_name, size, bold=False):
124
+ win_font_dir = "C:\\Windows\\Fonts"
125
+ paths = []
126
+ if bold:
127
+ paths.append(os.path.join(win_font_dir, f"{font_name}bd.ttf"))
128
+ paths.append(os.path.join(win_font_dir, f"{font_name}b.ttf"))
129
+ paths.append(os.path.join(win_font_dir, "arialbd.ttf"))
130
+ else:
131
+ paths.append(os.path.join(win_font_dir, f"{font_name}.ttf"))
132
+ paths.append(os.path.join(win_font_dir, "arial.ttf"))
133
+
134
+ for p in paths:
135
+ if os.path.exists(p):
136
+ try:
137
+ return ImageFont.truetype(p, size)
138
+ except Exception:
139
+ pass
140
+ return ImageFont.load_default()
141
+
142
+ # ==========================================
143
+ # Model Classes
144
+ # ==========================================
145
+
146
+ class Node:
147
+ def __init__(self, designation, shape, description="", x=100.0, y=100.0):
148
+ self.id = designation.strip()
149
+ self.shape = shape.lower()
150
+ self.description = description.strip()
151
+ self.x = float(x)
152
+ self.y = float(y)
153
+ self.w, self.h = self.default_sizes()
154
+
155
+ def default_sizes(self):
156
+ if self.shape == "circle":
157
+ return 80.0, 80.0
158
+ elif self.shape == "oval":
159
+ return 120.0, 60.0
160
+ elif self.shape == "square":
161
+ return 110.0, 70.0
162
+ elif self.shape == "diamond":
163
+ return 120.0, 90.0
164
+ elif self.shape == "inverted triangle":
165
+ return 120.0, 90.0
166
+ return 100.0, 60.0
167
+
168
+ def get_vertices(self):
169
+ cx, cy, w, h = self.x, self.y, self.w, self.h
170
+ if self.shape == "square":
171
+ return [
172
+ (cx - w/2, cy - h/2),
173
+ (cx + w/2, cy - h/2),
174
+ (cx + w/2, cy + h/2),
175
+ (cx - w/2, cy + h/2)
176
+ ]
177
+ elif self.shape == "diamond":
178
+ return [
179
+ (cx, cy - h/2),
180
+ (cx + w/2, cy),
181
+ (cx, cy + h/2),
182
+ (cx - w/2, cy)
183
+ ]
184
+ elif self.shape == "inverted triangle":
185
+ return [
186
+ (cx - w/2, cy - h/2),
187
+ (cx + w/2, cy - h/2),
188
+ (cx, cy + h/2)
189
+ ]
190
+ return []
191
+
192
+ def get_max_text_width(self):
193
+ if self.shape == "circle":
194
+ return self.w * 0.70
195
+ elif self.shape == "oval":
196
+ return self.w * 0.75
197
+ elif self.shape == "square":
198
+ return self.w * 0.82
199
+ elif self.shape == "diamond":
200
+ return self.w * 0.58
201
+ elif self.shape == "inverted triangle":
202
+ return self.w * 0.62
203
+ return self.w * 0.8
204
+
205
+ def to_dict(self):
206
+ return {
207
+ "id": self.id,
208
+ "shape": self.shape,
209
+ "description": self.description,
210
+ "x": self.x,
211
+ "y": self.y
212
+ }
213
+
214
+ @classmethod
215
+ def from_dict(cls, d):
216
+ return cls(d["id"], d["shape"], d.get("description", ""), d["x"], d["y"])
217
+
218
+
219
+ class Edge:
220
+ def __init__(self, u, v, style="straight"):
221
+ self.u = u.strip()
222
+ self.v = v.strip()
223
+ self.style = style.lower()
224
+
225
+ def to_dict(self):
226
+ return {
227
+ "u": self.u,
228
+ "v": self.v,
229
+ "style": self.style
230
+ }
231
+
232
+ @classmethod
233
+ def from_dict(cls, d):
234
+ return cls(d["u"], d["v"], d["style"])
235
+
236
+
237
+ class FlowchartModel:
238
+ def __init__(self):
239
+ self.nodes = {}
240
+ self.edges = []
241
+
242
+ def add_node(self, designation, shape, description="", x=100.0, y=100.0):
243
+ designation = designation.strip()
244
+ if not designation:
245
+ return False, "Node designation cannot be empty."
246
+ node = Node(designation, shape, description, x, y)
247
+ self.nodes[designation] = node
248
+ return True, node
249
+
250
+ def delete_node(self, designation):
251
+ designation = designation.strip()
252
+ if designation in self.nodes:
253
+ del self.nodes[designation]
254
+ self.edges = [e for e in self.edges if e.u != designation and e.v != designation]
255
+ return True
256
+ return False
257
+
258
+ def add_edge_path(self, path_str, style="straight"):
259
+ if "->" in path_str or "-->" in path_str:
260
+ s = path_str.replace("-->", "->")
261
+ parts = [p.strip() for p in s.split("->") if p.strip()]
262
+ elif "," in path_str:
263
+ parts = [p.strip() for p in path_str.split(",") if p.strip()]
264
+ else:
265
+ parts = [p.strip() for p in path_str.split() if p.strip()]
266
+
267
+ if len(parts) < 2:
268
+ return False, "Invalid path. Enter at least two nodes (e.g. A -> B)."
269
+
270
+ missing = [p for p in parts if p not in self.nodes]
271
+ if missing:
272
+ return False, f"Missing nodes: {', '.join(missing)}. Create them first."
273
+
274
+ added_count = 0
275
+ for i in range(len(parts) - 1):
276
+ u, v = parts[i], parts[i+1]
277
+ exists = any(e.u == u and e.v == v for e in self.edges)
278
+ if not exists:
279
+ self.edges.append(Edge(u, v, style))
280
+ added_count += 1
281
+
282
+ return True, f"Added {added_count} edge(s)."
283
+
284
+ def remove_edge(self, index):
285
+ if 0 <= index < len(self.edges):
286
+ self.edges.pop(index)
287
+ return True
288
+ return False
289
+
290
+ def clear(self):
291
+ self.nodes.clear()
292
+ self.edges.clear()
293
+
294
+ def auto_layout(self):
295
+ if not self.nodes:
296
+ return
297
+
298
+ adj = {name: [] for name in self.nodes}
299
+ in_degree = {name: 0 for name in self.nodes}
300
+ for e in self.edges:
301
+ if e.u in adj and e.v in adj:
302
+ adj[e.u].append(e.v)
303
+ in_degree[e.v] += 1
304
+
305
+ levels = {}
306
+ queue = []
307
+
308
+ for name in self.nodes:
309
+ if in_degree[name] == 0:
310
+ levels[name] = 0
311
+ queue.append(name)
312
+
313
+ if not queue:
314
+ first_node = list(self.nodes.keys())[0]
315
+ levels[first_node] = 0
316
+ queue.append(first_node)
317
+
318
+ visited = set()
319
+ while queue:
320
+ curr = queue.pop(0)
321
+ visited.add(curr)
322
+ curr_level = levels.get(curr, 0)
323
+
324
+ for neighbor in adj[curr]:
325
+ old_lvl = levels.get(neighbor, -1)
326
+ if curr_level + 1 > old_lvl:
327
+ levels[neighbor] = curr_level + 1
328
+
329
+ if neighbor not in visited and neighbor not in queue:
330
+ queue.append(neighbor)
331
+
332
+ for name in self.nodes:
333
+ if name not in levels:
334
+ levels[name] = 0
335
+
336
+ nodes_by_level = {}
337
+ for name, lvl in levels.items():
338
+ nodes_by_level.setdefault(lvl, []).append(name)
339
+
340
+ canvas_width = 800
341
+ level_height = 140
342
+ horizontal_spacing = 150
343
+
344
+ for lvl, lvl_nodes in sorted(nodes_by_level.items()):
345
+ lvl_nodes.sort()
346
+ num_nodes = len(lvl_nodes)
347
+ y = 100 + lvl * level_height
348
+ for i, name in enumerate(lvl_nodes):
349
+ x = (canvas_width / 2) + (i - (num_nodes - 1) / 2) * horizontal_spacing
350
+ node = self.nodes[name]
351
+ node.x = x
352
+ node.y = y
353
+
354
+ def save_to_json(self):
355
+ data = {
356
+ "nodes": [n.to_dict() for n in self.nodes.values()],
357
+ "edges": [e.to_dict() for e in self.edges]
358
+ }
359
+ return json.dumps(data, indent=4)
360
+
361
+ def load_from_json(self, json_str):
362
+ data = json.loads(json_str)
363
+ self.clear()
364
+ for n_dict in data.get("nodes", []):
365
+ node = Node.from_dict(n_dict)
366
+ self.nodes[node.id] = node
367
+ for e_dict in data.get("edges", []):
368
+ self.edges.append(Edge.from_dict(e_dict))
369
+
370
+ # ==========================================
371
+ # PIL Dashed Line Helper
372
+ # ==========================================
373
+
374
+ def draw_dashed_line_pil(draw, points, fill="black", width=2, dash_len=6, gap_len=6):
375
+ if len(points) < 2:
376
+ return
377
+
378
+ current_dash_left = dash_len
379
+ current_gap_left = 0
380
+ drawing = True
381
+
382
+ for i in range(len(points) - 1):
383
+ p1 = points[i]
384
+ p2 = points[i+1]
385
+
386
+ seg_dx = p2[0] - p1[0]
387
+ seg_dy = p2[1] - p1[1]
388
+ seg_len = math.hypot(seg_dx, seg_dy)
389
+ if seg_len == 0:
390
+ continue
391
+
392
+ vx = seg_dx / seg_len
393
+ vy = seg_dy / seg_len
394
+
395
+ dist_moved = 0
396
+ curr_pt = p1
397
+
398
+ while dist_moved < seg_len:
399
+ if drawing:
400
+ step = min(seg_len - dist_moved, current_dash_left)
401
+ next_pt = (curr_pt[0] + vx * step, curr_pt[1] + vy * step)
402
+ draw.line([curr_pt, next_pt], fill=fill, width=width)
403
+ dist_moved += step
404
+ current_dash_left -= step
405
+ curr_pt = next_pt
406
+ if current_dash_left <= 0:
407
+ drawing = False
408
+ current_gap_left = gap_len
409
+ else:
410
+ step = min(seg_len - dist_moved, current_gap_left)
411
+ next_pt = (curr_pt[0] + vx * step, curr_pt[1] + vy * step)
412
+ dist_moved += step
413
+ current_gap_left -= step
414
+ curr_pt = next_pt
415
+ if current_gap_left <= 0:
416
+ drawing = True
417
+ current_dash_left = dash_len
418
+
419
+ # ==========================================
420
+ # Flowchart Image Generator
421
+ # ==========================================
422
+
423
+ def render_flowchart_image(model):
424
+ if not model.nodes:
425
+ img = Image.new("RGB", (400, 300), "white")
426
+ return img
427
+
428
+ # Determine bounds
429
+ min_x = min(n.x - n.w/2 for n in model.nodes.values())
430
+ max_x = max(n.x + n.w/2 for n in model.nodes.values())
431
+ min_y = min(n.y - n.h/2 for n in model.nodes.values())
432
+ max_y = max(n.y + n.h/2 for n in model.nodes.values())
433
+
434
+ margin = 50.0
435
+ crop_x1 = min_x - margin
436
+ crop_y1 = min_y - margin
437
+ crop_x2 = max_x + margin
438
+ crop_y2 = max_y + margin
439
+
440
+ img_w = max(100, int(crop_x2 - crop_x1))
441
+ img_h = max(100, int(crop_y2 - crop_y1))
442
+
443
+ img = Image.new("RGB", (img_w, img_h), "white")
444
+ draw = ImageDraw.Draw(img)
445
+
446
+ def get_pt(cx, cy):
447
+ return int(cx - crop_x1), int(cy - crop_y1)
448
+
449
+ font_bold = get_pil_font("segoeui", 10, bold=True)
450
+ font_reg = get_pil_font("segoeui", 9, bold=False)
451
+
452
+ if hasattr(draw, 'textlength'):
453
+ measure_fn = lambda txt: draw.textlength(txt, font=font_reg)
454
+ else:
455
+ measure_fn = lambda txt: font_reg.getsize(txt)[0]
456
+
457
+ def draw_pil_arrowhead(p_end, p_from):
458
+ xe, ye = get_pt(p_end[0], p_end[1])
459
+ xf, yf = get_pt(p_from[0], p_from[1])
460
+ dx = xe - xf
461
+ dy = ye - yf
462
+ dist = math.hypot(dx, dy)
463
+ if dist == 0:
464
+ return
465
+ ux = dx / dist
466
+ uy = dy / dist
467
+
468
+ arrow_l = 12
469
+ arrow_w = 8
470
+ bx = xe - ux * arrow_l
471
+ by = ye - uy * arrow_l
472
+ px = -uy * (arrow_w / 2)
473
+ py = ux * (arrow_w / 2)
474
+
475
+ pts = [(xe, ye), (bx + px, by + py), (bx - px, by - py)]
476
+ draw.polygon(pts, fill="black", outline="black")
477
+
478
+ def draw_centered_text_pil(xy, text, font, fill="black"):
479
+ lines = text.split("\n")
480
+ try:
481
+ bbox = font.getbbox("Abgqp")
482
+ line_h = bbox[3] - bbox[1]
483
+ except:
484
+ line_h = font.getsize("Abgqp")[1]
485
+
486
+ line_widths = []
487
+ for line in lines:
488
+ if hasattr(draw, 'textlength'):
489
+ w = draw.textlength(line, font=font)
490
+ else:
491
+ w = font.getsize(line)[0]
492
+ line_widths.append(w)
493
+
494
+ total_height = line_h * len(lines) + 4 * (len(lines) - 1)
495
+ curr_y = xy[1] - total_height / 2
496
+
497
+ for i, line in enumerate(lines):
498
+ w = line_widths[i]
499
+ draw.text((xy[0] - w/2, curr_y), line, font=font, fill=fill)
500
+ curr_y += line_h + 4
501
+
502
+ # 1. Draw Edges
503
+ for edge in model.edges:
504
+ u_node = model.nodes.get(edge.u)
505
+ v_node = model.nodes.get(edge.v)
506
+ if not u_node or not v_node:
507
+ continue
508
+
509
+ if edge.style == "curved":
510
+ dx = v_node.x - u_node.x
511
+ dy = v_node.y - u_node.y
512
+ dist = math.hypot(dx, dy)
513
+ mx = (u_node.x + v_node.x) / 2
514
+ my = (u_node.y + v_node.y) / 2
515
+
516
+ if dist > 0:
517
+ nx = -dy / dist
518
+ ny = dx / dist
519
+ p_ctrl = (mx + nx * 45, my + ny * 45)
520
+ else:
521
+ p_ctrl = (mx, my + 45)
522
+
523
+ p_start = get_boundary_intersection(u_node, p_ctrl)
524
+ p_end = get_boundary_intersection(v_node, p_ctrl)
525
+
526
+ bezier_pts = get_quadratic_bezier_points(p_start, p_ctrl, p_end, 30)
527
+ mapped_pts = [get_pt(px, py) for px, py in bezier_pts]
528
+
529
+ if edge.style == "dotted":
530
+ draw_dashed_line_pil(draw, mapped_pts, fill="black", width=2, dash_len=4, gap_len=4)
531
+ else:
532
+ draw.line(mapped_pts, fill="black", width=2)
533
+
534
+ draw_pil_arrowhead(p_end, p_ctrl)
535
+ else:
536
+ p_start = get_boundary_intersection(u_node, (v_node.x, v_node.y))
537
+ p_end = get_boundary_intersection(v_node, (u_node.x, u_node.y))
538
+
539
+ p_start_m = get_pt(p_start[0], p_start[1])
540
+ p_end_m = get_pt(p_end[0], p_end[1])
541
+
542
+ if edge.style == "dotted":
543
+ steps = int(math.hypot(p_end_m[0] - p_start_m[0], p_end_m[1] - p_start_m[1]) / 6)
544
+ if steps < 2: steps = 2
545
+ pts = []
546
+ for s in range(steps + 1):
547
+ t = s / steps
548
+ pts.append((p_start_m[0] + t * (p_end_m[0] - p_start_m[0]), p_start_m[1] + t * (p_end_m[1] - p_start_m[1])))
549
+ draw_dashed_line_pil(draw, pts, fill="black", width=2, dash_len=4, gap_len=4)
550
+ else:
551
+ draw.line([p_start_m, p_end_m], fill="black", width=2)
552
+
553
+ draw_pil_arrowhead(p_end, p_start)
554
+
555
+ # 2. Draw Nodes
556
+ for node in model.nodes.values():
557
+ cx, cy, w, h = node.x, node.y, node.w, node.h
558
+ px, py = get_pt(cx, cy)
559
+
560
+ # Shadow
561
+ sh = 4
562
+ shadow_color = (233, 236, 239)
563
+ if node.shape in ("circle", "oval"):
564
+ draw.ellipse([px - w/2 + sh, py - h/2 + sh, px + w/2 + sh, py + h/2 + sh], fill=shadow_color, outline=None)
565
+ elif node.shape == "square":
566
+ draw.rectangle([px - w/2 + sh, py - h/2 + sh, px + w/2 + sh, py + h/2 + sh], fill=shadow_color, outline=None)
567
+ else:
568
+ sh_vertices = [(vx - crop_x1 + sh, vy - crop_y1 + sh) for vx, vy in node.get_vertices()]
569
+ draw.polygon(sh_vertices, fill=shadow_color, outline=None)
570
+
571
+ # Shape
572
+ if node.shape in ("circle", "oval"):
573
+ draw.ellipse([px - w/2, py - h/2, px + w/2, py + h/2], fill="white", outline="black", width=2)
574
+ elif node.shape == "square":
575
+ draw.rectangle([px - w/2, py - h/2, px + w/2, py + h/2], fill="white", outline="black", width=2)
576
+ else:
577
+ sh_vertices = [(vx - crop_x1, vy - crop_y1) for vx, vy in node.get_vertices()]
578
+ draw.polygon(sh_vertices, fill="white")
579
+ draw.line(sh_vertices + [sh_vertices[0]], fill="black", width=2, joint="curve")
580
+
581
+ # Text
582
+ max_w = node.get_max_text_width()
583
+ if node.description:
584
+ desc_wrapped = wrap_text_by_width(node.description, max_w, measure_fn)
585
+ draw_centered_text_pil((px, py - 12), node.id, font_bold, fill="black")
586
+ draw_centered_text_pil((px, py + 10), desc_wrapped, font_reg, fill=(73, 80, 87))
587
+ else:
588
+ draw_centered_text_pil((px, py), node.id, font_bold, fill="black")
589
+
590
+ return img
591
+
592
+ # ==========================================
593
+ # Streamlit App Logic
594
+ # ==========================================
595
+
596
+ st.title("📊 B&W Flowchart Sketcher")
597
+
598
+ # Initialize Model in Session State
599
+ if "model" not in st.session_state:
600
+ model = FlowchartModel()
601
+
602
+ # Load defaults
603
+ model.add_node("Start", "Circle", "Start", 400.0, 80.0)
604
+ model.add_node("Step 1", "Square", "Process Inputs", 400.0, 200.0)
605
+ model.add_node("Check", "Diamond", "Valid?", 400.0, 330.0)
606
+ model.add_node("Error", "Inverted Triangle", "Log Error", 220.0, 330.0)
607
+ model.add_node("End", "Oval", "Success Finish", 400.0, 460.0)
608
+
609
+ model.add_edge_path("Start -> Step 1", "straight")
610
+ model.add_edge_path("Step 1 -> Check", "straight")
611
+ model.add_edge_path("Check -> Error", "straight")
612
+ model.add_edge_path("Check -> End", "straight")
613
+ model.add_edge_path("Error -> Start", "curved")
614
+ st.session_state.model = model
615
+ else:
616
+ model = st.session_state.model
617
+
618
+ # Split Layout
619
+ col_ctrl, col_canvas = st.columns([1, 2])
620
+
621
+ with col_ctrl:
622
+ st.header("Controls")
623
+
624
+ # --- Node Management ---
625
+ with st.expander("Node Manager", expanded=True):
626
+ node_id = st.text_input("Designation / Label (Short ID)", key="node_id")
627
+ shape = st.selectbox("Icon Shape", ["Square", "Circle", "Oval", "Diamond", "Inverted Triangle"], key="shape")
628
+ desc = st.text_input("Description (Inside Shape)", key="desc")
629
+
630
+ col_node_btns = st.columns(2)
631
+ with col_node_btns[0]:
632
+ if st.button("Add/Save Node", use_container_width=True):
633
+ if node_id:
634
+ is_update = node_id in model.nodes
635
+ if is_update:
636
+ n = model.nodes[node_id]
637
+ n.shape = shape.lower()
638
+ n.description = desc
639
+ n.w, n.h = n.default_sizes()
640
+ else:
641
+ model.add_node(node_id, shape, desc, x=300.0 + 50.0 * len(model.nodes), y=200.0)
642
+ st.rerun()
643
+ else:
644
+ st.error("Short ID designation required.")
645
+ with col_node_btns[1]:
646
+ if st.button("Delete Node", use_container_width=True):
647
+ if node_id:
648
+ if model.delete_node(node_id):
649
+ st.rerun()
650
+ else:
651
+ st.error(f"Node '{node_id}' not found.")
652
+ else:
653
+ st.error("Enter a Short ID designation to delete.")
654
+
655
+ # --- Edge Management ---
656
+ with st.expander("Edge Manager", expanded=True):
657
+ edge_path = st.text_input("Path (e.g., A -> B -> C)", key="edge_path")
658
+ edge_style = st.selectbox("Connector Line Style", ["Straight", "Curved", "Dotted"], key="edge_style")
659
+
660
+ if st.button("Add Edges", use_container_width=True):
661
+ if edge_path:
662
+ success, msg = model.add_edge_path(edge_path, edge_style)
663
+ if success:
664
+ st.rerun()
665
+ else:
666
+ st.error(msg)
667
+ else:
668
+ st.error("Enter an edge path.")
669
+
670
+ # --- Manual Node Positioning ---
671
+ with st.expander("Manual Node Positioning", expanded=False):
672
+ if model.nodes:
673
+ selected_pos_node = st.selectbox("Select Node to Position", list(model.nodes.keys()))
674
+ node_to_move = model.nodes[selected_pos_node]
675
+ new_x = st.slider("X Coordinate", 0.0, 1000.0, float(node_to_move.x), step=10.0)
676
+ new_y = st.slider("Y Coordinate", 0.0, 1000.0, float(node_to_move.y), step=10.0)
677
+ if new_x != node_to_move.x or new_y != node_to_move.y:
678
+ node_to_move.x = new_x
679
+ node_to_move.y = new_y
680
+ st.rerun()
681
+ else:
682
+ st.info("Create nodes to enable manual positioning.")
683
+
684
+ # --- Lists and Deletions ---
685
+ with st.expander("Active Lists"):
686
+ st.subheader("Nodes")
687
+ if model.nodes:
688
+ for k, v in model.nodes.items():
689
+ st.text(f"• {k} ({v.shape.title()}) - {v.description}")
690
+ else:
691
+ st.text("No nodes.")
692
+
693
+ st.subheader("Edges")
694
+ if model.edges:
695
+ edge_labels = [f"{e.u} -> {e.v} ({e.style.title()})" for e in model.edges]
696
+ del_edge_idx = st.selectbox("Select Edge to Delete", range(len(model.edges)), format_func=lambda i: edge_labels[i])
697
+ if st.button("Delete Selected Edge", use_container_width=True):
698
+ model.remove_edge(del_edge_idx)
699
+ st.rerun()
700
+ else:
701
+ st.text("No edges.")
702
+
703
+ # --- Actions ---
704
+ with st.expander("Global Actions", expanded=True):
705
+ col_actions = st.columns(2)
706
+ with col_actions[0]:
707
+ if st.button("Auto-Layout", use_container_width=True):
708
+ model.auto_layout()
709
+ st.rerun()
710
+ with col_actions[1]:
711
+ if st.button("Clear All", use_container_width=True):
712
+ model.clear()
713
+ st.rerun()
714
+
715
+ # Save and Load
716
+ st.subheader("Save / Load Project")
717
+ json_data = model.save_to_json()
718
+ st.download_button(
719
+ label="Download JSON Project File",
720
+ data=json_data,
721
+ file_name="flowchart_project.json",
722
+ mime="application/json",
723
+ use_container_width=True
724
+ )
725
+
726
+ uploaded_file = st.file_uploader("Upload JSON Project", type="json")
727
+ if uploaded_file is not None:
728
+ try:
729
+ json_str = uploaded_file.read().decode("utf-8")
730
+ model.load_from_json(json_str)
731
+ st.success("Project loaded successfully!")
732
+ st.rerun()
733
+ except Exception as e:
734
+ st.error(f"Error loading project: {e}")
735
+
736
+ with col_canvas:
737
+ st.header("Sketch Canvas")
738
+
739
+ # Render flowchart B&W image
740
+ flowchart_img = render_flowchart_image(model)
741
+
742
+ # Display in browser
743
+ buffer = io.BytesIO()
744
+ flowchart_img.save(buffer, format="PNG")
745
+ img_bytes = buffer.getvalue()
746
+
747
+ st.image(img_bytes, caption="Clean B&W Flowchart (Auto-cropped)", use_container_width=False)
748
+
749
+ # Download Button
750
+ st.download_button(
751
+ label="📥 Download Flowchart Image (PNG)",
752
+ data=img_bytes,
753
+ file_name="flowchart_export.png",
754
+ mime="image/png"
755
+ )
flowchart_app.py ADDED
@@ -0,0 +1,1257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import tkinter as tk
2
+ from tkinter import ttk, messagebox, filedialog
3
+ import tkinter.font as tkfont
4
+ import math
5
+ import json
6
+ import os
7
+ from PIL import Image, ImageDraw, ImageFont
8
+
9
+ # ==========================================
10
+ # Geometry & Math Helpers
11
+ # ==========================================
12
+
13
+ def intersect_segments(p1, p2, q1, q2):
14
+ """
15
+ Finds the intersection of segment p1-p2 and segment q1-q2.
16
+ Returns (x, y) if they intersect, otherwise None.
17
+ """
18
+ x1, y1 = p1
19
+ x2, y2 = p2
20
+ x3, y3 = q1
21
+ x4, y4 = q2
22
+
23
+ denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1)
24
+ if denom == 0:
25
+ return None # Parallel or collinear
26
+
27
+ ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denom
28
+ ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denom
29
+
30
+ if 0 <= ua <= 1 and 0 <= ub <= 1:
31
+ return (x1 + ua * (x2 - x1), y1 + ua * (y2 - y1))
32
+ return None
33
+
34
+ def get_boundary_intersection(node, target):
35
+ """
36
+ Calculates where a ray from the center of `node` to `target` (x, y)
37
+ intersects the node's boundary.
38
+ """
39
+ cx, cy = node.x, node.y
40
+ tx, ty = target
41
+ dx = tx - cx
42
+ dy = ty - cy
43
+ dist = math.hypot(dx, dy)
44
+ if dist == 0:
45
+ return (cx, cy)
46
+
47
+ # Circle
48
+ if node.shape == "circle":
49
+ r = min(node.w, node.h) / 2
50
+ return (cx + r * dx / dist, cy + r * dy / dist)
51
+
52
+ # Oval (Ellipse)
53
+ elif node.shape == "oval":
54
+ a = node.w / 2
55
+ b = node.h / 2
56
+ t = 1.0 / math.sqrt((dx/a)**2 + (dy/b)**2 + 1e-9)
57
+ return (cx + t * dx, cy + t * dy)
58
+
59
+ # Polygon Shapes: square, diamond, inverted triangle
60
+ else:
61
+ if node.shape == "square":
62
+ vertices = [
63
+ (cx - node.w/2, cy - node.h/2),
64
+ (cx + node.w/2, cy - node.h/2),
65
+ (cx + node.w/2, cy + node.h/2),
66
+ (cx - node.w/2, cy + node.h/2)
67
+ ]
68
+ elif node.shape == "diamond":
69
+ vertices = [
70
+ (cx, cy - node.h/2),
71
+ (cx + node.w/2, cy),
72
+ (cx, cy + node.h/2),
73
+ (cx - node.w/2, cy)
74
+ ]
75
+ elif node.shape == "inverted triangle":
76
+ vertices = [
77
+ (cx - node.w/2, cy - node.h/2),
78
+ (cx + node.w/2, cy - node.h/2),
79
+ (cx, cy + node.h/2)
80
+ ]
81
+ else:
82
+ return (cx, cy)
83
+
84
+ # Cast a long ray from center in the direction of target to ensure we hit the boundary
85
+ ray_end = (cx + 10000 * dx / dist, cy + 10000 * dy / dist)
86
+
87
+ for i in range(len(vertices)):
88
+ v1 = vertices[i]
89
+ v2 = vertices[(i + 1) % len(vertices)]
90
+ pt = intersect_segments((cx, cy), ray_end, v1, v2)
91
+ if pt:
92
+ return pt
93
+ return (cx, cy)
94
+
95
+ def get_quadratic_bezier_points(p0, p1, p2, num_steps=20):
96
+ """Generates points along a quadratic Bezier curve."""
97
+ points = []
98
+ for i in range(num_steps + 1):
99
+ t = i / num_steps
100
+ x = (1-t)**2 * p0[0] + 2*(1-t)*t * p1[0] + t**2 * p2[0]
101
+ y = (1-t)**2 * p0[1] + 2*(1-t)*t * p1[1] + t**2 * p2[1]
102
+ points.append((x, y))
103
+ return points
104
+
105
+ # ==========================================
106
+ # Word Wrapping Helper
107
+ # ==========================================
108
+
109
+ def wrap_text_by_width(text, max_width, measure_fn):
110
+ """Wraps text on word boundaries based on a width measurement function."""
111
+ if not text:
112
+ return ""
113
+ words = text.split()
114
+ lines = []
115
+ current_line = []
116
+ for word in words:
117
+ test_line = " ".join(current_line + [word])
118
+ if measure_fn(test_line) <= max_width:
119
+ current_line.append(word)
120
+ else:
121
+ if current_line:
122
+ lines.append(" ".join(current_line))
123
+ current_line = [word]
124
+ else:
125
+ lines.append(word)
126
+ current_line = []
127
+ if current_line:
128
+ lines.append(" ".join(current_line))
129
+ return "\n".join(lines)
130
+
131
+ # ==========================================
132
+ # Pillow Font Loader
133
+ # ==========================================
134
+
135
+ def get_pil_font(font_name, size, bold=False):
136
+ """Resilient font loader for Pillow on Windows."""
137
+ win_font_dir = "C:\\Windows\\Fonts"
138
+ paths = []
139
+ if bold:
140
+ paths.append(os.path.join(win_font_dir, f"{font_name}bd.ttf"))
141
+ paths.append(os.path.join(win_font_dir, f"{font_name}b.ttf"))
142
+ paths.append(os.path.join(win_font_dir, "arialbd.ttf"))
143
+ else:
144
+ paths.append(os.path.join(win_font_dir, f"{font_name}.ttf"))
145
+ paths.append(os.path.join(win_font_dir, "arial.ttf"))
146
+
147
+ for p in paths:
148
+ if os.path.exists(p):
149
+ try:
150
+ return ImageFont.truetype(p, size)
151
+ except Exception:
152
+ pass
153
+ return ImageFont.load_default()
154
+
155
+ # ==========================================
156
+ # Model Classes
157
+ # ==========================================
158
+
159
+ class Node:
160
+ def __init__(self, designation, shape, description="", x=100.0, y=100.0):
161
+ self.id = designation.strip()
162
+ self.shape = shape.lower() # diamond, inverted triangle, square, circle, oval
163
+ self.description = description.strip()
164
+ self.x = float(x)
165
+ self.y = float(y)
166
+ self.w, self.h = self.default_sizes()
167
+
168
+ def default_sizes(self):
169
+ # Choose default dimensions based on shape to fit text neatly
170
+ if self.shape == "circle":
171
+ return 80.0, 80.0
172
+ elif self.shape == "oval":
173
+ return 120.0, 60.0
174
+ elif self.shape == "square":
175
+ return 110.0, 70.0
176
+ elif self.shape == "diamond":
177
+ return 120.0, 90.0
178
+ elif self.shape == "inverted triangle":
179
+ return 120.0, 90.0
180
+ return 100.0, 60.0
181
+
182
+ def get_vertices(self):
183
+ cx, cy, w, h = self.x, self.y, self.w, self.h
184
+ if self.shape == "square":
185
+ return [
186
+ (cx - w/2, cy - h/2),
187
+ (cx + w/2, cy - h/2),
188
+ (cx + w/2, cy + h/2),
189
+ (cx - w/2, cy + h/2)
190
+ ]
191
+ elif self.shape == "diamond":
192
+ return [
193
+ (cx, cy - h/2),
194
+ (cx + w/2, cy),
195
+ (cx, cy + h/2),
196
+ (cx - w/2, cy)
197
+ ]
198
+ elif self.shape == "inverted triangle":
199
+ return [
200
+ (cx - w/2, cy - h/2),
201
+ (cx + w/2, cy - h/2),
202
+ (cx, cy + h/2)
203
+ ]
204
+ return []
205
+
206
+ def get_max_text_width(self):
207
+ # Margins to prevent text touching borders
208
+ if self.shape == "circle":
209
+ return self.w * 0.70
210
+ elif self.shape == "oval":
211
+ return self.w * 0.75
212
+ elif self.shape == "square":
213
+ return self.w * 0.82
214
+ elif self.shape == "diamond":
215
+ return self.w * 0.58
216
+ elif self.shape == "inverted triangle":
217
+ return self.w * 0.62
218
+ return self.w * 0.8
219
+
220
+ def to_dict(self):
221
+ return {
222
+ "id": self.id,
223
+ "shape": self.shape,
224
+ "description": self.description,
225
+ "x": self.x,
226
+ "y": self.y
227
+ }
228
+
229
+ @classmethod
230
+ def from_dict(cls, d):
231
+ return cls(d["id"], d["shape"], d.get("description", ""), d["x"], d["y"])
232
+
233
+
234
+ class Edge:
235
+ def __init__(self, u, v, style="straight"):
236
+ self.u = u.strip() # From node ID
237
+ self.v = v.strip() # To node ID
238
+ self.style = style.lower() # straight, curved, dotted
239
+
240
+ def to_dict(self):
241
+ return {
242
+ "u": self.u,
243
+ "v": self.v,
244
+ "style": self.style
245
+ }
246
+
247
+ @classmethod
248
+ def from_dict(cls, d):
249
+ return cls(d["u"], d["v"], d["style"])
250
+
251
+
252
+ class FlowchartModel:
253
+ def __init__(self):
254
+ self.nodes = {} # ID -> Node
255
+ self.edges = [] # List of Edge objects
256
+
257
+ def add_node(self, designation, shape, description="", x=100.0, y=100.0):
258
+ designation = designation.strip()
259
+ if not designation:
260
+ return False, "Node designation cannot be empty."
261
+ node = Node(designation, shape, description, x, y)
262
+ self.nodes[designation] = node
263
+ return True, node
264
+
265
+ def delete_node(self, designation):
266
+ designation = designation.strip()
267
+ if designation in self.nodes:
268
+ del self.nodes[designation]
269
+ # Remove associated edges
270
+ self.edges = [e for e in self.edges if e.u != designation and e.v != designation]
271
+ return True
272
+ return False
273
+
274
+ def add_edge_path(self, path_str, style="straight"):
275
+ """
276
+ Parses paths like 'A -> B -> C' or 'A, B' or 'A - B' and adds
277
+ consecutive pairs as edges of the given style.
278
+ """
279
+ # Determine delimiters and split
280
+ if "->" in path_str or "-->" in path_str:
281
+ s = path_str.replace("-->", "->")
282
+ parts = [p.strip() for p in s.split("->") if p.strip()]
283
+ elif "," in path_str:
284
+ parts = [p.strip() for p in path_str.split(",") if p.strip()]
285
+ else:
286
+ parts = [p.strip() for p in path_str.split() if p.strip()]
287
+
288
+ if len(parts) < 2:
289
+ return False, "Invalid path. Enter at least two nodes (e.g. A -> B)."
290
+
291
+ # Verify all nodes exist
292
+ missing = [p for p in parts if p not in self.nodes]
293
+ if missing:
294
+ return False, f"Missing nodes: {', '.join(missing)}. Create them first."
295
+
296
+ added_count = 0
297
+ for i in range(len(parts) - 1):
298
+ u, v = parts[i], parts[i+1]
299
+ # Avoid duplicate edges of the same direction
300
+ exists = any(e.u == u and e.v == v for e in self.edges)
301
+ if not exists:
302
+ self.edges.append(Edge(u, v, style))
303
+ added_count += 1
304
+
305
+ return True, f"Added {added_count} edge(s)."
306
+
307
+ def remove_edge(self, index):
308
+ if 0 <= index < len(self.edges):
309
+ self.edges.pop(index)
310
+ return True
311
+ return False
312
+
313
+ def clear(self):
314
+ self.nodes.clear()
315
+ self.edges.clear()
316
+
317
+ def auto_layout(self):
318
+ """
319
+ Calculates a clean vertical hierarchical (layered) layout for the nodes.
320
+ """
321
+ if not self.nodes:
322
+ return
323
+
324
+ # 1. Build adjacency list and compute in-degrees
325
+ adj = {name: [] for name in self.nodes}
326
+ in_degree = {name: 0 for name in self.nodes}
327
+ for e in self.edges:
328
+ if e.u in adj and e.v in adj:
329
+ adj[e.u].append(e.v)
330
+ in_degree[e.v] += 1
331
+
332
+ # 2. Layer nodes using simple BFS-like sorting
333
+ levels = {}
334
+ queue = []
335
+
336
+ # Start nodes (in-degree = 0)
337
+ for name in self.nodes:
338
+ if in_degree[name] == 0:
339
+ levels[name] = 0
340
+ queue.append(name)
341
+
342
+ # Handle cycles/disconnected loops (if no 0-in-degree nodes exist)
343
+ if not queue:
344
+ first_node = list(self.nodes.keys())[0]
345
+ levels[first_node] = 0
346
+ queue.append(first_node)
347
+
348
+ visited = set()
349
+ while queue:
350
+ curr = queue.pop(0)
351
+ visited.add(curr)
352
+ curr_level = levels.get(curr, 0)
353
+
354
+ for neighbor in adj[curr]:
355
+ old_lvl = levels.get(neighbor, -1)
356
+ # Elevate level if current path is deeper
357
+ if curr_level + 1 > old_lvl:
358
+ levels[neighbor] = curr_level + 1
359
+
360
+ if neighbor not in visited and neighbor not in queue:
361
+ queue.append(neighbor)
362
+
363
+ # Catch any remaining nodes (e.g. disconnected nodes in cycles)
364
+ for name in self.nodes:
365
+ if name not in levels:
366
+ levels[name] = 0
367
+
368
+ # 3. Group by level
369
+ nodes_by_level = {}
370
+ for name, lvl in levels.items():
371
+ nodes_by_level.setdefault(lvl, []).append(name)
372
+
373
+ # 4. Assign Coordinates
374
+ # Canvas defaults: center is ~400
375
+ canvas_width = 800
376
+ level_height = 140
377
+ horizontal_spacing = 150
378
+
379
+ for lvl, lvl_nodes in sorted(nodes_by_level.items()):
380
+ lvl_nodes.sort() # Alphabetical for layout stability
381
+ num_nodes = len(lvl_nodes)
382
+ y = 100 + lvl * level_height
383
+ for i, name in enumerate(lvl_nodes):
384
+ x = (canvas_width / 2) + (i - (num_nodes - 1) / 2) * horizontal_spacing
385
+ node = self.nodes[name]
386
+ node.x = x
387
+ node.y = y
388
+
389
+ def save_to_file(self, filepath):
390
+ data = {
391
+ "nodes": [n.to_dict() for n in self.nodes.values()],
392
+ "edges": [e.to_dict() for e in self.edges]
393
+ }
394
+ with open(filepath, "w") as f:
395
+ json.dump(data, f, indent=4)
396
+
397
+ def load_from_file(self, filepath):
398
+ with open(filepath, "r") as f:
399
+ data = json.load(f)
400
+ self.clear()
401
+ for n_dict in data.get("nodes", []):
402
+ node = Node.from_dict(n_dict)
403
+ self.nodes[node.id] = node
404
+ for e_dict in data.get("edges", []):
405
+ self.edges.append(Edge.from_dict(e_dict))
406
+
407
+ # ==========================================
408
+ # Canvas GUI Widget
409
+ # ==========================================
410
+
411
+ class FlowchartCanvas(tk.Frame):
412
+ def __init__(self, parent, model, select_callback=None):
413
+ super().__init__(parent)
414
+ self.model = model
415
+ self.select_callback = select_callback
416
+
417
+ self.selected_node_id = None
418
+ self.dragged_node = None
419
+ self.drag_offset_x = 0
420
+ self.drag_offset_y = 0
421
+
422
+ # Scrollbars and canvas
423
+ self.canvas = tk.Canvas(self, bg="white", borderwidth=0, highlightthickness=0)
424
+ self.hbar = tk.Scrollbar(self, orient=tk.HORIZONTAL, command=self.canvas.xview)
425
+ self.vbar = tk.Scrollbar(self, orient=tk.VERTICAL, command=self.canvas.yview)
426
+
427
+ self.canvas.config(xscrollcommand=self.hbar.set, yscrollcommand=self.vbar.set)
428
+
429
+ self.grid(row=0, column=0, sticky="nsew")
430
+ self.canvas.grid(row=0, column=0, sticky="nsew")
431
+ self.vbar.grid(row=0, column=1, sticky="ns")
432
+ self.hbar.grid(row=1, column=0, sticky="ew")
433
+
434
+ self.rowconfigure(0, weight=1)
435
+ self.columnconfigure(0, weight=1)
436
+
437
+ # Grid settings
438
+ self.canvas.config(scrollregion=(0, 0, 1500, 1200))
439
+
440
+ # Fonts
441
+ self.font_bold = ("Segoe UI", 10, "bold")
442
+ self.font_reg = ("Segoe UI", 9)
443
+
444
+ # Event Bindings
445
+ self.canvas.bind("<Button-1>", self.on_press)
446
+ self.canvas.bind("<B1-Motion>", self.on_drag)
447
+ self.canvas.bind("<ButtonRelease-1>", self.on_release)
448
+
449
+ # Initial draw
450
+ self.redraw()
451
+
452
+ def update_scroll_region(self):
453
+ if not self.model.nodes:
454
+ self.canvas.config(scrollregion=(0, 0, 1200, 900))
455
+ return
456
+ xs = [n.x for n in self.model.nodes.values()]
457
+ ys = [n.y for n in self.model.nodes.values()]
458
+ min_x = min(xs) - 150
459
+ max_x = max(xs) + 150
460
+ min_y = min(ys) - 150
461
+ max_y = max(ys) + 150
462
+
463
+ min_x = min(0, min_x)
464
+ min_y = min(0, min_y)
465
+ max_x = max(1200, max_x)
466
+ max_y = max(900, max_y)
467
+ self.canvas.config(scrollregion=(min_x, min_y, max_x, max_y))
468
+
469
+ def on_press(self, event):
470
+ # Convert window event coordinates to canvas coordinates (taking scrolling into account)
471
+ cx = self.canvas.canvasx(event.x)
472
+ cy = self.canvas.canvasy(event.y)
473
+
474
+ # Check if clicked on a node
475
+ clicked_node = None
476
+ for node in self.model.nodes.values():
477
+ if (node.x - node.w/2 <= cx <= node.x + node.w/2 and
478
+ node.y - node.h/2 <= cy <= node.y + node.h/2):
479
+ clicked_node = node
480
+ break
481
+
482
+ if clicked_node:
483
+ self.selected_node_id = clicked_node.id
484
+ self.dragged_node = clicked_node
485
+ self.drag_offset_x = cx - clicked_node.x
486
+ self.drag_offset_y = cy - clicked_node.y
487
+ if self.select_callback:
488
+ self.select_callback(clicked_node.id)
489
+ else:
490
+ self.selected_node_id = None
491
+ if self.select_callback:
492
+ self.select_callback(None)
493
+
494
+ self.redraw()
495
+
496
+ def on_drag(self, event):
497
+ if self.dragged_node:
498
+ cx = self.canvas.canvasx(event.x)
499
+ cy = self.canvas.canvasy(event.y)
500
+ # Drag node center relative to cursor offset
501
+ self.dragged_node.x = cx - self.drag_offset_x
502
+ self.dragged_node.y = cy - self.drag_offset_y
503
+ self.redraw()
504
+
505
+ def on_release(self, event):
506
+ self.dragged_node = None
507
+ self.update_scroll_region()
508
+
509
+ def draw_arrowhead(self, p_end, p_from, canvas_line_id=None):
510
+ """Draws a clean, custom filled B&W arrowhead pointing at p_end."""
511
+ xe, ye = p_end
512
+ xf, yf = p_from
513
+ dx = xe - xf
514
+ dy = ye - yf
515
+ dist = math.hypot(dx, dy)
516
+ if dist == 0:
517
+ return
518
+
519
+ ux = dx / dist
520
+ uy = dy / dist
521
+
522
+ arrow_length = 12
523
+ arrow_width = 8
524
+
525
+ bx = xe - ux * arrow_length
526
+ by = ye - uy * arrow_length
527
+
528
+ px = -uy * (arrow_width / 2)
529
+ py = ux * (arrow_width / 2)
530
+
531
+ pts = [xe, ye, bx + px, by + py, bx - px, by - py]
532
+ self.canvas.create_polygon(pts, fill="#212529", outline="#212529")
533
+
534
+ def redraw(self):
535
+ self.canvas.delete("all")
536
+
537
+ # 1. Draw Grid Lines
538
+ region = self.canvas.cget("scrollregion")
539
+ if region:
540
+ _, _, r_w, r_h = map(float, region.split())
541
+ else:
542
+ r_w, r_h = 1200, 900
543
+
544
+ for x in range(0, int(r_w), 45):
545
+ self.canvas.create_line(x, 0, x, r_h, fill="#f1f3f5", dash=(2, 4))
546
+ for y in range(0, int(r_h), 45):
547
+ self.canvas.create_line(0, y, r_w, y, fill="#f1f3f5", dash=(2, 4))
548
+
549
+ # Measure function using Tkinter's Font class
550
+ tk_font_reg = tkfont.Font(family="Segoe UI", size=9)
551
+ measure_fn = lambda txt: tk_font_reg.measure(txt)
552
+
553
+ # 2. Draw Edges
554
+ for edge in self.model.edges:
555
+ u_node = self.model.nodes.get(edge.u)
556
+ v_node = self.model.nodes.get(edge.v)
557
+ if not u_node or not v_node:
558
+ continue
559
+
560
+ # Line style properties
561
+ dash_pattern = (4, 4) if edge.style == "dotted" else None
562
+
563
+ if edge.style == "curved":
564
+ # Compute control point offset from midpoint
565
+ dx = v_node.x - u_node.x
566
+ dy = v_node.y - u_node.y
567
+ dist = math.hypot(dx, dy)
568
+ mx = (u_node.x + v_node.x) / 2
569
+ my = (u_node.y + v_node.y) / 2
570
+
571
+ # Curvature vector
572
+ if dist > 0:
573
+ nx = -dy / dist
574
+ ny = dx / dist
575
+ p_ctrl = (mx + nx * 45, my + ny * 45)
576
+ else:
577
+ p_ctrl = (mx, my + 45)
578
+
579
+ # Calculate boundary intersections towards the control point
580
+ p_start = get_boundary_intersection(u_node, p_ctrl)
581
+ p_end = get_boundary_intersection(v_node, p_ctrl)
582
+
583
+ # Generate points along Bezier curve
584
+ bezier_pts = get_quadratic_bezier_points(p_start, p_ctrl, p_end, 25)
585
+ flat_coords = []
586
+ for pt in bezier_pts:
587
+ flat_coords.extend(pt)
588
+
589
+ # Draw curved segments
590
+ self.canvas.create_line(*flat_coords, fill="#212529", width=2, dash=dash_pattern)
591
+ # Arrowhead points tangent to the curve end (direction: p_end - p_ctrl)
592
+ self.draw_arrowhead(p_end, p_ctrl)
593
+ else:
594
+ # Straight / Dotted line
595
+ p_start = get_boundary_intersection(u_node, (v_node.x, v_node.y))
596
+ p_end = get_boundary_intersection(v_node, (u_node.x, u_node.y))
597
+
598
+ self.canvas.create_line(p_start[0], p_start[1], p_end[0], p_end[1],
599
+ fill="#212529", width=2, dash=dash_pattern)
600
+ self.draw_arrowhead(p_end, p_start)
601
+
602
+ # 3. Draw Nodes
603
+ for node in self.model.nodes.values():
604
+ cx, cy, w, h = node.x, node.y, node.w, node.h
605
+
606
+ # --- Draw Drop Shadow (Light gray, offset 4px) ---
607
+ sh_offset = 4
608
+ scx, scy = cx + sh_offset, cy + sh_offset
609
+ if node.shape in ("circle", "oval"):
610
+ self.canvas.create_oval(scx - w/2, scy - h/2, scx + w/2, scy + h/2, fill="#e9ecef", outline="")
611
+ elif node.shape == "square":
612
+ self.canvas.create_rectangle(scx - w/2, scy - h/2, scx + w/2, scy + h/2, fill="#e9ecef", outline="")
613
+ else:
614
+ sh_vertices = []
615
+ for vx, vy in node.get_vertices():
616
+ sh_vertices.extend([vx + sh_offset, vy + sh_offset])
617
+ self.canvas.create_polygon(sh_vertices, fill="#e9ecef", outline="")
618
+
619
+ # --- Draw Main Node Shape ---
620
+ outline_color = "#212529"
621
+ bg_color = "#ffffff"
622
+
623
+ if node.shape in ("circle", "oval"):
624
+ self.canvas.create_oval(cx - w/2, cy - h/2, cx + w/2, cy + h/2, fill=bg_color, outline=outline_color, width=2)
625
+ elif node.shape == "square":
626
+ self.canvas.create_rectangle(cx - w/2, cy - h/2, cx + w/2, cy + h/2, fill=bg_color, outline=outline_color, width=2)
627
+ else:
628
+ vertices = []
629
+ for vx, vy in node.get_vertices():
630
+ vertices.extend([vx, vy])
631
+ self.canvas.create_polygon(vertices, fill=bg_color, outline=outline_color, width=2)
632
+
633
+ # --- Draw Selected Highlight (Dashed boundary box) ---
634
+ if node.id == self.selected_node_id:
635
+ self.canvas.create_rectangle(cx - w/2 - 4, cy - h/2 - 4, cx + w/2 + 4, cy + h/2 + 4,
636
+ outline="#4dabf7", width=1.5, dash=(2, 2))
637
+
638
+ # --- Draw Text ---
639
+ max_w = node.get_max_text_width()
640
+
641
+ if node.description:
642
+ # Text wrapping on description
643
+ desc_wrapped = wrap_text_by_width(node.description, max_w, measure_fn)
644
+
645
+ # Draw Designation (Bold, centered slightly higher)
646
+ self.canvas.create_text(cx, cy - 12, text=node.id, font=self.font_bold, fill="#212529", anchor="center")
647
+ # Draw Description (Regular, centered slightly lower)
648
+ self.canvas.create_text(cx, cy + 10, text=desc_wrapped, font=self.font_reg, fill="#495057", anchor="center")
649
+ else:
650
+ # Designation only (Perfect center)
651
+ self.canvas.create_text(cx, cy, text=node.id, font=self.font_bold, fill="#212529", anchor="center")
652
+
653
+ # ==========================================
654
+ # Main App Controller GUI
655
+ # ==========================================
656
+
657
+ class FlowchartApp(tk.Tk):
658
+ def __init__(self):
659
+ super().__init__()
660
+ self.title("B&W Flowchart Sketcher")
661
+ self.geometry("1150x720")
662
+ self.configure(bg="#f8f9fa")
663
+
664
+ # Initialize model
665
+ self.model = FlowchartModel()
666
+
667
+ # Styles config
668
+ self.style = ttk.Style()
669
+ self.style.theme_use("clam")
670
+
671
+ # Set primary palette (Clean Slate)
672
+ self.style.configure(".", background="#f8f9fa", foreground="#212529", font=("Segoe UI", 9))
673
+ self.style.configure("TLabel", foreground="#343a40", font=("Segoe UI", 9, "bold"))
674
+ self.style.configure("TButton", background="#e9ecef", foreground="#212529", font=("Segoe UI", 9, "bold"), borderwidth=1)
675
+ self.style.map("TButton",
676
+ background=[("active", "#dfe2e6"), ("pressed", "#ced4da")],
677
+ foreground=[("active", "#212529")])
678
+ self.style.configure("Primary.TButton", background="#212529", foreground="#ffffff", borderwidth=0)
679
+ self.style.map("Primary.TButton",
680
+ background=[("active", "#343a40"), ("pressed", "#495057")],
681
+ foreground=[("active", "#ffffff")])
682
+
683
+ # Create Layout
684
+ self.setup_ui()
685
+
686
+ # Load sample flowchart
687
+ self.load_samples()
688
+
689
+ def setup_ui(self):
690
+ # Master grid setup
691
+ self.rowconfigure(0, weight=1)
692
+ self.columnconfigure(1, weight=1)
693
+
694
+ # 1. Left Sidebar Panel (Inputs and lists)
695
+ self.sidebar = tk.Frame(self, bg="#ffffff", bd=1, relief=tk.SOLID, width=330)
696
+ self.sidebar.grid(row=0, column=0, sticky="nsw", padx=10, pady=10)
697
+ self.sidebar.pack_propagate(False)
698
+
699
+ # Scrollable Sidebar Frame to handle smaller resolutions
700
+ canvas_sidebar = tk.Canvas(self.sidebar, bg="#ffffff", borderwidth=0, highlightthickness=0)
701
+ scrollbar_sidebar = ttk.Scrollbar(self.sidebar, orient=tk.VERTICAL, command=canvas_sidebar.yview)
702
+ self.sidebar_content = tk.Frame(canvas_sidebar, bg="#ffffff")
703
+
704
+ canvas_sidebar.create_window((0, 0), window=self.sidebar_content, anchor="nw")
705
+ canvas_sidebar.configure(yscrollcommand=scrollbar_sidebar.set)
706
+
707
+ scrollbar_sidebar.pack(side=tk.RIGHT, fill=tk.Y)
708
+ canvas_sidebar.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
709
+
710
+ # Bind scrolling size
711
+ self.sidebar_content.bind("<Configure>", lambda e: canvas_sidebar.configure(scrollregion=canvas_sidebar.bbox("all")))
712
+
713
+ # Padding inside content
714
+ padx_c, pady_c = 15, 6
715
+
716
+ # --- Node Editor Section ---
717
+ lbl_nodes_title = tk.Label(self.sidebar_content, text="NODE MANAGER", font=("Segoe UI", 11, "bold"), bg="#ffffff", fg="#212529")
718
+ lbl_nodes_title.pack(anchor="w", padx=padx_c, pady=(15, 5))
719
+
720
+ # Node Designation (ID)
721
+ lbl_node_id = ttk.Label(self.sidebar_content, text="Designation / Label (Short ID):", background="#ffffff")
722
+ lbl_node_id.pack(anchor="w", padx=padx_c, pady=pady_c)
723
+ self.entry_node_id = ttk.Entry(self.sidebar_content, width=28)
724
+ self.entry_node_id.pack(anchor="w", padx=padx_c)
725
+
726
+ # Node Shape
727
+ lbl_shape = ttk.Label(self.sidebar_content, text="Icon Shape:", background="#ffffff")
728
+ lbl_shape.pack(anchor="w", padx=padx_c, pady=pady_c)
729
+ self.combo_shape = ttk.Combobox(self.sidebar_content, values=["Square", "Circle", "Oval", "Diamond", "Inverted Triangle"], state="readonly", width=26)
730
+ self.combo_shape.set("Square")
731
+ self.combo_shape.pack(anchor="w", padx=padx_c)
732
+
733
+ # Node Description
734
+ lbl_desc = ttk.Label(self.sidebar_content, text="Description (Inside Shape):", background="#ffffff")
735
+ lbl_desc.pack(anchor="w", padx=padx_c, pady=pady_c)
736
+ self.entry_desc = ttk.Entry(self.sidebar_content, width=28)
737
+ self.entry_desc.pack(anchor="w", padx=padx_c)
738
+
739
+ # Node Action Buttons
740
+ btn_frame_node = tk.Frame(self.sidebar_content, bg="#ffffff")
741
+ btn_frame_node.pack(anchor="w", padx=padx_c, pady=(10, 10))
742
+
743
+ self.btn_add_node = ttk.Button(btn_frame_node, text="Add/Save Node", style="Primary.TButton", command=self.add_or_update_node)
744
+ self.btn_add_node.grid(row=0, column=0, padx=(0, 5))
745
+
746
+ self.btn_delete_node = ttk.Button(btn_frame_node, text="Delete Node", command=self.delete_node)
747
+ self.btn_delete_node.grid(row=0, column=1)
748
+
749
+ # Divider Line
750
+ ttk.Separator(self.sidebar_content, orient=tk.HORIZONTAL).pack(fill=tk.X, padx=padx_c, pady=10)
751
+
752
+ # --- Edge Editor Section ---
753
+ lbl_edges_title = tk.Label(self.sidebar_content, text="EDGE MANAGER", font=("Segoe UI", 11, "bold"), bg="#ffffff", fg="#212529")
754
+ lbl_edges_title.pack(anchor="w", padx=padx_c, pady=(5, 5))
755
+
756
+ # Edge sequence description
757
+ lbl_edge_path = ttk.Label(self.sidebar_content, text="Path (e.g. A -> B -> C):", background="#ffffff")
758
+ lbl_edge_path.pack(anchor="w", padx=padx_c, pady=pady_c)
759
+ self.entry_edge_path = ttk.Entry(self.sidebar_content, width=28)
760
+ self.entry_edge_path.pack(anchor="w", padx=padx_c)
761
+
762
+ # Edge style
763
+ lbl_edge_style = ttk.Label(self.sidebar_content, text="Connector Line Style:", background="#ffffff")
764
+ lbl_edge_style.pack(anchor="w", padx=padx_c, pady=pady_c)
765
+ self.combo_edge_style = ttk.Combobox(self.sidebar_content, values=["Straight", "Curved", "Dotted"], state="readonly", width=26)
766
+ self.combo_edge_style.set("Straight")
767
+ self.combo_edge_style.pack(anchor="w", padx=padx_c)
768
+
769
+ # Edge Action Buttons
770
+ btn_frame_edge = tk.Frame(self.sidebar_content, bg="#ffffff")
771
+ btn_frame_edge.pack(anchor="w", padx=padx_c, pady=(10, 10))
772
+
773
+ btn_add_edge = ttk.Button(btn_frame_edge, text="Add Edges", style="Primary.TButton", command=self.add_edges)
774
+ btn_add_edge.grid(row=0, column=0, padx=(0, 5))
775
+
776
+ # Divider Line
777
+ ttk.Separator(self.sidebar_content, orient=tk.HORIZONTAL).pack(fill=tk.X, padx=padx_c, pady=10)
778
+
779
+ # --- List Review & Clear ---
780
+ lbl_list_title = tk.Label(self.sidebar_content, text="EDGE LIST", font=("Segoe UI", 10, "bold"), bg="#ffffff", fg="#495057")
781
+ lbl_list_title.pack(anchor="w", padx=padx_c, pady=(5, 2))
782
+
783
+ # Listbox for edges
784
+ self.edge_listbox = tk.Listbox(self.sidebar_content, width=30, height=5, font=("Segoe UI", 9), relief=tk.SOLID, borderwidth=1)
785
+ self.edge_listbox.pack(anchor="w", padx=padx_c, pady=pady_c)
786
+
787
+ btn_delete_edge = ttk.Button(self.sidebar_content, text="Delete Selected Edge", command=self.delete_edge)
788
+ btn_delete_edge.pack(anchor="w", padx=padx_c, pady=(2, 10))
789
+
790
+ # --- Project Operations ---
791
+ ttk.Separator(self.sidebar_content, orient=tk.HORIZONTAL).pack(fill=tk.X, padx=padx_c, pady=10)
792
+
793
+ lbl_ops_title = tk.Label(self.sidebar_content, text="ACTIONS", font=("Segoe UI", 11, "bold"), bg="#ffffff", fg="#212529")
794
+ lbl_ops_title.pack(anchor="w", padx=padx_c, pady=(5, 5))
795
+
796
+ # Grid layout for bottom action buttons
797
+ actions_frame = tk.Frame(self.sidebar_content, bg="#ffffff")
798
+ actions_frame.pack(anchor="w", padx=padx_c, pady=(5, 20))
799
+
800
+ btn_auto_layout = ttk.Button(actions_frame, text="Auto-Layout", command=self.run_auto_layout)
801
+ btn_auto_layout.grid(row=0, column=0, padx=2, pady=2, sticky="ew")
802
+
803
+ btn_clear = ttk.Button(actions_frame, text="Clear All", command=self.clear_all)
804
+ btn_clear.grid(row=0, column=1, padx=2, pady=2, sticky="ew")
805
+
806
+ btn_save = ttk.Button(actions_frame, text="Save Project", command=self.save_project)
807
+ btn_save.grid(row=1, column=0, padx=2, pady=2, sticky="ew")
808
+
809
+ btn_load = ttk.Button(actions_frame, text="Load Project", command=self.load_project)
810
+ btn_load.grid(row=1, column=1, padx=2, pady=2, sticky="ew")
811
+
812
+ btn_export = ttk.Button(actions_frame, text="Export PNG", style="Primary.TButton", command=self.export_png)
813
+ btn_export.grid(row=2, column=0, columnspan=2, padx=2, pady=5, sticky="ew")
814
+
815
+ # 2. Right Canvas Panel
816
+ self.canvas_panel = tk.Frame(self, bg="#ffffff", bd=1, relief=tk.SOLID)
817
+ self.canvas_panel.grid(row=0, column=1, sticky="nsew", padx=(0, 10), pady=10)
818
+ self.canvas_panel.rowconfigure(0, weight=1)
819
+ self.canvas_panel.columnconfigure(0, weight=1)
820
+
821
+ # Create Flowchart Canvas widget
822
+ self.f_canvas = FlowchartCanvas(self.canvas_panel, self.model, select_callback=self.on_canvas_select)
823
+
824
+ def on_canvas_select(self, node_id):
825
+ """Callback when a node is clicked on the canvas."""
826
+ if node_id:
827
+ node = self.model.nodes.get(node_id)
828
+ if node:
829
+ # Load fields
830
+ self.entry_node_id.delete(0, tk.END)
831
+ self.entry_node_id.insert(0, node.id)
832
+ self.combo_shape.set(node.shape.title())
833
+ self.entry_desc.delete(0, tk.END)
834
+ self.entry_desc.insert(0, node.description)
835
+ self.btn_add_node.config(text="Save Node")
836
+ else:
837
+ # Clear fields
838
+ self.entry_node_id.delete(0, tk.END)
839
+ self.combo_shape.set("Square")
840
+ self.entry_desc.delete(0, tk.END)
841
+ self.btn_add_node.config(text="Add Node")
842
+
843
+ def add_or_update_node(self):
844
+ node_id = self.entry_node_id.get().strip()
845
+ shape = self.combo_shape.get().strip()
846
+ desc = self.entry_desc.get().strip()
847
+
848
+ if not node_id:
849
+ messagebox.showwarning("Input Error", "Node Designation (ID) is required.")
850
+ return
851
+
852
+ is_update = node_id in self.model.nodes
853
+
854
+ if is_update:
855
+ # Update existing node attributes
856
+ node = self.model.nodes[node_id]
857
+ node.shape = shape.lower()
858
+ node.description = desc
859
+ node.w, node.h = node.default_sizes() # update size if shape changed
860
+ else:
861
+ # Create a new node in center of current screen
862
+ # Calculate approx canvas center coordinates
863
+ cx = self.f_canvas.canvas.canvasx(self.f_canvas.canvas.winfo_width() / 2)
864
+ cy = self.f_canvas.canvas.canvasy(self.f_canvas.canvas.winfo_height() / 2)
865
+
866
+ # Avoid placing directly on top of each other
867
+ if not cx or cx < 100: cx = 200
868
+ if not cy or cy < 100: cy = 200
869
+
870
+ success, node = self.model.add_node(node_id, shape, desc, x=cx, y=cy)
871
+ if not success:
872
+ messagebox.showerror("Error", node)
873
+ return
874
+
875
+ self.f_canvas.redraw()
876
+ self.f_canvas.update_scroll_region()
877
+ self.refresh_edge_listbox()
878
+
879
+ # Reset input form
880
+ self.on_canvas_select(None)
881
+
882
+ def delete_node(self):
883
+ node_id = self.entry_node_id.get().strip()
884
+ if not node_id:
885
+ messagebox.showwarning("Select Node", "Select or type a node designation to delete.")
886
+ return
887
+
888
+ if node_id in self.model.nodes:
889
+ confirm = messagebox.askyesno("Delete Node", f"Are you sure you want to delete node '{node_id}' and all connecting edges?")
890
+ if confirm:
891
+ self.model.delete_node(node_id)
892
+ self.f_canvas.selected_node_id = None
893
+ self.f_canvas.redraw()
894
+ self.f_canvas.update_scroll_region()
895
+ self.refresh_edge_listbox()
896
+ self.on_canvas_select(None)
897
+ else:
898
+ messagebox.showerror("Not Found", f"Node '{node_id}' does not exist.")
899
+
900
+ def add_edges(self):
901
+ path = self.entry_edge_path.get().strip()
902
+ style = self.combo_edge_style.get().lower()
903
+
904
+ if not path:
905
+ messagebox.showwarning("Input Error", "Please specify an edge path (e.g. A -> B).")
906
+ return
907
+
908
+ success, msg = self.model.add_edge_path(path, style)
909
+ if success:
910
+ self.entry_edge_path.delete(0, tk.END)
911
+ self.f_canvas.redraw()
912
+ self.refresh_edge_listbox()
913
+ else:
914
+ messagebox.showerror("Error", msg)
915
+
916
+ def delete_edge(self):
917
+ selected_idx = self.edge_listbox.curselection()
918
+ if not selected_idx:
919
+ messagebox.showwarning("Select Edge", "Select an edge from the Edge List to delete.")
920
+ return
921
+
922
+ idx = selected_idx[0]
923
+ if self.model.remove_edge(idx):
924
+ self.f_canvas.redraw()
925
+ self.refresh_edge_listbox()
926
+
927
+ def refresh_edge_listbox(self):
928
+ self.edge_listbox.delete(0, tk.END)
929
+ for i, edge in enumerate(self.model.edges):
930
+ self.edge_listbox.insert(tk.END, f"{edge.u} -> {edge.v} ({edge.style.title()})")
931
+
932
+ def run_auto_layout(self):
933
+ if not self.model.nodes:
934
+ return
935
+ self.model.auto_layout()
936
+ self.f_canvas.redraw()
937
+ self.f_canvas.update_scroll_region()
938
+
939
+ def clear_all(self):
940
+ if messagebox.askyesno("Clear All", "Delete all nodes and edges from the workspace?"):
941
+ self.model.clear()
942
+ self.f_canvas.selected_node_id = None
943
+ self.f_canvas.redraw()
944
+ self.f_canvas.update_scroll_region()
945
+ self.refresh_edge_listbox()
946
+ self.on_canvas_select(None)
947
+
948
+ def save_project(self):
949
+ filename = filedialog.asksaveasfilename(
950
+ defaultextension=".json",
951
+ filetypes=[("JSON files", "*.json"), ("All Files", "*.*")],
952
+ title="Save Flowchart Project"
953
+ )
954
+ if filename:
955
+ try:
956
+ self.model.save_to_file(filename)
957
+ messagebox.showinfo("Saved", "Project saved successfully.")
958
+ except Exception as e:
959
+ messagebox.showerror("Error", f"Could not save project: {e}")
960
+
961
+ def load_project(self):
962
+ filename = filedialog.askopenfilename(
963
+ filetypes=[("JSON files", "*.json"), ("All Files", "*.*")],
964
+ title="Load Flowchart Project"
965
+ )
966
+ if filename:
967
+ try:
968
+ self.model.load_from_file(filename)
969
+ self.f_canvas.selected_node_id = None
970
+ self.f_canvas.redraw()
971
+ self.f_canvas.update_scroll_region()
972
+ self.refresh_edge_listbox()
973
+ self.on_canvas_select(None)
974
+ messagebox.showinfo("Loaded", "Project loaded successfully.")
975
+ except Exception as e:
976
+ messagebox.showerror("Error", f"Could not load project: {e}")
977
+
978
+ def load_samples(self):
979
+ # Create a beautiful sample flowchart to show on startup
980
+ self.model.add_node("Start", "Circle", "Start", 400.0, 80.0)
981
+ self.model.add_node("Step 1", "Square", "Process Inputs", 400.0, 200.0)
982
+ self.model.add_node("Check", "Diamond", "Valid?", 400.0, 330.0)
983
+ self.model.add_node("Error", "Inverted Triangle", "Log Error", 220.0, 330.0)
984
+ self.model.add_node("End", "Oval", "Success Finish", 400.0, 460.0)
985
+
986
+ self.model.add_edge_path("Start -> Step 1", "straight")
987
+ self.model.add_edge_path("Step 1 -> Check", "straight")
988
+ self.model.add_edge_path("Check -> Error", "straight")
989
+ self.model.add_edge_path("Check -> End", "straight")
990
+ self.model.add_edge_path("Error -> Start", "curved") # curve looping back
991
+
992
+ self.f_canvas.redraw()
993
+ self.f_canvas.update_scroll_region()
994
+ self.refresh_edge_listbox()
995
+
996
+ # ==========================================
997
+ # PIL Render & Export PNG
998
+ # ==========================================
999
+
1000
+ def export_png(self):
1001
+ if not self.model.nodes:
1002
+ messagebox.showwarning("Empty Project", "No nodes to export.")
1003
+ return
1004
+
1005
+ filename = filedialog.asksaveasfilename(
1006
+ defaultextension=".png",
1007
+ filetypes=[("PNG files", "*.png"), ("All Files", "*.*")],
1008
+ title="Export Flowchart to Image"
1009
+ )
1010
+ if not filename:
1011
+ return
1012
+
1013
+ try:
1014
+ # 1. Determine bounding box with padding
1015
+ xs = [n.x for n in self.model.nodes.values()]
1016
+ ys = [n.y for n in self.model.nodes.values()]
1017
+
1018
+ # Find bounds using outer coordinates
1019
+ min_x = min(n.x - n.w/2 for n in self.model.nodes.values())
1020
+ max_x = max(n.x + n.w/2 for n in self.model.nodes.values())
1021
+ min_y = min(n.y - n.h/2 for n in self.model.nodes.values())
1022
+ max_y = max(n.y + n.h/2 for n in self.model.nodes.values())
1023
+
1024
+ margin = 50.0
1025
+ crop_x1 = min_x - margin
1026
+ crop_y1 = min_y - margin
1027
+ crop_x2 = max_x + margin
1028
+ crop_y2 = max_y + margin
1029
+
1030
+ img_w = int(crop_x2 - crop_x1)
1031
+ img_h = int(crop_y2 - crop_y1)
1032
+
1033
+ # Create white Pillow canvas
1034
+ img = Image.new("RGB", (img_w, img_h), "white")
1035
+ draw = ImageDraw.Draw(img)
1036
+
1037
+ # Helper to map global coordinates to cropped coordinates
1038
+ def get_pt(cx, cy):
1039
+ return int(cx - crop_x1), int(cy - crop_y1)
1040
+
1041
+ # Load clean fonts
1042
+ font_bold = get_pil_font("segoeui", 10, bold=True)
1043
+ font_reg = get_pil_font("segoeui", 9, bold=False)
1044
+
1045
+ # Define measurement helper for PIL wrapping
1046
+ if hasattr(draw, 'textlength'):
1047
+ measure_fn = lambda txt: draw.textlength(txt, font=font_reg)
1048
+ else:
1049
+ measure_fn = lambda txt: font_reg.getsize(txt)[0]
1050
+
1051
+ # Helper to draw PIL custom arrow
1052
+ def draw_pil_arrowhead(p_end, p_from):
1053
+ xe, ye = get_pt(p_end[0], p_end[1])
1054
+ xf, yf = get_pt(p_from[0], p_from[1])
1055
+ dx = xe - xf
1056
+ dy = ye - yf
1057
+ dist = math.hypot(dx, dy)
1058
+ if dist == 0:
1059
+ return
1060
+ ux = dx / dist
1061
+ uy = dy / dist
1062
+
1063
+ arrow_l = 12
1064
+ arrow_w = 8
1065
+ bx = xe - ux * arrow_l
1066
+ by = ye - uy * arrow_l
1067
+ px = -uy * (arrow_w / 2)
1068
+ py = ux * (arrow_w / 2)
1069
+
1070
+ pts = [(xe, ye), (bx + px, by + py), (bx - px, by - py)]
1071
+ draw.polygon(pts, fill="black", outline="black")
1072
+
1073
+ # Helper to draw centered multiline text in PIL
1074
+ def draw_centered_text_pil(xy, text, font, fill="black"):
1075
+ lines = text.split("\n")
1076
+ # Get a constant line height for the font using a representative string
1077
+ try:
1078
+ bbox = font.getbbox("Abgqp")
1079
+ line_h = bbox[3] - bbox[1]
1080
+ except:
1081
+ line_h = font.getsize("Abgqp")[1]
1082
+
1083
+ line_widths = []
1084
+ for line in lines:
1085
+ if hasattr(draw, 'textlength'):
1086
+ w = draw.textlength(line, font=font)
1087
+ else:
1088
+ w = font.getsize(line)[0]
1089
+ line_widths.append(w)
1090
+
1091
+ total_height = line_h * len(lines) + 4 * (len(lines) - 1)
1092
+ curr_y = xy[1] - total_height / 2
1093
+
1094
+ for i, line in enumerate(lines):
1095
+ w = line_widths[i]
1096
+ draw.text((xy[0] - w/2, curr_y), line, font=font, fill=fill)
1097
+ curr_y += line_h + 4
1098
+
1099
+ # 2. Draw Edges first (so they render behind nodes)
1100
+ for edge in self.model.edges:
1101
+ u_node = self.model.nodes.get(edge.u)
1102
+ v_node = self.model.nodes.get(edge.v)
1103
+ if not u_node or not v_node:
1104
+ continue
1105
+
1106
+ # Curved Line
1107
+ if edge.style == "curved":
1108
+ dx = v_node.x - u_node.x
1109
+ dy = v_node.y - u_node.y
1110
+ dist = math.hypot(dx, dy)
1111
+ mx = (u_node.x + v_node.x) / 2
1112
+ my = (u_node.y + v_node.y) / 2
1113
+
1114
+ if dist > 0:
1115
+ nx = -dy / dist
1116
+ ny = dx / dist
1117
+ p_ctrl = (mx + nx * 45, my + ny * 45)
1118
+ else:
1119
+ p_ctrl = (mx, my + 45)
1120
+
1121
+ p_start = get_boundary_intersection(u_node, p_ctrl)
1122
+ p_end = get_boundary_intersection(v_node, p_ctrl)
1123
+
1124
+ bezier_pts = get_quadratic_bezier_points(p_start, p_ctrl, p_end, 30)
1125
+ mapped_pts = [get_pt(px, py) for px, py in bezier_pts]
1126
+
1127
+ if edge.style == "dotted":
1128
+ draw_dashed_line_pil(draw, mapped_pts, fill="black", width=2, dash_len=4, gap_len=4)
1129
+ else:
1130
+ draw.line(mapped_pts, fill="black", width=2)
1131
+
1132
+ draw_pil_arrowhead(p_end, p_ctrl)
1133
+
1134
+ # Straight or Dotted Line
1135
+ else:
1136
+ p_start = get_boundary_intersection(u_node, (v_node.x, v_node.y))
1137
+ p_end = get_boundary_intersection(v_node, (u_node.x, u_node.y))
1138
+
1139
+ p_start_m = get_pt(p_start[0], p_start[1])
1140
+ p_end_m = get_pt(p_end[0], p_end[1])
1141
+
1142
+ if edge.style == "dotted":
1143
+ # interpolate a few points to draw dashed line
1144
+ steps = int(math.hypot(p_end_m[0] - p_start_m[0], p_end_m[1] - p_start_m[1]) / 6)
1145
+ if steps < 2: steps = 2
1146
+ pts = []
1147
+ for s in range(steps + 1):
1148
+ t = s / steps
1149
+ pts.append((p_start_m[0] + t * (p_end_m[0] - p_start_m[0]), p_start_m[1] + t * (p_end_m[1] - p_start_m[1])))
1150
+ draw_dashed_line_pil(draw, pts, fill="black", width=2, dash_len=4, gap_len=4)
1151
+ else:
1152
+ draw.line([p_start_m, p_end_m], fill="black", width=2)
1153
+
1154
+ draw_pil_arrowhead(p_end, p_start)
1155
+
1156
+ # 3. Draw Nodes
1157
+ for node in self.model.nodes.values():
1158
+ cx, cy, w, h = node.x, node.y, node.w, node.h
1159
+
1160
+ # Bounding coordinates in PIL layout
1161
+ px, py = get_pt(cx, cy)
1162
+
1163
+ # --- Drop Shadow ---
1164
+ sh = 4
1165
+ shadow_color = (233, 236, 239) # #e9ecef
1166
+
1167
+ if node.shape in ("circle", "oval"):
1168
+ draw.ellipse([px - w/2 + sh, py - h/2 + sh, px + w/2 + sh, py + h/2 + sh], fill=shadow_color, outline=None)
1169
+ elif node.shape == "square":
1170
+ draw.rectangle([px - w/2 + sh, py - h/2 + sh, px + w/2 + sh, py + h/2 + sh], fill=shadow_color, outline=None)
1171
+ else:
1172
+ sh_vertices = [(vx - crop_x1 + sh, vy - crop_y1 + sh) for vx, vy in node.get_vertices()]
1173
+ draw.polygon(sh_vertices, fill=shadow_color, outline=None)
1174
+
1175
+ # --- Main Shape ---
1176
+ if node.shape in ("circle", "oval"):
1177
+ draw.ellipse([px - w/2, py - h/2, px + w/2, py + h/2], fill="white", outline="black", width=2)
1178
+ elif node.shape == "square":
1179
+ draw.rectangle([px - w/2, py - h/2, px + w/2, py + h/2], fill="white", outline="black", width=2)
1180
+ else:
1181
+ sh_vertices = [(vx - crop_x1, vy - crop_y1) for vx, vy in node.get_vertices()]
1182
+ # Draw filled shape
1183
+ draw.polygon(sh_vertices, fill="white")
1184
+ # Draw thick border (polygon outlines don't support custom width in older Pillow)
1185
+ draw.line(sh_vertices + [sh_vertices[0]], fill="black", width=2, joint="curve")
1186
+
1187
+ # --- Text Content ---
1188
+ max_w = node.get_max_text_width()
1189
+
1190
+ if node.description:
1191
+ desc_wrapped = wrap_text_by_width(node.description, max_w, measure_fn)
1192
+
1193
+ # Designation (Bold, centered slightly higher)
1194
+ draw_centered_text_pil((px, py - 12), node.id, font_bold, fill="black")
1195
+ # Description (Regular, centered slightly lower)
1196
+ draw_centered_text_pil((px, py + 10), desc_wrapped, font_reg, fill=(73, 80, 87))
1197
+ else:
1198
+ # Designation only
1199
+ draw_centered_text_pil((px, py), node.id, font_bold, fill="black")
1200
+
1201
+ # Save PIL Image
1202
+ img.save(filename, "PNG")
1203
+ messagebox.showinfo("Export Success", f"Flowchart exported successfully to:\n{filename}")
1204
+
1205
+ except Exception as e:
1206
+ messagebox.showerror("Export Failed", f"Could not export flowchart: {e}")
1207
+
1208
+ def draw_dashed_line_pil(draw, points, fill="black", width=2, dash_len=6, gap_len=6):
1209
+ """Walks along a list of connected points and draws dashes in PIL."""
1210
+ if len(points) < 2:
1211
+ return
1212
+
1213
+ current_dash_left = dash_len
1214
+ current_gap_left = 0
1215
+ drawing = True
1216
+
1217
+ for i in range(len(points) - 1):
1218
+ p1 = points[i]
1219
+ p2 = points[i+1]
1220
+
1221
+ seg_dx = p2[0] - p1[0]
1222
+ seg_dy = p2[1] - p1[1]
1223
+ seg_len = math.hypot(seg_dx, seg_dy)
1224
+ if seg_len == 0:
1225
+ continue
1226
+
1227
+ vx = seg_dx / seg_len
1228
+ vy = seg_dy / seg_len
1229
+
1230
+ dist_moved = 0
1231
+ curr_pt = p1
1232
+
1233
+ while dist_moved < seg_len:
1234
+ if drawing:
1235
+ step = min(seg_len - dist_moved, current_dash_left)
1236
+ next_pt = (curr_pt[0] + vx * step, curr_pt[1] + vy * step)
1237
+ draw.line([curr_pt, next_pt], fill=fill, width=width)
1238
+ dist_moved += step
1239
+ current_dash_left -= step
1240
+ curr_pt = next_pt
1241
+ if current_dash_left <= 0:
1242
+ drawing = False
1243
+ current_gap_left = gap_len
1244
+ else:
1245
+ step = min(seg_len - dist_moved, current_gap_left)
1246
+ next_pt = (curr_pt[0] + vx * step, curr_pt[1] + vy * step)
1247
+ dist_moved += step
1248
+ current_gap_left -= step
1249
+ curr_pt = next_pt
1250
+ if current_gap_left <= 0:
1251
+ drawing = True
1252
+ current_dash_left = dash_len
1253
+
1254
+
1255
+ if __name__ == "__main__":
1256
+ app = FlowchartApp()
1257
+ app.mainloop()
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ Pillow
2
+ streamlit