"""Animate what addition and scaling do to a vector drawn as an arrow.

Two panels. On the left the second arrow is carried to the tip of the
first, and the sum appears from the origin to where it lands. On the right
one arrow is scaled by four values of k, including a negative one, which
reverses it. Every scaling of a vector lies on one line through the origin,
so the four arrows do not clutter each other.

The animation is CSS inside the file, the way orbit.py writes the Mandelbrot
orbits. No script runs. A reader who asks for reduced motion is given the
finished state of the left panel and all four arrows of the right one, so
the figure still says what it is for.

Writes vectors-anim.svg beside this file.
"""
V = (3.0, 1.0)                  # the first vector, left panel
W = (1.0, 2.0)                  # the second, carried to the tip of the first
U = (1.2, 0.9)                  # the vector scaled on the right
KS = (2.0, 1.5, 0.5, -1.0)      # the factors it is scaled by, in order

WIN_ADD = (-1.5, -1.5)          # bottom-left corner of each window
WIN_MUL = (-3.0, -2.6)
UNITS = (8.0, 6.0)              # window size, the same for both panels
S = 44                          # svg units per drawing unit
PAD_L, PAD_T, PAD_B, GAP = 26, 44, 52, 54
PAD_R = 64          # room for the label at the tip of the longest arrow
CYCLE = 10.0                    # seconds for one pass

TEAL, INDIGO, ROSE = "#00706B", "#3A49A6", "#A93356"
INK, MUTED, GRID, FINE = "#0D1620", "#5A6874", "#D3DBE3", "#E1E7ED"

PW, PH = UNITS[0] * S, UNITS[1] * S
W_SVG = PAD_L + PW * 2 + GAP + PAD_R
H_SVG = PAD_T + PH + PAD_B


def frame(x0, win):
    """Return the two functions that place a drawing point in this panel."""
    xmin, ymin = win
    return (lambda u: x0 + (u - xmin) * S,
            lambda v: PAD_T + (ymin + UNITS[1] - v) * S)


def paper(x0, win, title):
    """Ruled paper, two axes, and the panel's name above it."""
    X, Y = frame(x0, win)
    xmin, ymin = win
    out = [f'<rect x="{x0}" y="{PAD_T}" width="{PW}" height="{PH}" '
           f'fill="none" stroke="{GRID}" stroke-width="1"/>']
    n = 0.0
    while n <= UNITS[0] + 1e-9:                     # vertical rules
        u = xmin + n
        wide = abs(u - round(u)) < 1e-9
        out.append(f'<line x1="{X(u):.1f}" y1="{PAD_T}" x2="{X(u):.1f}" '
                   f'y2="{PAD_T + PH}" stroke="{GRID if wide else FINE}" '
                   f'stroke-width="{1 if wide else 0.7}"/>')
        n += 0.5
    n = 0.0
    while n <= UNITS[1] + 1e-9:                     # horizontal rules
        v = ymin + n
        wide = abs(v - round(v)) < 1e-9
        out.append(f'<line x1="{x0}" y1="{Y(v):.1f}" x2="{x0 + PW}" '
                   f'y2="{Y(v):.1f}" stroke="{GRID if wide else FINE}" '
                   f'stroke-width="{1 if wide else 0.7}"/>')
        n += 0.5
    out.append(f'<line x1="{x0}" y1="{Y(0):.1f}" x2="{x0 + PW}" '
               f'y2="{Y(0):.1f}" stroke="{INK}" stroke-width="1.3"/>')
    out.append(f'<line x1="{X(0):.1f}" y1="{PAD_T}" x2="{X(0):.1f}" '
               f'y2="{PAD_T + PH}" stroke="{INK}" stroke-width="1.3"/>')
    out.append(f'<text class="hd" x="{x0}" y="{PAD_T - 16}">{title}</text>')
    return out


