mewo

Picture Trail

A picture's trail through time on a pinned stage: a huge name crosses the view, its holders pass beside it in turn, and a second name follows and stays faded behind it. No dependencies.

No dependencies

Seen inBetween Rivers

Customize

Holders6
Picture#1

Install

Terminal
npx shadcn@latest add @mewo/picture-trail
Not using the registry directory?

Point the CLI straight at the item URL:

npx shadcn@latest add https://www.mewo-ui.com/r/picture-trail.json

Props

PropTypeDefaultDescription
topstring"Conqueror"The huge name that crosses the view first, from the bottom to the top.
bottomstring"Last ruler"The huge name that follows and stays, faded, behind the picture.
holdersstring[]six dynastiesThe holders, passing in turn beside the picture, left and right (six have room).
image{ src, alt?, credit: { title, by, line, href? } }a picsum photoThe picture (about 9 : 10), with its credit under it.
classNamestring—Merged onto the root: the tall section the stage pins in (six view heights).

Source

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export interface PictureTrailCredit {
  /** The picture's name, underlined (linked when href is set). */
  title: string
  /** Place and date, after the name. */
  by: string
  /** The small grey line: where it is kept, the photograph's credit and licence. */
  line: string
  /** Where the name links (it opens in a new tab). */
  href?: string
}

export interface PictureTrailProps extends React.ComponentProps<"section"> {
  /** The huge name that crosses the view first, from the bottom to the top. */
  top?: string
  /** The huge name that follows and stays, faded, behind the picture. */
  bottom?: string
  /** The holders, passing in turn beside the picture, left and right (six have room). */
  holders?: string[]
  /** The picture (about 9 : 10), with its credit under it. */
  image?: { src: string; alt?: string; credit: PictureTrailCredit }
}

export type PictureTrailLayout = "desktop" | "tablet" | "phone"
/** One keyframe: [view heights after the stage pins, centre x (% of the view's width), centre y (% of its height),
 * scale, opacity]. */
type Key = readonly number[]
type Track = { size: readonly [number, number]; keys: readonly Key[]; on?: readonly (readonly [number, number])[] }
type Part = "top" | "bottom" | "buyer0" | "buyer1" | "buyer2" | "buyer3" | "buyer4" | "buyer5" | "img" | "credit"
type Layout = { view: readonly [number, number] } & Record<Part, Track>

