// SwipeDeck: a stack of cards to go through one at a time, each flung left or right, as
// in a dating or a flashcard app. The top card follows the finger and turns as it goes,
// a word on it says which way it is heading, and it leaves when it is past 38% of the
// deck's width or thrown fast enough; short of that it springs back. Undo takes back as many
// as were made, last first. The buttons and the arrow keys do all of it without a swipe,
// and in a wide container the choices made stand in two lists beside the deck.
//
//   component Picks() {
//       state kept = []
//       <SwipeDeck cards={places} left_label="Skip" right_label="Keep"
//                  on_swipe={(id, way) => { if way == "right" { kept = kept + [id] } }}/>
//   }
//
// cards: each { id, title, subtitle?, body?, image?, alt?, color? }: image is an address, and
// color any CSS colour behind a card without one. A card without a title is left out, and
// a second card with an id already seen too. left_label, right_label: the words for each
// way, on the card and the buttons. height: the stack's height in pixels (380).
// on_swipe(id, way): inside one of your components, a card has gone, "left" or "right".
// on_undo(id): that card came back. on_empty(): the last card went. label: the deck's name
// for screen readers. empty_text: what shows when none are left. class.

import ui/theme.qwe use ui_cls

let swipe_deck_css = css`
.ui-swipe-deck { container-type: inline-size; color: var(--ui-fg); font: 15px/1.45 var(--ui-font); }
.ui-swipe-deck-inner { display: grid; gap: 14px; }
.ui-swipe-deck-stack { position: relative; width: 100%; max-width: 420px; margin: 0 auto; touch-action: pan-y; outline: none; border-radius: calc(var(--ui-radius) + 4px); overscroll-behavior-x: contain; }
.ui-swipe-deck-stack:focus-visible { box-shadow: var(--ui-focus); }
/* The slot takes its place in the stack from markup, the card its movement from the finger: a style attribute is
   rewritten whole when what it reads changes, so on one element a card arriving would throw a held card home. */
.ui-swipe-deck-slot { position: absolute; inset: 0 0 20px 0; z-index: calc(3 - var(--i));
  transform: translateY(calc(var(--i) * 9px)) scale(calc(1 - var(--i) * .04)); transform-origin: 50% 100%; transition: transform .24s cubic-bezier(.2, .8, .2, 1); }
.ui-swipe-deck-card { position: absolute; inset: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--ui-border);
  border-radius: calc(var(--ui-radius) + 4px); background: var(--ui-surface); box-shadow: var(--ui-shadow);
  transform-origin: 50% 100%; transition: transform .24s cubic-bezier(.2, .8, .2, 1);
  user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
.ui-swipe-deck-moving .ui-swipe-deck-top { transition: none; }
.ui-swipe-deck-flying .ui-swipe-deck-top { transition: transform .22s ease-in; }
.ui-swipe-deck-top { cursor: grab; }
.ui-swipe-deck-moving .ui-swipe-deck-top { cursor: grabbing; }
.ui-swipe-deck-back-left { animation: ui-swipe-deck-back-left .26s cubic-bezier(.2, .8, .2, 1); }
.ui-swipe-deck-back-right { animation: ui-swipe-deck-back-right .26s cubic-bezier(.2, .8, .2, 1); }
@keyframes ui-swipe-deck-back-left { from { transform: translateX(-120%) rotate(-12deg); } }
@keyframes ui-swipe-deck-back-right { from { transform: translateX(120%) rotate(12deg); } }
.ui-swipe-deck-pic { flex: 1; min-height: 0; background: var(--c, var(--ui-subtle)); }
.ui-swipe-deck-pic img { display: block; width: 100%; height: 100%; object-fit: cover; -webkit-user-drag: none; pointer-events: none; }
.ui-swipe-deck-text { flex: none; padding: 12px 16px 14px; }
.ui-swipe-deck-text h3 { margin: 0; font-size: 19px; line-height: 1.25; }
.ui-swipe-deck-text p { margin: 2px 0 0; color: var(--ui-muted); font-size: 14px; }
.ui-swipe-deck-stamp { position: absolute; top: 18px; padding: 2px 10px; border: 3px solid currentColor; border-radius: var(--ui-radius-sm); background: var(--ui-surface);
  font-size: 20px; font-weight: 700; opacity: 0; pointer-events: none; }
.ui-swipe-deck-stamp-right { left: 16px; color: var(--ui-success); transform: rotate(-10deg); opacity: var(--right, 0); }
.ui-swipe-deck-stamp-left { right: 16px; color: var(--ui-danger); transform: rotate(10deg); opacity: var(--left, 0); }
.ui-swipe-deck-none { position: absolute; inset: 0 0 20px 0; display: grid; place-items: center; padding: 20px; border: 1px dashed var(--ui-border);
  border-radius: calc(var(--ui-radius) + 4px); color: var(--ui-muted); text-align: center; }
.ui-swipe-deck-bar { display: flex; justify-content: center; gap: 10px; }
.ui-swipe-deck-btn { min-width: 76px; min-height: 48px; padding: 0 16px; border: 1px solid var(--ui-border); border-radius: 999px; background: var(--ui-surface);
  color: var(--ui-fg); font: 600 15px/1 var(--ui-font); cursor: pointer; }
.ui-swipe-deck-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }
.ui-swipe-deck-btn[aria-disabled="true"] { color: var(--ui-muted); opacity: .6; cursor: default; }
.ui-swipe-deck-left { color: var(--ui-danger); }
.ui-swipe-deck-right { color: var(--ui-success); }
.ui-swipe-deck-count { margin: 0; color: var(--ui-muted); font-size: 13px; text-align: center; }
.ui-swipe-deck-side { display: none; }
.ui-swipe-deck-side h4 { margin: 0 0 4px; font-size: 13px; color: var(--ui-muted); text-transform: uppercase; letter-spacing: .05em; }
.ui-swipe-deck-side ul { margin: 0 0 14px; padding: 0; list-style: none; }
.ui-swipe-deck-side li { padding: 3px 0; border-bottom: 1px solid var(--ui-border); }
@media (hover: hover) { .ui-swipe-deck-btn:not([aria-disabled="true"]):hover { background: var(--ui-subtle); } }
@container (min-width: 560px) {
  .ui-swipe-deck-inner { grid-template-columns: minmax(0, 1fr) 200px; grid-template-rows: auto auto auto; column-gap: 28px; }
  .ui-swipe-deck-side { display: block; grid-column: 2; grid-row: 1 / span 3; }
}
@media (prefers-reduced-motion: reduce) {
  .ui-swipe-deck-slot, .ui-swipe-deck-card, .ui-swipe-deck-flying .ui-swipe-deck-top { transition: none; }
  .ui-swipe-deck-back-left, .ui-swipe-deck-back-right { animation: none; }
}
`

