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

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.
def assignPositions( decisions: Sequence[int], attributes: Optional[Dict[int, dict]] = None, method: Literal['stacked', 'square'] = 'square') -> Dict[int, Tuple[float, float]]:
 92def assignPositions(
 93    decisions: Sequence[base.DecisionID],
 94    attributes: Optional[Dict[base.DecisionID, dict]] = None,
 95    method: GraphLayoutMethod = "square"
 96) -> base.Layout:
 97    """
 98    Given a sequence of decision IDs, plus optionally a dictionary
 99    mapping those IDs to attribute dictionaries, computes a layout for
100    the decisions according to the specified method, returning a
101    dictionary mapping each decision ID to its position in the layout.
102
103    Different layout methods may required different attributes to be
104    available.
105    """
106    if method == "stacked":
107        return {d: (0, 0) for d in decisions}  #  all nodes at (0, 0)
108    elif method == "square":
109        return assignSquarePositions(decisions)
110    elif method == "line":
111        return assignLinePositions(decisions)
112    else:
113        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]]:
116def assignSquarePositions(
117    decisions: Sequence[base.DecisionID]
118) -> base.Layout:
119    """
120    Creates and returns a dictionary of positions for the given sequence
121    of decisions using the 'square' layout: it arranges them into a big
122    square.
123    """
124    result = {}
125    # Figure out side length of the square that will fit them all
126    side = math.ceil((len(decisions)**0.5))
127    # Put 'em in a square
128    for i, d in enumerate(decisions):
129        result[d] = (i % side, i // side)
130    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]]:
133def assignLinePositions(
134    decisions: Sequence[base.DecisionID]
135) -> base.Layout:
136    """
137    Creates and returns a dictionary of positions for the given sequence
138    of decisions using the 'line' layout: it arranges them into a
139    straight horizontal line.
140    """
141    result = {}
142    # Put 'em in a line
143    for i, d in enumerate(decisions):
144        result[d] = (float(i), 0.0)
145    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 setFinalPositions( exploration: exploration.core.DiscreteExploration, method: Literal['stacked', 'square'] = 'square') -> None:
148def setFinalPositions(
149    exploration: core.DiscreteExploration,
150    method: GraphLayoutMethod = "square"
151) -> None:
152    """
153    Adds a "finalPositions" attribute to the given exploration which
154    contains a dictionary mapping decision IDs to `Position`s. Every
155    decision that ever existed over the course of the exploration is
156    assigned a position.
157    """
158    exploration.layouts["final"] = assignPositions(
159        exploration.allDecisions(),
160        method=method
161    )

Adds a "finalPositions" attribute 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) -> None:
163def setPathPositions(exploration: core.DiscreteExploration) -> None:
164    """
165    Adds a "pathPositions" attribute to the given exploration which
166    contains a dictionary mapping decision IDs to `Position`s. This
167    includes every visited decision and all of their neighbors, but does
168    NOT include decisions which were never visited and are not neighbors
169    of a visited decision.
170    """
171    # Lay out visited decisions in a line:
172    onPath = exploration.allVisitedDecisions()
173    result = assignLinePositions(onPath)
174    # Get the final graph to add neighbors from
175    finalGraph = exploration.getSituation().graph
176    # Track already-accounted-for neighbors
177    seen: Set[base.DecisionID] = set()
178    # Add neighbors to our layout
179    for decision in onPath:
180        # copy x of this decision
181        x = result[decision][0]
182        # Track y coordinates going down below the line
183        y = -1.0
184        try:
185            neighbors = finalGraph.destinationsFrom(decision)
186        except core.MissingDecisionError:
187            continue
188            # decision on path may have been merged or deleted by end of
189            # exploration. We don't want to get neighbors in the step the
190            # node was visited, because it's likely many of those will
191            # have been explored by the time we get to the final graph,
192            # and we also don't want to include any merged/deleted
193            # neighbors in our graph, despite the fact we're including
194            # merged or deleted path nodes.
195        # TODO: Sort these by step added?
196        for dID in neighbors.values():
197            # We only include all neighbors which are not elsewhere on
198            # the path. It's possible some of these may be confirmed, but
199            # that's fine, they were not active at any step.
200            if dID not in onPath:
201                result[dID] = (x, y)
202                y -= 1.0  # next node will be lower down
203
204    exploration.layouts["path"] = result

Adds a "pathPositions" attribute 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.

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]:
218def pz(n: Number) -> Number:
219    """
220    Converts -0.0 to 0.0 and leaves all other numbers alone.
221    """
222    if n == -0.0:
223        return 0.0
224    else:
225        return n