// The motion per screen shape, measured on a reference view: each part's layout size there and its keyframes.
// Positions are shares of the view, so they hold in any box; sizes are set on the box's width (the unit below), as
// on the reference view, so the parts land where they were measured. The credit hangs under the picture.
const TRACKS: Record<PictureTrailLayout, Layout> = {
  desktop: {
    view: [1440, 900],
    top: { size: [1103,207], keys: [[0,49.97,114.94,1,0],[0.1,49.97,114.94,1,0],[0.15,49.97,114.94,1,1],[1,49.97,24.06,1,1],[1.05,49.97,17.06,1,1],[1.5,49.97,-29.5,1,1],[5,49.97,-29.61,1,1]] },
    bottom: { size: [1371,207], keys: [[0,50.03,133.39,1,0],[0.1,50.03,133.39,1,0],[0.15,50.03,133.39,1,1],[3.65,50.03,133.39,1,1],[4.1,50.03,85.39,1,1],[4.15,50.03,79.94,1,0.89],[4.35,50.03,58.61,1,0.21],[5,50.03,-10.93,1,0.2]] },
    buyer0: { size: [405,130], keys: [[0,19.06,107.22,1,0],[0.1,19.06,107.22,1,0],[0.15,19.06,107.22,1,1],[1,19.06,107.22,1,1],[1.05,19.06,105.56,1,1],[2.4,19.06,-38.89,1,1],[2.45,19.06,-42.78,1,1],[5,19.06,-42.78,1,1]] },
    buyer1: { size: [405,130], keys: [[0,80.94,107.22,1,0],[0.1,80.94,107.22,1,0],[0.15,80.94,107.22,1,1],[1.5,80.94,107.22,1,1],[2.9,80.94,-42.33,1,1],[5,80.94,-42.78,1,1]] },
    buyer2: { size: [405,65], keys: [[0,19.06,103.61,1,0],[0.1,19.06,103.61,1,0],[0.15,19.06,103.61,1,1],[1.95,19.06,103.61,1,1],[2,19.06,100.28,1,1],[3.35,19.06,-44.17,1,1],[3.4,19.06,-46.39,1,1],[5,19.06,-46.39,1,1]] },
    buyer3: { size: [405,65], keys: [[0,80.94,103.61,1,0],[0.1,80.94,103.61,1,0],[0.15,80.94,103.61,1,1],[2.4,80.94,103.61,1,1],[2.45,80.94,102.17,1,1],[3.8,80.94,-42.28,1,1],[3.85,80.94,-46.39,1,1],[5,80.94,-46.39,1,1]] },
    buyer4: { size: [405,65], keys: [[0,19.06,103.61,1,0],[0.1,19.06,103.61,1,0],[0.15,19.06,103.61,1,1],[2.9,19.06,103.61,1,1],[4.3,19.06,-45.83,1,1],[4.35,19.06,-46.39,1,1],[5,19.06,-46.39,1,1]] },
    buyer5: { size: [405,130], keys: [[0,80.94,107.22,1,0],[0.1,80.94,107.22,1,0],[0.15,80.94,107.22,1,1],[3.35,80.94,107.22,1,1],[3.4,80.94,104.11,1,1],[4.75,80.94,-40.33,1,1],[4.8,80.94,-42.78,1,1],[5,80.94,-42.78,1,1]] },
    img: { size: [1440,1595], keys: [[0,50,143.94,0.2542,0],[0.1,50,143.94,0.2542,0],[0.15,50,143.94,0.2542,1],[0.3,50,126.72,0.2542,1],[0.5,50,106.61,0.2542,1],[0.7,50,89.61,0.2542,1],[0.9,50,75.72,0.2542,1],[1.05,50,67.39,0.2542,1],[1.15,50,62.83,0.2542,1],[1.3,50,57.39,0.2542,1],[1.4,50,54.72,0.2542,1],[1.55,50,52.28,0.2542,1],[1.75,50,51.39,0.2542,1],[5,50,47.48,0.2542,1]] },
    credit: { size: [178,48], keys: [[0,50,169.11,1,0],[0.1,50,169.11,1,0],[0.15,50,169.11,1,1],[0.3,50,151.89,1,1],[0.4,50,141.44,1,1],[0.55,50,127.22,1,1],[0.7,50,114.78,1,1],[0.8,50,107.44,1,1],[0.9,50,101,1,1],[1.05,50,92.56,1,1],[1.15,50,88,1,1],[1.3,50,82.56,1,1],[1.45,50,78.89,1,1],[1.6,50,77,1,1],[1.75,50,76.56,1,1],[5,50,72.64,1,1]] },
  },
  tablet: {
    view: [768, 900],
    top: { size: [358,67], keys: [[0,50,105.39,1,0],[0.1,50,105.39,1,0],[0.15,50,105.39,1,1],[0.25,50,89.28,1,1],[0.35,50,75.06,1,1],[0.4,50,68.72,1,1],[0.5,50,57.39,1,1],[0.6,50,47.72,1,1],[0.75,50,36.17,1,1],[0.85,50,30.17,1,1],[0.95,50,25.39,1,1],[1.05,50,21.61,1,1],[1.2,50,17.83,1,1],[1.3,50,16.17,1,1],[1.45,50,14.83,1,1],[1.7,50,14.28,1,1],[3.15,50,14.28,1,1],[3.3,50,13.39,1,1],[3.4,50,11.39,1,1],[3.5,50,7.61,1,1],[3.55,50,4.83,1,1],[3.6,50,1.5,1,1],[3.7,50,-7.39,1,1],[3.8,50,-19.72,1,1],[3.85,50,-27.39,1,1],[3.95,50,-44.61,1,1],[4,50,-52.17,1,1],[4.05,50,-58.72,1,1],[4.15,50,-69.17,1,1],[4.25,50,-76.5,1,1],[4.3,50,-79.17,1,1],[4.4,50,-82.83,1,1],[4.55,50,-85.28,1,1],[5,50,-85.72,1,1]] },
    bottom: { size: [445,67], keys: [[0,50.07,136.83,1,0],[0.1,50.07,136.83,1,0],[0.15,50.07,136.83,1,1],[2.1,50.07,136.83,1,1],[2.15,50.07,131.06,1,1],[2.2,50.07,122.72,1,1],[2.25,50.07,115.39,1,1],[2.35,50.07,104.06,1,1],[2.4,50.07,99.61,1,1],[2.45,50.07,96.17,1,1],[2.5,50.07,93.28,1,1],[2.6,50.07,89.5,1,1],[2.7,50.07,87.61,1,1],[2.8,50.07,86.94,1,1],[3.5,50.07,86.83,1,1],[3.6,50.07,86.28,1,1],[3.75,50.07,83.5,1,1],[3.85,50.07,79.39,1,1],[3.95,50.07,73.06,1,1],[4.05,50.07,63.61,1,1],[4.1,50.07,57.72,1,1],[4.15,50.07,50.83,1,1],[4.3,50.07,25.83,1,1],[4.35,50.07,18.5,1,1],[4.45,50.07,6.83,1,1],[4.5,50.07,2.28,1,1],[4.6,50.07,-4.72,1,1],[4.7,50.07,-9.17,1,1],[4.8,50.07,-11.72,1,1],[5,50.07,-13.17,1,1]] },
    buyer0: { size: [744,29], keys: [[0,50,135.39,1,0],[0.1,50,135.39,1,0],[0.15,50,135.39,1,1],[1.05,50,135.39,1,1],[1.1,50,128.5,1,1],[1.2,50,112.06,1,1],[1.3,50,97.61,1,1],[1.45,50,79.61,1,1],[1.6,50,65.5,1,1],[1.7,50,57.94,1,1],[1.85,50,49.17,1,1],[1.95,50,44.83,1,1],[2.05,50,41.5,1,1],[2.15,50,39.06,1,1],[2.3,50,36.72,1,1],[2.55,50,35.39,1,1],[3.25,50,35.17,1,1],[3.4,50,33.5,1,1],[3.5,50,30.61,1,1],[3.55,50,28.5,1,1],[3.65,50,22.39,1,1],[3.75,50,13.39,1,1],[3.85,50,0.94,1,1],[3.9,50,-6.61,1,1],[4,50,-23.94,1,1],[4.05,50,-31.39,1,1],[4.1,50,-37.94,1,1],[4.2,50,-48.28,1,1],[4.3,50,-55.5,1,1],[4.35,50,-58.17,1,1],[4.45,50,-61.83,1,1],[4.6,50,-64.28,1,1],[5,50,-64.51,1,1]] },
    buyer1: { size: [744,29], keys: [[0,50,141.17,1,0],[0.1,50,141.17,1,0],[0.15,50,141.17,1,1],[1.1,50,141.17,1,1],[1.15,50,133.94,1,1],[1.2,50,125.5,1,1],[1.3,50,110.17,1,1],[1.4,50,96.83,1,1],[1.5,50,85.28,1,1],[1.6,50,75.5,1,1],[1.7,50,67.28,1,1],[1.8,50,60.5,1,1],[1.9,50,54.94,1,1],[2.05,50,48.83,1,1],[2.15,50,46.06,1,1],[2.3,50,43.17,1,1],[2.6,50,41.28,1,1],[3.3,50,41.06,1,1],[3.45,50,39.39,1,1],[3.55,50,36.5,1,1],[3.65,50,31.5,1,1],[3.7,50,28.06,1,1],[3.8,50,19.06,1,1],[3.9,50,6.61,1,1],[3.95,50,-1.06,1,1],[4.05,50,-18.39,1,1],[4.1,50,-25.83,1,1],[4.15,50,-32.28,1,1],[4.25,50,-42.61,1,1],[4.35,50,-49.83,1,1],[4.45,50,-54.5,1,1],[4.6,50,-57.94,1,1],[5,50,-58.83,1,1]] },
    buyer2: { size: [744,29], keys: [[0,50,147.06,1,0],[0.1,50,147.06,1,0],[0.15,50,147.06,1,1],[1.15,50,147.06,1,1],[1.35,50,115.72,1,1],[1.45,50,102.5,1,1],[1.55,50,90.94,1,1],[1.65,50,81.17,1,1],[1.75,50,73.06,1,1],[1.85,50,66.28,1,1],[1.95,50,60.72,1,1],[2.1,50,54.72,1,1],[2.2,50,51.83,1,1],[2.35,50,49.06,1,1],[2.5,50,47.61,1,1],[2.7,50,47.06,1,1],[3.35,50,46.94,1,1],[3.5,50,45.17,1,1],[3.6,50,42.28,1,1],[3.7,50,37.28,1,1],[3.75,50,33.83,1,1],[3.85,50,24.72,1,1],[3.9,50,18.94,1,1],[3.95,50,12.17,1,1],[4,50,4.5,1,1],[4.1,50,-12.83,1,1],[4.15,50,-20.17,1,1],[4.25,50,-32.17,1,1],[4.35,50,-40.83,1,1],[4.45,50,-46.61,1,1],[4.6,50,-51.28,1,1],[4.75,50,-52.83,1,1],[5,50,-52.84,1,1]] },
    buyer3: { size: [744,29], keys: [[0,50,152.94,1,0],[0.1,50,152.94,1,0],[0.15,50,152.94,1,1],[1.2,50,152.94,1,1],[1.35,50,128.72,1,1],[1.4,50,121.39,1,1],[1.5,50,108.06,1,1],[1.55,50,102.17,1,1],[1.65,50,91.61,1,1],[1.75,50,82.61,1,1],[1.85,50,75.17,1,1],[1.95,50,69.06,1,1],[2.05,50,64.28,1,1],[2.2,50,58.94,1,1],[2.35,50,55.61,1,1],[2.45,50,54.28,1,1],[2.65,50,53.06,1,1],[3.4,50,52.72,1,1],[3.55,50,50.94,1,1],[3.65,50,48.06,1,1],[3.75,50,43.06,1,1],[3.85,50,35.39,1,1],[3.9,50,30.39,1,1],[4,50,17.72,1,1],[4.05,50,9.94,1,1],[4.15,50,-7.17,1,1],[4.2,50,-14.61,1,1],[4.25,50,-21.06,1,1],[4.3,50,-26.5,1,1],[4.4,50,-35.06,1,1],[4.5,50,-40.83,1,1],[4.65,50,-45.5,1,1],[4.8,50,-46.94,1,1],[5,50,-46.94,1,1]] },
    buyer4: { size: [744,29], keys: [[0,50,158.83,1,0],[0.1,50,158.83,1,0],[0.15,50,158.83,1,1],[1.25,50,158.83,1,1],[1.35,50,142.17,1,1],[1.45,50,126.94,1,1],[1.5,50,120.06,1,1],[1.6,50,107.83,1,1],[1.7,50,97.28,1,1],[1.8,50,88.39,1,1],[1.9,50,80.94,1,1],[2,50,74.83,1,1],[2.1,50,70.06,1,1],[2.2,50,66.28,1,1],[2.3,50,63.5,1,1],[2.45,50,60.72,1,1],[2.75,50,58.83,1,1],[3.45,50,58.61,1,1],[3.6,50,56.83,1,1],[3.7,50,53.83,1,1],[3.75,50,51.61,1,1],[3.85,50,45.28,1,1],[3.95,50,36.06,1,1],[4.05,50,23.39,1,1],[4.2,50,-1.61,1,1],[4.25,50,-8.94,1,1],[4.3,50,-15.39,1,1],[4.35,50,-20.83,1,1],[4.45,50,-29.39,1,1],[4.55,50,-35.06,1,1],[4.65,50,-38.61,1,1],[4.75,50,-40.39,1,1],[5,50,-41.06,1,1]] },
    buyer5: { size: [744,29], keys: [[0,50,164.72,1,0],[0.1,50,164.72,1,0],[0.15,50,164.72,1,1],[1.3,50,164.72,1,1],[1.45,50,139.83,1,1],[1.55,50,125.72,1,1],[1.65,50,113.39,1,1],[1.75,50,102.94,1,1],[1.85,50,94.06,1,1],[1.95,50,86.61,1,1],[2.05,50,80.61,1,1],[2.15,50,75.83,1,1],[2.25,50,72.06,1,1],[2.4,50,68.17,1,1],[2.55,50,65.94,1,1],[2.8,50,64.72,1,1],[3.5,50,64.5,1,1],[3.65,50,62.61,1,1],[3.75,50,59.61,1,1],[3.85,50,54.5,1,1],[3.9,50,50.94,1,1],[4,50,41.72,1,1],[4.05,50,35.83,1,1],[4.1,50,28.94,1,1],[4.15,50,21.06,1,1],[4.25,50,3.94,1,1],[4.3,50,-3.39,1,1],[4.4,50,-15.17,1,1],[4.5,50,-23.61,1,1],[4.6,50,-29.28,1,1],[4.7,50,-32.72,1,1],[4.8,50,-34.5,1,1],[5,50,-35.17,1,1]] },
    img: { size: [1056,1170], keys: [[0,50,150,0.3845,0],[0.1,50,150,0.3845,0],[0.15,50,150,0.3845,1],[0.2,50,149.33,0.3845,1],[0.3,50,132.11,0.3845,1],[0.35,50,124.33,0.3845,1],[0.45,50,110.22,0.3845,1],[0.6,50,92.67,0.3845,1],[0.7,50,83.11,0.3845,1],[0.85,50,71.56,0.3845,1],[1,50,63.11,0.3845,1],[1.1,50,58.89,0.3845,0.95],[1.25,50,54.44,0.3845,0.76],[1.5,50,50.78,0.3845,0.48],[1.7,50,50,0.3845,0.31],[1.95,50,49.44,0.3845,0.21],[4,50,44.44,0.3845,0.2],[4.65,50,42.89,0.3845,0.87],[5,50,42,0.3845,1]] },
    credit: { size: [178,55], keys: [[0,50,178.06,1,0],[0.1,50,178.06,1,0],[0.15,50,178.06,1,1],[0.2,50,177.39,1,1],[0.3,50,160.17,1,1],[0.35,50,152.39,1,1],[0.45,50,138.28,1,1],[0.6,50,120.72,1,1],[0.7,50,111.17,1,1],[0.85,50,99.61,1,1],[0.95,50,93.72,1,1],[1.1,50,86.94,1,0.95],[1.25,50,82.5,1,0.76],[1.4,50,79.83,1,0.58],[1.5,50,78.83,1,0.48],[1.9,50,77.61,1,0.22],[4,50,72.5,1,0.2],[4.6,50,71.06,1,0.83],[5,50,70.06,1,1]] },
  },
  phone: {
    view: [375, 812],
    top: { size: [298,56], keys: [[0,49.87,113.79,1,0],[0.05,49.87,113.79,1,0],[0.1,49.87,113.79,1,1],[0.15,49.87,104.8,1,1],[0.25,49.87,88.55,1,1],[0.35,49.87,74.26,1,1],[0.45,49.87,61.95,1,1],[0.56,49.87,51.48,1,1],[0.66,49.87,42.73,1,1],[0.76,49.87,35.34,1,1],[0.86,49.87,29.43,1,1],[0.96,49.87,24.63,1,1],[1.06,49.87,21.06,1,1],[1.21,49.87,17.24,1,1],[1.46,49.87,14.41,1,1],[3.13,49.87,14.04,1,1],[3.28,49.87,13.3,1,1],[3.38,49.87,11.45,1,1],[3.48,49.87,8,1,1],[3.53,49.87,5.42,1,1],[3.64,49.87,-1.6,1,1],[3.69,49.87,-6.28,1,1],[3.79,49.87,-18.35,1,1],[3.84,49.87,-25.74,1,1],[3.94,49.87,-43.1,1,1],[3.99,49.87,-50.99,1,1],[4.04,49.87,-57.76,1,1],[4.14,49.87,-68.6,1,1],[4.24,49.87,-76.23,1,1],[4.34,49.87,-81.28,1,1],[4.39,49.87,-82.88,1,1],[4.54,49.87,-85.59,1,1],[5,49.87,-85.96,1,1]] },
    bottom: { size: [371,56], keys: [[0,50,137.07,1,0],[0.05,50,137.07,1,0],[0.1,50,137.07,1,1],[2.12,50,136.82,1,1],[2.17,50,127.59,1,1],[2.22,50,119.58,1,1],[2.27,50,112.81,1,1],[2.32,50,107.02,1,1],[2.37,50,102.09,1,1],[2.42,50,98.15,1,1],[2.53,50,92.36,1,1],[2.58,50,90.52,1,1],[2.68,50,88.18,1,1],[2.83,50,87.07,1,1],[3.59,50,86.7,1,1],[3.74,50,84.11,1,1],[3.84,50,80.3,1,1],[3.89,50,77.59,1,1],[3.99,50,69.95,1,1],[4.09,50,59.24,1,1],[4.19,50,44.7,1,1],[4.29,50,27.22,1,1],[4.34,50,19.83,1,1],[4.39,50,13.3,1,1],[4.49,50,2.96,1,1],[4.6,50,-4.19,1,1],[4.64,50,-6.77,1,1],[4.75,50,-10.34,1,1],[4.85,50,-12.07,1,1],[5,50,-12.93,1,1]] },
    buyer0: { size: [355,24], keys: [[0,50,136.45,1,0],[0.05,50,136.45,1,0],[0.1,50,136.45,1,1],[1.06,50,136.45,1,1],[1.16,50,119.21,1,1],[1.26,50,103.94,1,1],[1.36,50,90.64,1,1],[1.46,50,79.19,1,1],[1.57,50,69.58,1,1],[1.67,50,61.45,1,1],[1.77,50,54.8,1,1],[1.87,50,49.38,1,1],[2.02,50,43.47,1,1],[2.12,50,40.76,1,1],[2.27,50,38.18,1,1],[2.58,50,36.45,1,1],[3.23,50,36.33,1,1],[3.38,50,34.98,1,1],[3.48,50,32.39,1,1],[3.59,50,27.71,1,1],[3.64,50,24.51,1,1],[3.74,50,15.89,1,1],[3.79,50,10.34,1,1],[3.84,50,3.82,1,1],[3.89,50,-3.69,1,1],[3.99,50,-21.06,1,1],[4.04,50,-28.82,1,1],[4.09,50,-35.59,1,1],[4.19,50,-46.43,1,1],[4.29,50,-53.94,1,1],[4.34,50,-56.77,1,1],[4.44,50,-60.59,1,1],[4.54,50,-62.56,1,1],[4.7,50,-63.55,1,1],[5,50,-63.55,1,1]] },
    buyer1: { size: [355,24], keys: [[0,50,141.87,1,0],[0.05,50,141.87,1,0],[0.1,50,141.87,1,1],[1.11,50,141.63,1,1],[1.16,50,132.64,1,1],[1.26,50,116.38,1,1],[1.36,50,102.09,1,1],[1.46,50,89.9,1,1],[1.57,50,79.31,1,1],[1.67,50,70.57,1,1],[1.77,50,63.18,1,1],[1.87,50,57.27,1,1],[1.97,50,52.46,1,1],[2.12,50,47.41,1,1],[2.22,50,45.07,1,1],[2.37,50,42.98,1,1],[2.63,50,41.87,1,1],[3.28,50,41.75,1,1],[3.43,50,40.27,1,1],[3.53,50,37.68,1,1],[3.64,50,33,1,1],[3.69,50,29.8,1,1],[3.79,50,21.06,1,1],[3.89,50,8.87,1,1],[3.94,50,1.35,1,1],[4.04,50,-16.01,1,1],[4.09,50,-23.77,1,1],[4.14,50,-30.42,1,1],[4.24,50,-41.13,1,1],[4.34,50,-48.65,1,1],[4.44,50,-53.57,1,1],[4.54,50,-56.4,1,1],[4.64,50,-57.76,1,1],[5,50,-58.13,1,1]] },
    buyer2: { size: [355,24], keys: [[0,50,147.29,1,0],[0.05,50,147.29,1,0],[0.1,50,147.29,1,1],[1.11,50,147.29,1,1],[1.16,50,146.55,1,1],[1.26,50,129.31,1,1],[1.36,50,114.04,1,1],[1.46,50,100.86,1,1],[1.52,50,94.95,1,1],[1.67,50,79.93,1,1],[1.77,50,71.92,1,1],[1.92,50,62.56,1,1],[2.07,50,55.91,1,1],[2.17,50,52.71,1,1],[2.27,50,50.49,1,1],[2.42,50,48.4,1,1],[2.68,50,47.29,1,1],[3.33,50,47.17,1,1],[3.48,50,45.69,1,1],[3.59,50,42.98,1,1],[3.69,50,38.3,1,1],[3.74,50,34.98,1,1],[3.79,50,31.03,1,1],[3.84,50,26.23,1,1],[3.89,50,20.57,1,1],[3.94,50,14.04,1,1],[3.99,50,6.4,1,1],[4.09,50,-10.96,1,1],[4.14,50,-18.72,1,1],[4.24,50,-31.03,1,1],[4.34,50,-40.02,1,1],[4.39,50,-43.35,1,1],[4.49,50,-48.28,1,1],[4.64,50,-51.85,1,1],[4.8,50,-52.71,1,1],[5,50,-52.71,1,1]] },
    buyer3: { size: [355,24], keys: [[0,50,152.71,1,0],[0.05,50,152.71,1,0],[0.1,50,152.71,1,1],[1.16,50,152.71,1,1],[1.21,50,151.6,1,1],[1.26,50,142.61,1,1],[1.36,50,126.48,1,1],[1.46,50,112.32,1,1],[1.57,50,100.12,1,1],[1.67,50,89.78,1,1],[1.77,50,81.03,1,1],[1.92,50,70.57,1,1],[2.02,50,65.39,1,1],[2.12,50,61.21,1,1],[2.27,50,56.9,1,1],[2.42,50,54.31,1,1],[2.73,50,52.71,1,1],[3.38,50,52.59,1,1],[3.53,50,51.11,1,1],[3.64,50,48.4,1,1],[3.69,50,46.31,1,1],[3.79,50,40.27,1,1],[3.89,50,31.4,1,1],[3.99,50,19.09,1,1],[4.04,50,11.45,1,1],[4.14,50,-5.91,1,1],[4.24,50,-20.2,1,1],[4.29,50,-25.99,1,1],[4.39,50,-34.85,1,1],[4.49,50,-40.76,1,1],[4.54,50,-42.86,1,1],[4.64,50,-45.69,1,1],[4.75,50,-46.92,1,1],[5,50,-47.29,1,1]] },
    buyer4: { size: [355,24], keys: [[0,50,158.13,1,0],[0.05,50,158.13,1,0],[0.1,50,158.13,1,1],[1.21,50,158.13,1,1],[1.26,50,156.53,1,1],[1.36,50,139.29,1,1],[1.41,50,131.53,1,1],[1.46,50,124.26,1,1],[1.57,50,111.08,1,1],[1.67,50,99.88,1,1],[1.77,50,90.39,1,1],[1.87,50,82.39,1,1],[1.97,50,75.86,1,1],[2.12,50,68.47,1,1],[2.22,50,64.9,1,1],[2.37,50,61.21,1,1],[2.47,50,59.73,1,1],[2.78,50,58.13,1,1],[3.48,50,57.76,1,1],[3.59,50,56.4,1,1],[3.69,50,53.69,1,1],[3.79,50,48.89,1,1],[3.84,50,45.57,1,1],[3.94,50,36.58,1,1],[3.99,50,30.79,1,1],[4.04,50,24.14,1,1],[4.09,50,16.5,1,1],[4.19,50,-0.99,1,1],[4.24,50,-8.5,1,1],[4.34,50,-20.81,1,1],[4.44,50,-29.56,1,1],[4.54,50,-35.47,1,1],[4.64,50,-39.16,1,1],[4.8,50,-41.5,1,1],[5,50,-41.87,1,1]] },
    buyer5: { size: [355,24], keys: [[0,50,163.55,1,0],[0.05,50,163.55,1,0],[0.1,50,163.55,1,1],[1.26,50,163.55,1,1],[1.31,50,161.58,1,1],[1.36,50,152.71,1,1],[1.46,50,136.58,1,1],[1.57,50,122.54,1,1],[1.67,50,110.47,1,1],[1.77,50,100.12,1,1],[1.87,50,91.5,1,1],[1.97,50,84.24,1,1],[2.07,50,78.45,1,1],[2.17,50,73.77,1,1],[2.27,50,70.2,1,1],[2.47,50,65.76,1,1],[2.68,50,63.92,1,1],[2.83,50,63.55,1,1],[3.53,50,63.18,1,1],[3.64,50,61.82,1,1],[3.74,50,58.99,1,1],[3.84,50,54.19,1,1],[3.89,50,50.74,1,1],[3.99,50,41.75,1,1],[4.09,50,29.19,1,1],[4.14,50,21.55,1,1],[4.24,50,4.06,1,1],[4.29,50,-3.45,1,1],[4.34,50,-9.98,1,1],[4.39,50,-15.64,1,1],[4.49,50,-24.26,1,1],[4.6,50,-30.17,1,1],[4.7,50,-33.74,1,1],[4.85,50,-36.08,1,1],[5,50,-36.45,1,1]] },
    img: { size: [953,1056], keys: [[0,50,150,0.3851,0],[0.05,50,150,0.3851,0],[0.1,50,150,0.3851,1],[0.15,50,150,0.3851,1],[0.2,50,148.89,0.3851,1],[0.25,50,140.02,0.3851,1],[0.35,50,123.77,0.3851,1],[0.45,50,109.61,0.3851,1],[0.56,50,97.41,0.3851,1],[0.61,50,92,0.3851,1],[0.76,50,78.33,0.3851,1],[0.91,50,67.86,0.3851,1],[1.06,50,60.47,0.3851,1],[1.21,50,55.42,0.3851,0.81],[1.31,50,53.2,0.3851,0.68],[1.41,50,51.6,0.3851,0.56],[1.57,50,50.37,0.3851,0.41],[1.97,50,49.38,0.3851,0.2],[4.04,50,44.33,0.3851,0.2],[4.64,50,42.86,0.3851,0.87],[5,50,42.01,0.3852,1]] },
    credit: { size: [149,47], keys: [[0,50,177.89,1,0],[0.05,50,177.89,1,0],[0.1,50,177.89,1,1],[0.15,50,177.89,1,1],[0.2,50,176.79,1,1],[0.3,50,159.54,1,1],[0.4,50,144.4,1,1],[0.51,50,131.22,1,1],[0.66,50,114.96,1,1],[0.76,50,106.22,1,1],[0.86,50,98.95,1,1],[0.96,50,93.04,1,1],[1.11,50,86.39,1,0.94],[1.26,50,82.08,1,0.74],[1.36,50,80.23,1,0.62],[1.52,50,78.63,1,0.46],[1.92,50,77.4,1,0.21],[4.04,50,72.23,1,0.2],[4.6,50,70.87,1,0.83],[4.8,50,70.38,1,0.96],[5,50,69.89,1,0.98]] },
  },
}
// where the sequence starts once the stage pins: the first name is already entering the view at the bottom
const START = 0.45
/** View heights of scroll the whole sequence takes once the stage pins. */
export const PICTURE_TRAIL_LENGTH = 5 - START

