exploration.display
- Authors: Peter Mawhorter, Tiffany Lin, & Presha Goel
- Consulted:
- Date: 2022-4-15
- Purpose: Code to support visualizing decision graphs and explorations.
Defines functions for graph layout and drawing for
exploration.core.DecisionGraph objects. See the explorationViewer
module for more info on how these are used. This module computes layout
positions, but actually displaying the graphs is done via HTML.
TODO: Anchor-free localization implementation?
1""" 2- Authors: Peter Mawhorter, Tiffany Lin, & Presha Goel 3- Consulted: 4- Date: 2022-4-15 5- Purpose: Code to support visualizing decision graphs and explorations. 6 7Defines functions for graph layout and drawing for 8`exploration.core.DecisionGraph` objects. See the `explorationViewer` 9module for more info on how these are used. This module computes layout 10positions, but actually displaying the graphs is done via HTML. 11 12TODO: Anchor-free localization implementation? 13""" 14 15from typing import ( 16 Dict, Tuple, Literal, TypeAlias, Sequence, Optional, Set, Union, 17 List, cast 18) 19 20import math 21import copy 22 23import networkx as nx 24 25from . import base 26from . import core 27from . import analysis 28 29BlockPosition: 'TypeAlias' = Tuple[int, int, int] 30""" 31A type alias: block positions indicate the x/y coordinates of the 32north-west corner of a node, as well as its side length in grid units 33(all nodes are assumed to be square). 34""" 35 36 37BlockLayout: 'TypeAlias' = Dict[base.DecisionID, BlockPosition] 38""" 39A type alias: block layouts map each decision in a particular graph to a 40block position which indicates both position and size in a unit grid. 41""" 42 43 44def roomSize(connections: int) -> int: 45 """ 46 For a room with the given number of connections, returns the side 47 length of the smallest square which can accommodate that many 48 connections. Note that outgoing/incoming reciprocal pairs to/from a 49 single destination should only count as one connection, because they 50 don't need more than one space on the room perimeter. Even with zero 51 connections, we still return 1 as the room size. 52 """ 53 if connections == 0: 54 return 1 55 return 1 + (connections - 1) // 4 56 57 58def expandBlocks(layout: BlockLayout) -> None: 59 """ 60 Modifies the given block layout by adding extra space between each 61 positioned node: it triples the coordinates of each node, and then 62 shifts them south and east by 1 unit each, by maintaining the nodes 63 at their original sizes, TODO... 64 """ 65 # TODO 66 67 68#def blockLayoutFor(region: core.DecisionGraph) -> BlockLayout: 69# """ 70# Computes a unit-grid position and size for each room in an 71# `exploration.core.DecisionGraph`, laying out the rooms as 72# non-overlapping square blocks. In many cases, connections will be 73# stretched across empty space, but no explicit space is reserved for 74# connections. 75# """ 76# # TODO 77 78GraphLayoutMethod: 'TypeAlias' = Literal[ 79 "stacked", 80 "square", 81 "line", 82 "arc" 83] 84""" 85The options for layouts of a decision graph. They are: 86 87- 'stacked': Assigns *all* nodes to position (0, 0). Use this if you want 88 to generate an empty layout that you plan to modify. Doesn't require 89 any attributes. 90- 'square': Takes the square root of the number of decisions, then places 91 them in order into a square with that side length (rounded up). This 92 is a very simple but also terrible algorithm. Doesn't require any 93 attributes. 94- 'line': Lays out the decisions in a straight line. Doesn't require any 95 attributes. 96- 'arc': Lays out decisions on an arc. Doesn't require any attributes. 97""" 98 99def assignPositions( 100 decisions: Sequence[base.DecisionID], 101 attributes: Optional[Dict[base.DecisionID, dict]] = None, 102 method: GraphLayoutMethod = "square" 103) -> base.Layout: 104 """ 105 Given a sequence of decision IDs, plus optionally a dictionary 106 mapping those IDs to attribute dictionaries, computes a layout for 107 the decisions according to the specified method, returning a 108 dictionary mapping each decision ID to its position in the layout. 109 110 Different layout methods may required different attributes to be 111 available. 112 """ 113 if method == "stacked": 114 return {d: (0, 0) for d in decisions} # all nodes at (0, 0) 115 elif method == "square": 116 return assignSquarePositions(decisions) 117 elif method == "line": 118 return assignLinePositions(decisions) 119 elif method == "arc": 120 return assignArcPositions(decisions) 121 else: 122 raise ValueError(f"Invalid layout method {method!r}.") 123 124 125def assignSquarePositions( 126 decisions: Sequence[base.DecisionID] 127) -> base.Layout: 128 """ 129 Creates and returns a dictionary of positions for the given sequence 130 of decisions using the 'square' layout: it arranges them into a big 131 square. 132 """ 133 result = {} 134 # Figure out side length of the square that will fit them all 135 side = math.ceil((len(decisions)**0.5)) 136 # Put 'em in a square 137 for i, d in enumerate(decisions): 138 result[d] = (i % side, i // side) 139 return result 140 141 142def assignLinePositions( 143 decisions: Sequence[base.DecisionID] 144) -> base.Layout: 145 """ 146 Creates and returns a dictionary of positions for the given sequence 147 of decisions using the 'line' layout: it arranges them into a 148 straight horizontal line. 149 """ 150 result = {} 151 # Put 'em in a line 152 for i, d in enumerate(decisions): 153 result[d] = (float(i), 0.0) 154 return result 155 156 157def assignArcPositions( 158 decisions: Sequence[base.DecisionID], 159 radiusFactor: float = 1.5 160) -> base.Layout: 161 """ 162 Creates and returns a dictionary of positions for the given sequence 163 of decisions using the 'arc' layout: it arranges them along an arc 164 curving towards -y relative to a straight line. The start and end 165 of the arc are at (0, 0) and (N, 0) respectively, where N is the 166 number of decisions to be laid out. The `radiusFactor` must be at 167 least 1; when it's 1 the radius is N/2 and the shape is a full 168 semi-circle, as `radiusFactor` increases the radius of the circle 169 increases proportionally making the arc gentler. 170 """ 171 n = len(decisions) 172 cx = n / 2 # x-position of center halfway down line 173 r = radiusFactor * cx # radius of the circle 174 # y-position so that that radius intersects both (0, 0) and (n, 0) 175 cy = (r * r - cx * cx)**0.5 176 # angle from straight-down to either end of arc (note y and x 177 # intentionally reversed here) 178 th = math.atan2(cx, cy) 179 thSlice = th * 2 / n 180 thStart = 1.5 * math.pi - th 181 result = {} 182 # Traverse the arc 183 for i, d in enumerate(decisions): 184 angle = thStart + thSlice * i 185 result[d] = ( 186 cx + r * math.cos(angle), 187 cy + r * math.sin(angle) 188 ) 189 return result 190 191 192def setFinalPositions( 193 exploration: core.DiscreteExploration, 194 method: GraphLayoutMethod = "square" 195) -> None: 196 """ 197 Adds a "final" layout to the given exploration which contains a 198 dictionary mapping decision IDs to `Position`s. Every decision that 199 ever existed over the course of the exploration is assigned a 200 position. 201 """ 202 exploration.layouts["final"] = assignPositions( 203 exploration.allDecisions(), 204 method=method 205 ) 206 207 208def setPathPositions( 209 exploration: core.DiscreteExploration, 210 arc: bool = True 211) -> None: 212 """ 213 Adds a "path" layout to the given exploration which contains a 214 dictionary mapping decision IDs to `Position`s. This includes every 215 visited decision and all of their neighbors, but does NOT include 216 decisions which were never visited and are not neighbors of a visited 217 decision. 218 219 Positions nodes along an arc below the X axis, each slightly more 220 than 1 unit apart, with unvisited neighbors descending below the node 221 they're a neighbor of in a parabola. This layout attempts to set 222 things up so that edges don't end up parallel and overlapping. You 223 can set `arc` to False to instead lay things out on a line, with 224 unvisited neighbors descending in a line below the node they connect 225 to. The linear layout would have lots of ambiguities if you tried to 226 relax it using a force simulation, whereas the arc layout would 227 have few ambiguities. 228 """ 229 # Lay out visited decisions in a line: 230 onPath = exploration.allVisitedDecisions() 231 if arc: 232 result = assignArcPositions(onPath, 5) # a gentle arc 233 else: 234 result = assignLinePositions(onPath) # a straight line 235 # Get the final graph to add neighbors from 236 finalGraph = exploration.getSituation().graph 237 # Track already-accounted-for neighbors 238 seen: Set[base.DecisionID] = set() 239 # Add neighbors to our layout 240 for decision in onPath: 241 # copy x of this decision 242 x = result[decision][0] 243 dx = 0.05 244 # Track y coordinates going down below the arc 245 y = result[decision][1] - 1.0 246 try: 247 neighbors = finalGraph.destinationsFrom(decision) 248 except core.MissingDecisionError: 249 continue 250 # decision on path may have been merged or deleted by end of 251 # exploration. We don't want to get neighbors in the step the 252 # node was visited, because it's likely many of those will 253 # have been explored by the time we get to the final graph, 254 # and we also don't want to include any merged/deleted 255 # neighbors in our graph, despite the fact we're including 256 # merged or deleted path nodes. 257 # TODO: Sort these by step added? 258 for dID in neighbors.values(): 259 # We only include all neighbors which are not elsewhere on 260 # the path. It's possible some of these may be confirmed, but 261 # that's fine, they were not active at any step. 262 if dID not in onPath: 263 result[dID] = (x, y) 264 # not just straight down if we're arcing 265 if arc: 266 dx += 0.03 # dx changes so nodes aren't in a line 267 x += dx # next node will be slightly farther to the right 268 y -= 1.0 # next node will be lower down 269 270 exploration.layouts["path"] = result 271 272 273def setCustomPositions( 274 exploration: core.DiscreteExploration, 275 fromLayout: str = "final" 276) -> None: 277 """ 278 Sets up a 'custom' layout to be edited. Copies from the "final" 279 layout by default, or you can specify which layout to copy from. If 280 the specified layout doesn't exist, a square layout of all decisions 281 is used. 282 """ 283 copyFrom = exploration.layouts.get(fromLayout) 284 if copyFrom is None: 285 copied = assignPositions( 286 exploration.allDecisions(), 287 method="square" 288 ) 289 else: 290 copied = copy.deepcopy(copyFrom) 291 exploration.layouts["custom"] = copied 292 293 294#---------------# 295# Baryeccentric # 296#---------------# 297 298Number: 'TypeAlias' = Union[int, float] 299""" 300For arguments that can be either an integer or a float. 301""" 302 303 304# Testing support functions 305def pz(n: Number) -> Number: 306 """ 307 Converts -0.0 to 0.0 and leaves all other numbers alone. 308 """ 309 if n == -0.0: 310 return 0.0 311 else: 312 return n 313 314 315def rt(t: base.LayoutPosition) -> base.LayoutPosition: 316 """ 317 Rounds off both parts of a 2-element tuple to 6 decimal places, and 318 then also converts -0.0 to +0.0 in either position. 319 """ 320 return ( 321 pz(round(t[0], 6)), 322 pz(round(t[1], 6)) 323 ) 324 325 326def rtl(tl: Sequence[base.LayoutPosition]) -> List[base.LayoutPosition]: 327 """ 328 Applies `rt` to a sequence of positions, returning a list. 329 """ 330 return [rt(t) for t in tl] 331 332 333def distance(a: base.LayoutPosition, b: base.LayoutPosition) -> float: 334 """ 335 Calculates the distance between two points, using the distance 336 formula in 2 dimensions. For example: 337 338 >>> distance((0, 0), (0, 3)) 339 3.0 340 >>> distance((0, 0), (3, 0)) 341 3.0 342 >>> distance((0, 0), (3, 4)) 343 5.0 344 """ 345 x1, y1 = a 346 x2, y2 = b 347 348 return (((x2 - x1) ** 2) + ((y2 - y1) ** 2)) ** 0.5 349 350 351def mid( 352 a: base.LayoutPosition, 353 b: base.LayoutPosition 354) -> base.LayoutPosition: 355 """ 356 Returns the midpoint between two points. For example: 357 358 >>> rt(mid((0, 0), (1, 0))) 359 (0.5, 0.0) 360 >>> rt(mid((0, 0), (3, 8))) 361 (1.5, 4.0) 362 >>> rt(mid((3, -3), (-3, 3))) 363 (0.0, 0.0) 364 """ 365 x1, y1 = a 366 x2, y2 = b 367 368 return ((x1 + x2) / 2, (y1 + y2) / 2) 369 370 371def vAdd( 372 a: base.LayoutPosition, 373 b: base.LayoutPosition 374) -> base.LayoutPosition: 375 """ 376 Returns the vector addition result for two layout positions. 377 For example: 378 379 >>> vAdd((0.0, 0.0), (1.0, 1.0)) 380 (1.0, 1.0) 381 >>> vAdd((1.0, 1.0), (0.0, 0.0)) 382 (1.0, 1.0) 383 >>> vAdd((1.0, 1.0), (2.0, -3.0)) 384 (3.0, -2.0) 385 >>> vAdd((1.0, 1.0), (1.0, 1.0)) 386 (2.0, 2.0) 387 """ 388 x1, y1 = a 389 x2, y2 = b 390 391 return (x1 + x2, y1 + y2) 392 393 394def vSub( 395 a: base.LayoutPosition, 396 b: base.LayoutPosition 397) -> base.LayoutPosition: 398 """ 399 Returns the vector between points a and b (from b to a, which is 400 also a - b in vector math). For example: 401 402 >>> vSub((1.0, 1.0), (0.0, 0.0)) 403 (1.0, 1.0) 404 >>> vSub((2.0, -3.0), (1.0, 1.0)) 405 (1.0, -4.0) 406 >>> vSub((1.0, 1.0), (1.0, 1.0)) 407 (0.0, 0.0) 408 """ 409 x1, y1 = a 410 x2, y2 = b 411 412 return (x1 - x2, y1 - y2) 413 414def norm(v: base.LayoutPosition) -> base.LayoutPosition: 415 """ 416 Normalizes the given vector, returning a vector in the same direction 417 whose length is 1. Returns the zero-vector if given the zero-vector, 418 which is the only case where the length of the result is not 1. 419 420 For example: 421 422 >>> norm((0, 0)) 423 (0.0, 0.0) 424 >>> norm((2, 0)) 425 (1.0, 0.0) 426 >>> norm((102, 0)) 427 (1.0, 0.0) 428 >>> norm((0, -3)) 429 (0.0, -1.0) 430 """ 431 length = distance((0, 0), v) 432 if length == 0: 433 return (0.0, 0.0) 434 else: 435 return (v[0] / length, v[1] / length) 436 437 438def isATriangle(x: Number, y: Number, z: Number) -> bool: 439 """ 440 Checks whether three side lengths can form a triangle. For example: 441 442 >>> isATriangle(3, 4, 5) 443 True 444 >>> isATriangle(1, 1, 1) 445 True 446 >>> isATriangle(100, 99, 1) 447 False 448 >>> isATriangle(100, 99, 2) 449 True 450 >>> isATriangle(2, 100, 99) 451 True 452 >>> isATriangle(99, 2, 100) 453 True 454 >>> isATriangle(3, 2, 10) 455 False 456 >>> isATriangle(5, 1, 1) 457 False 458 >>> isATriangle(9, 18.01, 9) 459 False 460 """ 461 return ((x + y > z) and (y + z > x) and (z + x > y)) 462 463 464def scaleBy( 465 vector: base.LayoutPosition, 466 scale: Number 467) -> base.LayoutPosition: 468 """ 469 Scales the given vector by the specified scale. 470 Examples: 471 472 >>> rt(scaleBy((1.0, 0.0), 3)) 473 (3.0, 0.0) 474 >>> rt(scaleBy((3.0, 4.0), 10)) 475 (30.0, 40.0) 476 >>> rt(scaleBy((6.0, 8.0), 5)) 477 (30.0, 40.0) 478 >>> rt(scaleBy((0.0, 2.0), -2)) 479 (0.0, -4.0) 480 >>> rt(scaleBy((0.0, 0.0), 1000)) 481 (0.0, 0.0) 482 """ 483 x, y = vector 484 return (x * scale, y * scale) 485 486 487def scaleTo( 488 vector: base.LayoutPosition, 489 length: Number 490) -> base.LayoutPosition: 491 """ 492 Scales the given vector to the specified length. Note that if the 493 vector is (0, 0), it will remain (0, 0) so the result won't actually 494 have the specified length in that one case. Examples: 495 496 >>> rt(scaleTo((1, 0), 3)) 497 (3.0, 0.0) 498 >>> rt(scaleTo((3, 4), 10)) 499 (6.0, 8.0) 500 >>> rt(scaleTo((6, 8), 5)) 501 (3.0, 4.0) 502 >>> rt(scaleTo((0, 2), -2)) 503 (0.0, -2.0) 504 >>> rt(scaleTo((0, 0), 1000)) 505 (0.0, 0.0) 506 """ 507 lengthNow = distance((0, 0), vector) 508 if lengthNow == 0: 509 return (0.0, 0.0) 510 else: 511 x, y = vector 512 return ( 513 (x / lengthNow) * length, 514 (y / lengthNow) * length 515 ) 516 517 518def circleIntersections( 519 a: base.LayoutPosition, 520 b: base.LayoutPosition, 521 aRadius: Number, 522 bRadius: Number 523) -> List[base.LayoutPosition]: 524 """ 525 Calculates the intersection point(s) between two circles centered at 526 points `a` and `b` with the given radii. Returns a list of 0, 1, or 2 527 positions depending on the relationship between the circles. Note 528 that if two circles are the same circle, it should in theory return a 529 list of infinite positions; in that case we return a list with four 530 positions that are the places where horizontal and vertical lines 531 through the shared center intersect the shared circle. Examples: 532 533 >>> rtl(circleIntersections((0, 0), (2, 0), 1, 1)) # single point 534 [(1.0, 0.0)] 535 >>> rtl(circleIntersections((0, 0), (0, 2), 1, 1)) # single point 536 [(0.0, 1.0)] 537 >>> rtl(circleIntersections((0, 0), (6, 8), 5, 5)) # two 3/4/5 triangles 538 [(3.0, 4.0)] 539 >>> rtl(circleIntersections((0, 0), (2, 0), 1.5, 1.5)) # two points 540 [(1.0, -1.118034), (1.0, 1.118034)] 541 >>> rtl(circleIntersections((0, 0), (0, 0), 2, 3)) # no points 542 [] 543 >>> rtl(circleIntersections((0, 0), (2, 0), 0.5, 0.5)) # no points 544 [] 545 >>> rtl(circleIntersections((-3, 4), (3, 4), 5, 5)) # two 3/4/5 triangles 546 [(0.0, 0.0), (0.0, 8.0)] 547 >>> rtl(circleIntersections((-4, -3), (4, -3), 5, 5)) # two 3/4/5 triangles 548 [(0.0, -6.0), (0.0, 0.0)] 549 >>> rtl(circleIntersections((0.0, 0.0), (0.0, 0.0), 5, 5)) # infinity 550 [(5.0, 0.0), (0.0, -5.0), (-5.0, 0.0), (0.0, 5.0)] 551 """ 552 x1, y1 = a 553 x2, y2 = b 554 555 d = distance(a, b) 556 557 # If the circles are too far apart or if the distance between their 558 # centers is so small compared to the difference between their radii 559 # that one is entirely inside the other, there are no points of 560 # intersection. 561 if d > aRadius + bRadius or d < abs(aRadius - bRadius): 562 return [] 563 elif aRadius + bRadius == d: # one point of intersection 564 vec = scaleTo((x2 - x1, y2 - y1), aRadius) 565 return [ (x1 + vec[0], y1 + vec[1]) ] 566 elif x1 == x2 and y1 == y2 and aRadius == bRadius: # same circle 567 return [ # clockwise from 3 o'clock if +y is up and +x is right 568 (x1 + aRadius, y1), 569 (x1, y1 - aRadius), 570 (x1 - aRadius, y1), 571 (x1, y1 + aRadius) 572 ] 573 574 # Otherwise we have 2 points of intersection 575 # TODO: Explain this math a bit... 576 aa = (aRadius**2 - bRadius**2 + d**2) / (2 * d) 577 h = (aRadius**2 - aa**2)**0.5 578 579 x0 = x1 + aa * (x2 - x1) / d 580 y0 = y1 + aa * (y2 - y1) / d 581 582 x3 = x0 + h * (y2 - y1) / d 583 y3 = y0 - h * (x2 - x1) / d 584 585 x4 = x0 - h * (y2 - y1) / d 586 y4 = y0 + h * (x2 - x1) / d 587 588 return [(x3, y3), (x4, y4)] 589 590 591def bestFitIntersection( 592 a: base.LayoutPosition, 593 b: base.LayoutPosition, 594 aRad: Number, 595 bRad: Number 596): 597 """ 598 Given two circles which may or may not intersect (specified as 599 centers `a` and `b` plus respective radii), returns a point that's 600 on the line through their centers that's on the shortest segment of 601 that line which connects one circle to the other (this may or may 602 not be between the two centers if one circle encircles the other). 603 604 The point is placed along that segment so that its distance from 605 circle a divided by its distance from circle b is proportional to 606 the radius of circle a divided by the radius of circle b (it ends up 607 closer to the smaller circle). 608 609 If the two circles have the same center, we return a point that has 610 the same y-coordinate as that center, and of the two equally valid 611 points of that nature, we return the one with the greater 612 x-coordinate. 613 614 Some examples: 615 >>> rt(bestFitIntersection((0, 0), (100, 0), 180, 40)) 616 (147.272727, 0.0) 617 >>> rt(bestFitIntersection((0, 0), (50, 87), 60, 78)) 618 (21.73913, 37.826087) 619 >>> rt(bestFitIntersection((100, 0), (50, 87), 70, 78)) 620 (76.351351, 41.148649) 621 622 >>> rt(bestFitIntersection((0, 0), (8, 6), 5, 5)) # circles touch 623 (4.0, 3.0) 624 >>> rt(bestFitIntersection((0, 0), (12, 9), 10, 5)) # circles touch 625 (8.0, 6.0) 626 >>> rt(bestFitIntersection((-20, -20), (-30, 20), 10, 10)) # r1 == r2 627 (-25.0, 0.0) 628 >>> rt(bestFitIntersection((-30, 20), (-20, -20), 10, 10)) # other order 629 (-25.0, 0.0) 630 >>> rt(bestFitIntersection((0, 0), (0, 0), 12, 24)) # same center 631 (16.0, 0.0) 632 >>> # we arbitrarily pick a point horizontal from the center 633 >>> # note that (-16.0, 0.0) is equally valid but we pick the option 634 >>> # with the higher x-coordinate 635 >>> rt(bestFitIntersection((0, 0), (0, 0), 24, 12)) # works same other way 636 (16.0, 0.0) 637 >>> rt(bestFitIntersection((0, 0), (0, 0), 10, 10)) # same circle 638 (10.0, 0.0) 639 >>> rt(bestFitIntersection((0, 0), (0, 0), 0, 0)) # zero-radius same center 640 (0.0, 0.0) 641 >>> rt(bestFitIntersection((0, 0), (2, 0), 0, 0)) # zero-radius diff center 642 (1.0, 0.0) 643 >>> rt(bestFitIntersection((2, 0), (0, 0), 0, 0)) # other direction 644 (1.0, 0.0) 645 >>> rt(bestFitIntersection((0, 0), (2, 0), 1, 0)) # single zero-radius 646 (2.0, 0.0) 647 """ 648 import sys 649 dist = distance(a, b) 650 vect = norm(vSub(b, a)) 651 if vect == (0.0, 0.0): # same center 652 vect = (1.0, 0.0) # horizontal 653 654 # Find all four points at which the line between a and b intersects 655 # the a and b circles: 656 aVec = scaleBy(vect, aRad) 657 aLow = vSub(a, aVec) 658 aHigh = vAdd(a, aVec) 659 bVec = scaleBy(vect, bRad) 660 bLow = vSub(b, bVec) 661 bHigh = vAdd(b, bVec) 662 663 # Now find which pair of one point from A and one from B is closest 664 # There's probably a more mathy way to do this, but this is simple to 665 # reason about. 666 closest = None 667 bestDist = None 668 for (p1, p2) in [ 669 (aHigh, bHigh), 670 (aHigh, bLow), 671 (aLow, bHigh), 672 (aLow, bLow), 673 ]: 674 pointSep = distance(p1, p2) 675 # Note strict < here biases towards earlier pairs in the order 676 # above, such that 'high' points beat low ones on ties 677 if closest is None or pointSep < bestDist: 678 closest = (p1, p2) 679 bestDist = pointSep 680 681 assert closest is not None 682 683 # Now find a point between the two closest points-on-circle where the 684 # proportion between distances to each matches the ratio of the radii 685 # of the circles: 686 onA, onB = closest 687 between = vSub(onB, onA) 688 if between == (0.0, 0.0): # same point, so return it 689 return onA 690 dirBetween = norm(between) 691 distBetween = distance(onA, onB) 692 if aRad + bRad == 0: # both zero-radius; return average of the two 693 return ((onA[0] + onB[0]) / 2, (onA[1] + onB[1]) / 2) 694 howFarAlong = aRad / (aRad + bRad) 695 return vAdd(onA, scaleBy(dirBetween, howFarAlong * distBetween)) 696 697 698def baryeccentricPosition( 699 a: base.LayoutPosition, 700 b: base.LayoutPosition, 701 c: base.LayoutPosition, 702 distA: Number, 703 distB: Number, 704 distC: Number 705): 706 """ 707 Returns a "baryeccentric" position given three reference points and 708 three numbers indicating distances to each of them. If the distances 709 are in agreement and together specify a particular point within (or 710 outside of) the reference triangle, we return that point. If the 711 two or more of the distances are too short to touch each other, we 712 compromise at a position most consistent with them, and if the 713 distances are too long we also compromise. 714 715 For best results, you should ensure that the reference points make a 716 triangle rather than a line or point. 717 718 We find a compromise by treating each reference point + distance as a 719 circle. We first compute the intersection points between each pair of 720 circles, resulting in 0-4 intersection points per pair (see 721 `circleIntersection`). For pairs with no intersection, we use 722 `bestFitIntersection` to come up with a single "intersection" point. 723 Now for pairs with 2+ intersection points, we pick the single 724 intersection point whose distance to the third point is most 725 consistent with the measured third distance. This leaves us with 3 726 intersection points: one for each pair of reference points. We 727 average these three points to come up with the final result. 728 729 TODO: consider the perfectly-overlapping circles case a bit more... 730 731 Some examples: 732 733 >>> baryeccentricPosition((0, 0), (6, 8), (6, 0), 5, 5, 5) 734 (3.0, 4.0) 735 >>> baryeccentricPosition((0, 0), (-6, 8), (-6, 0), 5, 5, 5) 736 (-3.0, 4.0) 737 >>> baryeccentricPosition((0, 0), (-6, -8), (-6, 0), 5, 5, 5) 738 (-3.0, -4.0) 739 >>> baryeccentricPosition((0, 0), (3.0, 4.0), (3.0, 0), 5, 0, 4) 740 (3.0, 4.0) 741 >>> baryeccentricPosition((0, 0), (3.0, 4.0), (3.0, 0), 0, 5, 3) 742 (0.0, 0.0) 743 >>> baryeccentricPosition((0, 0), (3.0, 4.0), (3.0, 0), 3, 4, 0) 744 (3.0, 0.0) 745 >>> rt(baryeccentricPosition((-8, 6), (8, 6), (0, -10), 10, 10, 10)) 746 (0.0, 0.0) 747 >>> rt(baryeccentricPosition((-8, 6), (8, 6), (0, -12), 10, 10, 0)) 748 (0.0, -8.0) 749 >>> rt(baryeccentricPosition((-8, -6), (0, 12), (8, -6), 10, 0, 10)) 750 (0.0, 8.0) 751 >>> rt(baryeccentricPosition((0, 12), (-8, -6), (8, -6), 0, 10, 10)) 752 (0.0, 8.0) 753 >>> rt(baryeccentricPosition((-4, 3), (4, 3), (0, -5), 5, 5, 0)) 754 (0.0, -3.333333) 755 >>> rt(baryeccentricPosition((-1, 0), (1, 0), (0, -1), 1, 1, 1)) 756 (0.0, 0.0) 757 >>> rt(baryeccentricPosition( 758 ... (-25.3, 45.8), (12.4, -24.3), (35.9, 58.2), 759 ... 61.2, 35.5, 28.4 760 ... )) 761 (27.693092, 20.240286) 762 >>> rt(baryeccentricPosition( 763 ... (-25.3, 45.8), (12.4, -24.3), (35.9, 58.2), 764 ... 102.5, 12.8, 89.4 765 ... )) 766 (28.437607, -32.62218) 767 768 Edge case examples: 769 770 >>> baryeccentricPosition((0, 0), (0, 0), (0, 0), 5, 5, 5) 771 (5.0, 0.0) 772 >>> baryeccentricPosition((0, 0), (0, 0), (0, 0), 0, 0, 0) 773 (0.0, 0.0) 774 """ 775 # TODO: Should we print a warning if the points aren't a triangle? 776 777 # First, find intersection point(s) for each pair 778 abPoints = circleIntersections(a, b, distA, distB) 779 acPoints = circleIntersections(a, c, distA, distC) 780 bcPoints = circleIntersections(b, c, distB, distC) 781 782 # if circles don't touch, add an estimated point 783 if len(abPoints) == 0: 784 abPoints = [bestFitIntersection(a, b, distA, distB)] 785 if len(acPoints) == 0: 786 acPoints = [bestFitIntersection(a, c, distA, distC)] 787 if len(bcPoints) == 0: 788 bcPoints = [bestFitIntersection(b, c, distB, distC)] 789 790 # If circles touch a multiple places, narrow that down to one by 791 # figuring out which is most consistent with the third distance 792 if len(abPoints) == 1: 793 abPoint = abPoints[0] 794 else: # must be > 1 point per above 795 assert len(abPoints) > 1 796 abPoint = None 797 bestError = None 798 for p in abPoints: 799 thirdDist = distance(p, c) 800 error = abs(thirdDist - distC) 801 if abPoint is None or error < cast(float, bestError): 802 abPoint = p 803 bestError = error 804 805 if len(acPoints) == 1: 806 acPoint = acPoints[0] 807 else: # must be > 1 point per above 808 assert len(acPoints) > 1 809 acPoint = None 810 bestError = None 811 for p in acPoints: 812 thirdDist = distance(p, b) 813 error = abs(thirdDist - distB) 814 if bestError is None or error < bestError: 815 acPoint = p 816 bestError = error 817 818 if len(bcPoints) == 1: 819 bcPoint = bcPoints[0] 820 else: # must be > 1 point per above 821 assert len(bcPoints) > 1 822 bcPoint = None 823 bestError = None 824 for p in bcPoints: 825 thirdDist = distance(p, a) 826 error = abs(thirdDist - distA) 827 if bestError is None or error < bestError: 828 bcPoint = p 829 bestError = error 830 831 assert abPoint is not None 832 assert acPoint is not None 833 assert bcPoint is not None 834 835 # At this point, ab/ac/bc point variables should be assigned properly 836 return ( 837 (abPoint[0] + acPoint[0] + bcPoint[0]) / 3, 838 (abPoint[1] + acPoint[1] + bcPoint[1]) / 3, 839 ) 840 841 842def baryeccentricLayout( 843 exploration: core.DiscreteExploration, 844 specifiedNodes: Optional[base.Layout] = None 845) -> base.Layout: 846 """ 847 Computes a baryeccentric coordinate layout for all decisions in the 848 final step of the given exploration, using the specified positions 849 of a few nodes given in `specifiedNodes`. `specifiedNodes` should 850 specify positions for at least 3 decisions, and those positions must 851 form a triangle, not a line or point. If `specifiedNodes` does not 852 contain enough decisions (or if it's not provided), decisions will 853 be added to it as follows: 854 855 - If it's empty, add the node with the lowest id at position (0, 0). 856 - If it's got only one decision or we just added one node, add the 857 node that's furthest from that node in terms of hop distance. 858 We'll position this second node at the same y-coordinate as the 859 first, but with an x-coordinate equal to the hop distance between 860 it and the first node. If multiple nodes are tied for furthest, 861 add the one with the lowest id. 862 - If it's got only two decisions or we just added one or two, add the 863 node whose sum of hop distances to the two already selected is 864 largest. We position this third node such that the hop distances 865 to each of the already-placed nodes are respected and it forms a 866 triangle, or if that's not possible due to those distances being 867 too short, we position it partway between them proportional to 868 those two distances with an artificial offset perpendicular to 869 the line between the two other points. Ties are broken towards 870 nodes with a shorter max hop distance to either of the two 871 already-placed nodes, and then towards lower node IDs. 872 873 If the number of nodes in the entire graph is 1 or 2, we return a 874 layout positioning the first node at (0, 0) and (if it exists) the 875 second node at (1, 0). 876 877 Some examples: 878 879 # TODO 880 >> baryeccentricLayout(TODO) 881 """ 882 hops = analysis.shortestHopPaths( 883 exploration[-1].graph, 884 lambda src, transition, dst, graph: ( 885 'journey' not in graph.transitionTags(src, transition) 886 ) 887 ) 888 # Now we can use `analysis.hopDistnace` given `hops` plus two 889 # decision IDs to get the hop distance between any two decisions. 890 891 # Create empty layout by default: 892 if specifiedNodes is None: 893 specifiedNodes = {} 894 895 # Select at least 3 specific nodes 896 if len(specifiedNodes) < 3: 897 finalGraph = exploration[-1].graph 898 allDecisions = sorted(finalGraph) 899 900 # Bail out if we have fewer than 3 total decisions 901 if len(allDecisions) < 3: 902 result = {} 903 if len(allDecisions) > 0: 904 result[allDecisions[0]] = (0.0, 0.0) 905 if len(allDecisions) > 1: 906 result[allDecisions[1]] = (1.0, 0.0) 907 return result 908 909 # Add a decision at (0, 0) if we didn't have any specified 910 if len(specifiedNodes) < 1: 911 # Find largest weakly connected component: 912 bigCC = max(nx.weakly_connected_components(finalGraph), key=len) 913 # Use an arbitrary node from that component 914 specifiedNodes[list(bigCC)[0]] = (0.0, 0.0) 915 916 assert len(specifiedNodes) >= 1 917 918 # If 1 specified or just added, add furthest-away decision 919 if len(specifiedNodes) < 2: 920 first = list(specifiedNodes)[0] 921 best = None 922 bestDist = None 923 # Find furthest connected node 924 for dID in allDecisions: 925 if dID == first: 926 # Skip node that we already assigned 927 continue 928 dist = analysis.hopDistance(hops, dID, first) 929 # Note > here breaks ties towards lower IDs 930 if dist is not None and (bestDist is None or dist > bestDist): 931 best = dID 932 bestDist = dist 933 # if no nodes are connected, we've got a big problem, but 934 # we'll push on by selecting the node with the second-lowest 935 # node ID. 936 if best is None: 937 # Find first un-specified node ID: 938 second = None 939 for second in allDecisions: 940 dFirst = analysis.hopDistance(hops, second, first) 941 if ( 942 second not in specifiedNodes 943 and dFirst is not None 944 ): 945 # second will remain at this value after loop 946 break 947 else: # if we never hit break 948 for second in allDecisions: 949 if second not in specifiedNodes: 950 # second will remain at this value after loop 951 break 952 assert second is not None 953 # Just put it at (1, 0) since hops aren't informative 954 specifiedNodes[second] = (1.0, 0.0) 955 else: 956 assert best != first 957 assert bestDist is not None 958 firstPos = specifiedNodes[first] 959 # Same y-value as first one, with x-dist as hop dist 960 specifiedNodes[best] = (firstPos[0] + bestDist, firstPos[1]) 961 962 assert len(specifiedNodes) >= 2 963 964 # If only two specified (and or one or two just added) we look 965 # for the node with best combined distance to those two, 966 # breaking ties towards smaller max-distance to either and then 967 # towards smaller ID values. 968 if len(specifiedNodes) < 3: 969 first, second = list(specifiedNodes)[:2] 970 best = None 971 bestCombined = None 972 bestLonger = None 973 bestDists = None 974 for dID in allDecisions: 975 if dID in specifiedNodes: 976 # Skip already-placed nodes 977 continue 978 distA = analysis.hopDistance(hops, dID, first) 979 distB = analysis.hopDistance(hops, dID, second) 980 if distA is None or distB is None: 981 # Note: *shouldn't* be possible for only one to be 982 # None, but we don't take chances here 983 continue 984 combined = distA + distB 985 longer = max(distA, distB) 986 if ( 987 # first one 988 bestCombined is None 989 # better combined distance (further away) 990 or combined > bestCombined 991 # tied combined and better max distance (more evenly 992 # placed between at *shorter* max dist) 993 or ( 994 combined == bestCombined 995 and longer < cast(float, bestLonger) 996 # Note strict < here breaks ties towards lower IDs 997 ) 998 ): 999 best = dID 1000 bestCombined = combined 1001 bestLonger = longer 1002 bestDists = (distA, distB) 1003 1004 firstPos = specifiedNodes[first] 1005 secondPos = specifiedNodes[second] 1006 1007 abDist = analysis.hopDistance(hops, first, second) 1008 # They were chosen based on being connected... 1009 assert abDist is not None 1010 assert bestDists is not None 1011 # Could happen if only two nodes are connected, for example 1012 if best is None or sum(bestDists) < abDist: 1013 # Just put it artificially between them 1014 vect = ( 1015 secondPos[0] - firstPos[0], 1016 secondPos[1] - firstPos[1] 1017 ) 1018 # perpendicular vector 1019 ortho = (vect[1], -vect[0]) 1020 # Just use first decision that's not already specified 1021 if best is not None: 1022 third = best 1023 else: 1024 third = None 1025 for third in allDecisions: 1026 thirdHopsA = analysis.hopDistance(hops, first, third) 1027 thirdHopsB = analysis.hopDistance(hops, second, third) 1028 if ( 1029 third not in specifiedNodes 1030 and thirdHopsA is not None 1031 and thirdHopsB is not None 1032 ): 1033 # third will remain on this node 1034 break 1035 else: # if we never hit the break 1036 for third in allDecisions: 1037 if third not in specifiedNodes: 1038 # third will remain on this node 1039 break 1040 assert third is not None 1041 assert third != first 1042 assert third != second 1043 # Offset orthogonally by half the distance between 1044 specifiedNodes[third] = ( 1045 firstPos[0] + vect[0]/2 + ortho[0]/2, 1046 firstPos[1] + vect[1]/2 + ortho[0]/2 1047 ) 1048 else: 1049 # Position the best candidate to form a triangle where 1050 # distances are proportional; we know distances are long 1051 # enough to make a triangle 1052 distA = analysis.hopDistance(hops, dID, first) 1053 candidates = circleIntersections( 1054 firstPos, 1055 secondPos, 1056 *bestDists 1057 ) 1058 if len(candidates) == 0: 1059 assert distA is not None 1060 assert distB is not None 1061 where = bestFitIntersection( 1062 firstPos, 1063 secondPos, 1064 distA, 1065 distB 1066 ) 1067 else: 1068 where = candidates[0] 1069 assert best != first 1070 assert best != second 1071 specifiedNodes[best] = where 1072 1073 assert len(specifiedNodes) >= 3 1074 1075 # TODO: Don't just use first 3 here... 1076 # Grab first 3 decision IDs from layout 1077 a, b, c = list(specifiedNodes.keys())[:3] 1078 # Get their positions 1079 aPos = specifiedNodes[a] 1080 bPos = specifiedNodes[b] 1081 cPos = specifiedNodes[c] 1082 # create initial result using just specified positions 1083 result = { 1084 a: aPos, 1085 b: bPos, 1086 c: cPos 1087 } 1088 # Now we need to compute positions of each other node... 1089 # We use `exploration.allDecisions` as the set of nodes we want to 1090 # establish positions for, even though some of them may have been 1091 # deleted by the end and thus may not appear in our hops data. 1092 toLayOut = exploration.allDecisions() 1093 # value for default positions 1094 default = 1.0 1095 for decision in toLayOut: 1096 aHops = analysis.hopDistance(hops, a, decision) 1097 bHops = analysis.hopDistance(hops, b, decision) 1098 cHops = analysis.hopDistance(hops, c, decision) 1099 1100 # if hops is none for one, it should be none for all 1101 if aHops is None or bHops is None or cHops is None: 1102 # Put it at a default position on a parabola 1103 # TODO: Better default here? 1104 result[decision] = (default, default**1.1) 1105 default += 0.1 1106 else: 1107 assert aHops is not None 1108 assert bHops is not None 1109 assert cHops is not None 1110 1111 # Place according to baryeccentric position 1112 result[decision] = baryeccentricPosition( 1113 aPos, 1114 bPos, 1115 cPos, 1116 aHops, 1117 bHops, 1118 cHops 1119 ) 1120 1121 # Return result at end... 1122 return result 1123 1124def setBaryeccentricPositions( 1125 exploration: core.DiscreteExploration, 1126 method: GraphLayoutMethod = "square" 1127) -> None: 1128 """ 1129 Adds a "baryeccentric" layout to the given exploration that uses the 1130 `baryeccentricLayout` function to determine node positions. Uses 1131 an empty set of specified nodes so that they'll be determined 1132 automatically. 1133 """ 1134 exploration.layouts["baryeccentric"] = baryeccentricLayout( 1135 exploration, 1136 {} 1137 )
A type alias: block positions indicate the x/y coordinates of the north-west corner of a node, as well as its side length in grid units (all nodes are assumed to be square).
A type alias: block layouts map each decision in a particular graph to a block position which indicates both position and size in a unit grid.
45def roomSize(connections: int) -> int: 46 """ 47 For a room with the given number of connections, returns the side 48 length of the smallest square which can accommodate that many 49 connections. Note that outgoing/incoming reciprocal pairs to/from a 50 single destination should only count as one connection, because they 51 don't need more than one space on the room perimeter. Even with zero 52 connections, we still return 1 as the room size. 53 """ 54 if connections == 0: 55 return 1 56 return 1 + (connections - 1) // 4
For a room with the given number of connections, returns the side length of the smallest square which can accommodate that many connections. Note that outgoing/incoming reciprocal pairs to/from a single destination should only count as one connection, because they don't need more than one space on the room perimeter. Even with zero connections, we still return 1 as the room size.
59def expandBlocks(layout: BlockLayout) -> None: 60 """ 61 Modifies the given block layout by adding extra space between each 62 positioned node: it triples the coordinates of each node, and then 63 shifts them south and east by 1 unit each, by maintaining the nodes 64 at their original sizes, TODO... 65 """ 66 # TODO
Modifies the given block layout by adding extra space between each positioned node: it triples the coordinates of each node, and then shifts them south and east by 1 unit each, by maintaining the nodes at their original sizes, TODO...
The options for layouts of a decision graph. They are:
- 'stacked': Assigns all nodes to position (0, 0). Use this if you want to generate an empty layout that you plan to modify. Doesn't require any attributes.
- 'square': Takes the square root of the number of decisions, then places them in order into a square with that side length (rounded up). This is a very simple but also terrible algorithm. Doesn't require any attributes.
- 'line': Lays out the decisions in a straight line. Doesn't require any attributes.
- 'arc': Lays out decisions on an arc. Doesn't require any attributes.
100def assignPositions( 101 decisions: Sequence[base.DecisionID], 102 attributes: Optional[Dict[base.DecisionID, dict]] = None, 103 method: GraphLayoutMethod = "square" 104) -> base.Layout: 105 """ 106 Given a sequence of decision IDs, plus optionally a dictionary 107 mapping those IDs to attribute dictionaries, computes a layout for 108 the decisions according to the specified method, returning a 109 dictionary mapping each decision ID to its position in the layout. 110 111 Different layout methods may required different attributes to be 112 available. 113 """ 114 if method == "stacked": 115 return {d: (0, 0) for d in decisions} # all nodes at (0, 0) 116 elif method == "square": 117 return assignSquarePositions(decisions) 118 elif method == "line": 119 return assignLinePositions(decisions) 120 elif method == "arc": 121 return assignArcPositions(decisions) 122 else: 123 raise ValueError(f"Invalid layout method {method!r}.")
Given a sequence of decision IDs, plus optionally a dictionary mapping those IDs to attribute dictionaries, computes a layout for the decisions according to the specified method, returning a dictionary mapping each decision ID to its position in the layout.
Different layout methods may required different attributes to be available.
126def assignSquarePositions( 127 decisions: Sequence[base.DecisionID] 128) -> base.Layout: 129 """ 130 Creates and returns a dictionary of positions for the given sequence 131 of decisions using the 'square' layout: it arranges them into a big 132 square. 133 """ 134 result = {} 135 # Figure out side length of the square that will fit them all 136 side = math.ceil((len(decisions)**0.5)) 137 # Put 'em in a square 138 for i, d in enumerate(decisions): 139 result[d] = (i % side, i // side) 140 return result
Creates and returns a dictionary of positions for the given sequence of decisions using the 'square' layout: it arranges them into a big square.
143def assignLinePositions( 144 decisions: Sequence[base.DecisionID] 145) -> base.Layout: 146 """ 147 Creates and returns a dictionary of positions for the given sequence 148 of decisions using the 'line' layout: it arranges them into a 149 straight horizontal line. 150 """ 151 result = {} 152 # Put 'em in a line 153 for i, d in enumerate(decisions): 154 result[d] = (float(i), 0.0) 155 return result
Creates and returns a dictionary of positions for the given sequence of decisions using the 'line' layout: it arranges them into a straight horizontal line.
158def assignArcPositions( 159 decisions: Sequence[base.DecisionID], 160 radiusFactor: float = 1.5 161) -> base.Layout: 162 """ 163 Creates and returns a dictionary of positions for the given sequence 164 of decisions using the 'arc' layout: it arranges them along an arc 165 curving towards -y relative to a straight line. The start and end 166 of the arc are at (0, 0) and (N, 0) respectively, where N is the 167 number of decisions to be laid out. The `radiusFactor` must be at 168 least 1; when it's 1 the radius is N/2 and the shape is a full 169 semi-circle, as `radiusFactor` increases the radius of the circle 170 increases proportionally making the arc gentler. 171 """ 172 n = len(decisions) 173 cx = n / 2 # x-position of center halfway down line 174 r = radiusFactor * cx # radius of the circle 175 # y-position so that that radius intersects both (0, 0) and (n, 0) 176 cy = (r * r - cx * cx)**0.5 177 # angle from straight-down to either end of arc (note y and x 178 # intentionally reversed here) 179 th = math.atan2(cx, cy) 180 thSlice = th * 2 / n 181 thStart = 1.5 * math.pi - th 182 result = {} 183 # Traverse the arc 184 for i, d in enumerate(decisions): 185 angle = thStart + thSlice * i 186 result[d] = ( 187 cx + r * math.cos(angle), 188 cy + r * math.sin(angle) 189 ) 190 return result
Creates and returns a dictionary of positions for the given sequence
of decisions using the 'arc' layout: it arranges them along an arc
curving towards -y relative to a straight line. The start and end
of the arc are at (0, 0) and (N, 0) respectively, where N is the
number of decisions to be laid out. The radiusFactor must be at
least 1; when it's 1 the radius is N/2 and the shape is a full
semi-circle, as radiusFactor increases the radius of the circle
increases proportionally making the arc gentler.
193def setFinalPositions( 194 exploration: core.DiscreteExploration, 195 method: GraphLayoutMethod = "square" 196) -> None: 197 """ 198 Adds a "final" layout to the given exploration which contains a 199 dictionary mapping decision IDs to `Position`s. Every decision that 200 ever existed over the course of the exploration is assigned a 201 position. 202 """ 203 exploration.layouts["final"] = assignPositions( 204 exploration.allDecisions(), 205 method=method 206 )
Adds a "final" layout to the given exploration which contains a
dictionary mapping decision IDs to Positions. Every decision that
ever existed over the course of the exploration is assigned a
position.
209def setPathPositions( 210 exploration: core.DiscreteExploration, 211 arc: bool = True 212) -> None: 213 """ 214 Adds a "path" layout to the given exploration which contains a 215 dictionary mapping decision IDs to `Position`s. This includes every 216 visited decision and all of their neighbors, but does NOT include 217 decisions which were never visited and are not neighbors of a visited 218 decision. 219 220 Positions nodes along an arc below the X axis, each slightly more 221 than 1 unit apart, with unvisited neighbors descending below the node 222 they're a neighbor of in a parabola. This layout attempts to set 223 things up so that edges don't end up parallel and overlapping. You 224 can set `arc` to False to instead lay things out on a line, with 225 unvisited neighbors descending in a line below the node they connect 226 to. The linear layout would have lots of ambiguities if you tried to 227 relax it using a force simulation, whereas the arc layout would 228 have few ambiguities. 229 """ 230 # Lay out visited decisions in a line: 231 onPath = exploration.allVisitedDecisions() 232 if arc: 233 result = assignArcPositions(onPath, 5) # a gentle arc 234 else: 235 result = assignLinePositions(onPath) # a straight line 236 # Get the final graph to add neighbors from 237 finalGraph = exploration.getSituation().graph 238 # Track already-accounted-for neighbors 239 seen: Set[base.DecisionID] = set() 240 # Add neighbors to our layout 241 for decision in onPath: 242 # copy x of this decision 243 x = result[decision][0] 244 dx = 0.05 245 # Track y coordinates going down below the arc 246 y = result[decision][1] - 1.0 247 try: 248 neighbors = finalGraph.destinationsFrom(decision) 249 except core.MissingDecisionError: 250 continue 251 # decision on path may have been merged or deleted by end of 252 # exploration. We don't want to get neighbors in the step the 253 # node was visited, because it's likely many of those will 254 # have been explored by the time we get to the final graph, 255 # and we also don't want to include any merged/deleted 256 # neighbors in our graph, despite the fact we're including 257 # merged or deleted path nodes. 258 # TODO: Sort these by step added? 259 for dID in neighbors.values(): 260 # We only include all neighbors which are not elsewhere on 261 # the path. It's possible some of these may be confirmed, but 262 # that's fine, they were not active at any step. 263 if dID not in onPath: 264 result[dID] = (x, y) 265 # not just straight down if we're arcing 266 if arc: 267 dx += 0.03 # dx changes so nodes aren't in a line 268 x += dx # next node will be slightly farther to the right 269 y -= 1.0 # next node will be lower down 270 271 exploration.layouts["path"] = result
Adds a "path" layout to the given exploration which contains a
dictionary mapping decision IDs to Positions. This includes every
visited decision and all of their neighbors, but does NOT include
decisions which were never visited and are not neighbors of a visited
decision.
Positions nodes along an arc below the X axis, each slightly more
than 1 unit apart, with unvisited neighbors descending below the node
they're a neighbor of in a parabola. This layout attempts to set
things up so that edges don't end up parallel and overlapping. You
can set arc to False to instead lay things out on a line, with
unvisited neighbors descending in a line below the node they connect
to. The linear layout would have lots of ambiguities if you tried to
relax it using a force simulation, whereas the arc layout would
have few ambiguities.
274def setCustomPositions( 275 exploration: core.DiscreteExploration, 276 fromLayout: str = "final" 277) -> None: 278 """ 279 Sets up a 'custom' layout to be edited. Copies from the "final" 280 layout by default, or you can specify which layout to copy from. If 281 the specified layout doesn't exist, a square layout of all decisions 282 is used. 283 """ 284 copyFrom = exploration.layouts.get(fromLayout) 285 if copyFrom is None: 286 copied = assignPositions( 287 exploration.allDecisions(), 288 method="square" 289 ) 290 else: 291 copied = copy.deepcopy(copyFrom) 292 exploration.layouts["custom"] = copied
Sets up a 'custom' layout to be edited. Copies from the "final" layout by default, or you can specify which layout to copy from. If the specified layout doesn't exist, a square layout of all decisions is used.
For arguments that can be either an integer or a float.
306def pz(n: Number) -> Number: 307 """ 308 Converts -0.0 to 0.0 and leaves all other numbers alone. 309 """ 310 if n == -0.0: 311 return 0.0 312 else: 313 return n
Converts -0.0 to 0.0 and leaves all other numbers alone.
316def rt(t: base.LayoutPosition) -> base.LayoutPosition: 317 """ 318 Rounds off both parts of a 2-element tuple to 6 decimal places, and 319 then also converts -0.0 to +0.0 in either position. 320 """ 321 return ( 322 pz(round(t[0], 6)), 323 pz(round(t[1], 6)) 324 )
Rounds off both parts of a 2-element tuple to 6 decimal places, and then also converts -0.0 to +0.0 in either position.
327def rtl(tl: Sequence[base.LayoutPosition]) -> List[base.LayoutPosition]: 328 """ 329 Applies `rt` to a sequence of positions, returning a list. 330 """ 331 return [rt(t) for t in tl]
Applies rt to a sequence of positions, returning a list.
334def distance(a: base.LayoutPosition, b: base.LayoutPosition) -> float: 335 """ 336 Calculates the distance between two points, using the distance 337 formula in 2 dimensions. For example: 338 339 >>> distance((0, 0), (0, 3)) 340 3.0 341 >>> distance((0, 0), (3, 0)) 342 3.0 343 >>> distance((0, 0), (3, 4)) 344 5.0 345 """ 346 x1, y1 = a 347 x2, y2 = b 348 349 return (((x2 - x1) ** 2) + ((y2 - y1) ** 2)) ** 0.5
Calculates the distance between two points, using the distance formula in 2 dimensions. For example:
>>> distance((0, 0), (0, 3))
3.0
>>> distance((0, 0), (3, 0))
3.0
>>> distance((0, 0), (3, 4))
5.0
352def mid( 353 a: base.LayoutPosition, 354 b: base.LayoutPosition 355) -> base.LayoutPosition: 356 """ 357 Returns the midpoint between two points. For example: 358 359 >>> rt(mid((0, 0), (1, 0))) 360 (0.5, 0.0) 361 >>> rt(mid((0, 0), (3, 8))) 362 (1.5, 4.0) 363 >>> rt(mid((3, -3), (-3, 3))) 364 (0.0, 0.0) 365 """ 366 x1, y1 = a 367 x2, y2 = b 368 369 return ((x1 + x2) / 2, (y1 + y2) / 2)
Returns the midpoint between two points. For example:
>>> rt(mid((0, 0), (1, 0)))
(0.5, 0.0)
>>> rt(mid((0, 0), (3, 8)))
(1.5, 4.0)
>>> rt(mid((3, -3), (-3, 3)))
(0.0, 0.0)
372def vAdd( 373 a: base.LayoutPosition, 374 b: base.LayoutPosition 375) -> base.LayoutPosition: 376 """ 377 Returns the vector addition result for two layout positions. 378 For example: 379 380 >>> vAdd((0.0, 0.0), (1.0, 1.0)) 381 (1.0, 1.0) 382 >>> vAdd((1.0, 1.0), (0.0, 0.0)) 383 (1.0, 1.0) 384 >>> vAdd((1.0, 1.0), (2.0, -3.0)) 385 (3.0, -2.0) 386 >>> vAdd((1.0, 1.0), (1.0, 1.0)) 387 (2.0, 2.0) 388 """ 389 x1, y1 = a 390 x2, y2 = b 391 392 return (x1 + x2, y1 + y2)
Returns the vector addition result for two layout positions. For example:
>>> vAdd((0.0, 0.0), (1.0, 1.0))
(1.0, 1.0)
>>> vAdd((1.0, 1.0), (0.0, 0.0))
(1.0, 1.0)
>>> vAdd((1.0, 1.0), (2.0, -3.0))
(3.0, -2.0)
>>> vAdd((1.0, 1.0), (1.0, 1.0))
(2.0, 2.0)
395def vSub( 396 a: base.LayoutPosition, 397 b: base.LayoutPosition 398) -> base.LayoutPosition: 399 """ 400 Returns the vector between points a and b (from b to a, which is 401 also a - b in vector math). For example: 402 403 >>> vSub((1.0, 1.0), (0.0, 0.0)) 404 (1.0, 1.0) 405 >>> vSub((2.0, -3.0), (1.0, 1.0)) 406 (1.0, -4.0) 407 >>> vSub((1.0, 1.0), (1.0, 1.0)) 408 (0.0, 0.0) 409 """ 410 x1, y1 = a 411 x2, y2 = b 412 413 return (x1 - x2, y1 - y2)
Returns the vector between points a and b (from b to a, which is also a - b in vector math). For example:
>>> vSub((1.0, 1.0), (0.0, 0.0))
(1.0, 1.0)
>>> vSub((2.0, -3.0), (1.0, 1.0))
(1.0, -4.0)
>>> vSub((1.0, 1.0), (1.0, 1.0))
(0.0, 0.0)
415def norm(v: base.LayoutPosition) -> base.LayoutPosition: 416 """ 417 Normalizes the given vector, returning a vector in the same direction 418 whose length is 1. Returns the zero-vector if given the zero-vector, 419 which is the only case where the length of the result is not 1. 420 421 For example: 422 423 >>> norm((0, 0)) 424 (0.0, 0.0) 425 >>> norm((2, 0)) 426 (1.0, 0.0) 427 >>> norm((102, 0)) 428 (1.0, 0.0) 429 >>> norm((0, -3)) 430 (0.0, -1.0) 431 """ 432 length = distance((0, 0), v) 433 if length == 0: 434 return (0.0, 0.0) 435 else: 436 return (v[0] / length, v[1] / length)
Normalizes the given vector, returning a vector in the same direction whose length is 1. Returns the zero-vector if given the zero-vector, which is the only case where the length of the result is not 1.
For example:
>>> norm((0, 0))
(0.0, 0.0)
>>> norm((2, 0))
(1.0, 0.0)
>>> norm((102, 0))
(1.0, 0.0)
>>> norm((0, -3))
(0.0, -1.0)
439def isATriangle(x: Number, y: Number, z: Number) -> bool: 440 """ 441 Checks whether three side lengths can form a triangle. For example: 442 443 >>> isATriangle(3, 4, 5) 444 True 445 >>> isATriangle(1, 1, 1) 446 True 447 >>> isATriangle(100, 99, 1) 448 False 449 >>> isATriangle(100, 99, 2) 450 True 451 >>> isATriangle(2, 100, 99) 452 True 453 >>> isATriangle(99, 2, 100) 454 True 455 >>> isATriangle(3, 2, 10) 456 False 457 >>> isATriangle(5, 1, 1) 458 False 459 >>> isATriangle(9, 18.01, 9) 460 False 461 """ 462 return ((x + y > z) and (y + z > x) and (z + x > y))
Checks whether three side lengths can form a triangle. For example:
>>> isATriangle(3, 4, 5)
True
>>> isATriangle(1, 1, 1)
True
>>> isATriangle(100, 99, 1)
False
>>> isATriangle(100, 99, 2)
True
>>> isATriangle(2, 100, 99)
True
>>> isATriangle(99, 2, 100)
True
>>> isATriangle(3, 2, 10)
False
>>> isATriangle(5, 1, 1)
False
>>> isATriangle(9, 18.01, 9)
False
465def scaleBy( 466 vector: base.LayoutPosition, 467 scale: Number 468) -> base.LayoutPosition: 469 """ 470 Scales the given vector by the specified scale. 471 Examples: 472 473 >>> rt(scaleBy((1.0, 0.0), 3)) 474 (3.0, 0.0) 475 >>> rt(scaleBy((3.0, 4.0), 10)) 476 (30.0, 40.0) 477 >>> rt(scaleBy((6.0, 8.0), 5)) 478 (30.0, 40.0) 479 >>> rt(scaleBy((0.0, 2.0), -2)) 480 (0.0, -4.0) 481 >>> rt(scaleBy((0.0, 0.0), 1000)) 482 (0.0, 0.0) 483 """ 484 x, y = vector 485 return (x * scale, y * scale)
Scales the given vector by the specified scale. Examples:
>>> rt(scaleBy((1.0, 0.0), 3))
(3.0, 0.0)
>>> rt(scaleBy((3.0, 4.0), 10))
(30.0, 40.0)
>>> rt(scaleBy((6.0, 8.0), 5))
(30.0, 40.0)
>>> rt(scaleBy((0.0, 2.0), -2))
(0.0, -4.0)
>>> rt(scaleBy((0.0, 0.0), 1000))
(0.0, 0.0)
488def scaleTo( 489 vector: base.LayoutPosition, 490 length: Number 491) -> base.LayoutPosition: 492 """ 493 Scales the given vector to the specified length. Note that if the 494 vector is (0, 0), it will remain (0, 0) so the result won't actually 495 have the specified length in that one case. Examples: 496 497 >>> rt(scaleTo((1, 0), 3)) 498 (3.0, 0.0) 499 >>> rt(scaleTo((3, 4), 10)) 500 (6.0, 8.0) 501 >>> rt(scaleTo((6, 8), 5)) 502 (3.0, 4.0) 503 >>> rt(scaleTo((0, 2), -2)) 504 (0.0, -2.0) 505 >>> rt(scaleTo((0, 0), 1000)) 506 (0.0, 0.0) 507 """ 508 lengthNow = distance((0, 0), vector) 509 if lengthNow == 0: 510 return (0.0, 0.0) 511 else: 512 x, y = vector 513 return ( 514 (x / lengthNow) * length, 515 (y / lengthNow) * length 516 )
Scales the given vector to the specified length. Note that if the vector is (0, 0), it will remain (0, 0) so the result won't actually have the specified length in that one case. Examples:
>>> rt(scaleTo((1, 0), 3))
(3.0, 0.0)
>>> rt(scaleTo((3, 4), 10))
(6.0, 8.0)
>>> rt(scaleTo((6, 8), 5))
(3.0, 4.0)
>>> rt(scaleTo((0, 2), -2))
(0.0, -2.0)
>>> rt(scaleTo((0, 0), 1000))
(0.0, 0.0)
519def circleIntersections( 520 a: base.LayoutPosition, 521 b: base.LayoutPosition, 522 aRadius: Number, 523 bRadius: Number 524) -> List[base.LayoutPosition]: 525 """ 526 Calculates the intersection point(s) between two circles centered at 527 points `a` and `b` with the given radii. Returns a list of 0, 1, or 2 528 positions depending on the relationship between the circles. Note 529 that if two circles are the same circle, it should in theory return a 530 list of infinite positions; in that case we return a list with four 531 positions that are the places where horizontal and vertical lines 532 through the shared center intersect the shared circle. Examples: 533 534 >>> rtl(circleIntersections((0, 0), (2, 0), 1, 1)) # single point 535 [(1.0, 0.0)] 536 >>> rtl(circleIntersections((0, 0), (0, 2), 1, 1)) # single point 537 [(0.0, 1.0)] 538 >>> rtl(circleIntersections((0, 0), (6, 8), 5, 5)) # two 3/4/5 triangles 539 [(3.0, 4.0)] 540 >>> rtl(circleIntersections((0, 0), (2, 0), 1.5, 1.5)) # two points 541 [(1.0, -1.118034), (1.0, 1.118034)] 542 >>> rtl(circleIntersections((0, 0), (0, 0), 2, 3)) # no points 543 [] 544 >>> rtl(circleIntersections((0, 0), (2, 0), 0.5, 0.5)) # no points 545 [] 546 >>> rtl(circleIntersections((-3, 4), (3, 4), 5, 5)) # two 3/4/5 triangles 547 [(0.0, 0.0), (0.0, 8.0)] 548 >>> rtl(circleIntersections((-4, -3), (4, -3), 5, 5)) # two 3/4/5 triangles 549 [(0.0, -6.0), (0.0, 0.0)] 550 >>> rtl(circleIntersections((0.0, 0.0), (0.0, 0.0), 5, 5)) # infinity 551 [(5.0, 0.0), (0.0, -5.0), (-5.0, 0.0), (0.0, 5.0)] 552 """ 553 x1, y1 = a 554 x2, y2 = b 555 556 d = distance(a, b) 557 558 # If the circles are too far apart or if the distance between their 559 # centers is so small compared to the difference between their radii 560 # that one is entirely inside the other, there are no points of 561 # intersection. 562 if d > aRadius + bRadius or d < abs(aRadius - bRadius): 563 return [] 564 elif aRadius + bRadius == d: # one point of intersection 565 vec = scaleTo((x2 - x1, y2 - y1), aRadius) 566 return [ (x1 + vec[0], y1 + vec[1]) ] 567 elif x1 == x2 and y1 == y2 and aRadius == bRadius: # same circle 568 return [ # clockwise from 3 o'clock if +y is up and +x is right 569 (x1 + aRadius, y1), 570 (x1, y1 - aRadius), 571 (x1 - aRadius, y1), 572 (x1, y1 + aRadius) 573 ] 574 575 # Otherwise we have 2 points of intersection 576 # TODO: Explain this math a bit... 577 aa = (aRadius**2 - bRadius**2 + d**2) / (2 * d) 578 h = (aRadius**2 - aa**2)**0.5 579 580 x0 = x1 + aa * (x2 - x1) / d 581 y0 = y1 + aa * (y2 - y1) / d 582 583 x3 = x0 + h * (y2 - y1) / d 584 y3 = y0 - h * (x2 - x1) / d 585 586 x4 = x0 - h * (y2 - y1) / d 587 y4 = y0 + h * (x2 - x1) / d 588 589 return [(x3, y3), (x4, y4)]
Calculates the intersection point(s) between two circles centered at
points a and b with the given radii. Returns a list of 0, 1, or 2
positions depending on the relationship between the circles. Note
that if two circles are the same circle, it should in theory return a
list of infinite positions; in that case we return a list with four
positions that are the places where horizontal and vertical lines
through the shared center intersect the shared circle. Examples:
>>> rtl(circleIntersections((0, 0), (2, 0), 1, 1)) # single point
[(1.0, 0.0)]
>>> rtl(circleIntersections((0, 0), (0, 2), 1, 1)) # single point
[(0.0, 1.0)]
>>> rtl(circleIntersections((0, 0), (6, 8), 5, 5)) # two 3/4/5 triangles
[(3.0, 4.0)]
>>> rtl(circleIntersections((0, 0), (2, 0), 1.5, 1.5)) # two points
[(1.0, -1.118034), (1.0, 1.118034)]
>>> rtl(circleIntersections((0, 0), (0, 0), 2, 3)) # no points
[]
>>> rtl(circleIntersections((0, 0), (2, 0), 0.5, 0.5)) # no points
[]
>>> rtl(circleIntersections((-3, 4), (3, 4), 5, 5)) # two 3/4/5 triangles
[(0.0, 0.0), (0.0, 8.0)]
>>> rtl(circleIntersections((-4, -3), (4, -3), 5, 5)) # two 3/4/5 triangles
[(0.0, -6.0), (0.0, 0.0)]
>>> rtl(circleIntersections((0.0, 0.0), (0.0, 0.0), 5, 5)) # infinity
[(5.0, 0.0), (0.0, -5.0), (-5.0, 0.0), (0.0, 5.0)]
592def bestFitIntersection( 593 a: base.LayoutPosition, 594 b: base.LayoutPosition, 595 aRad: Number, 596 bRad: Number 597): 598 """ 599 Given two circles which may or may not intersect (specified as 600 centers `a` and `b` plus respective radii), returns a point that's 601 on the line through their centers that's on the shortest segment of 602 that line which connects one circle to the other (this may or may 603 not be between the two centers if one circle encircles the other). 604 605 The point is placed along that segment so that its distance from 606 circle a divided by its distance from circle b is proportional to 607 the radius of circle a divided by the radius of circle b (it ends up 608 closer to the smaller circle). 609 610 If the two circles have the same center, we return a point that has 611 the same y-coordinate as that center, and of the two equally valid 612 points of that nature, we return the one with the greater 613 x-coordinate. 614 615 Some examples: 616 >>> rt(bestFitIntersection((0, 0), (100, 0), 180, 40)) 617 (147.272727, 0.0) 618 >>> rt(bestFitIntersection((0, 0), (50, 87), 60, 78)) 619 (21.73913, 37.826087) 620 >>> rt(bestFitIntersection((100, 0), (50, 87), 70, 78)) 621 (76.351351, 41.148649) 622 623 >>> rt(bestFitIntersection((0, 0), (8, 6), 5, 5)) # circles touch 624 (4.0, 3.0) 625 >>> rt(bestFitIntersection((0, 0), (12, 9), 10, 5)) # circles touch 626 (8.0, 6.0) 627 >>> rt(bestFitIntersection((-20, -20), (-30, 20), 10, 10)) # r1 == r2 628 (-25.0, 0.0) 629 >>> rt(bestFitIntersection((-30, 20), (-20, -20), 10, 10)) # other order 630 (-25.0, 0.0) 631 >>> rt(bestFitIntersection((0, 0), (0, 0), 12, 24)) # same center 632 (16.0, 0.0) 633 >>> # we arbitrarily pick a point horizontal from the center 634 >>> # note that (-16.0, 0.0) is equally valid but we pick the option 635 >>> # with the higher x-coordinate 636 >>> rt(bestFitIntersection((0, 0), (0, 0), 24, 12)) # works same other way 637 (16.0, 0.0) 638 >>> rt(bestFitIntersection((0, 0), (0, 0), 10, 10)) # same circle 639 (10.0, 0.0) 640 >>> rt(bestFitIntersection((0, 0), (0, 0), 0, 0)) # zero-radius same center 641 (0.0, 0.0) 642 >>> rt(bestFitIntersection((0, 0), (2, 0), 0, 0)) # zero-radius diff center 643 (1.0, 0.0) 644 >>> rt(bestFitIntersection((2, 0), (0, 0), 0, 0)) # other direction 645 (1.0, 0.0) 646 >>> rt(bestFitIntersection((0, 0), (2, 0), 1, 0)) # single zero-radius 647 (2.0, 0.0) 648 """ 649 import sys 650 dist = distance(a, b) 651 vect = norm(vSub(b, a)) 652 if vect == (0.0, 0.0): # same center 653 vect = (1.0, 0.0) # horizontal 654 655 # Find all four points at which the line between a and b intersects 656 # the a and b circles: 657 aVec = scaleBy(vect, aRad) 658 aLow = vSub(a, aVec) 659 aHigh = vAdd(a, aVec) 660 bVec = scaleBy(vect, bRad) 661 bLow = vSub(b, bVec) 662 bHigh = vAdd(b, bVec) 663 664 # Now find which pair of one point from A and one from B is closest 665 # There's probably a more mathy way to do this, but this is simple to 666 # reason about. 667 closest = None 668 bestDist = None 669 for (p1, p2) in [ 670 (aHigh, bHigh), 671 (aHigh, bLow), 672 (aLow, bHigh), 673 (aLow, bLow), 674 ]: 675 pointSep = distance(p1, p2) 676 # Note strict < here biases towards earlier pairs in the order 677 # above, such that 'high' points beat low ones on ties 678 if closest is None or pointSep < bestDist: 679 closest = (p1, p2) 680 bestDist = pointSep 681 682 assert closest is not None 683 684 # Now find a point between the two closest points-on-circle where the 685 # proportion between distances to each matches the ratio of the radii 686 # of the circles: 687 onA, onB = closest 688 between = vSub(onB, onA) 689 if between == (0.0, 0.0): # same point, so return it 690 return onA 691 dirBetween = norm(between) 692 distBetween = distance(onA, onB) 693 if aRad + bRad == 0: # both zero-radius; return average of the two 694 return ((onA[0] + onB[0]) / 2, (onA[1] + onB[1]) / 2) 695 howFarAlong = aRad / (aRad + bRad) 696 return vAdd(onA, scaleBy(dirBetween, howFarAlong * distBetween))
Given two circles which may or may not intersect (specified as
centers a and b plus respective radii), returns a point that's
on the line through their centers that's on the shortest segment of
that line which connects one circle to the other (this may or may
not be between the two centers if one circle encircles the other).
The point is placed along that segment so that its distance from circle a divided by its distance from circle b is proportional to the radius of circle a divided by the radius of circle b (it ends up closer to the smaller circle).
If the two circles have the same center, we return a point that has the same y-coordinate as that center, and of the two equally valid points of that nature, we return the one with the greater x-coordinate.
Some examples:
>>> rt(bestFitIntersection((0, 0), (100, 0), 180, 40))
(147.272727, 0.0)
>>> rt(bestFitIntersection((0, 0), (50, 87), 60, 78))
(21.73913, 37.826087)
>>> rt(bestFitIntersection((100, 0), (50, 87), 70, 78))
(76.351351, 41.148649)
>>> rt(bestFitIntersection((0, 0), (8, 6), 5, 5)) # circles touch
(4.0, 3.0)
>>> rt(bestFitIntersection((0, 0), (12, 9), 10, 5)) # circles touch
(8.0, 6.0)
>>> rt(bestFitIntersection((-20, -20), (-30, 20), 10, 10)) # r1 == r2
(-25.0, 0.0)
>>> rt(bestFitIntersection((-30, 20), (-20, -20), 10, 10)) # other order
(-25.0, 0.0)
>>> rt(bestFitIntersection((0, 0), (0, 0), 12, 24)) # same center
(16.0, 0.0)
>>> # we arbitrarily pick a point horizontal from the center
>>> # note that (-16.0, 0.0) is equally valid but we pick the option
>>> # with the higher x-coordinate
>>> rt(bestFitIntersection((0, 0), (0, 0), 24, 12)) # works same other way
(16.0, 0.0)
>>> rt(bestFitIntersection((0, 0), (0, 0), 10, 10)) # same circle
(10.0, 0.0)
>>> rt(bestFitIntersection((0, 0), (0, 0), 0, 0)) # zero-radius same center
(0.0, 0.0)
>>> rt(bestFitIntersection((0, 0), (2, 0), 0, 0)) # zero-radius diff center
(1.0, 0.0)
>>> rt(bestFitIntersection((2, 0), (0, 0), 0, 0)) # other direction
(1.0, 0.0)
>>> rt(bestFitIntersection((0, 0), (2, 0), 1, 0)) # single zero-radius
(2.0, 0.0)
699def baryeccentricPosition( 700 a: base.LayoutPosition, 701 b: base.LayoutPosition, 702 c: base.LayoutPosition, 703 distA: Number, 704 distB: Number, 705 distC: Number 706): 707 """ 708 Returns a "baryeccentric" position given three reference points and 709 three numbers indicating distances to each of them. If the distances 710 are in agreement and together specify a particular point within (or 711 outside of) the reference triangle, we return that point. If the 712 two or more of the distances are too short to touch each other, we 713 compromise at a position most consistent with them, and if the 714 distances are too long we also compromise. 715 716 For best results, you should ensure that the reference points make a 717 triangle rather than a line or point. 718 719 We find a compromise by treating each reference point + distance as a 720 circle. We first compute the intersection points between each pair of 721 circles, resulting in 0-4 intersection points per pair (see 722 `circleIntersection`). For pairs with no intersection, we use 723 `bestFitIntersection` to come up with a single "intersection" point. 724 Now for pairs with 2+ intersection points, we pick the single 725 intersection point whose distance to the third point is most 726 consistent with the measured third distance. This leaves us with 3 727 intersection points: one for each pair of reference points. We 728 average these three points to come up with the final result. 729 730 TODO: consider the perfectly-overlapping circles case a bit more... 731 732 Some examples: 733 734 >>> baryeccentricPosition((0, 0), (6, 8), (6, 0), 5, 5, 5) 735 (3.0, 4.0) 736 >>> baryeccentricPosition((0, 0), (-6, 8), (-6, 0), 5, 5, 5) 737 (-3.0, 4.0) 738 >>> baryeccentricPosition((0, 0), (-6, -8), (-6, 0), 5, 5, 5) 739 (-3.0, -4.0) 740 >>> baryeccentricPosition((0, 0), (3.0, 4.0), (3.0, 0), 5, 0, 4) 741 (3.0, 4.0) 742 >>> baryeccentricPosition((0, 0), (3.0, 4.0), (3.0, 0), 0, 5, 3) 743 (0.0, 0.0) 744 >>> baryeccentricPosition((0, 0), (3.0, 4.0), (3.0, 0), 3, 4, 0) 745 (3.0, 0.0) 746 >>> rt(baryeccentricPosition((-8, 6), (8, 6), (0, -10), 10, 10, 10)) 747 (0.0, 0.0) 748 >>> rt(baryeccentricPosition((-8, 6), (8, 6), (0, -12), 10, 10, 0)) 749 (0.0, -8.0) 750 >>> rt(baryeccentricPosition((-8, -6), (0, 12), (8, -6), 10, 0, 10)) 751 (0.0, 8.0) 752 >>> rt(baryeccentricPosition((0, 12), (-8, -6), (8, -6), 0, 10, 10)) 753 (0.0, 8.0) 754 >>> rt(baryeccentricPosition((-4, 3), (4, 3), (0, -5), 5, 5, 0)) 755 (0.0, -3.333333) 756 >>> rt(baryeccentricPosition((-1, 0), (1, 0), (0, -1), 1, 1, 1)) 757 (0.0, 0.0) 758 >>> rt(baryeccentricPosition( 759 ... (-25.3, 45.8), (12.4, -24.3), (35.9, 58.2), 760 ... 61.2, 35.5, 28.4 761 ... )) 762 (27.693092, 20.240286) 763 >>> rt(baryeccentricPosition( 764 ... (-25.3, 45.8), (12.4, -24.3), (35.9, 58.2), 765 ... 102.5, 12.8, 89.4 766 ... )) 767 (28.437607, -32.62218) 768 769 Edge case examples: 770 771 >>> baryeccentricPosition((0, 0), (0, 0), (0, 0), 5, 5, 5) 772 (5.0, 0.0) 773 >>> baryeccentricPosition((0, 0), (0, 0), (0, 0), 0, 0, 0) 774 (0.0, 0.0) 775 """ 776 # TODO: Should we print a warning if the points aren't a triangle? 777 778 # First, find intersection point(s) for each pair 779 abPoints = circleIntersections(a, b, distA, distB) 780 acPoints = circleIntersections(a, c, distA, distC) 781 bcPoints = circleIntersections(b, c, distB, distC) 782 783 # if circles don't touch, add an estimated point 784 if len(abPoints) == 0: 785 abPoints = [bestFitIntersection(a, b, distA, distB)] 786 if len(acPoints) == 0: 787 acPoints = [bestFitIntersection(a, c, distA, distC)] 788 if len(bcPoints) == 0: 789 bcPoints = [bestFitIntersection(b, c, distB, distC)] 790 791 # If circles touch a multiple places, narrow that down to one by 792 # figuring out which is most consistent with the third distance 793 if len(abPoints) == 1: 794 abPoint = abPoints[0] 795 else: # must be > 1 point per above 796 assert len(abPoints) > 1 797 abPoint = None 798 bestError = None 799 for p in abPoints: 800 thirdDist = distance(p, c) 801 error = abs(thirdDist - distC) 802 if abPoint is None or error < cast(float, bestError): 803 abPoint = p 804 bestError = error 805 806 if len(acPoints) == 1: 807 acPoint = acPoints[0] 808 else: # must be > 1 point per above 809 assert len(acPoints) > 1 810 acPoint = None 811 bestError = None 812 for p in acPoints: 813 thirdDist = distance(p, b) 814 error = abs(thirdDist - distB) 815 if bestError is None or error < bestError: 816 acPoint = p 817 bestError = error 818 819 if len(bcPoints) == 1: 820 bcPoint = bcPoints[0] 821 else: # must be > 1 point per above 822 assert len(bcPoints) > 1 823 bcPoint = None 824 bestError = None 825 for p in bcPoints: 826 thirdDist = distance(p, a) 827 error = abs(thirdDist - distA) 828 if bestError is None or error < bestError: 829 bcPoint = p 830 bestError = error 831 832 assert abPoint is not None 833 assert acPoint is not None 834 assert bcPoint is not None 835 836 # At this point, ab/ac/bc point variables should be assigned properly 837 return ( 838 (abPoint[0] + acPoint[0] + bcPoint[0]) / 3, 839 (abPoint[1] + acPoint[1] + bcPoint[1]) / 3, 840 )
Returns a "baryeccentric" position given three reference points and three numbers indicating distances to each of them. If the distances are in agreement and together specify a particular point within (or outside of) the reference triangle, we return that point. If the two or more of the distances are too short to touch each other, we compromise at a position most consistent with them, and if the distances are too long we also compromise.
For best results, you should ensure that the reference points make a triangle rather than a line or point.
We find a compromise by treating each reference point + distance as a
circle. We first compute the intersection points between each pair of
circles, resulting in 0-4 intersection points per pair (see
circleIntersection). For pairs with no intersection, we use
bestFitIntersection to come up with a single "intersection" point.
Now for pairs with 2+ intersection points, we pick the single
intersection point whose distance to the third point is most
consistent with the measured third distance. This leaves us with 3
intersection points: one for each pair of reference points. We
average these three points to come up with the final result.
TODO: consider the perfectly-overlapping circles case a bit more...
Some examples:
>>> baryeccentricPosition((0, 0), (6, 8), (6, 0), 5, 5, 5)
(3.0, 4.0)
>>> baryeccentricPosition((0, 0), (-6, 8), (-6, 0), 5, 5, 5)
(-3.0, 4.0)
>>> baryeccentricPosition((0, 0), (-6, -8), (-6, 0), 5, 5, 5)
(-3.0, -4.0)
>>> baryeccentricPosition((0, 0), (3.0, 4.0), (3.0, 0), 5, 0, 4)
(3.0, 4.0)
>>> baryeccentricPosition((0, 0), (3.0, 4.0), (3.0, 0), 0, 5, 3)
(0.0, 0.0)
>>> baryeccentricPosition((0, 0), (3.0, 4.0), (3.0, 0), 3, 4, 0)
(3.0, 0.0)
>>> rt(baryeccentricPosition((-8, 6), (8, 6), (0, -10), 10, 10, 10))
(0.0, 0.0)
>>> rt(baryeccentricPosition((-8, 6), (8, 6), (0, -12), 10, 10, 0))
(0.0, -8.0)
>>> rt(baryeccentricPosition((-8, -6), (0, 12), (8, -6), 10, 0, 10))
(0.0, 8.0)
>>> rt(baryeccentricPosition((0, 12), (-8, -6), (8, -6), 0, 10, 10))
(0.0, 8.0)
>>> rt(baryeccentricPosition((-4, 3), (4, 3), (0, -5), 5, 5, 0))
(0.0, -3.333333)
>>> rt(baryeccentricPosition((-1, 0), (1, 0), (0, -1), 1, 1, 1))
(0.0, 0.0)
>>> rt(baryeccentricPosition(
... (-25.3, 45.8), (12.4, -24.3), (35.9, 58.2),
... 61.2, 35.5, 28.4
... ))
(27.693092, 20.240286)
>>> rt(baryeccentricPosition(
... (-25.3, 45.8), (12.4, -24.3), (35.9, 58.2),
... 102.5, 12.8, 89.4
... ))
(28.437607, -32.62218)
Edge case examples:
>>> baryeccentricPosition((0, 0), (0, 0), (0, 0), 5, 5, 5)
(5.0, 0.0)
>>> baryeccentricPosition((0, 0), (0, 0), (0, 0), 0, 0, 0)
(0.0, 0.0)
843def baryeccentricLayout( 844 exploration: core.DiscreteExploration, 845 specifiedNodes: Optional[base.Layout] = None 846) -> base.Layout: 847 """ 848 Computes a baryeccentric coordinate layout for all decisions in the 849 final step of the given exploration, using the specified positions 850 of a few nodes given in `specifiedNodes`. `specifiedNodes` should 851 specify positions for at least 3 decisions, and those positions must 852 form a triangle, not a line or point. If `specifiedNodes` does not 853 contain enough decisions (or if it's not provided), decisions will 854 be added to it as follows: 855 856 - If it's empty, add the node with the lowest id at position (0, 0). 857 - If it's got only one decision or we just added one node, add the 858 node that's furthest from that node in terms of hop distance. 859 We'll position this second node at the same y-coordinate as the 860 first, but with an x-coordinate equal to the hop distance between 861 it and the first node. If multiple nodes are tied for furthest, 862 add the one with the lowest id. 863 - If it's got only two decisions or we just added one or two, add the 864 node whose sum of hop distances to the two already selected is 865 largest. We position this third node such that the hop distances 866 to each of the already-placed nodes are respected and it forms a 867 triangle, or if that's not possible due to those distances being 868 too short, we position it partway between them proportional to 869 those two distances with an artificial offset perpendicular to 870 the line between the two other points. Ties are broken towards 871 nodes with a shorter max hop distance to either of the two 872 already-placed nodes, and then towards lower node IDs. 873 874 If the number of nodes in the entire graph is 1 or 2, we return a 875 layout positioning the first node at (0, 0) and (if it exists) the 876 second node at (1, 0). 877 878 Some examples: 879 880 # TODO 881 >> baryeccentricLayout(TODO) 882 """ 883 hops = analysis.shortestHopPaths( 884 exploration[-1].graph, 885 lambda src, transition, dst, graph: ( 886 'journey' not in graph.transitionTags(src, transition) 887 ) 888 ) 889 # Now we can use `analysis.hopDistnace` given `hops` plus two 890 # decision IDs to get the hop distance between any two decisions. 891 892 # Create empty layout by default: 893 if specifiedNodes is None: 894 specifiedNodes = {} 895 896 # Select at least 3 specific nodes 897 if len(specifiedNodes) < 3: 898 finalGraph = exploration[-1].graph 899 allDecisions = sorted(finalGraph) 900 901 # Bail out if we have fewer than 3 total decisions 902 if len(allDecisions) < 3: 903 result = {} 904 if len(allDecisions) > 0: 905 result[allDecisions[0]] = (0.0, 0.0) 906 if len(allDecisions) > 1: 907 result[allDecisions[1]] = (1.0, 0.0) 908 return result 909 910 # Add a decision at (0, 0) if we didn't have any specified 911 if len(specifiedNodes) < 1: 912 # Find largest weakly connected component: 913 bigCC = max(nx.weakly_connected_components(finalGraph), key=len) 914 # Use an arbitrary node from that component 915 specifiedNodes[list(bigCC)[0]] = (0.0, 0.0) 916 917 assert len(specifiedNodes) >= 1 918 919 # If 1 specified or just added, add furthest-away decision 920 if len(specifiedNodes) < 2: 921 first = list(specifiedNodes)[0] 922 best = None 923 bestDist = None 924 # Find furthest connected node 925 for dID in allDecisions: 926 if dID == first: 927 # Skip node that we already assigned 928 continue 929 dist = analysis.hopDistance(hops, dID, first) 930 # Note > here breaks ties towards lower IDs 931 if dist is not None and (bestDist is None or dist > bestDist): 932 best = dID 933 bestDist = dist 934 # if no nodes are connected, we've got a big problem, but 935 # we'll push on by selecting the node with the second-lowest 936 # node ID. 937 if best is None: 938 # Find first un-specified node ID: 939 second = None 940 for second in allDecisions: 941 dFirst = analysis.hopDistance(hops, second, first) 942 if ( 943 second not in specifiedNodes 944 and dFirst is not None 945 ): 946 # second will remain at this value after loop 947 break 948 else: # if we never hit break 949 for second in allDecisions: 950 if second not in specifiedNodes: 951 # second will remain at this value after loop 952 break 953 assert second is not None 954 # Just put it at (1, 0) since hops aren't informative 955 specifiedNodes[second] = (1.0, 0.0) 956 else: 957 assert best != first 958 assert bestDist is not None 959 firstPos = specifiedNodes[first] 960 # Same y-value as first one, with x-dist as hop dist 961 specifiedNodes[best] = (firstPos[0] + bestDist, firstPos[1]) 962 963 assert len(specifiedNodes) >= 2 964 965 # If only two specified (and or one or two just added) we look 966 # for the node with best combined distance to those two, 967 # breaking ties towards smaller max-distance to either and then 968 # towards smaller ID values. 969 if len(specifiedNodes) < 3: 970 first, second = list(specifiedNodes)[:2] 971 best = None 972 bestCombined = None 973 bestLonger = None 974 bestDists = None 975 for dID in allDecisions: 976 if dID in specifiedNodes: 977 # Skip already-placed nodes 978 continue 979 distA = analysis.hopDistance(hops, dID, first) 980 distB = analysis.hopDistance(hops, dID, second) 981 if distA is None or distB is None: 982 # Note: *shouldn't* be possible for only one to be 983 # None, but we don't take chances here 984 continue 985 combined = distA + distB 986 longer = max(distA, distB) 987 if ( 988 # first one 989 bestCombined is None 990 # better combined distance (further away) 991 or combined > bestCombined 992 # tied combined and better max distance (more evenly 993 # placed between at *shorter* max dist) 994 or ( 995 combined == bestCombined 996 and longer < cast(float, bestLonger) 997 # Note strict < here breaks ties towards lower IDs 998 ) 999 ): 1000 best = dID 1001 bestCombined = combined 1002 bestLonger = longer 1003 bestDists = (distA, distB) 1004 1005 firstPos = specifiedNodes[first] 1006 secondPos = specifiedNodes[second] 1007 1008 abDist = analysis.hopDistance(hops, first, second) 1009 # They were chosen based on being connected... 1010 assert abDist is not None 1011 assert bestDists is not None 1012 # Could happen if only two nodes are connected, for example 1013 if best is None or sum(bestDists) < abDist: 1014 # Just put it artificially between them 1015 vect = ( 1016 secondPos[0] - firstPos[0], 1017 secondPos[1] - firstPos[1] 1018 ) 1019 # perpendicular vector 1020 ortho = (vect[1], -vect[0]) 1021 # Just use first decision that's not already specified 1022 if best is not None: 1023 third = best 1024 else: 1025 third = None 1026 for third in allDecisions: 1027 thirdHopsA = analysis.hopDistance(hops, first, third) 1028 thirdHopsB = analysis.hopDistance(hops, second, third) 1029 if ( 1030 third not in specifiedNodes 1031 and thirdHopsA is not None 1032 and thirdHopsB is not None 1033 ): 1034 # third will remain on this node 1035 break 1036 else: # if we never hit the break 1037 for third in allDecisions: 1038 if third not in specifiedNodes: 1039 # third will remain on this node 1040 break 1041 assert third is not None 1042 assert third != first 1043 assert third != second 1044 # Offset orthogonally by half the distance between 1045 specifiedNodes[third] = ( 1046 firstPos[0] + vect[0]/2 + ortho[0]/2, 1047 firstPos[1] + vect[1]/2 + ortho[0]/2 1048 ) 1049 else: 1050 # Position the best candidate to form a triangle where 1051 # distances are proportional; we know distances are long 1052 # enough to make a triangle 1053 distA = analysis.hopDistance(hops, dID, first) 1054 candidates = circleIntersections( 1055 firstPos, 1056 secondPos, 1057 *bestDists 1058 ) 1059 if len(candidates) == 0: 1060 assert distA is not None 1061 assert distB is not None 1062 where = bestFitIntersection( 1063 firstPos, 1064 secondPos, 1065 distA, 1066 distB 1067 ) 1068 else: 1069 where = candidates[0] 1070 assert best != first 1071 assert best != second 1072 specifiedNodes[best] = where 1073 1074 assert len(specifiedNodes) >= 3 1075 1076 # TODO: Don't just use first 3 here... 1077 # Grab first 3 decision IDs from layout 1078 a, b, c = list(specifiedNodes.keys())[:3] 1079 # Get their positions 1080 aPos = specifiedNodes[a] 1081 bPos = specifiedNodes[b] 1082 cPos = specifiedNodes[c] 1083 # create initial result using just specified positions 1084 result = { 1085 a: aPos, 1086 b: bPos, 1087 c: cPos 1088 } 1089 # Now we need to compute positions of each other node... 1090 # We use `exploration.allDecisions` as the set of nodes we want to 1091 # establish positions for, even though some of them may have been 1092 # deleted by the end and thus may not appear in our hops data. 1093 toLayOut = exploration.allDecisions() 1094 # value for default positions 1095 default = 1.0 1096 for decision in toLayOut: 1097 aHops = analysis.hopDistance(hops, a, decision) 1098 bHops = analysis.hopDistance(hops, b, decision) 1099 cHops = analysis.hopDistance(hops, c, decision) 1100 1101 # if hops is none for one, it should be none for all 1102 if aHops is None or bHops is None or cHops is None: 1103 # Put it at a default position on a parabola 1104 # TODO: Better default here? 1105 result[decision] = (default, default**1.1) 1106 default += 0.1 1107 else: 1108 assert aHops is not None 1109 assert bHops is not None 1110 assert cHops is not None 1111 1112 # Place according to baryeccentric position 1113 result[decision] = baryeccentricPosition( 1114 aPos, 1115 bPos, 1116 cPos, 1117 aHops, 1118 bHops, 1119 cHops 1120 ) 1121 1122 # Return result at end... 1123 return result
Computes a baryeccentric coordinate layout for all decisions in the
final step of the given exploration, using the specified positions
of a few nodes given in specifiedNodes. specifiedNodes should
specify positions for at least 3 decisions, and those positions must
form a triangle, not a line or point. If specifiedNodes does not
contain enough decisions (or if it's not provided), decisions will
be added to it as follows:
- If it's empty, add the node with the lowest id at position (0, 0).
- If it's got only one decision or we just added one node, add the node that's furthest from that node in terms of hop distance. We'll position this second node at the same y-coordinate as the first, but with an x-coordinate equal to the hop distance between it and the first node. If multiple nodes are tied for furthest, add the one with the lowest id.
- If it's got only two decisions or we just added one or two, add the node whose sum of hop distances to the two already selected is largest. We position this third node such that the hop distances to each of the already-placed nodes are respected and it forms a triangle, or if that's not possible due to those distances being too short, we position it partway between them proportional to those two distances with an artificial offset perpendicular to the line between the two other points. Ties are broken towards nodes with a shorter max hop distance to either of the two already-placed nodes, and then towards lower node IDs.
If the number of nodes in the entire graph is 1 or 2, we return a layout positioning the first node at (0, 0) and (if it exists) the second node at (1, 0).
Some examples:
TODO
baryeccentricLayout(TODO)
1125def setBaryeccentricPositions( 1126 exploration: core.DiscreteExploration, 1127 method: GraphLayoutMethod = "square" 1128) -> None: 1129 """ 1130 Adds a "baryeccentric" layout to the given exploration that uses the 1131 `baryeccentricLayout` function to determine node positions. Uses 1132 an empty set of specified nodes so that they'll be determined 1133 automatically. 1134 """ 1135 exploration.layouts["baryeccentric"] = baryeccentricLayout( 1136 exploration, 1137 {} 1138 )
Adds a "baryeccentric" layout to the given exploration that uses the
baryeccentricLayout function to determine node positions. Uses
an empty set of specified nodes so that they'll be determined
automatically.