#!/usr/bin/env python3
"""The Moon's track through Earth's shadow -- drawn the way it stood in the sky.

The standard diagram of a lunar eclipse puts the shadow at the centre and runs
the Moon across it. That is the right picture; it is usually just drawn in the
wrong frame for this argument. Textbook versions use the plane of the orbits,
whose "up" is nobody's up, and the p. 169 claim is entirely about which way is
up.

So: the same familiar picture, with the observer's zenith as up. The Moon's
distance from the shadow's centre and its position angle both come straight from
the frame table, which measures them in the observer's own horizontal frame.
Nothing is rotated by hand and no sign convention is assumed.

Read it and the claim answers itself. The Moon sits *below* the shadow's centre
all morning, so the umbra's edge reaches the top of the disc first. It never
gets all the way in: the Moon set with a rim still showing, and totality began
two minutes later, below the horizon.

Positions from docs/selenelion/selenelion-frames.json (JPL DE421 via
scripts/selenelion_2011.py).

    python3 shadow_track_figure.py
"""

import json, math, os, sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SRC = os.path.join(ROOT, 'docs', 'selenelion', 'selenelion-frames.json')
DATA3D = os.path.join(ROOT, 'docs', 'selenelion', 'data', 'eclipse-2011-12-10-3d.json')
IMG = os.path.join(ROOT, 'docs', 'selenelion', 'img')

W, H = 900, 616
CX, CY = 480.0, 262.0
PPD = 150.0                      # pixels per degree

INK, DIM, FAINT = '#2E4057', '#6E675B', '#a09a8c'
RED = '#c0392b'
UMBRA, PENUM = '#3d2328', '#8a8275'
LIT, RIM = '#efe6d2', '#8d8677'

# Two stations, same shadow, same minutes. The only thing that differs is which
# way each man's "up" pointed -- which is the whole moon-tilt claim, so the two
# figures are drawn to identical scale and differ only in the tilt of the track.
SITES = {
    'santafe': dict(
        out='fig-shadow-track.svg',
        title='The Moon’s path through Earth’s shadow, with up as the observer saw it',
        sub='10 December 2011 over Santa Fe. Shadow fixed, Moon moving — the usual '
            'eclipse diagram, drawn in the observer’s frame rather than the orbits’.',
        marks=[
            ('05:20', 'still outside the umbra, in the penumbra', 'out'),
            ('05:46', 'first contact — the umbra reaches the top of the disc', 'out'),
            ('06:15', '42% of the diameter covered', 'out'),
            ('06:47', 'the frame printed at p. 169 — 82% covered', 'key'),
            ('07:04', 'the Moon sets, 99% covered, a rim still lit', 'set'),
        ],
        foot=[
            ('The Moon is below the shadow’s centre in every one of those positions, so the '
             'umbra’s edge arrives at the TOP of the disc and works down.', INK, '700'),
            ('That is the whole of the p. 169 question. The shadow’s centre is the anti-solar '
             'point — the same direction as the dark band you can watch rise in the east', DIM, None),
            ('at any sunset — and that morning it stood above the Moon and sank faster, 12.2° '
             'an hour against the Moon’s 11.7°. No model was asked for a shadow on top.', DIM, None),
            ('The Sun rose at 07:02 and the Moon set at 07:04, so the track stops at the horizon '
             'with a rim still lit. Totality began two minutes later, out of sight.', DIM, None),
        ],
    ),
    'illinois': dict(
        out='fig-shadow-track-illinois.svg',
        title='The same shadow, the same minutes, from 1,443 km east',
        sub='Monks Mound at Cahokia, Illinois. Identical scale to the Santa Fe figure — the '
            'only difference is which way this man’s “up” pointed.',
        marks=[
            ('06:30', 'the umbra has not reached the disc yet — he says so', 'out'),
            ('06:45', 'first contact, the same minute as at Santa Fe', 'out'),
            ('07:04', 'his last frame of the sky — 28% covered', 'key'),
            ('07:07', 'the Moon sets, six seconds after his sunrise', 'set'),
        ],
        foot=[
            ('The bite is on top here too — but it leans the other way.', INK, '700'),
            ('At any moment when both men could see the Moon, the two tilts differ by about '
             'EIGHT degrees: the umbra sits left of his vertical and right of Santa Fe’s,', DIM, None),
            ('because they stand 1,443 km apart and their verticals point in different '
             'directions. Each man’s own footage differs by more, because by the time', DIM, None),
            ('the second was filming his dawn the eclipse had moved on. Eight degrees is the '
             'part that is purely about where you stand.', DIM, None),
            ('His Moon set at 07:07, six seconds after his Sun rose, so this track stops almost '
             'the moment it could have become a selenelion.', DIM, None),
        ],
    ),
}


