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    )
BlockPosition: TypeAlias = Tuple[int, int, int]

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).

BlockLayout: TypeAlias = Dict[int, Tuple[int, int, int]]

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.

def roomSize(connections: int) -> int:
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.

def expandBlocks(layout: Dict[int, Tuple[int, int, int]]) -> None:
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...

GraphLayoutMethod: TypeAlias = Literal['stacked', 'square', 'line', 'arc']

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.
def assignPositions( decisions: Sequence[int], attributes: Optional[Dict[int, dict]] = None, method: Literal['stacked', 'square', 'line', 'arc'] = 'square') -> Dict[int, Tuple[float, float]]:
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.

def assignSquarePositions(decisions: Sequence[int]) -> Dict[int, Tuple[float, float]]:
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.

def assignLinePositions(decisions: Sequence[int]) -> Dict[int, Tuple[float, float]]:
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.

def assignArcPositions( decisions: Sequence[int], radiusFactor: float = 1.5) -> Dict[int, Tuple[float, float]]:
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.

def setFinalPositions( exploration: exploration.core.DiscreteExploration, method: Literal['stacked', 'square', 'line', 'arc'] = 'square') -> None:
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.

def setPathPositions( exploration: exploration.core.DiscreteExploration, arc: bool = True) -> None:
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.

def setCustomPositions( exploration: exploration.core.DiscreteExploration, fromLayout: str = 'final') -> None:
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.

Number: TypeAlias = Union[int, float]

For arguments that can be either an integer or a float.

def pz(n: Union[int, float]) -> Union[int, 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.

def rt(t: Tuple[float, float]) -> Tuple[float, float]:
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.

def rtl(tl: Sequence[Tuple[float, float]]) -> List[Tuple[float, float]]:
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.

def distance(a: Tuple[float, float], b: Tuple[float, float]) -> float:
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
def mid(a: Tuple[float, float], b: Tuple[float, float]) -> Tuple[float, float]:
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)
def vAdd(a: Tuple[float, float], b: Tuple[float, float]) -> Tuple[float, float]:
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)
def vSub(a: Tuple[float, float], b: Tuple[float, float]) -> Tuple[float, float]:
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)
def norm(v: Tuple[float, float]) -> Tuple[float, float]:
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)
def isATriangle(x: Union[int, float], y: Union[int, float], z: Union[int, float]) -> bool:
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
def scaleBy( vector: Tuple[float, float], scale: Union[int, float]) -> Tuple[float, float]:
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)
def scaleTo( vector: Tuple[float, float], length: Union[int, float]) -> Tuple[float, float]:
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)
def circleIntersections( a: Tuple[float, float], b: Tuple[float, float], aRadius: Union[int, float], bRadius: Union[int, float]) -> List[Tuple[float, float]]:
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)]
def bestFitIntersection( a: Tuple[float, float], b: Tuple[float, float], aRad: Union[int, float], bRad: Union[int, float]):
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)
def baryeccentricPosition( a: Tuple[float, float], b: Tuple[float, float], c: Tuple[float, float], distA: Union[int, float], distB: Union[int, float], distC: Union[int, float]):
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)
def baryeccentricLayout( exploration: exploration.core.DiscreteExploration, specifiedNodes: Optional[Dict[int, Tuple[float, float]]] = None) -> Dict[int, Tuple[float, float]]:
 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)

def setBaryeccentricPositions( exploration: exploration.core.DiscreteExploration, method: Literal['stacked', 'square', 'line', 'arc'] = 'square') -> None:
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.