def arrow(x0, win, tip, colour, tail=(0.0, 0.0), width=2.4, opacity=1.0,
          dash=None, head=True):
    X, Y = frame(x0, win)
    extra = f' stroke-dasharray="{dash}"' if dash else ""
    mark = f' marker-end="url(#vh{colour[1:]})"' if head else ""
    return (f'<line x1="{X(tail[0]):.1f}" y1="{Y(tail[1]):.1f}" '
            f'x2="{X(tip[0]):.1f}" y2="{Y(tip[1]):.1f}" stroke="{colour}" '
            f'stroke-width="{width}" opacity="{opacity}"{extra}{mark}/>')


def mid(a, b=(0.0, 0.0)):
    return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)


def label(x0, win, at, text, colour, dx=9, dy=-8, cls="zl", anchor="start"):
    X, Y = frame(x0, win)
    return (f'<text class="{cls}" x="{X(at[0]) + dx:.1f}" '
            f'y="{Y(at[1]) + dy:.1f}" fill="{colour}" '
            f'text-anchor="{anchor}">{text}</text>')


LEFT, RIGHT = PAD_L, PAD_L + PW + GAP
body = []

# The left panel. v stands still, w is carried from the origin to the tip
# of v, and the sum is drawn from the origin to where w now ends.
body += paper(LEFT, WIN_ADD, "adding two vectors")
body.append(arrow(LEFT, WIN_ADD, W, INDIGO, opacity=0.26, dash="5 4"))

# The carried arrow is drawn first so that v's head lands on top of where
# it arrives. Drawn the other way round, the blue tail covers the green
# head and the two read as one arrow overshooting the other.
DX, DY = V[0] * S, -V[1] * S
body.append('<g class="slide">')
body.append(arrow(LEFT, WIN_ADD, W, INDIGO))
body.append(label(LEFT, WIN_ADD, mid(W), "w", INDIGO, dx=-10, dy=-6,
                  anchor="end"))
body.append('</g>')

body.append(arrow(LEFT, WIN_ADD, V, TEAL))
body.append(label(LEFT, WIN_ADD, mid(V), "v", TEAL, dx=6, dy=18))

total = (V[0] + W[0], V[1] + W[1])
body.append('<g class="appear">')
body.append(arrow(LEFT, WIN_ADD, total, ROSE, width=2.6))
body.append(label(LEFT, WIN_ADD, total, "v + w", ROSE, dx=11, dy=-13))
body.append('</g>')
body.append(f'<text class="note" x="{LEFT}" y="{PAD_T + PH + 26}">'
            f'w is carried to the tip of v, and the sum reaches its end'
            f'</text>')

# The right panel. One arrow that grows, shrinks and turns over, with the
# four factors marked on the line it never leaves. Scaling the whole arrow
# is what a scalar does, so the drawing is one CSS transform: scale(k)
# about the origin of this panel. A negative k mirrors the arrow through
# that origin, which is exactly the vector pointing the other way.
body += paper(RIGHT, WIN_MUL, "scaling one vector")
XR, YR = frame(RIGHT, WIN_MUL)
OX, OY = XR(0), YR(0)

# At right angles to v, pointing down and to the right on the screen.
LEN = (U[0] ** 2 + U[1] ** 2) ** 0.5
OFF_X, OFF_Y = (U[1] / LEN) * 20, (U[0] / LEN) * 20
# Every factor gets a mark on the line. Only the two ends are named:
# the middle ones sit close enough to the origin that a label there runs
# into an axis, and the note underneath lists them anyway.
for k in KS:
    tip = (U[0] * k, U[1] * k)
    body.append(f'<circle cx="{XR(tip[0]):.1f}" cy="{YR(tip[1]):.1f}" r="3.4" '
                f'fill="{MUTED}"/>')
    if k in (max(KS), min(KS)):
        body.append(label(RIGHT, WIN_MUL, tip, f"k = {k:g}", MUTED,
                          dx=OFF_X + 5, dy=OFF_Y + 6))

body.append(arrow(RIGHT, WIN_MUL, U, TEAL, opacity=0.30))
body.append('<g class="grow">')
body.append(arrow(RIGHT, WIN_MUL, U, ROSE, width=2.2))
body.append('</g>')
body.append(label(RIGHT, WIN_MUL, (U[0] * 0.82, U[1] * 0.82), "v", TEAL,
                  dx=-OFF_X - 4, dy=-OFF_Y + 2, anchor="end"))