def esc(s):
    return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')


def txt(x, y, s, size=11, fill=INK, anchor='middle', weight=None, halo=False):
    w = f' font-weight="{weight}"' if weight else ''
    h = (' stroke="#fff" stroke-width="3.2" stroke-linejoin="round" paint-order="stroke"'
         if halo else '')
    return (f'<text x="{x:.1f}" y="{y:.1f}" font-size="{size}" text-anchor="{anchor}" '
            f'fill="{fill}"{w}{h}>{esc(s)}</text>')


def mins(t):
    h, m = t.split(':')
    return int(h) * 60 + int(m)


def at(F, t):
    """Frame at any minute, linearly interpolated between the 2-minute rows."""
    want = mins(t)
    for a, b in zip(F, F[1:]):
        ma, mb = mins(a['t']), mins(b['t'])
        if ma <= want <= mb:
            u = 0.0 if mb == ma else (want - ma) / (mb - ma)
            return {'t': t,
                    'sep': a['sep'] + u * (b['sep'] - a['sep']),
                    'pa': a['pa'] + u * (b['pa'] - a['pa']),
                    'umag': a['umag'] + u * (b['umag'] - a['umag']),
                    'rum': a['rum'], 'rmo': a['rmo'], 'sds': a['sds']}
    return None


def pos(f):
    """Where the Moon's centre sits relative to the shadow's, on screen.

    `pa` is the position angle of the shadow's centre as seen FROM the Moon,
    measured from the observer's up and positive toward increasing azimuth. The
    Moon as seen from the shadow therefore lies at pa + 180.
    """
    r = f['sep'] * PPD
    a = math.radians(f['pa'])
    return CX - r * math.sin(a), CY + r * math.cos(a)


def load(site):
    """Frames for one station, in the observer's own horizontal frame."""
    if site == 'santafe':
        return json.load(open(SRC))['frames']
    d = json.load(open(DATA3D))
    out = []
    for r in d['timeline']:
        h, m, _ = r['ut'].split(':')
        o = r['observers']['illinois']
        sds = math.degrees(math.asin(696000.0 / r['d_sun_km']))
        out.append({'t': f'{(int(h)-6)%24:02d}:{m}',
                    'sep': o['umbra_sep'], 'pa': o['umbra_pa'], 'umag': o['umag'],
                    'rum': o['umbra_radius'], 'rmo': o['moon_sd'], 'sds': sds})
    return out