Converts -0.0 to 0.0 and leaves all other numbers alone.

def rt(t: Tuple[float, float]) -> Tuple[float, float]:
228def rt(t: base.LayoutPosition) -> base.LayoutPosition:
229    """
230    Rounds off both parts of a 2-element tuple to 6 decimal places, and
231    then also converts -0.0 to +0.0 in either position.
232    """
233    return (
234        pz(round(t[0], 6)),
235        pz(round(t[1], 6))
236    )

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]]:
239def rtl(tl: Sequence[base.LayoutPosition]) -> List[base.LayoutPosition]:
240    """
241    Applies `rt` to a sequence of positions, returning a list.
242    """
243    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:
246def distance(a: base.LayoutPosition, b: base.LayoutPosition) -> float:
247    """
248    Calculates the distance between two points, using the distance
249    formula in 2 dimensions. For example:
250
251    >>> distance((0, 0), (0, 3))
252    3.0
253    >>> distance((0, 0), (3, 0))
254    3.0
255    >>> distance((0, 0), (3, 4))
256    5.0
257    """
258    x1, y1 = a
259    x2, y2 = b
260
261    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]:
264def mid(
265    a: base.LayoutPosition,
266    b: base.LayoutPosition
267) -> base.LayoutPosition:
268    """
269    Returns the midpoint between two points. For example:
270
271    >>> rt(mid((0, 0), (1, 0)))
272    (0.5, 0.0)
273    >>> rt(mid((0, 0), (3, 8)))
274    (1.5, 4.0)
275    >>> rt(mid((3, -3), (-3, 3)))
276    (0.0, 0.0)
277    """
278    x1, y1 = a
279    x2, y2 = b
280
281    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]:
284def vAdd(
285    a: base.LayoutPosition,
286    b: base.LayoutPosition
287) -> base.LayoutPosition:
288    """
289    Returns the vector addition result for two layout positions.
290    For example:
291
292    >>> vAdd((0.0, 0.0), (1.0, 1.0))
293    (1.0, 1.0)
294    >>> vAdd((1.0, 1.0), (0.0, 0.0))
295    (1.0, 1.0)
296    >>> vAdd((1.0, 1.0), (2.0, -3.0))
297    (3.0, -2.0)
298    >>> vAdd((1.0, 1.0), (1.0, 1.0))
299    (2.0, 2.0)
300    """
301    x1, y1 = a
302    x2, y2 = b
303
304    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]:
307def vSub(
308    a: base.LayoutPosition,
309    b: base.LayoutPosition
310) -> base.LayoutPosition:
311    """
312    Returns the vector between points a and b (from b to a, which is
313    also a - b in vector math). For example:
314
315    >>> vSub((1.0, 1.0), (0.0, 0.0))
316    (1.0, 1.0)
317    >>> vSub((2.0, -3.0), (1.0, 1.0))
318    (1.0, -4.0)
319    >>> vSub((1.0, 1.0), (1.0, 1.0))
320    (0.0, 0.0)
321    """
322    x1, y1 = a
323    x2, y2 = b
324
325    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]:
327def norm(v: base.LayoutPosition) -> base.LayoutPosition:
328    """
329    Normalizes the given vector, returning a vector in the same direction
330    whose length is 1. Returns the zero-vector if given the zero-vector,
331    which is the only case where the length of the result is not 1.
332
333    For example:
334
335    >>> norm((0, 0))
336    (0.0, 0.0)
337    >>> norm((2, 0))
338    (1.0, 0.0)
339    >>> norm((102, 0))
340    (1.0, 0.0)
341    >>> norm((0, -3))
342    (0.0, -1.0)
343    """
344    length = distance((0, 0), v)
345    if length == 0:
346        return (0.0, 0.0)
347    else:
348        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:
351def isATriangle(x: Number, y: Number, z: Number) -> bool:
352    """
353    Checks whether three side lengths can form a triangle. For example:
354
355    >>> isATriangle(3, 4, 5)
356    True
357    >>> isATriangle(1, 1, 1)
358    True
359    >>> isATriangle(100, 99, 1)
360    False
361    >>> isATriangle(100, 99, 2)
362    True
363    >>> isATriangle(2, 100, 99)
364    True
365    >>> isATriangle(99, 2, 100)
366    True
367    >>> isATriangle(3, 2, 10)
368    False
369    >>> isATriangle(5, 1, 1)
370    False
371    >>> isATriangle(9, 18.01, 9)
372    False
373    """
374    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]:
377def scaleBy(
378    vector: base.LayoutPosition,
379    scale: Number
380) -> base.LayoutPosition:
381    """
382    Scales the given vector by the specified scale.
383    Examples:
384
385    >>> rt(scaleBy((1.0, 0.0), 3))
386    (3.0, 0.0)
387    >>> rt(scaleBy((3.0, 4.0), 10))
388    (30.0, 40.0)
389    >>> rt(scaleBy((6.0, 8.0), 5))
390    (30.0, 40.0)
391    >>> rt(scaleBy((0.0, 2.0), -2))
392    (0.0, -4.0)
393    >>> rt(scaleBy((0.0, 0.0), 1000))
394    (0.0, 0.0)
395    """
396    x, y = vector
397    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]:
400def scaleTo(
401    vector: base.LayoutPosition,
402    length: Number
403) -> base.LayoutPosition:
404    """
405    Scales the given vector to the specified length. Note that if the
406    vector is (0, 0), it will remain (0, 0) so the result won't actually
407    have the specified length in that one case. Examples:
408
409    >>> rt(scaleTo((1, 0), 3))
410    (3.0, 0.0)
411    >>> rt(scaleTo((3, 4), 10))
412    (6.0, 8.0)
413    >>> rt(scaleTo((6, 8), 5))
414    (3.0, 4.0)
415    >>> rt(scaleTo((0, 2), -2))
416    (0.0, -2.0)
417    >>> rt(scaleTo((0, 0), 1000))
418    (0.0, 0.0)
419    """
420    lengthNow = distance((0, 0), vector)
421    if lengthNow == 0:
422        return (0.0, 0.0)
423    else:
424        x, y = vector
425        return (
426            (x / lengthNow) * length,
427            (y / lengthNow) * length
428        )

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]]:
431def circleIntersections(
432    a: base.LayoutPosition,
433    b: base.LayoutPosition,
434    aRadius: Number,
435    bRadius: Number
436) -> List[base.LayoutPosition]:
437    """
438    Calculates the intersection point(s) between two circles centered at
439    points `a` and `b` with the given radii. Returns a list of 0, 1, or 2
440    positions depending on the relationship between the circles. Note
441    that if two circles are the same circle, it should in theory return a
442    list of infinite positions; in that case we return a list with four
443    positions that are the places where horizontal and vertical lines
444    through the shared center intersect the shared circle. Examples:
445
446    >>> rtl(circleIntersections((0, 0), (2, 0), 1, 1))  # single point
447    [(1.0, 0.0)]
448    >>> rtl(circleIntersections((0, 0), (0, 2), 1, 1))  # single point
449    [(0.0, 1.0)]
450    >>> rtl(circleIntersections((0, 0), (6, 8), 5, 5))  # two 3/4/5 triangles
451    [(3.0, 4.0)]
452    >>> rtl(circleIntersections((0, 0), (2, 0), 1.5, 1.5))  # two points
453    [(1.0, -1.118034), (1.0, 1.118034)]
454    >>> rtl(circleIntersections((0, 0), (0, 0), 2, 3))  # no points
455    []
456    >>> rtl(circleIntersections((0, 0), (2, 0), 0.5, 0.5))  # no points
457    []
458    >>> rtl(circleIntersections((-3, 4), (3, 4), 5, 5))  # two 3/4/5 triangles
459    [(0.0, 0.0), (0.0, 8.0)]
460    >>> rtl(circleIntersections((-4, -3), (4, -3), 5, 5))  # two 3/4/5 triangles
461    [(0.0, -6.0), (0.0, 0.0)]
462    >>> rtl(circleIntersections((0.0, 0.0), (0.0, 0.0), 5, 5))  # infinity
463    [(5.0, 0.0), (0.0, -5.0), (-5.0, 0.0), (0.0, 5.0)]
464    """
465    x1, y1 = a
466    x2, y2 = b
467
468    d = distance(a, b)
469
470    # If the circles are too far apart or if the distance between their
471    # centers is so small compared to the difference between their radii
472    # that one is entirely inside the other, there are no points of
473    # intersection.
474    if d > aRadius + bRadius or d < abs(aRadius - bRadius):
475        return []
476    elif aRadius + bRadius == d:  # one point of intersection
477        vec = scaleTo((x2 - x1, y2 - y1), aRadius)
478        return [ (x1 + vec[0], y1 + vec[1]) ]
479    elif x1 == x2 and y1 == y2 and aRadius == bRadius:  # same circle
480        return [  # clockwise from 3 o'clock if +y is up and +x is right
481            (x1 + aRadius, y1),
482            (x1, y1 - aRadius),
483            (x1 - aRadius, y1),
484            (x1, y1 + aRadius)
485        ]
486
487    # Otherwise we have 2 points of intersection
488    # TODO: Explain this math a bit...
489    a = (aRadius**2 - bRadius**2 + d**2) / (2 * d)
490    h = (aRadius**2 - a**2)**0.5
491
492    x0 = x1 + a * (x2 - x1) / d
493    y0 = y1 + a * (y2 - y1) / d
494
495    x3 = x0 + h * (y2 - y1) / d
496    y3 = y0 - h * (x2 - x1) / d
497
498    x4 = x0 - h * (y2 - y1) / d
499    y4 = y0 + h * (x2 - x1) / d
500
501    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]):
504def bestFitIntersection(
505    a: base.LayoutPosition,
506    b: base.LayoutPosition,
507    aRad: Number,
508    bRad: Number
509):
510    """
511    Given two circles which may or may not intersect (specified as
512    centers `a` and `b` plus respective radii), returns a point that's 
513    on the line through their centers that's on the shortest segment of
514    that line which connects one circle to the other (this may or may
515    not be between the two centers if one circle encircles the other).
516
517    The point is placed along that segment so that its distance from
518    circle a divided by its distance from circle b is proportional to
519    the radius of circle a divided by the radius of circle b (it ends up
520    closer to the smaller circle).
521
522    If the two circles have the same center, we return a point that has
523    the same y-coordinate as that center, and of the two equally valid
524    points of that nature, we return the one with the greater
525    x-coordinate.
526
527    Some examples:
528    >>> rt(bestFitIntersection((0, 0), (100, 0), 180, 40))
529    (147.272727, 0.0)
530    >>> rt(bestFitIntersection((0, 0), (50, 87), 60, 78))
531    (21.73913, 37.826087)
532    >>> rt(bestFitIntersection((100, 0), (50, 87), 70, 78))
533    (76.351351, 41.148649)
534
535    >>> rt(bestFitIntersection((0, 0), (8, 6), 5, 5))  # circles touch
536    (4.0, 3.0)
537    >>> rt(bestFitIntersection((0, 0), (12, 9), 10, 5))  # circles touch
538    (8.0, 6.0)
539    >>> rt(bestFitIntersection((-20, -20), (-30, 20), 10, 10))  # r1 == r2
540    (-25.0, 0.0)
541    >>> rt(bestFitIntersection((-30, 20), (-20, -20), 10, 10))  # other order
542    (-25.0, 0.0)
543    >>> rt(bestFitIntersection((0, 0), (0, 0), 12, 24))  # same center
544    (16.0, 0.0)
545    >>> # we arbitrarily pick a point horizontal from the center
546    >>> # note that (-16.0, 0.0) is equally valid but we pick the option
547    >>> # with the higher x-coordinate
548    >>> rt(bestFitIntersection((0, 0), (0, 0), 24, 12))  # works same other way
549    (16.0, 0.0)
550    >>> rt(bestFitIntersection((0, 0), (0, 0), 10, 10))  # same circle
551    (10.0, 0.0)
552    >>> rt(bestFitIntersection((0, 0), (0, 0), 0, 0))  # zero-radius same center
553    (0.0, 0.0)
554    >>> rt(bestFitIntersection((0, 0), (2, 0), 0, 0))  # zero-radius diff center
555    (1.0, 0.0)
556    >>> rt(bestFitIntersection((2, 0), (0, 0), 0, 0))  # other direction
557    (1.0, 0.0)
558    >>> rt(bestFitIntersection((0, 0), (2, 0), 1, 0))  # single zero-radius
559    (2.0, 0.0)
560    """
561    import sys
562    dist = distance(a, b)
563    vect = norm(vSub(b, a))
564    if vect == (0.0, 0.0):  # same center
565        vect = (1.0, 0.0)  # horizontal
566
567    # Find all four points at which the line between a and b intersects
568    # the a and b circles:
569    aVec = scaleBy(vect, aRad)
570    aLow = vSub(a, aVec)
571    aHigh = vAdd(a, aVec)
572    bVec = scaleBy(vect, bRad)
573    bLow = vSub(b, bVec)
574    bHigh = vAdd(b, bVec)
575
576    # Now find which pair of one point from A and one from B is closest
577    # There's probably a more mathy way to do this, but this is simple to
578    # reason about.
579    closest = None
580    bestDist = None
581    for (p1, p2) in [
582        (aHigh, bHigh),
583        (aHigh, bLow),
584        (aLow, bHigh),
585        (aLow, bLow),
586    ]:
587        pointSep = distance(p1, p2)
588        # Note strict < here biases towards earlier pairs in the order
589        # above, such that 'high' points beat low ones on ties
590        if closest is None or pointSep < bestDist:
591            closest = (p1, p2)
592            bestDist = pointSep
593
594    # Now find a point between the two closest points-on-circle where the
595    # proportion between distances to each matches the ratio of the radii
596    # of the circles:
597    onA, onB = closest
598    between = vSub(onB, onA)
599    if between == (0.0, 0.0):  # same point, so return it
600        return onA
601    dirBetween = norm(between)
602    distBetween = distance(onA, onB)
603    if aRad + bRad == 0:  # both zero-radius; return average of the two
604        return ((onA[0] + onB[0]) / 2, (onA[1] + onB[1]) / 2)
605    howFarAlong = aRad / (aRad + bRad)
606    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]):
609def baryeccentricPosition(
610    a: base.LayoutPosition,
611    b: base.LayoutPosition,
612    c: base.LayoutPosition,
613    distA: Number,
614    distB: Number,
615    distC: Number
616):
617    """
618    Returns a "baryeccentric" position given three reference points and
619    three numbers indicating distances to each of them. If the distances
620    are in agreement and together specify a particular point within (or
621    outside of) the reference triangle, we return that point. If the
622    two or more of the distances are too short to touch each other, we
623    compromise at a position most consistent with them, and if the
624    distances are too long we also compromise.
625
626    For best results, you should ensure that the reference points make a
627    triangle rather than a line or point.
628
629    We find a compromise by treating each reference point + distance as a
630    circle. We first compute the intersection points between each pair of
631    circles, resulting in 0-4 intersection points per pair (see
632    `circleIntersection`). For pairs with no intersection, we use
633    `bestFitIntersection` to come up with a single "intersection" point.
634    Now for pairs with 2+ intersection points, we pick the single
635    intersection point whose distance to the third point is most
636    consistent with the measured third distance. This leaves us with 3
637    intersection points: one for each pair of reference points. We
638    average these three points to come up with the final result.
639
640    TODO: consider the perfectly-overlapping circles case a bit more...
641
642    Some examples:
643
644    >>> baryeccentricPosition((0, 0), (6, 8), (6, 0), 5, 5, 5)
645    (3.0, 4.0)
646    >>> baryeccentricPosition((0, 0), (-6, 8), (-6, 0), 5, 5, 5)
647    (-3.0, 4.0)
648    >>> baryeccentricPosition((0, 0), (-6, -8), (-6, 0), 5, 5, 5)
649    (-3.0, -4.0)
650    >>> baryeccentricPosition((0, 0), (3.0, 4.0), (3.0, 0), 5, 0, 4)
651    (3.0, 4.0)
652    >>> baryeccentricPosition((0, 0), (3.0, 4.0), (3.0, 0), 0, 5, 3)
653    (0.0, 0.0)
654    >>> baryeccentricPosition((0, 0), (3.0, 4.0), (3.0, 0), 3, 4, 0)
655    (3.0, 0.0)
656    >>> rt(baryeccentricPosition((-8, 6), (8, 6), (0, -10), 10, 10, 10))
657    (0.0, 0.0)
658    >>> rt(baryeccentricPosition((-8, 6), (8, 6), (0, -12), 10, 10, 0))
659    (0.0, -8.0)
660    >>> rt(baryeccentricPosition((-8, -6), (0, 12), (8, -6), 10, 0, 10))
661    (0.0, 8.0)
662    >>> rt(baryeccentricPosition((0, 12), (-8, -6), (8, -6), 0, 10, 10))
663    (0.0, 8.0)
664    >>> rt(baryeccentricPosition((-4, 3), (4, 3), (0, -5), 5, 5, 0))
665    (0.0, -3.333333)
666    >>> rt(baryeccentricPosition((-1, 0), (1, 0), (0, -1), 1, 1, 1))
667    (0.0, 0.0)
668    >>> rt(baryeccentricPosition(
669    ...     (-25.3, 45.8), (12.4, -24.3), (35.9, 58.2),
670    ...     61.2, 35.5, 28.4
671    ... ))
672    (27.693092, 20.240286)
673    >>> rt(baryeccentricPosition(
674    ...     (-25.3, 45.8), (12.4, -24.3), (35.9, 58.2),
675    ...     102.5, 12.8, 89.4
676    ... ))
677    (28.437607, -32.62218)
678
679    Edge case examples:
680
681    >>> baryeccentricPosition((0, 0), (0, 0), (0, 0), 5, 5, 5)
682    (5.0, 0.0)
683    >>> baryeccentricPosition((0, 0), (0, 0), (0, 0), 0, 0, 0)
684    (0.0, 0.0)
685    """
686    # TODO: Should we print a warning if the points aren't a triangle?
687
688    # First, find intersection point(s) for each pair
689    abPoints = circleIntersections(a, b, distA, distB)
690    acPoints = circleIntersections(a, c, distA, distC)
691    bcPoints = circleIntersections(b, c, distB, distC)
692
693    # if circles don't touch, add an estimated point
694    if len(abPoints) == 0:
695        abPoints = [bestFitIntersection(a, b, distA, distB)]
696    if len(acPoints) == 0:
697        acPoints = [bestFitIntersection(a, c, distA, distC)]
698    if len(bcPoints) == 0:
699        bcPoints = [bestFitIntersection(b, c, distB, distC)]
700
701    # If circles touch a multiple places, narrow that down to one by
702    # figuring out which is most consistent with the third distance
703    if len(abPoints) == 1:
704        abPoint = abPoints[0]
705    else:  # must be > 1 point per above
706        assert len(abPoints) > 1
707        abPoint = None
708        bestError = None
709        for p in abPoints:
710            thirdDist = distance(p, c)
711            error = abs(thirdDist - distC)
712            if abPoint is None or error < bestError:
713                abPoint = p
714                bestError = error
715
716    if len(acPoints) == 1:
717        acPoint = acPoints[0]
718    else:  # must be > 1 point per above
719        assert len(acPoints) > 1
720        acPoint = None
721        bestError = None
722        for p in acPoints:
723            thirdDist = distance(p, b)
724            error = abs(thirdDist - distB)
725            if acPoint is None or error < bestError:
726                acPoint = p
727                bestError = error
728
729    if len(bcPoints) == 1:
730        bcPoint = bcPoints[0]
731    else:  # must be > 1 point per above
732        assert len(bcPoints) > 1
733        bcPoint = None
734        bestError = None
735        for p in bcPoints:
736            thirdDist = distance(p, a)
737            error = abs(thirdDist - distA)
738            if bcPoint is None or error < bestError:
739                bcPoint = p
740                bestError = error
741
742    # At this point, ab/ac/bc point variables should be assigned properly
743    return (
744        (abPoint[0] + acPoint[0] + bcPoint[0]) / 3,
745        (abPoint[1] + acPoint[1] + bcPoint[1]) / 3,
746    )

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]]:
 749def baryeccentricLayout(
 750    exploration: core.DiscreteExploration,
 751    specifiedNodes: Optional[base.Layout] = None
 752) -> base.Layout:
 753    """
 754    Computes a baryeccentric coordinate layout for all decisions in the
 755    final step of the given exploration, using the specified positions
 756    of a few nodes given in `specifiedNodes`. `specifiedNodes` should
 757    specify positions for at least 3 decisions, and those positions must
 758    form a triangle, not a line or point. If `specifiedNodes` does not
 759    contain enough decisions (or if it's not provided), decisions will
 760    be added to it as follows:
 761
 762    - If it's empty, add the node with the lowest id at position (0, 0).
 763    - If it's got only one decision or we just added one node, add the
 764        node that's furthest from that node in terms of hop distance.
 765        We'll position this second node at the same y-coordinate as the
 766        first, but with an x-coordinate equal to the hop distance between
 767        it and the first node. If multiple nodes are tied for furthest,
 768        add the one with the lowest id.
 769    - If it's got only two decisions or we just added one or two, add the
 770        node whose sum of hop distances to the two already selected is
 771        largest. We position this third node such that the hop distances
 772        to each of the already-placed nodes are respected and it forms a
 773        triangle, or if that's not possible due to those distances being
 774        too short, we position it partway between them proportional to
 775        those two distances with an artificial offset perpendicular to
 776        the line between the two other points. Ties are broken towards
 777        nodes with a shorter max hop distance to either of the two
 778        already-placed nodes, and then towards lower node IDs.
 779
 780    If the number of nodes in the entire graph is 1 or 2, we return a
 781    layout positioning the first node at (0, 0) and (if it exists) the
 782    second node at (1, 0).
 783
 784    Some examples:
 785
 786    # TODO
 787    >> baryeccentricLayout(TODO)
 788    """
 789    hops = analysis.shortestHopPaths(
 790        exploration,
 791        lambda src, transition, dst, graph: (
 792            'journey' not in graph.transitionTags(src, transition)
 793        )
 794    )
 795    # Now we can use `analysis.hopDistnace` given `hops` plus two
 796    # decision IDs to get the hop distance between any two decisions.
 797
 798    # Create empty layout by default:
 799    if specifiedNodes is None:
 800        specifiedNodes = {}
 801
 802    # Select at least 3 specific nodes
 803    if len(specifiedNodes) < 3:
 804        finalGraph = exploration[-1].graph
 805        allDecisions = sorted(finalGraph)
 806
 807        # Bail out if we have fewer than 3 total decisions
 808        if len(allDecisions) < 3:
 809            result = {}
 810            result[allDecisions[0]] = (0.0, 0.0)
 811            if len(allDecisions) > 1:
 812                result[allDecisions[1]] = (1.0, 0.0)
 813            return result
 814
 815        # Add a decision at (0, 0) if we didn't have any specified
 816        if len(specifiedNodes) < 1:
 817            # Find largest weakly connected component:
 818            bigCC = max(nx.weakly_connected_components(finalGraph), key=len)
 819            # Use an arbitrary node from that component
 820            specifiedNodes[list(bigCC)[0]] = (0.0, 0.0)
 821
 822        assert len(specifiedNodes) >= 1
 823
 824        # If 1 specified or just added, add furthest-away decision
 825        if len(specifiedNodes) < 2:
 826            first = list(specifiedNodes)[0]
 827            best = None
 828            bestDist = None
 829            # Find furthest connected node
 830            for dID in allDecisions:
 831                if dID == first:
 832                    # Skip node that we already assigned
 833                    continue
 834                dist = analysis.hopDistance(hops, dID, first)
 835                # Note > here breaks ties towards lower IDs
 836                if dist is not None and (bestDist is None or dist > bestDist):
 837                    best = dID
 838                    bestDist = dist
 839            # if no nodes are connected, we've got a big problem, but
 840            # we'll push on by selecting the node with the second-lowest
 841            # node ID.
 842            if best is None:
 843                # Find first un-specified node ID:
 844                second = None
 845                for second in allDecisions:
 846                    dFirst = analysis.hopDistance(hops, second, first)
 847                    if (
 848                         second not in specifiedNodes
 849                     and dFirst is not None
 850                    ):
 851                        # second will remain at this value after loop
 852                        break
 853                else:  # if we never hit break
 854                    for second in allDecisions:
 855                        if second not in specifiedNodes:
 856                            # second will remain at this value after loop
 857                            break
 858                assert second is not None
 859                # Just put it at (1, 0) since hops aren't informative
 860                specifiedNodes[second] = (1.0, 0.0)
 861            else:
 862                assert best != first
 863                firstPos = specifiedNodes[first]
 864                # Same y-value as first one, with x-dist as hop dist
 865                specifiedNodes[best] = (firstPos[0] + bestDist, firstPos[1])
 866
 867        assert len(specifiedNodes) >= 2
 868
 869        # If only two specified (and or one or two just added) we look
 870        # for the node with best combined distance to those two,
 871        # breaking ties towards smaller max-distance to either and then
 872        # towards smaller ID values.
 873        if len(specifiedNodes) < 3:
 874            first, second = list(specifiedNodes)[:2]
 875            best = None
 876            bestCombined = None
 877            bestLonger = None
 878            bestDists = None
 879            for dID in allDecisions:
 880                if dID in specifiedNodes:
 881                    # Skip already-placed nodes
 882                    continue
 883                distA = analysis.hopDistance(hops, dID, first)
 884                distB = analysis.hopDistance(hops, dID, second)
 885                if distA is None or distB is None:
 886                    # Note: *shouldn't* be possible for only one to be
 887                    # None, but we don't take chances here
 888                    continue
 889                combined = distA + distB
 890                longer = max(distA, distB)
 891                if (
 892                    # first one
 893                    best is None
 894                    # better combined distance (further away)
 895                 or combined > bestCombined
 896                    # tied combined and better max distance (more evenly
 897                    # placed between at *shorter* max dist)
 898                 or (
 899                        combined == bestCombined
 900                    and longer < bestLonger
 901                        # Note strict < here breaks ties towards lower IDs
 902                    )
 903                ):
 904                    best = dID
 905                    bestCombined = combined
 906                    bestLonger = longer
 907                    bestDists = (distA, distB)
 908
 909            firstPos = specifiedNodes[first]
 910            secondPos = specifiedNodes[second]
 911
 912            abDist = analysis.hopDistance(hops, first, second)
 913            # Could happen if only two nodes are connected, for example
 914            if best is None or sum(bestDists) < abDist:
 915                # Just put it artificially between them
 916                vect = (
 917                    secondPos[0] - firstPos[0],
 918                    secondPos[1] - firstPos[1]
 919                )
 920                # perpendicular vector
 921                ortho = (vect[1], -vect[0])
 922                # Just use first decision that's not already specified
 923                if best is not None:
 924                    third = best
 925                else:
 926                    third = None
 927                    for third in allDecisions:
 928                        thirdHopsA = analysis.hopDistance(hops, first, third)
 929                        thirdHopsB = analysis.hopDistance(hops, second, third)
 930                        if (
 931                             third not in specifiedNodes
 932                         and thirdHopsA is not None
 933                         and thirdHopsB is not None
 934                        ):
 935                            # third will remain on this node
 936                            break
 937                    else:  # if we never hit the break
 938                        for third in allDecisions:
 939                            if third not in specifiedNodes:
 940                                # third will remain on this node
 941                                break
 942                    assert third is not None
 943                assert third != first
 944                assert third != second
 945                # Offset orthogonally by half the distance between
 946                specifiedNodes[third] = (
 947                    firstPos[0] + vect[0]/2 + ortho[0]/2,
 948                    firstPos[1] + vect[1]/2 + ortho[0]/2
 949                )
 950            else:
 951                # Position the best candidate to form a triangle where
 952                # distances are proportional; we know distances are long
 953                # enough to make a triangle
 954                distA = analysis.hopDistance(hops, dID, first)
 955                candidates = circleIntersections(
 956                    firstPos,
 957                    secondPos,
 958                    *bestDists
 959                )
 960                if len(candidates) == 0:
 961                    where = bestFitIntersection(
 962                        first,
 963                        second,
 964                        distA,
 965                        distB
 966                    )
 967                else:
 968                    where = candidates[0]
 969                assert best != first
 970                assert best != second
 971                specifiedNodes[best] = where
 972
 973            assert len(specifiedNodes) >= 3
 974
 975    # TODO: Don't just use first 3 here...
 976    # Grab first 3 decision IDs from layout
 977    a, b, c = list(specifiedNodes.keys())[:3]
 978    # Get their positions
 979    aPos = specifiedNodes[a]
 980    bPos = specifiedNodes[b]
 981    cPos = specifiedNodes[c]
 982    # create initial result using just specified positions
 983    result = {
 984        a: aPos,
 985        b: bPos,
 986        c: cPos
 987    }
 988    # Now we need to compute positions of each other node...
 989    # We use `exploration.allDecisions` as the set of nodes we want to
 990    # establish positions for, even though some of them may have been
 991    # deleted by the end and thus may not appear in our hops data.
 992    toLayOut = exploration.allDecisions()
 993    # value for default positions
 994    default = 1.0
 995    print("Using decisions:")
 996    print(a, exploration.latestDecisionInfo(a)["name"])
 997    print(b, exploration.latestDecisionInfo(b)["name"])
 998    print(c, exploration.latestDecisionInfo(c)["name"])
 999    for decision in toLayOut:
1000        aHops = analysis.hopDistance(hops, a, decision)
1001        bHops = analysis.hopDistance(hops, b, decision)
1002        cHops = analysis.hopDistance(hops, c, decision)
1003
1004        # if hops is none for one, it should be none for all
1005        if aHops is None or bHops is None or cHops is None:
1006            # Put it at a default position on a parabola
1007            # TODO: Better default here?
1008            result[decision] = (default, default**1.1)
1009            default += 0.1
1010        else:
1011            assert aHops is not None
1012            assert bHops is not None
1013            assert cHops is not None
1014
1015            # Place according to baryeccentric position
1016            result[decision] = baryeccentricPosition(
1017                aPos,
1018                bPos,
1019                cPos,
1020                aHops,
1021                bHops,
1022                cHops
1023            )
1024
1025    # Return result at end...
1026    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'] = 'square') -> None:
1028def setBaryeccentricPositions(
1029    exploration: core.DiscreteExploration,
1030    method: GraphLayoutMethod = "square"
1031) -> None:
1032    """
1033    Adds a "baryeccentric" layout to the given exploration.
1034    """
1035    exploration.layouts["baryeccentric"] = baryeccentricLayout(
1036        exploration,
1037        {}
1038    )

Adds a "baryeccentric" layout to the given exploration.