const DISPLAY = "font-heading text-[length:min(calc(var(--story-u)*23),25cqh)] leading-none font-normal tracking-[-0.03em] whitespace-nowrap max-[900px]:text-[length:calc(var(--story-u)*5.6)]"
const H2 = "font-heading text-[length:calc(var(--story-u)*6)] leading-[1.2] font-normal tracking-[-0.02em] max-[900px]:text-[length:calc(var(--story-u)*2.8)] max-[900px]:leading-none"
// every moving part sits at the stage's top left and is moved by translate + scale
const PART = "invisible absolute top-0 left-0 origin-center will-change-transform"

/** The layout for a box: a wide one from 900px across, else a tablet, or a phone below an aspect of 13 : 20. */
export function layoutFor(width: number, height: number): PictureTrailLayout {
  if (width >= 900) return "desktop"
  return width / Math.max(1, height) >= 13 / 20 ? "tablet" : "phone"
}

/** A track's values at `v` view heights after the stage pins, linearly between keys and held past either end:
 * [centre x, centre y, scale, opacity] into out. */
export function trackAt(track: Track, v: number, out: number[]) {
  const keys = track.keys
  let a = keys[0]!
  let b = a
  if (v >= keys[keys.length - 1]![0]!) a = b = keys[keys.length - 1]!
  else if (v > a[0]!) {
    let lo = 0
    let hi = keys.length - 1
    while (hi - lo > 1) {
      const m = (lo + hi) >> 1
      if (keys[m]![0]! <= v) lo = m
      else hi = m
    }
    a = keys[lo]!
    b = keys[hi]!
  }
  const f = b[0]! > a[0]! ? (v - a[0]!) / (b[0]! - a[0]!) : 0
  for (let c = 1; c <= 4; c++) {
    const from = a[c] ?? 1
    out[c - 1] = from + ((b[c] ?? 1) - from) * f
  }
  return out
}