def tiltmark(o, f, x, y):
    """Draw the observer's vertical at the Moon, and the angle to the shadow.

    This is the number the two figures exist to compare, so it is drawn rather
    than left for the reader to eyeball off the track.
    """
    L = 96.0
    o.append(f'<line x1="{x:.1f}" y1="{y:.1f}" x2="{x:.1f}" y2="{y-L:.1f}" '
             f'stroke="{INK}" stroke-width="1.4" stroke-dasharray="4 4" stroke-opacity="0.85"/>')
    o.append(f'<line x1="{x:.1f}" y1="{y:.1f}" x2="{CX:.1f}" y2="{CY:.1f}" '
             f'stroke="{RED}" stroke-width="1.6"/>')
    pa = f['pa']
    a0, a1 = -90.0, -90.0 - pa               # screen angles, y down
    lo, hi = (a1, a0) if a1 < a0 else (a0, a1)
    R = 40.0
    x0, y0 = x + R * math.cos(math.radians(lo)), y + R * math.sin(math.radians(lo))
    x1, y1 = x + R * math.cos(math.radians(hi)), y + R * math.sin(math.radians(hi))
    o.append(f'<path d="M {x0:.1f} {y0:.1f} A {R} {R} 0 0 1 {x1:.1f} {y1:.1f}" fill="none" '
             f'stroke="{RED}" stroke-width="1.4"/>')
    # The label goes in clear sky to the left, with a leader back to the arc --
    # at identical scale the Illinois discs crowd the Moon and anything parked
    # beside it lands on top of another disc.
    am = math.radians((lo + hi) / 2)
    mx, my = x + R * math.cos(am), y + R * math.sin(am)
    lx, ly = CX - 232.0, CY + 150.0
    o.append(f'<line x1="{lx+96:.1f}" y1="{ly-4:.1f}" x2="{mx:.1f}" y2="{my:.1f}" '
             f'stroke="{RED}" stroke-width="1" stroke-dasharray="3 3" stroke-opacity="0.7"/>')
    side = 'left' if pa < 0 else 'right'
    o.append(txt(lx, ly, f'{abs(pa):.0f}° {side} of up', 13, RED, 'middle', '700', halo=True))
    o.append(txt(lx, ly + 15, 'the shadow’s centre, measured', 9.5, DIM, 'middle', halo=True))
    o.append(txt(lx, ly + 27, 'from this observer’s vertical', 9.5, DIM, 'middle', halo=True))