// The cards that can be shown: those with a title, each id once, ids as text.
fn swipe_deck_clean(cards) {
    if type_of(cards) != "list" { return [] }
    let named = cards | filter((c) => type_of(c) == "map" && get(c, "title") != null)
    // One without an id gets its place, marked so it cannot be taken for a real id of that number.
    let all = enumerate(named) | map((p) => { ...p[1], id: get(p[1], "id") != null ? str(p[1].id) : `_${p[0]}` })
    let ids = all | map((c) => c.id)
    enumerate(all) | filter((p) => index_of(ids, p[1].id) == p[0]) | map((p) => p[1])
}

fn swipe_deck_axis(dx: num, dy: num, slop: num) -> str {
    if abs(dx) < slop && abs(dy) < slop { return "" }
    abs(dx) > abs(dy) ? "x" : "y"
}

// How far the card turns, in degrees, for a card moved dx: 12 at a full width, 16 at most.
fn swipe_deck_tilt(dx: num, width: num) -> num {
    if width <= 0 { return 0 }
    let turn = dx / width * 12
    round(max([-16, min([16, turn])]), 2)
}

// Where a release at dx with speed v (pixels a millisecond, signed) sends the card: "left",
// "right", or "" to spring back. A throw counts from a small distance, one against the way
// the card has gone takes it back, and otherwise it must be past 38% of the width.
fn swipe_deck_decide(dx: num, v: num, width: num) -> str {
    let way = dx >= 0 ? "right" : "left"
    if abs(v) >= 0.5 && abs(dx) >= 24 { return v * dx > 0 ? way : "" }
    abs(dx) >= width * 0.38 ? way : ""
}

fn swipe_deck_sample(s, pos: num, now: num) {
    let dt = now - s.at
    dt < 32 ? s : { pos, at: now, v: (pos - s.pos) / dt }
}

// The top card's element. This and the two below are top-level fns, not the component's
// own `let` functions: one of those that calls a method of the page, or a prop, gives a
// Promise, and a handler calling it would go on late, after the next click had come.
fn swipe_deck_top(stack) = stack.querySelector(".ui-swipe-deck-top")