/** Whether a timed part is on at `v` (inside one of its windows). */
export const isOn = (track: Track, v: number) => (track.on ?? []).some(([from, to]) => v >= from && v < to)

/** The nearest ancestor that scrolls vertically, or null when the page itself does. */
function scrollParent(node: HTMLElement): HTMLElement | null {
  for (let el = node.parentElement; el && el !== document.body && el !== document.documentElement; el = el.parentElement) {
    const { overflowY } = getComputedStyle(el)
    if (overflowY === "auto" || overflowY === "scroll") return el
  }
  return null
}

/**
 * A picture's trail through time, told as the reader scrolls on a pinned stage: a huge name crosses the view, the
 * holders pass in turn beside the picture, and a second huge name follows and stays faded behind it. Pins to its
 * nearest scroll container, or to the page. Only transform and opacity change as it scrolls; with reduced motion
 * it rests at its end.
 */
export function PictureTrail({
  top = "Conqueror",
  bottom = "Last ruler",
  holders = ["First dynasty", "Second dynasty", "Third dynasty", "Fourth dynasty", "Fifth dynasty", "Sixth dynasty"],
  image = {
    src: "https://picsum.photos/seed/mewo-picture-trail-1/1330/1478",
    credit: { title: "The siege", by: "c. 1258 CE", line: "A museum somewhere. Placeholder photograph.", href: "#" },
  },
  className,
  style,
  ...props
}: PictureTrailProps) {
  const stageRef = React.useRef<HTMLDivElement>(null)

  React.useEffect(() => {
    const stage = stageRef.current
    // The root's ref stays the consumer's, so the section is reached through the stage.
    const section = stage?.parentElement
    if (!stage || !section) return
    const scroller = scrollParent(section)
    const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false
    const parts = [...stage.querySelectorAll<HTMLElement>("[data-part]")]
    const out = [0, 0, 1, 1]
    type Bound = { el: HTMLElement; part: Part; w: number; h: number; y: number; h2: number; last: string }
    let bound: Bound[] = []
    let layout: PictureTrailLayout = "desktop"
    let width = 1
    let height = 1
    let near = true
    let raf = 0

    const measure = () => {
      // Inside a scroll container the stage pins to that container, not the window.
      if (scroller) section.style.setProperty("--mewo-viewport", `${scroller.clientHeight}px`)
      width = Math.max(1, stage.clientWidth)
      height = Math.max(1, stage.clientHeight)
      layout = layoutFor(width, height)
      bound = parts.map((el) => ({ el, part: el.dataset.part as Part, w: el.offsetWidth, h: el.offsetHeight, y: 0, h2: 0, last: "" }))
    }
    const update = () => {
      raf = 0
      const viewport = scroller ? scroller.clientHeight : window.innerHeight
      const top = section.getBoundingClientRect().top - (scroller ? scroller.getBoundingClientRect().top : 0)
      const v = START + (reduced ? PICTURE_TRAIL_LENGTH : -top / Math.max(1, viewport))
      const set = TRACKS[layout]
      const vw = width / 100
      const vh = height / 100
      let picture: Bound | undefined
      for (const b of bound) {
        const track = set[b.part]
        if (!track) continue
        if (track.on) {
          // a timed part sits still and fades by its windows
          const on = isOn(track, v)
          if (on !== b.el.hasAttribute("data-on")) b.el.toggleAttribute("data-on", on)
        }
        const [cx, cy, s, o] = trackAt(track, v, out) as [number, number, number, number]
        const x = cx * vw - b.w / 2
        let y = cy * vh - b.h / 2
        // the credit hangs under the picture, whatever the box's shape
        if (b.part === "credit" && picture) y = picture.y + (picture.h * (1 + picture.h2)) / 2 + vh
        if (b.part === "img") {
          picture = b
          b.y = y
          b.h2 = s
        }
        const key = `${x.toFixed(1)}|${y.toFixed(1)}|${s.toFixed(4)}|${o.toFixed(3)}`
        if (key === b.last) continue
        b.last = key
        // position through translate, so a CSS scale on the part (the timed line's entrance) scales it about its centre
        b.el.style.translate = `${x.toFixed(2)}px ${y.toFixed(2)}px`
        b.el.style.transform = `scale(${s.toFixed(4)})`
        if (track.on) continue
        b.el.style.opacity = o.toFixed(3)
        b.el.style.visibility = o < 0.002 ? "hidden" : "visible"
      }
    }
    const schedule = () => {
      if (!raf && near) raf = requestAnimationFrame(update)
    }
    const remeasure = () => {
      measure()
      schedule()
    }

    measure()
    schedule()
    const resizeObserver = new ResizeObserver(remeasure)
    resizeObserver.observe(stage)
    if (scroller) resizeObserver.observe(scroller)
    document.fonts?.ready.then(remeasure)
    // far from view it doesn't measure on every scroll (against the window, so a scroll box leaving the page counts)
    const intersection = new IntersectionObserver(
      (entries) => {
        near = entries.some((entry) => entry.isIntersecting)
        if (near) {
          for (const b of bound) b.last = ""
          schedule()
        }
      },
      { rootMargin: "50% 0px" }
    )
    intersection.observe(section)
    const target = scroller ?? window
    target.addEventListener("scroll", schedule, { passive: true })
    return () => {
      cancelAnimationFrame(raf)
      resizeObserver.disconnect()
      intersection.disconnect()
      target.removeEventListener("scroll", schedule)
    }
  }, [holders.length])

  const alt = image.alt ?? `${image.credit.title}, ${image.credit.by}`
  return (
    <section
      data-slot="picture-trail"
      className={cn(
        "[--story-u:min(2.6667cqw,12px)] min-[901px]:[--story-u:0.625cqw] min-[901px]:@max-[900px]:[--story-u:min(2.6667cqw,12px)] text-[length:var(--story-u)]",
        "relative h-[calc(var(--mewo-viewport,100vh)*var(--mewo-length))]",
        className
      )}
      style={{ "--mewo-length": PICTURE_TRAIL_LENGTH + 1, ...style } as React.CSSProperties}
      {...props}
    >
      <div ref={stageRef} data-slot="picture-trail-stage" className="sticky top-0 h-[var(--mewo-viewport,100vh)] w-full overflow-hidden [container-type:size]">
        <p data-part="top" className={cn(PART, DISPLAY)}>
          {top}
        </p>
        <p data-part="bottom" className={cn(PART, DISPLAY)}>
          {bottom}
        </p>
        {holders.slice(0, 6).map((h, i) => (
          <p
            key={i}
            data-part={`buyer${i}`}
            className={cn(
              PART,
              "w-[calc(var(--story-u)*45)] max-w-full max-[900px]:w-[calc(100cqw-calc(var(--story-u)*2))] max-[900px]:text-center max-[900px]:text-[length:calc(var(--story-u)*2.4)] max-[900px]:leading-none max-[900px]:whitespace-nowrap",
              H2
            )}
          >
            {h}
          </p>
        ))}
        <div data-part="img" className={cn(PART, "aspect-[2000/2215] w-[100cqw] max-[900px]:h-[130cqh] max-[900px]:w-auto")}>
          {/* eslint-disable-next-line @next/next/no-img-element -- items cannot use next/image */}
          <img src={image.src} alt={alt} decoding="async" className="absolute inset-0 size-full object-cover" />
        </div>
        <p data-part="credit" className={cn(PART, "grid w-[calc(var(--story-u)*19.8)] gap-[calc(var(--story-u)*0.5)] text-center max-[900px]:w-[calc(var(--story-u)*15)]")}>
          <span className="text-[length:max(12px,calc(var(--story-u)*1.2))] leading-[1.2] max-[900px]:text-[length:calc(var(--story-u)*1)]">
            <a href={image.credit.href} target="_blank" rel="noopener noreferrer" className="pointer-events-auto">
              <em className="not-italic underline underline-offset-[0.15em]">{image.credit.title}</em>
            </a>
            , {image.credit.by}
          </span>
          <span className="text-[10px] leading-[1.25] text-muted-foreground">{image.credit.line}</span>
        </p>
      </div>
    </section>
  )
}