knit-graphs 0.0.6__py3-none-any.whl → 0.0.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. knit_graphs-0.0.6.dist-info/licenses/LICENSE → LICENSE +21 -21
  2. README.md +75 -0
  3. docs/Makefile +20 -0
  4. docs/make.bat +35 -0
  5. docs/source/api/knit_graphs.Course.rst +7 -0
  6. docs/source/api/knit_graphs.Knit_Graph.rst +7 -0
  7. docs/source/api/knit_graphs.Knit_Graph_Visualizer.rst +7 -0
  8. docs/source/api/knit_graphs.Loop.rst +7 -0
  9. docs/source/api/knit_graphs.Pull_Direction.rst +7 -0
  10. docs/source/api/knit_graphs.Yarn.rst +7 -0
  11. docs/source/api/knit_graphs.artin_wale_braids.Crossing_Direction.rst +7 -0
  12. docs/source/api/knit_graphs.artin_wale_braids.Loop_Braid_Graph.rst +7 -0
  13. docs/source/api/knit_graphs.artin_wale_braids.Wale.rst +7 -0
  14. docs/source/api/knit_graphs.artin_wale_braids.Wale_Braid.rst +7 -0
  15. docs/source/api/knit_graphs.artin_wale_braids.Wale_Braid_Word.rst +7 -0
  16. docs/source/api/knit_graphs.artin_wale_braids.Wale_Group.rst +7 -0
  17. docs/source/api/knit_graphs.artin_wale_braids.rst +23 -0
  18. docs/source/api/knit_graphs.basic_knit_graph_generators.rst +7 -0
  19. docs/source/api/knit_graphs.rst +32 -0
  20. docs/source/conf.py +335 -0
  21. docs/source/index.rst +71 -0
  22. docs/source/installation.rst +67 -0
  23. knit_graphs/Course.py +156 -104
  24. knit_graphs/Knit_Graph.py +249 -186
  25. knit_graphs/Knit_Graph_Visualizer.py +675 -0
  26. knit_graphs/Loop.py +141 -155
  27. knit_graphs/Pull_Direction.py +68 -23
  28. knit_graphs/Yarn.py +424 -267
  29. knit_graphs/__init__.py +3 -3
  30. knit_graphs/_base_classes.py +173 -0
  31. knit_graphs/artin_wale_braids/Crossing_Direction.py +74 -15
  32. knit_graphs/artin_wale_braids/Loop_Braid_Graph.py +95 -62
  33. knit_graphs/artin_wale_braids/Wale.py +169 -93
  34. knit_graphs/artin_wale_braids/Wale_Braid.py +50 -30
  35. knit_graphs/artin_wale_braids/Wale_Braid_Word.py +99 -54
  36. knit_graphs/artin_wale_braids/Wale_Group.py +136 -88
  37. knit_graphs/basic_knit_graph_generators.py +251 -0
  38. knit_graphs-0.0.8.dist-info/LICENSE +21 -0
  39. {knit_graphs-0.0.6.dist-info → knit_graphs-0.0.8.dist-info}/METADATA +33 -24
  40. knit_graphs-0.0.8.dist-info/RECORD +42 -0
  41. {knit_graphs-0.0.6.dist-info → knit_graphs-0.0.8.dist-info}/WHEEL +1 -1
  42. knit_graphs/__about__.py +0 -4
  43. knit_graphs/knit_graph_generators/__init__.py +0 -0
  44. knit_graphs/knit_graph_generators/basic_knit_graph_generators.py +0 -248
  45. knit_graphs/knit_graph_visualizer/Stitch_Visualizer.py +0 -427
  46. knit_graphs/knit_graph_visualizer/__init__.py +0 -0
  47. knit_graphs-0.0.6.dist-info/RECORD +0 -22