// A held card drawn x, y from home, tilted, its stamp showing as far as it has gone.
fn swipe_deck_hold(stack, card, x: num, y: num) {
    if card != null {
        let w = stack.offsetWidth
        card.style.transform = x != 0 || y != 0 ? `translate(${x}px, ${y}px) rotate(${swipe_deck_tilt(x, w)}deg)` : ""
        let p = w > 0 ? min([1, abs(x) / (w * 0.38)]) : 0
        card.style.setProperty("--right", x > 0 ? `${p}` : "0")
        card.style.setProperty("--left", x < 0 ? `${p}` : "0")
    }
}

// on_swipe and on_empty, for a card that has landed: { id, way, rest }, or null for none.
fn swipe_deck_tell(landed, on_swipe, on_empty) {
    if landed != null {
        on_swipe && on_swipe(landed.id, landed.way)
        if landed.rest == 0 { on_empty && on_empty() }
    }
}

// The speed at release, signed: none if the finger stopped before it lifted.
fn swipe_deck_speed(s, pos: num, now: num, moved: num) -> num {
    if now - moved > 80 { return 0 }
    let dt = now - s.at
    let since = dt >= 8 ? (pos - s.pos) / dt : 0
    abs(since) > abs(s.v) ? since : s.v
}

component SwipeDeck(cards = [], left_label: str = "Skip", right_label: str = "Keep", height: int = 380, label: str = "Cards",
                    empty_text: str = "No more cards.", on_swipe = null, on_undo = null, on_empty = null, class: str? = null) {
    style global swipe_deck_css
    let deck = swipe_deck_clean(cards)
    state history = []       // what was done, oldest first: { id, way, title }
    state moving = false
    state flying = null      // the way the top card is leaving
    state back = null        // { id, way } of a card just brought back, for its way in
    state said = ""
    state stack = null
    state g = null
    let done = history | map((h) => h.id)
    let waiting = deck | filter((c) => !contains(done, c.id))
    let shown = take(waiting, 3)
    // The card in flight lands. It calls nothing but builtins, so it is done at once and what
    // follows it sees the deck as it now is; swipe_deck_tell gives the props the news.
    let finish = () => {
        if flying != null && len(waiting) > 0 {
            let c = waiting[0]
            let way = flying
            let rest = len(waiting) - 1     // now: waiting follows history as soon as that is set
            flying = null
            history = history + [{ id: c.id, way, title: c.title }]
            said = `${way == "right" ? right_label : left_label}: ${c.title}. ${rest} left.`
            return { id: c.id, way, rest }
        }
        null
    }
    let fling = (way: str) => {
        swipe_deck_tell(finish(), on_swipe, on_empty)
        if len(waiting) > 0 {
            moving = false
            flying = way
            let w = stack.offsetWidth * 1.5
            let card = swipe_deck_top(stack)
            if card != null {
                card.style.transform = `translate(${way == "right" ? w : -w}px, 30px) rotate(${way == "right" ? 18 : -18}deg)`
            }
        }
    }
    let undo = () => {
        swipe_deck_tell(finish(), on_swipe, on_empty)
        if len(history) > 0 {
            let h = history[len(history) - 1]
            back = { id: h.id, way: h.way }
            history = slice(history, 0, len(history) - 1)
            said = `Back: ${h.title}. ${len(waiting)} left.`
            on_undo && on_undo(h.id)
        }
    }
    if flying != null { every(210, () => { swipe_deck_tell(finish(), on_swipe, on_empty) }) }
    <div class={ui_cls(["ui-swipe-deck", class])}>
        <div.ui-swipe-deck-inner>
            <div class={ui_cls(["ui-swipe-deck-stack", moving && "ui-swipe-deck-moving", flying != null && "ui-swipe-deck-flying"])}
                 style={`height: ${height}px`} tabindex="0" role="group" aria-label={label}
                 aria-roledescription="deck of cards"
                 @mount={(me) => { stack = me }}
                 @keydown={(e) => {
                     if e.key == "ArrowLeft" { e.preventDefault(); fling("left") }
                     if e.key == "ArrowRight" { e.preventDefault(); fling("right") }
                     if e.key == "Backspace" || (e.key == "z" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); undo() }
                 }}
                 @pointerdown={(e) => {
                     g = e.isPrimary && e.button == 0 && flying == null && len(waiting) > 0
                         ? { id: e.pointerId, x: e.clientX, y: e.clientY, on: false, dx: 0, moved: e.timeStamp, s: { pos: e.clientX, at: e.timeStamp, v: 0 } }
                         : null
                 }}
                 @pointermove={(e) => {
                     if g != null && e.pointerId == g.id {
                         let dx = e.clientX - g.x
                         let dy = e.clientY - g.y
                         if !g.on {
                             let way = swipe_deck_axis(dx, dy, 8)
                             if way == "y" { g = null }
                             if way == "x" {
                                 stack.setPointerCapture(e.pointerId)
                                 back = null
                                 moving = true
                                 // This card, whatever comes to stand on top of it while it is held.
                                 g = { ...g, on: true, card: swipe_deck_top(stack) }
                             }
                         }
                         if g != null && g.on {
                             swipe_deck_hold(stack, g.card, dx, dy * 0.4)
                             g = { ...g, dx, moved: e.timeStamp, s: swipe_deck_sample(g.s, e.clientX, e.timeStamp) }
                         }
                     }
                 }}
                 @pointerup={(e) => {
                     if g != null && e.pointerId == g.id {
                         let was = g
                         g = null
                         if was.on {
                             let way = swipe_deck_decide(was.dx, swipe_deck_speed(was.s, e.clientX, e.timeStamp, was.moved), stack.offsetWidth)
                             moving = false
                             let still_top = was.card != null && swipe_deck_top(stack) != null && was.card.isSameNode(swipe_deck_top(stack))
                             if way != "" && still_top { fling(way) } else { swipe_deck_hold(stack, was.card, 0, 0) }
                         }
                     }
                 }}
                 @pointercancel={(e) => {
                     if g != null && e.pointerId == g.id {
                         let was = g
                         g = null
                         moving = false
                         if was.on { swipe_deck_hold(stack, was.card, 0, 0) }
                     }
                 }}>
                {len(waiting) == 0 && <div.ui-swipe-deck-none>{empty_text}</div>}
                {for [i, c] in enumerate(shown) => <div.ui-swipe-deck-slot key={c.id} style={`--i: ${i}`} aria-hidden={i > 0 ? "true" : null}><article
                        class={ui_cls(["ui-swipe-deck-card", i == 0 && "ui-swipe-deck-top",
                                       back != null && i == 0 && back.id == c.id && `ui-swipe-deck-back-${back.way}`])}>
                    <div.ui-swipe-deck-pic style={get(c, "color") != null ? `--c: ${c.color}` : null}>
                        {get(c, "image") != null && <img src={c.image} alt={get(c, "alt") ?? ""} draggable="false"/>}
                    </div>
                    <div.ui-swipe-deck-text>
                        <h3>{c.title}</h3>
                        {get(c, "subtitle") != null && <p>{c.subtitle}</p>}
                        {get(c, "body") != null && <p>{c.body}</p>}
                    </div>
                    <span.ui-swipe-deck-stamp.ui-swipe-deck-stamp-right aria-hidden="true">{right_label}</span>
                    <span.ui-swipe-deck-stamp.ui-swipe-deck-stamp-left aria-hidden="true">{left_label}</span>
                </article></div>}
            </div>
            <div.ui-swipe-deck-bar>
                <button.ui-swipe-deck-btn.ui-swipe-deck-left type="button" aria-disabled={len(waiting) == 0 ? "true" : null}
                        @click={() => { if len(waiting) > 0 { fling("left") } }}>{left_label}</button>
                <button.ui-swipe-deck-btn.ui-swipe-deck-undo type="button" aria-disabled={len(history) == 0 ? "true" : null}
                        @click={() => { undo() }}>Undo</button>
                <button.ui-swipe-deck-btn.ui-swipe-deck-right type="button" aria-disabled={len(waiting) == 0 ? "true" : null}
                        @click={() => { if len(waiting) > 0 { fling("right") } }}>{right_label}</button>
            </div>
            <p.ui-swipe-deck-count role="status">{said != "" ? said : `${len(waiting)} of ${len(deck)} left.`}</p>
            <div.ui-swipe-deck-side>
                <h4>{right_label}</h4>
                <ul.ui-swipe-deck-kept>{for h in history | filter((h) => h.way == "right") => <li key={h.id}>{h.title}</li>}</ul>
                <h4>{left_label}</h4>
                <ul.ui-swipe-deck-passed>{for h in history | filter((h) => h.way == "left") => <li key={h.id}>{h.title}</li>}</ul>
            </div>
        </div>
    </div>
}