body.append(f'<text class="note" x="{RIGHT}" y="{PAD_T + PH + 26}">'
            f'k runs through 2, 1.5, 0.5 and -1, all on one line'
            f'</text>')

# Markers, one per colour, named after the colour so no two collide.
defs = "".join(
    f'<marker id="vh{c[1:]}" viewBox="0 0 10 10" refX="9" refY="5" '
    f'markerWidth="5.5" markerHeight="5.5" orient="auto-start-reverse">'
    f'<path d="M0 0 L10 5 L0 10 z" fill="{c}"/></marker>'
    for c in (TEAL, INDIGO, ROSE))

# One slot per factor on the right, and one pass of carry-and-add on the left.
# k moves smoothly and rests on each marked factor on the way. Passing
# through 0 collapses the arrow to a point before it comes back the other
# way, which is what multiplying by a negative number does to it.
STOPS = [(0, 1), (10, 1), (26, 2), (38, 2), (54, 0.5), (64, 0.5),
         (82, -1), (92, -1), (100, 1)]
frames = "@keyframes vgrow{" + "".join(
    f"{t}%{{transform:scale({k:g})}}" for t, k in STOPS) + "}"
frames += ("@keyframes vslide{0%,12%{transform:translate(0,0)}"
           f"35%,88%{{transform:translate({DX:.1f}px,{DY:.1f}px)}}"
           "100%{transform:translate(0,0)}}")
frames += ("@keyframes vappear{0%,35%{opacity:0}"
           "50%,88%{opacity:1}96%,100%{opacity:0}}")

R = "#vecfig "
rules = (f"{R}.grow{{transform-box:view-box;"
         f"transform-origin:{OX:.1f}px {OY:.1f}px;"
         f"animation:vgrow {CYCLE}s ease-in-out infinite}}")
rules += (f"{R}.slide{{animation:vslide {CYCLE}s ease-in-out infinite}}"
          f"{R}.appear{{opacity:0;animation:vappear {CYCLE}s ease-in-out "
          f"infinite}}")

style = ("<style>"
         f'{R}text{{font-family:ui-sans-serif,system-ui,-apple-system,'
         '"Segoe UI",Roboto,Helvetica,Arial,sans-serif}'
         f"{R}.hd{{font-size:15px;fill:{INK};font-weight:600}}"
         f"{R}.note{{font-size:12.5px;fill:{MUTED}}}"
         f"{R}.zl{{font-size:13px;font-weight:600}}"
         f"{frames}{rules}"
         # Reduced motion: the left panel finished, and every arrow on the
         # right at once. They are collinear, so all four stay readable.
         f"@media (prefers-reduced-motion:reduce){{"
         f"{R}.slide{{animation:none;transform:translate({DX:.1f}px,{DY:.1f}px)}}"
         f"{R}.appear{{animation:none;opacity:1}}"
         f"{R}.grow{{animation:none;transform:scale(2)}}}}"
         "</style>")

svg = [f'<svg id="vecfig" xmlns="http://www.w3.org/2000/svg" '
       f'viewBox="0 0 {W_SVG:.0f} {H_SVG:.0f}" width="{W_SVG:.0f}" '
       f'height="{H_SVG:.0f}">',
       f'<defs>{defs}</defs>', style] + body + ['</svg>']

# What each drawing colour becomes in the file. The page carries this
# drawing rather than linking it, so it inherits the reader's theme. The
# marker ids hold the same hex without its leading hash, so "#00706B"
# never occurs inside one and this cannot corrupt them.
PROPERTY = {TEAL: "--teal", INDIGO: "--indigo", ROSE: "--rose", INK: "--ink",
            MUTED: "--muted", GRID: "--line", FINE: "--sunk"}
text = "\n".join(svg)
for literal, prop in PROPERTY.items():
    text = text.replace(literal, f"var({prop}, {literal.lower()})")

open("vectors-anim.svg", "w", encoding="utf-8").write(text)
print("vectors-anim.svg written")