@@ -0,0 +1,675 @@
1
+ """Module used to visualize a Knit graph with the plotly graph object library.
2
+
3
+ This module provides comprehensive visualization capabilities for knit graphs using Plotly.
4
+ It handles the positioning of loops, rendering of yarn paths, stitch edges, and cable crossings to create interactive 2D visualizations of knitted structures.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import sys
10
+ from typing import Iterable, cast
11
+
12
+ import plotly.io as pio
13
+ from networkx import DiGraph
14
+ from plotly.graph_objs import Figure, Layout, Scatter
15
+
16
+ from knit_graphs.artin_wale_braids.Crossing_Direction import Crossing_Direction
17
+ from knit_graphs.Course import Course
18
+ from knit_graphs.Knit_Graph import Knit_Graph
19
+ from knit_graphs.Loop import Loop
20
+ from knit_graphs.Pull_Direction import Pull_Direction
21
+
22
+
23
+ def configure_plotly_environment() -> None:
24
+ """Auto-configure Plotly based on environment detection to avoid socket issues."""
25
+ # Check if we're in a testing environment
26
+ if ('pytest' in sys.modules or 'unittest' in sys.modules or
27
+ os.environ.get('TESTING') or os.environ.get('CI')):
28
+ # For testing, don't set a default renderer - we'll handle this with show_figure=False
29
+ pass
30
+ elif 'ipykernel' in sys.modules or 'jupyter' in sys.modules:
31
+ # Jupyter environment - use notebook renderer
32
+ pio.renderers.default = "notebook"
33
+ else:
34
+ # Development environment - use browser but with timeout to prevent hanging
35
+ pio.renderers.default = "browser"
36
+
37
+
38
+ # Configure environment on import
39
+ configure_plotly_environment()
40
+
41
+
42
+ class Knit_Graph_Visualizer:
43
+ """A class used to visualize a knit graph using the plotly graph objects library.
44
+
45
+ This class converts knit graph data structures into interactive 2D visualizations by calculating loop positions,
46
+ rendering yarn paths, and displaying stitch relationships with appropriate styling for different stitch types and cable crossings.
47
+
48
+ Attributes:
49
+ knit_graph (Knit_Graph): The knit graph to visualize.
50
+ courses (list[Course]): List of courses (horizontal rows) in the knit graph.
51
+ base_width (float): The width of the base course used for scaling.
52
+ base_left (float): The leftmost position of the base course.
53
+ loops_to_course (dict[Loop, Course]): Mapping from loops to their containing courses.
54
+ data_graph (DiGraph): Internal graph for storing loop positions and visualization data.
55
+ left_zero_align (bool): Whether to align the left edge of courses to zero.
56
+ balance_by_base_width (bool): Whether to scale course widths to match the base course.
57
+ start_on_left (bool): Whether to start knitting visualization from the left side.
58
+ top_course_index (int): The index of the topmost course to visualize.
59
+ first_course_index (int): The index of the first (bottom) course to visualize.
60
+ """
61
+
62
+ def __init__(self, knit_graph: Knit_Graph, first_course_index: int = 0, top_course_index: int | None = None,
63
+ start_on_left: bool = True,
64
+ balance_by_base_width: bool = False,
65
+ left_zero_align: bool = True):
66
+ """Initialize the knit graph visualizer with specified configuration options.
67
+
68
+ Args:
69
+ knit_graph (Knit_Graph): The knit graph to be visualized.
70
+ first_course_index (int, optional): The index of the first course to include in the visualization. Defaults to 0.
71
+ top_course_index (int | None, optional): The index of the last course to include in the visualization. If None, includes all courses up to the top.
72
+ start_on_left (bool, optional): Whether to position the first loop on the left side of the visualization. Defaults to True.
73
+ balance_by_base_width (bool, optional): Whether to scale all course widths to match the base course width. Defaults to False.
74
+ left_zero_align (bool, optional): Whether to align the leftmost loop of each course to x=0. Defaults to True.
75
+ """
76
+ self.left_zero_align: bool = left_zero_align
77
+ self.balance_by_base_width: bool = balance_by_base_width
78
+ self.start_on_left: bool = start_on_left
79
+ self.knit_graph: Knit_Graph = knit_graph
80
+ self.courses: list[Course] = knit_graph.get_courses()
81
+ if top_course_index is None:
82
+ top_course_index = len(self.courses)
83
+ self.top_course_index: int = top_course_index
84
+ self.first_course_index: int = first_course_index
85
+ self.base_width: float = float(len(self.courses[first_course_index])) # Updates when creating base course.
86
+ self.base_left: float = 0.0 # Updates when creating the base course.
87
+ self.loops_to_course: dict[Loop, Course] = {}
88
+ for course in self.courses:
89
+ self.loops_to_course.update({loop: course for loop in course})
90
+ self.data_graph: DiGraph = DiGraph()
91
+ self._loops_need_placement: set[Loop] = set()
92
+ self._loop_markers: list[Scatter] = []
93
+ self._yarn_traces: list[Scatter] = []
94
+ self._top_knit_trace_data: dict[str: list[float] | list[tuple[Loop, Loop] | bool]] = {'x': [], 'y': [], 'edge': [], 'is_start': []}
95
+ self._bot_knit_trace_data: dict[str: list[float] | list[tuple[Loop, Loop] | bool]] = {'x': [], 'y': [], 'edge': [], 'is_start': []}
96
+ self._top_purl_trace_data: dict[str: list[float] | list[tuple[Loop, Loop] | bool]] = {'x': [], 'y': [], 'edge': [], 'is_start': []}
97
+ self._bot_purl_trace_data: dict[str: list[float] | list[tuple[Loop, Loop] | bool]] = {'x': [], 'y': [], 'edge': [], 'is_start': []}
98
+ self._knit_trace_data: dict[str: list[float] | list[tuple[Loop, Loop] | bool]] = {'x': [], 'y': [], 'edge': [], 'is_start': []}
99
+ self._purl_trace_data: dict[str: list[float] | list[tuple[Loop, Loop] | bool]] = {'x': [], 'y': [], 'edge': [], 'is_start': []}
100
+ # Form the visualization.
101
+ self._position_loops()
102
+ self._set_loop_markers()
103
+ self._set_yarn_traces()
104
+ self._add_stitch_edges()
105
+
106
+ def make_figure(self, graph_title: str = "Knit Graph") -> Figure:
107
+ """Generate the interactive figure to visualize this knit graph.
108
+
109
+ Args:
110
+ graph_title (str, optional): The title to display on the figure. Defaults to "Knit Graph".
111
+
112
+ Returns:
113
+ Figure: The plotly figure object.
114
+ """
115
+ go_layout = Layout(title=graph_title,
116
+ showlegend=True,
117
+ hovermode='closest',
118
+ margin=dict(b=20, l=5, r=5, t=40)
119
+ )
120
+ figure_data = [self._top_knit_trace(), self._top_purl_trace(),
121
+ self._no_cross_knit_trace(), self._no_cross_purl_trace(),
122
+ self._bot_knit_trace(), self._bot_purl_trace()]
123
+ figure_data.extend(self._yarn_traces)
124
+ figure_data.extend(self._loop_markers)
125
+ fig = Figure(data=figure_data,
126
+ layout=go_layout)
127
+ return fig
128
+
129
+ def show_figure(self, graph_title: str = "Knit Graph", renderer: str | None = None) -> None:
130
+ """Generate and display the interactive figure to visualize this knit graph.
131
+
132
+ Args:
133
+ graph_title (str, optional): The title to display on the figure. Defaults to "Knit Graph".
134
+ renderer (str, optional): Plotly renderer to use. If None, uses the default configured renderer.
135
+ """
136
+ fig = self.make_figure(graph_title)
137
+
138
+ # Configure display to minimize resource usage
139
+ config = {
140
+ 'displayModeBar': False, # Hide toolbar to reduce resource usage
141
+ 'displaylogo': False, # Hide plotly logo
142
+ 'staticPlot': False, # Keep interactive
143
+ 'scrollZoom': False, # Allow zoom
144
+ 'doubleClick': 'reset+autosize' # Double-click behavior
145
+ }
146
+
147
+ try:
148
+ if renderer:
149
+ fig.show(renderer=renderer, config=config)
150
+ else:
151
+ fig.show(config=config)
152
+ except Exception as e:
153
+ print(f"Warning: Could not display figure: {e}")
154
+ print("Figure created successfully but display failed. Consider using show_figure=False and accessing the returned Figure object directly.")
155
+
156
+ def _no_cross_knit_trace(self, line_width: float = 4.0, knit_color: str = 'blue') -> Scatter:
157
+ """Create a scatter trace for knit stitches not involved in cable crossings.
158
+
159
+ Args:
160
+ line_width (float, optional): The width of the lines representing the stitch edges. Defaults to 4.0.
161
+ knit_color (str, optional): The color of knit stitches in the visualization. Defaults to 'blue'.
162
+
163
+ Returns:
164
+ Scatter: The plotly scatter object used to visualize knit stitches not involved in cables.
165
+ """
166
+ return self._stitch_trace(self._knit_trace_data, "Knit Stitches", knit_color, line_width, opacity=0.8)
167
+
168
+ def _top_knit_trace(self, line_width: float = 5.0, knit_color: str = 'blue') -> Scatter:
169
+ """Create a scatter trace for knit stitches that cross over other stitches in cables.
170
+
171
+ Args:
172
+ line_width (float, optional): The width of the lines representing the stitch edges. Defaults to 5.0.
173
+ knit_color (str, optional): The color of knit stitches in the visualization. Defaults to 'blue'.
174
+
175
+ Returns:
176
+ Scatter: The plotly scatter object used to visualize knit stitches on top of cable crossings.
177
+ """
178
+ return self._stitch_trace(self._top_knit_trace_data, "Knit Stitches on Top of Cable", knit_color, line_width, opacity=1.0)
179
+
180
+ def _bot_knit_trace(self, line_width: float = 3.0, knit_color: str = 'blue') -> Scatter:
181
+ """Create a scatter trace for knit stitches that cross under other stitches in cables.
182
+
183
+ Args:
184
+ line_width (float, optional): The width of the lines representing the stitch edges. Defaults to 3.0.
185
+ knit_color (str, optional): The color of knit stitches in the visualization. Defaults to 'blue'.
186
+
187
+ Returns:
188
+ Scatter: The plotly scatter object used to visualize knit stitches below cable crossings.
189
+ """
190
+ return self._stitch_trace(self._bot_knit_trace_data, "Knit Stitches Below Cable", knit_color, line_width, opacity=.5)
191
+
192
+ def _no_cross_purl_trace(self, line_width: float = 4.0, purl_color: str = 'red') -> Scatter:
193
+ """Create a scatter trace for purl stitches not involved in cable crossings.
194
+
195
+ Args:
196
+ line_width (float, optional): The width of the lines representing the stitch edges. Defaults to 4.0.
197
+ purl_color (str, optional): The color of purl stitches in the visualization. Defaults to 'red'.
198
+
199
+ Returns:
200
+ Scatter: The plotly scatter object used to visualize purl stitches not involved in cables.
201
+ """
202
+ return self._stitch_trace(self._purl_trace_data, "Purl Stitches", purl_color, line_width, opacity=0.8)
203
+
204
+ def _top_purl_trace(self, line_width: float = 5.0, purl_color: str = 'red') -> Scatter:
205
+ """Create a scatter trace for purl stitches that cross over other stitches in cables.
206
+
207
+ Args:
208
+ line_width (float, optional): The width of the lines representing the stitch edges. Defaults to 5.0.
209
+ purl_color (str, optional): The color of purl stitches in the visualization. Defaults to 'red'.
210
+
211
+ Returns:
212
+ Scatter: The plotly scatter object used to visualize purl stitches on top of cable crossings.
213
+ """
214
+ return self._stitch_trace(self._top_purl_trace_data, "Purl Stitches on Top of Cable", purl_color, line_width, opacity=1.0)
215
+
216
+ def _bot_purl_trace(self, line_width: float = 3.0, purl_color: str = 'red') -> Scatter:
217
+ """Create a scatter trace for purl stitches that cross under other stitches in cables.
218
+
219
+ Args:
220
+ line_width (float, optional): The width of the lines representing the stitch edges. Defaults to 3.0.
221
+ purl_color (str, optional): The color of purl stitches in the visualization. Defaults to 'red'.
222
+
223
+ Returns:
224
+ Scatter: The plotly scatter object used to visualize purl stitches below cable crossings.
225
+ """
226
+ return self._stitch_trace(self._bot_purl_trace_data, "Purl Stitches Below Cable", purl_color, line_width, opacity=.5)
227
+
228
+ @staticmethod
229
+ def _stitch_trace(trace_data: dict[str: list[float] | list[tuple[Loop, Loop] | bool]], trace_name: str, trace_color: str, line_width: float, opacity: float) -> Scatter:
230
+ """Create a generic scatter trace for stitch visualization with specified styling.
231
+
232
+ Args:
233
+ trace_data (dict): The trace data containing x, y coordinates and edge information to be plotted.
234
+ trace_name (str): The name of the trace to show in the figure legend.
235
+ trace_color (str): The color of the trace lines.
236
+ line_width (float): The width of lines representing the stitch edges.
237
+ opacity (float): The opacity of the trace lines (0.0 to 1.0).
238
+
239
+ Returns:
240
+ Scatter: The plotly scatter object configured to visualize the given stitch traces.
241
+ """
242
+ return Scatter(name=trace_name,
243
+ x=trace_data['x'], y=trace_data['y'],
244
+ line=dict(width=line_width, color=trace_color, dash='solid'),
245
+ opacity=opacity,
246
+ mode='lines')
247
+
248
+ def _add_cable_edges(self) -> None:
249
+ """Add all stitch edges that are involved in cable crossings to the appropriate trace data."""
250
+ for left_loop, right_loop in self.knit_graph.braid_graph.loop_crossing_graph.edges:
251
+ crossing_direction = self.knit_graph.braid_graph.get_crossing(left_loop, right_loop)
252
+ for left_parent in left_loop.parent_loops:
253
+ self._add_stitch_edge(left_parent, left_loop, crossing_direction)
254
+ for right_parent in right_loop.parent_loops:
255
+ self._add_stitch_edge(right_parent, right_loop, ~crossing_direction)
256
+
257
+ def _add_stitch_edges(self) -> None:
258
+ """Add all stitch edges to the visualization trace data based on their type and cable position."""
259
+ self._add_cable_edges()
260
+ # Add remaining stitches as though they have no cable crossing.
261
+ for u, v in self.knit_graph.stitch_graph.edges:
262
+ if (not self._stitch_has_position(u, v) # This edge has not been placed
263
+ and self._loop_has_position(u) and self._loop_has_position(v)): # Both loops do have positions.
264
+ self._add_stitch_edge(u, v, Crossing_Direction.No_Cross)
265
+
266
+ def _add_stitch_edge(self, u: Loop, v: Loop, crossing_direction: Crossing_Direction) -> None:
267
+ """Add a single stitch edge to the appropriate trace data based on stitch type and cable crossing."""
268
+ pull_direction = self.knit_graph.get_pull_direction(u, v)
269
+ if pull_direction is None:
270
+ return # No edge between these loops
271
+ elif pull_direction is Pull_Direction.BtF: # Knit Stitch:
272
+ if crossing_direction is Crossing_Direction.Over_Right:
273
+ trace_data = self._top_knit_trace_data
274
+ elif crossing_direction is Crossing_Direction.Under_Right:
275
+ trace_data = self._bot_knit_trace_data
276
+ else:
277
+ trace_data = self._knit_trace_data
278
+ else:
279
+ if crossing_direction is Crossing_Direction.Over_Right:
280
+ trace_data = self._top_purl_trace_data
281
+ elif crossing_direction is Crossing_Direction.Under_Right:
282
+ trace_data = self._bot_purl_trace_data
283
+ else:
284
+ trace_data = self._purl_trace_data
285
+ self.data_graph.add_edge(u, v, pull_direction=pull_direction)
286
+ trace_data['x'].append(self._get_x_of_loop(u))
287
+ trace_data['y'].append(self._get_y_of_loop(u))
288
+ trace_data['edge'].append((u, v))
289
+ trace_data['is_start'].append(True)
290
+ trace_data['x'].append(self._get_x_of_loop(v))
291
+ trace_data['y'].append(self._get_y_of_loop(v))
292
+ trace_data['edge'].append((u, v))
293
+ trace_data['is_start'].append(False)
294
+ trace_data['x'].append(None)
295
+ trace_data['y'].append(None)
296
+
297
+ def _set_loop_markers(self, loop_size: float = 30.0, loop_border_width: float = 2.0) -> None:
298
+ """Create plotly scatter objects to mark the position of each loop in the visualization."""
299
+ yarns_to_loop_data = {yarn: {'x': [self._get_x_of_loop(loop) for loop in yarn],
300
+ 'y': [self._get_y_of_loop(loop) for loop in yarn],
301
+ 'loop_id': [loop.loop_id for loop in yarn]
302
+ }
303
+ for yarn in self.knit_graph.yarns
304
+ }
305
+ self._loop_markers = [Scatter(name=f"Loops on {yarn.yarn_id}", x=yarn_data['x'], y=yarn_data['y'], text=yarn_data['loop_id'],
306
+ textposition='middle center',
307
+ mode='markers+text',
308
+ marker=dict(
309
+ reversescale=True,
310
+ color=yarn.properties.color,
311
+ size=loop_size,
312
+ line_width=loop_border_width))
313
+ for yarn, yarn_data in yarns_to_loop_data.items()]
314
+
315
+ def _set_yarn_traces(self, line_width: float = 1.0, smoothing: float = 1.3) -> None:
316
+ """Create plotly traces representing the path of each yarn through the knitted structure."""
317
+ yarns_to_float_data = {}
318
+ for yarn in self.knit_graph.yarns:
319
+ float_data: dict[str, list[float]] = {'x': [], 'y': []}
320
+ for u in yarn:
321
+ if self._loop_has_position(u):
322
+ float_data['x'].append(self._get_x_of_loop(u))
323
+ float_data['y'].append(self._get_y_of_loop(u))
324
+ yarns_to_float_data[yarn] = float_data
325
+ self._yarn_traces = [Scatter(name=yarn.yarn_id,
326
+ x=float_data['x'], y=float_data['y'],
327
+ line=dict(width=line_width,
328
+ color=yarn.properties.color,
329
+ shape='spline',
330
+ smoothing=smoothing),
331
+ mode='lines')
332
+ for yarn, float_data in yarns_to_float_data.items()]
333
+
334
+ def _position_loops(self) -> None:
335
+ """Calculate and set the x,y coordinate positions of all loops to be visualized."""
336
+ self._position_base_course()
337
+ self._place_loops_in_courses()
338
+ self._shift_knit_purl()
339
+ self._shift_loops_by_float_alignment()
340
+
341
+ def _shift_knit_purl(self, shift: float = 0.1) -> None:
342
+ """Adjust the horizontal position of loops to visually distinguish knit from purl stitches."""
343
+ has_knits = any(self.knit_graph.get_pull_direction(u, v) is Pull_Direction.BtF for u, v in self.knit_graph.stitch_graph.edges)
344
+ has_purls = any(self.knit_graph.get_pull_direction(u, v) is Pull_Direction.FtB for u, v in self.knit_graph.stitch_graph.edges)
345
+ if not (has_knits and has_purls):
346
+ return # Don't make any changes, because all stitches are of the same type.
347
+ yarn_over_align = set()
348
+ for loop in self.data_graph.nodes:
349
+ if not loop.has_parent_loops(): # Yarn-over
350
+ if self.knit_graph.has_child_loop(loop): # Align yarn-overs with one child to its child
351
+ yarn_over_align.add(loop)
352
+ continue # Don't shift yarn-overs
353
+ knit_parents = len([u for u in loop.parent_loops if self.knit_graph.get_pull_direction(u, loop) is Pull_Direction.BtF])
354
+ purl_parents = len([u for u in loop.parent_loops if self.knit_graph.get_pull_direction(u, loop) is Pull_Direction.FtB])
355
+ if knit_parents > purl_parents: # Shift the loop as though it is being knit.
356
+ self._set_x_of_loop(loop, self._get_x_of_loop(loop) - shift)
357
+ elif purl_parents > knit_parents: # Shift the loop as though it is being purled.
358
+ self._set_x_of_loop(loop, self._get_x_of_loop(loop) + shift)
359
+
360
+ for loop in yarn_over_align:
361
+ child_loop = self.knit_graph.get_child_loop(loop)
362
+ assert isinstance(child_loop, Loop)
363
+ self._set_x_of_loop(loop, self._get_x_of_loop(child_loop))
364
+
365
+ def _shift_loops_by_float_alignment(self, float_increment: float = 0.25) -> None:
366
+ """Adjust the vertical position of loops based on their float relationships."""
367
+ for yarn in self.knit_graph.yarns:
368
+ for u, v, front_loops in yarn.loops_in_front_of_floats():
369
+ for front_loop in front_loops:
370
+ if u in self._get_course_of_loop(front_loop) and v in self._get_course_of_loop(front_loop): # same course, adjust float position
371
+ self._set_y_of_loop(front_loop, self._get_y_of_loop(front_loop) - float_increment) # shift loop down to show it is in front of the float.
372
+ for u, v, back_loops in yarn.loops_behind_floats():
373
+ for back_loop in back_loops:
374
+ if u in self._get_course_of_loop(back_loop) and v in self._get_course_of_loop(back_loop): # same course, adjust float position
375
+ self._set_y_of_loop(back_loop, self._get_y_of_loop(back_loop) + float_increment) # shift loop up to show it is behind the float.
376
+
377
+ def _get_course_of_loop(self, loop: Loop) -> Course:
378
+ """Get the course (horizontal row) that contains the specified loop."""
379
+ return self.loops_to_course[loop]
380
+
381
+ def _place_loop(self, loop: Loop, x: float, y: float) -> None:
382
+ """Add a loop to the visualization data graph at the specified coordinates."""
383
+ if self._loop_has_position(loop):
384
+ self._set_x_of_loop(loop, x)
385
+ self._set_y_of_loop(loop, y)
386
+ else:
387
+ self.data_graph.add_node(loop, x=x, y=y)
388
+
389
+ def _set_x_of_loop(self, loop: Loop, x: float) -> None:
390
+ """Update the x coordinate of a loop that already exists in the visualization data graph."""
391
+ if self._loop_has_position(loop):
392
+ self.data_graph.nodes[loop]['x'] = x
393
+ else:
394
+ raise KeyError(f"Loop {loop} is not in the data graph")
395
+
396
+ def _set_y_of_loop(self, loop: Loop, y: float) -> None:
397
+ """Update the y coordinate of a loop that already exists in the visualization data graph."""
398
+ if self._loop_has_position(loop):
399
+ self.data_graph.nodes[loop]['y'] = y
400
+ else:
401
+ raise KeyError(f"Loop {loop} is not in the data graph")
402
+
403
+ def _get_x_of_loop(self, loop: Loop) -> float:
404
+ """Get the x coordinate of a loop from the visualization data graph."""
405
+ if self._loop_has_position(loop):
406
+ return float(self.data_graph.nodes[loop]['x'])
407
+ else:
408
+ raise KeyError(f"Loop {loop} is not in the data graph")
409
+
410
+ def _get_y_of_loop(self, loop: Loop) -> float:
411
+ """Get the y coordinate of a loop from the visualization data graph."""
412
+ if self._loop_has_position(loop):
413
+ return float(self.data_graph.nodes[loop]['y'])
414
+ else:
415
+ raise KeyError(f"Loop {loop} is not in the data graph")
416
+
417
+ def _loop_has_position(self, loop: Loop) -> bool:
418
+ """Check if a loop has been positioned in the visualization data graph."""
419
+ return bool(self.data_graph.has_node(loop))
420
+
421
+ def _stitch_has_position(self, u: Loop, v: Loop) -> bool:
422
+ """Check if a stitch edge between two loops has been added to the visualization data graph."""
423
+ return bool(self.data_graph.has_edge(u, v))
424
+
425
+ def _place_loops_in_courses(self, course_spacing: float = 1.0) -> None:
426
+ """Position loops in all courses above the base course using parent relationships and yarn connections."""
427
+ y = course_spacing
428
+ for course in self.courses[self.first_course_index + 1:self.top_course_index]:
429
+ self._place_loops_by_parents(course, y)
430
+ self._swap_loops_in_cables(course)
431
+ self._left_align_course(course)
432
+ self._balance_course(course)
433
+ y += course_spacing # Shift y coordinate up with each course
434
+
435
+ def _swap_loops_in_cables(self, course: Course) -> None:
436
+ """Swap the horizontal positions of loops involved in cable crossings within a course."""
437
+ for left_loop in course:
438
+ for right_loop in self.knit_graph.braid_graph.left_crossing_loops(left_loop):
439
+ crossing_direction = self.knit_graph.braid_graph.get_crossing(left_loop, right_loop)
440
+ if crossing_direction is not Crossing_Direction.No_Cross: # Swap the position of loops that cross each other.
441
+ left_x = self._get_x_of_loop(left_loop)
442
+ self._set_x_of_loop(left_loop, self._get_x_of_loop(right_loop))
443
+ self._set_x_of_loop(right_loop, left_x)
444
+
445
+ def _place_loops_by_parents(self, course: Course, y: float) -> None:
446
+ """Position loops in a course based on the average position of their parent loops."""
447
+ for x, loop in enumerate(course):
448
+ self._set_loop_x_by_parent_average(loop, y)
449
+ placed_loops = set()
450
+ for loop in self._loops_need_placement:
451
+ placed = self._set_loop_between_yarn_neighbors(loop, y)
452
+ if placed:
453
+ placed_loops.add(loop)
454
+ self._loops_need_placement.difference_update(placed_loops)
455
+ assert len(self._loops_need_placement) == 0, f"Loops {self._loops_need_placement} remain unplaced."
456
+ # A loops past the first course should have at least one yarn neighbor to place them.
457
+
458
+ def _set_loop_x_by_parent_average(self, loop: Loop, y: float) -> None:
459
+ """Set the x coordinate of a loop based on the weighted average position of its parent loops."""
460
+ if len(loop.parent_loops) == 0:
461
+ self._loops_need_placement.add(loop)
462
+ return
463
+
464
+ def _parent_weight(stack_position: int) -> float:
465
+ return float(len(loop.parent_loops) - stack_position)
466
+
467
+ parent_positions = {self._get_x_of_loop(parent_loop) * _parent_weight(stack_pos): # position of parents weighted by their stack position.
468
+ _parent_weight(stack_pos) # weight of the stack position.
469
+ for stack_pos, parent_loop in enumerate(loop.parent_loops)
470
+ if self.data_graph.has_node(parent_loop)} # Only include parent loops that are positioned.
471
+ x = sum(parent_positions.keys()) / sum(parent_positions.values())
472
+ self._place_loop(loop, x=x, y=y)
473
+
474
+ def _set_loop_between_yarn_neighbors(self, loop: Loop, y: float, spacing: float = 1.0) -> bool:
475
+ """Position a loop based on the average position of its neighboring loops along the yarn."""
476
+ spacing = abs(spacing) # Ensure spacing is positive.
477
+ x_neighbors = []
478
+ prior_loop = loop.prior_loop_on_yarn()
479
+ next_loop = loop.next_loop_on_yarn()
480
+ if prior_loop is not None and self._loop_has_position(prior_loop):
481
+ if self._get_y_of_loop(prior_loop) == y: # Include the spacing to ensure these are not at overlapping positions.
482
+ x_neighbors.append(self._get_x_of_loop(prior_loop) + spacing)
483
+ else: # Don't include spacing because the prior loop is on the prior course.
484
+ x_neighbors.append(self._get_x_of_loop(prior_loop))
485
+ if next_loop is not None and self._loop_has_position(next_loop):
486
+ if self._get_y_of_loop(next_loop) == y: # Include the spacing to ensure these are not at overlapping positions.
487
+ x_neighbors.append(self._get_x_of_loop(next_loop) - spacing)
488
+ else: # Don't include spacing because the prior loop is on the prior course.
489
+ x_neighbors.append(self._get_x_of_loop(next_loop))
490
+ if len(x_neighbors) == 0:
491
+ return False
492
+ x = (sum(x_neighbors) / float(len(x_neighbors))) # the average of the two neighbors
493
+ self._place_loop(loop, x=x, y=y)
494
+ return True
495
+
496
+ def _position_base_course(self) -> None:
497
+ """Position the loops in the bottom course of the visualization and establish base metrics."""
498
+ base_course = self.courses[self.first_course_index]
499
+ if (len(self.courses) > self.first_course_index + 1 # There are more courses to show after the base course
500
+ and base_course.in_round_with(self.courses[self.first_course_index + 1])): # The first course is knit in the round to form a tube structure.
501
+ self._get_base_round_course_positions(base_course)
502
+ else:
503
+ self._get_base_row_course_positions(base_course)
504
+ self._left_align_course(base_course)
505
+ self.base_left = min(self._get_x_of_loop(loop) for loop in base_course)
506
+ max_x = max(self._get_x_of_loop(loop) for loop in base_course)
507
+ self.base_width = max_x - self.base_left
508
+
509
+ def _get_base_round_course_positions(self, base_course: Course, loop_space: float = 1.0, back_shift: float = 0.5) -> None:
510
+ """Position loops in the base course for circular/tube knitting structure."""
511
+ split_index = len(base_course) // 2 # Split the course in half to form a tube.
512
+ front_loops: list[Loop] = cast(list[Loop], base_course[:split_index])
513
+ front_set: set[Loop] = set(front_loops)
514
+ back_loops: list[Loop] = cast(list[Loop], base_course[split_index:])
515
+ if self.start_on_left:
516
+ back_loops = [*reversed(back_loops)]
517
+ else:
518
+ front_loops = [*reversed(front_loops)]
519
+ for x, l in enumerate(front_loops):
520
+ self._place_loop(l, x=x, y=0)
521
+ for x, back_loop in enumerate(back_loops):
522
+ float_positions = [self._get_x_of_loop(front_loop) for front_loop in back_loop.front_floats if front_loop in front_set]
523
+ if len(float_positions) > 0: # If the back loop is floating behind other loops in the front of the course, set the position to be centered between the loops it is floating behind.
524
+ self._place_loop(back_loop, x=sum(float_positions) / float(len(float_positions)), y=0.0)
525
+ elif self.start_on_left:
526
+ self._place_loop(back_loop, x=(x * loop_space) + back_shift, y=0)
527
+ else:
528
+ self._place_loop(back_loop, x=(x * loop_space) - back_shift, y=0)
529
+
530
+ def _get_base_row_course_positions(self, base_course: Course, loop_space: float = 1.0) -> None:
531
+ """Position loops in the base course for flat/row knitting structure."""
532
+ loops: Iterable[Loop] = list(base_course)
533
+ if not self.start_on_left:
534
+ loops = reversed(base_course)
535
+ for x, loop in enumerate(loops):
536
+ self._place_loop(loop, x=x * loop_space, y=0)
537
+
538
+ def _left_align_course(self, course: Course) -> None:
539
+ """Align the leftmost loop of a course to x=0 if left alignment is enabled."""
540
+ if self.left_zero_align:
541
+ current_left = min(self._get_x_of_loop(loop) for loop in course)
542
+ if current_left != 0.0:
543
+ for loop in course:
544
+ self._set_x_of_loop(loop, self._get_x_of_loop(loop) - current_left)
545
+
546
+ def _balance_course(self, course: Course) -> None:
547
+ """Scale the width of a course to match the base course width if balancing is enabled."""
548
+ current_left = min(self._get_x_of_loop(loop) for loop in course)
549
+ max_x = max(self._get_x_of_loop(loop) for loop in course)
550
+ course_width = max_x - current_left
551
+ if self.balance_by_base_width and course_width != self.base_width:
552
+ def _target_distance_from_left(l: Loop) -> float:
553
+ current_distance_from_left = self._get_x_of_loop(l) - current_left
554
+ return (current_distance_from_left * self.base_width) / course_width
555
+
556
+ for loop in course:
557
+ self._set_x_of_loop(loop, _target_distance_from_left(loop) + current_left)
558
+
559
+ def x_coordinate_differences(self, other: Knit_Graph_Visualizer) -> dict[Loop: tuple[float | None, float | None]]:
560
+ """Find the differences in x-coordinates between two knitgraph visualizations. Used for testing and comparing visualization results.
561
+
562
+ Args:
563
+ other (Knit_Graph_Visualizer): The knitgraph visualization to compare to.
564
+
565
+ Returns:
566
+ dict[Loop: tuple[float | None, float | None]]: The differences in x-coordinates.
567
+ * The keys of the dictionary are loops with differences between the two visualizations.
568
+ * The values of the dictionary are tuples of floats or None.
569
+ ** The first value in each tuple is the x-coordinate of the loop in this knitgraph visualization or None if the loop is not in this visualization.
570
+ ** The second value of each tuple is the x-coordinate of the loop in the other visualization or NOne if the loop is not in that visualization.
571
+
572
+ """
573
+ differences = {l: (self._get_x_of_loop(l), None) for l in self.data_graph.nodes if not other.data_graph.has_node(l)}
574
+ differences.update({l: (None, other._get_x_of_loop(l)) for l in other.data_graph.nodes if not self.data_graph.has_node(l)})
575
+ differences.update({l: (self._get_x_of_loop(l), other._get_x_of_loop(l)) for l in self.data_graph.nodes if other.data_graph.has_node(l) and self._get_x_of_loop(l) != other._get_x_of_loop(l)})
576
+ return differences
577
+
578
+ def y_coordinate_differences(self, other: Knit_Graph_Visualizer) -> dict[Loop: tuple[float | None, float | None]]:
579
+ """Find the differences in y-coordinates between two knitgraph visualizations. Used for testing and comparing visualization results.
580
+
581
+ Args:
582
+ other (Knit_Graph_Visualizer): The knitgraph visualization to compare to.
583
+
584
+ Returns:
585
+ dict[Loop: tuple[float | None, float | None]]: The differences in y-coordinates.
586
+ * The keys of the dictionary are loops with differences between the two visualizations.
587
+ * The values of the dictionary are tuples of floats or None.
588
+ ** The first value in each tuple is the y-coordinate of the loop in this knitgraph visualization or None if the loop is not in this visualization.
589
+ ** The second value of each tuple is the y-coordinate of the loop in the other visualization or NOne if the loop is not in that visualization.
590
+
591
+ """
592
+ differences = {l: (self._get_y_of_loop(l), None) for l in self.data_graph.nodes if not other.data_graph.has_node(l)}
593
+ differences.update({l: (None, other._get_y_of_loop(l)) for l in other.data_graph.nodes if not self.data_graph.has_node(l)})
594
+ differences.update({l: (self._get_y_of_loop(l), other._get_y_of_loop(l)) for l in self.data_graph.nodes if other.data_graph.has_node(l) and self._get_y_of_loop(l) != other._get_y_of_loop(l)})
595
+ return differences
596
+
597
+ def __eq__(self, other: Knit_Graph_Visualizer) -> bool:
598
+ """Two visualizations are equal if share the same x,y coordinates for all loops in the visualization and both contain the same set of loop nodes.
599
+ Args:
600
+ other (Knit_Graph_Visualizer): The knitgraph visualization to compare to.
601
+
602
+ Returns:
603
+ bool: True if the knitgraph visualizations are equal, False otherwise.
604
+ """
605
+ return len(self.data_graph.nodes) == len(other.data_graph.nodes) and len(self.x_coordinate_differences(other)) == 0 and len(self.y_coordinate_differences(other)) == 0
606
+
607
+
608
+ def visualize_knit_graph(knit_graph: Knit_Graph, first_course_index: int = 0, top_course_index: int | None = None,
609
+ start_on_left: bool = True, balance_by_base_width: bool = False,
610
+ left_zero_align: bool = True, graph_title: str = "knit_graph",
611
+ show_figure: bool = True, renderer: str | None = None) -> Figure:
612
+ """Generate and optionally display a plotly visualization of the given knit graph with specified configuration.
613
+
614
+ Args:
615
+ knit_graph (Knit_Graph): The knit graph to visualize.
616
+ first_course_index (int): Index of the first (bottom) course to include in the visualization. Defaults to 0.
617
+ top_course_index (int | None): Index of the last (top) course to include in the visualization. If None, visualizes up to the top of the knit graph.
618
+ start_on_left (bool): Whether the first loop knit is presumed to be positioned on the left of the pattern. Defaults to True.
619
+ balance_by_base_width (bool): Whether to scale all course widths to match the first course width. Defaults to False.
620
+ left_zero_align (bool): Whether to align the leftmost position of each course with x=0 in the figure. Defaults to True.
621
+ graph_title (str): The title to display on the generated figure. Defaults to "knit_graph".
622
+ show_figure (bool, optional): If True, the visualization will be shown. Defaults to True.
623
+ renderer (str, optional): Plotly renderer to use. Common options: 'browser', 'notebook'. Defaults to None (uses configured default).
624
+
625
+ Returns:
626
+ Figure: The plotly figure object.
627
+ """
628
+ visualizer = Knit_Graph_Visualizer(knit_graph, first_course_index, top_course_index, start_on_left, balance_by_base_width, left_zero_align)
629
+ fig = visualizer.make_figure(graph_title)
630
+
631
+ if show_figure:
632
+ try:
633
+ # Configure display to minimize resource usage
634
+ config = {
635
+ 'displayModeBar': False, # Hide toolbar to reduce resource usage
636
+ 'displaylogo': False, # Hide plotly logo
637
+ 'staticPlot': False, # Keep interactive
638
+ 'scrollZoom': True, # Allow zoom
639
+ 'doubleClick': 'reset+autosize' # Double-click behavior
640
+ }
641
+
642
+ if renderer:
643
+ fig.show(renderer=renderer, config=config)
644
+ else:
645
+ fig.show(config=config)
646
+ except Exception as e:
647
+ print(f"Warning: Could not display figure '{graph_title}': {e}")
648
+ print("Figure created successfully but display failed.")
649
+
650
+ return fig
651
+
652
+
653
+ def visualize_knit_graph_safe(knit_graph: Knit_Graph, first_course_index: int = 0, top_course_index: int | None = None,
654
+ start_on_left: bool = True, balance_by_base_width: bool = False,
655
+ left_zero_align: bool = True, graph_title: str = "knit_graph") -> Figure:
656
+ """Generate a plotly visualization of the given knit graph with specified configuration.
657
+ This function is safe for UnitTest and other headless environments because it does not attempt to show the visualization.
658
+
659
+ Args:
660
+ knit_graph (Knit_Graph): The knit graph to visualize.
661
+ first_course_index (int): Index of the first (bottom) course to include in the visualization. Defaults to 0.
662
+ top_course_index (int | None): Index of the last (top) course to include in the visualization. If None, visualizes up to the top of the knit graph.
663
+ start_on_left (bool): Whether the first loop knit is presumed to be positioned on the left of the pattern. Defaults to True.
664
+ balance_by_base_width (bool): Whether to scale all course widths to match the first course width. Defaults to False.
665
+ left_zero_align (bool): Whether to align the leftmost position of each course with x=0 in the figure. Defaults to True.
666
+ graph_title (str): The title to display on the generated figure. Defaults to "knit_graph".
667
+
668
+ Returns:
669
+ Figure: The plotly figure object.
670
+ """
671
+ return visualize_knit_graph(knit_graph, first_course_index=first_course_index, top_course_index=top_course_index,
672
+ start_on_left=start_on_left,
673
+ balance_by_base_width=balance_by_base_width,
674
+ left_zero_align=left_zero_align, graph_title=graph_title,
675
+ show_figure=False)