def build(site):
    C = SITES[site]
    MARKS = C['marks']
    F = load(site)
    key = [m for m in MARKS if m[2] == 'key'][0][0]
    ref = at(F, key)
    r_umb = ref['rum'] * PPD
    r_moon = ref['rmo'] * PPD
    r_pen = (ref['rum'] / 1.02 + 2 * ref['sds']) * 1.02 * PPD

    lo_t, hi_t = mins(MARKS[0][0]), mins(MARKS[-1][0])
    run = [f for f in F if f['sep'] <= 1.24 and lo_t - 30 <= mins(f['t']) <= hi_t]
    setm = mins([m for m in MARKS if m[2] == 'set'][0][0])
    upto_set = [f for f in run if mins(f['t']) <= setm]
    after = [f for f in run if mins(f['t']) >= setm]

    o = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" width="{W}" height="{H}">',
         f'<rect width="{W}" height="{H}" fill="#fff"/>',
         txt(W / 2, 28, C['title'], 14, INK, weight='700'),
         txt(W / 2, 47, C['sub'], 10.5, FAINT)]

    o.append(f'<circle cx="{CX}" cy="{CY}" r="{r_pen:.1f}" fill="{PENUM}" fill-opacity="0.13" '
             f'stroke="{PENUM}" stroke-width="1" stroke-dasharray="5 5"/>')
    o.append(f'<circle cx="{CX}" cy="{CY}" r="{r_umb:.1f}" fill="{UMBRA}" fill-opacity="0.82" '
             f'stroke="{UMBRA}" stroke-width="1.2"/>')
    o.append(f'<circle cx="{CX}" cy="{CY}" r="2.6" fill="#fff"/>')
    o.append(txt(CX, CY - r_umb + 20, 'umbra', 11, '#f0e6e0', weight='700'))
    o.append(txt(CX, CY - r_pen + 15, 'penumbra', 10, DIM))
    o.append(txt(CX + 9, CY + 4, 'the anti-solar point', 9.5, '#f0e6e0', 'start'))

    ax, ay = W - 58.0, CY - 40
    o.append(f'<line x1="{ax}" y1="{ay+50}" x2="{ax}" y2="{ay-44}" stroke="{INK}" stroke-width="2"/>')
    o.append(f'<path d="M {ax-6} {ay-36} L {ax} {ay-48} L {ax+6} {ay-36} Z" fill="{INK}"/>')
    o.append(txt(ax, ay - 56, 'up', 12.5, INK, weight='700'))
    for k, s in enumerate(('the observer’s', 'zenith — not', 'the orbits’ plane')):
        o.append(txt(ax, ay + 66 + k * 12, s, 9.5, DIM))

    pts = ' '.join('%.1f,%.1f' % pos(f) for f in upto_set)
    o.append(f'<polyline points="{pts}" fill="none" stroke="{RED}" stroke-width="2"/>')
    if after:
        pts2 = ' '.join('%.1f,%.1f' % pos(f) for f in after)
        o.append(f'<polyline points="{pts2}" fill="none" stroke="{RED}" stroke-width="1.6" '
                 f'stroke-dasharray="4 4" stroke-opacity="0.55"/>')

    marks_xy = []
    order = sorted(range(len(MARKS)), key=lambda j: MARKS[j][2] == 'key')
    for i in order:
        t, note, kind = MARKS[i]
        f = at(F, t)
        if f is None:
            continue
        x, y = pos(f)
        uid = f'c{i}'
        o.append(f'<clipPath id="{uid}"><circle cx="{x:.1f}" cy="{y:.1f}" r="{r_moon:.1f}"/></clipPath>')
        op = '0.5' if kind == 'set' else '1'
        o.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{r_moon:.1f}" fill="{LIT}" '
                 f'fill-opacity="{op}" stroke="{RIM}" stroke-width="1.2"/>')
        o.append(f'<circle cx="{CX}" cy="{CY}" r="{r_umb:.1f}" fill="{UMBRA}" '
                 f'fill-opacity="{0.5 if kind == "set" else 0.9}" clip-path="url(#{uid})"/>')
        if kind == 'key':
            o.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{r_moon+7:.1f}" fill="none" '
                     f'stroke="{RED}" stroke-width="1.8"/>')
            tiltmark(o, f, x, y)
        dy = -10 if kind == 'set' else 4
        o.append(txt(x + r_moon + 9, y + dy, t, 11, RED if kind == 'key' else INK,
                     'start', '700', halo=True))
        marks_xy.append((i, t, note, kind, x, y))

    for f in run:
        if mins(f['t']) % 10 == 0:
            tx, ty = pos(f)
            o.append(f'<circle cx="{tx:.1f}" cy="{ty:.1f}" r="2.1" fill="{RED}"/>')

    ky = CY - 96
    o.append(txt(30, ky - 18, 'what each disc is', 10.5, INK, 'start', '700'))
    for _, t, note, kind, x, y in sorted(marks_xy):
        col = RED if kind == 'key' else DIM
        o.append(f'<circle cx="36" cy="{ky-4:.1f}" r="4.5" fill="{LIT}" stroke="{RIM}"/>')
        o.append(txt(48, ky, t, 10, INK, 'start', '700'))
        o.append(txt(88, ky, note, 9.5, col, 'start'))
        ky += 19

    y = 506
    for line, colour, wt in C['foot']:
        o.append(txt(W / 2, y, line, 11, colour, weight=wt))
        y += 17

    o.append(txt(W / 2, H - 12, 'Separations and position angles are measured in the observer’s '
                                'own horizontal frame; the umbra carries Danjon’s 2% '
                                'enlargement. JPL DE421. scripts/shadow_track_figure.py', 9, FAINT))
    o.append('</svg>')

    out = os.path.join(IMG, C['out'])
    open(out, 'w').write('\n'.join(o))
    print(f'wrote {out}')
    for t, _, _ in MARKS:
        f = at(F, t)
        if f:
            print(f'  {t}  sep {f["sep"]:.3f}°  pa {f["pa"]:+6.1f}°  '
                  f'covered {f["umag"]*100:5.0f}%')


if __name__ == '__main__':
    which = sys.argv[1:] or list(SITES)
    for s in which:
        build(s)
