# Kontroller og egenskaber

Hvorfor filmens egenskaber (props) er JSON, hvordan defaultProps og controls lader en vært vise et egenskabspanel, og hvad element-id’er bruges til.

Canonical: https://zxn.studio/da/motion/controls-and-props

En films input er dens egenskaber, også kaldet props. De er almindelig JSON, har standardværdier og er beskrevet godt nok til, at en vært kan vise et egenskabspanel til dem. Gør du det rigtigt, kan et menneske, der aldrig har åbnet filen, ændre navnet på en navnebjælke, vælge en side og rendere den.

## Egenskaber er JSON, og det er en regel

En films props gemmes i projektfiler, flettes ind fra en fil på kommandolinjen (`--props ./props.json`) og sendes mellem processer — editoren, rendererens skjulte vindue og CLI'et. Hele rejsen foregår gennem `JSON.stringify`. Derfor skal props kunne overleve den.

`packages/core/src/types.ts` angiver præcis, hvad det betyder:

```ts
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[];
export type JsonObject = { readonly [key: string]: JsonValue };
```

`defineFilm<Props extends JsonObject>` begrænser dine props til dette og kontrollerer det også ved kørsel — både når filmen defineres og igen ved hvert `render()`. Følgende er udelukket:

- **Funktioner.** Ingen render-callbacks, ingen indsendt easing-funktion, ingen formateringsfunktion. En film tager data og bestemmer, hvad den vil gøre med dem.
- **Datoer.** En `Date` overlever `stringify` som en streng og vender tilbage som en streng, hvilket er en fejl, der blot venter på en tidszone. Send et tal eller en ISO-streng, og fortolk det i filmen.
- **Klasseinstanser.** `defineFilm` afviser objekter, hvis prototype hverken er `Object.prototype` eller `null`, fordi klassen ikke følger med.
- **`undefined`, `NaN` og `Infinity`.** Kun endelige tal; brug `null` til »ingenting«.
- **Cirkulære referencer.** Afvises med en navngiven fejl i stedet for at hænge.
- **React-elementer.** De er objekter med funktioner i. Sammensæt inde i filmen, ikke gennem props.

Det giver en tydelig fejl i stedet for lydløst at kassere en nøgle, og det er pointen: En prop, der forsvandt mellem forhåndsvisning og eksport, er den værste slags fejl at finde ved slutningen af en rendering.

## Standardværdier med defaultProps

Hver prop skal have en standardværdi, og det kræver typen — `defaultProps: Props`, ikke `Partial<Props>`. Der er to grunde. En film skal kunne renderes helt uden input, fordi et miniaturebillede, en test og en agent, der undersøger den, alle vil gøre netop det. Og `render()` fletter: `{ ...defaultProps, ...inputProps }`, så en vært kan nøjes med at sende de nøgler, et menneske har ændret.

Lad standardværdierne give en god film. Det er det, man ser, første gang filmen tilføjes, og en standardværdi på `""` eller `0` ser defekt ud frem for tom.

## Redigeringskontroller med controls

`controls` er et valgfrit map fra propnavn til en beskrivelse af, hvordan den redigeres. Hele typen:

```ts
type FilmControl = Readonly<{
  type: "text" | "number" | "color" | "boolean" | "choice";
  label: string;
  min?: number;
  max?: number;
  step?: number;
  options?: readonly string[];
}>;
```

Fem typer, ét påkrævet `label`, og resten gælder, hvor det giver mening:

| Type | Proptype | Ekstra felter | Vises som |
| --- | --- | --- | --- |
| `text` | `string` | — | Et tekstfelt. |
| `number` | `number` | `min`, `max`, `step` | En skyder eller talkontrol med grænser. |
| `color` | `string` | — | En farveprøve og farvevælger. |
| `boolean` | `boolean` | — | En kontakt. |
| `choice` | `string` | `options` | En menu med `options`. |

Der findes bevidst ingen `object`- eller `array`-kontrol. En kontrol er noget, et menneske kan justere; en liste af datapunkter er ikke.

**Hver prop bør have en kontrol.** Kontrollerne udgør hele forskellen mellem en film, en redaktør kan bruge, og en film, kun dens forfatter kan ændre. En prop uden kontrol er usynlig i egenskabspanelet, så den kan kun ændres ved at redigere kilden eller skrive en props-fil manuelt — fint for en beregnet konstant, men forkert for noget, et menneske ville justere.

`describeFilm(film)` viderefører `controls` til den `FilmDescriptor`, værten modtager. ZXN Studios egenskabspanel læser dette map, tegner én række pr. post i objektets deklarationsrækkefølge og sender et delvist props-objekt tilbage med kun det ændrede. `min`, `max` og `step` afgrænser skyderen, så en film ikke skal forsvare sig mod et billedantal på -3. Intet i core tegner nogen brugergrænseflade: Core beskriver, værten bestemmer.

## En navnebjælke, forstået gennem dens egenskaber

@video(lower-third)

`examples/recipes/lower-third.tsx` er det tilfælde, kontrollerne er skabt til: en navnebjælke, som en producer vil omskrive til hvert interview uden nogensinde at ville åbne en kodeeditor.

```tsx
export const lowerThirdFilm = defineFilm({
  id: "lower-third",
  title: "Lower third",
  width: 1920,
  height: 1080,
  frameRate: 30,
  frames: 150,
  component: LowerThird,
  defaultProps: { name: "Ada Okafor", role: "Lead Animator", accent: "#2ec4b6", side: "left" },
  controls: {
    name: { type: "text", label: "Name" },
    role: { type: "text", label: "Role" },
    accent: { type: "color", label: "Accent" },
    side: { type: "choice", label: "Side", options: ["left", "right"] },
  },
});
```

Fire props, fire kontroller, ingen huller.

`side` er et `choice` frem for en boolesk værdi kaldet `onTheRight`, fordi etiketten i egenskabspanelet så viser »Side: venstre / højre« i stedet for at bede nogen regne ud, hvad falsk betyder. Proppens type er `string` — et `choice` er altid en streng, og filmen sammenligner den:

```tsx
  const direction = side === "right" ? 1 : -1;
```

Baggrunden er gennemsigtig. `<FullFrame>` sætter her kun layout og indvendig afstand, ingen `background`, så bjælken kan ligge over videomateriale i en redigering, når den renderes til et format med alfakanal:

```tsx
    <FullFrame style={{ alignItems: "flex-end", justifyContent: side === "right" ? "flex-end" : "flex-start", padding: "0 120px 130px" }}>
```

Og hele tekstens forløb — indgang, pause, udgang — er ét `mapRange` med fire støttepunkter:

```tsx
  const text = mapRange(frame, [8, 22, last - 20, last - 8], [0, 1, 1, 0], { clamp: true, ease: easing.easeOut });
```

`mapRange` tager tilsvarende lister med to eller flere støttepunkter, så fire inputpunkter og fire outputpunkter beskriver stigning, pause og fald på én linje. Tidsforløbet er læselig med et blik, og der er ingen kæde af indlejrede ternære udtryk at lave fejl i. Bemærk, at de sidste to støttepunkter udledes af `film.frames` i stedet for at være skrevet som faste værdier: Ændr filmens længde, og udgangen lander stadig ved slutningen.

## Numeriske kontroller

@video(elapsed-clock)

`examples/recipes/elapsed-clock.tsx` bevæger et ur fra ét klokkeslæt til et andet. Begge tidspunkter er tal og får derfor grænser:

```tsx
  controls: {
    from: { type: "number", label: "From (hours)", min: 0, max: 24, step: 0.25 },
    to: { type: "number", label: "To (hours)", min: 0, max: 48, step: 0.25 },
    face: { type: "color", label: "Face" },
    accent: { type: "color", label: "Accent" },
    caption: { type: "text", label: "Caption" },
  },
```

`step: 0.25` afspejler proppens betydning: Timerne har en decimaldel, og et kvarter er den enhed, man faktisk ville vælge. `max: 48` på `to` og `24` på `from` tillader en bevægelse forbi midnat uden at tillade et starttidspunkt, der ikke findes. Etiketterne angiver »(timer)«, fordi `from` og `to` alene kunne være hvad som helst.

Filmen dokumenterer også enheden på typen, hvor den, der læser koden, vil kigge:

```tsx
  /** 24-hour start and end, as hours with a decimal part: 9.5 is 09:30. */
  readonly from: number;
  readonly to: number;
```

## Element-id'er

To dataattributter markerer de dele af en film, et menneske kan ønske at styre enkeltvis:

- `data-zxn-element-id` — et stabilt id, unikt i filmen.
- `data-zxn-name` — en læsbar etiket til en liste i værtens brugergrænseflade.

```tsx
      <div data-zxn-element-id="strap" style={{ display: "flex", alignItems: "stretch", transform: `translateX(${direction * ((1 - slide) * 140 + leave * 140)}px)`, opacity: Math.min(slide * 1.4, 1 - leave) }}>
        <div data-zxn-element-id="rule" style={{ width: 12, background: accent, borderRadius: 6, transform: `scaleY(${slide})`, transformOrigin: "50% 100%" }} />
```

En vært, der kender disse id'er, kan lade nogen klikke på stillingslinjen i forhåndsvisningen og sætte nøglebilleder for dens opacitet eller flytte bjælken to billeder senere uden at røre andet. Uden dem er filmen ét uigennemsigtigt rektangel.

To ting gør et id nyttigt:

**Stabilt.** Samme element har samme id på hvert billede og på tværs af renderinger. `bar-${name}` i søjlediagramræset er knyttet til dataene, ikke en arrayposition, så en søjle, der overhaler en anden, beholder sit id. Et id udledt af et løkkeindeks ville overdrage en persons nøglebilleder til en anden søjle, så snart rækkefølgen ændrede sig.

**Meningsfuldt.** Sæt dem på de ting, et menneske ville pege på: titlen, bjælken, stregen, en søjle eller en fremhævelse. Omkring et dusin pr. film er den rigtige størrelsesorden.

**Sæt dem ikke på partikler.** Et regnfelt, et stjernefelt, et støjfelt — alt, der tegnes i en løkke med hundredvis af elementer — får ingen id'er. Ingen vil vælge regndråbe nummer fire hundrede, attributterne koster DOM på hvert billede, og sådan et felt bør under alle omstændigheder være ét `<Canvas2D>`, hvor der ikke er elementer at mærke. Se [Tegn på canvas](/da/motion/canvas) og [Ydeevne](/da/motion/performance).

## Et kort med alle fem kontroller

Hver prop her kan justeres, og filmen udleder alt af billednummeret.

```tsx
import { FullFrame, defineFilm, easing, mapRange, springValue, useTimeline } from "@zxn/motion-core";

/*
 * A stat card: one number, said well.
 *
 * Every prop has a control, so the whole card is editable from an inspector --
 * the wording, the figure, the colour, whether the change chip is shown, and
 * which corner it sits in. Nothing is a constant that only the author can reach.
 */

type Props = {
  readonly label: string;
  readonly value: number;
  readonly unit: string;
  readonly change: number;
  readonly accent: string;
  readonly showChange: boolean;
  readonly corner: string;
};

function StatCard({ label, value, unit, change, accent, showChange, corner }: Props) {
  const { frame, film } = useTimeline();
  const last = film.frames - 1;
  const rise = springValue({ frame, frameRate: film.frameRate, stiffness: 140, damping: 18, clamp: true });
  const leave = mapRange(frame, [last - 14, last], [0, 1], { clamp: true, ease: easing.easeIn });
  // The figure counts up, then holds long enough to be read.
  const count = mapRange(frame, [10, 58], [0, value], { clamp: true, ease: easing.easeOut });
  const chip = mapRange(frame, [52, 68], [0, 1], { clamp: true, ease: easing.easeOut });
  const onRight = corner === "right";

  return (
    <FullFrame style={{ background: "#0f1117", alignItems: "center", justifyContent: onRight ? "flex-end" : "flex-start", padding: "0 160px", fontFamily: "Inter, system-ui, sans-serif" }}>
      <div
        data-zxn-element-id="card"
        data-zxn-name="Stat card"
        style={{
          width: 760, padding: "56px 64px 62px", borderRadius: 32,
          background: "rgba(255,255,255,.04)", border: `2px solid ${accent}`,
          opacity: Math.min(rise, 1 - leave),
          transform: `translateY(${(1 - rise) * 60 + leave * 40}px)`,
        }}
      >
        <div data-zxn-element-id="label" style={{ fontSize: 38, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "rgba(243,239,231,.55)" }}>{label}</div>
        <div data-zxn-element-id="figure" style={{ marginTop: 18, display: "flex", alignItems: "baseline", gap: 14, color: "#f3efe7" }}>
          <span style={{ fontSize: 190, fontWeight: 900, letterSpacing: "-0.05em", lineHeight: 1, fontVariantNumeric: "tabular-nums" }}>{Math.round(count)}</span>
          <span style={{ fontSize: 76, fontWeight: 700, color: accent }}>{unit}</span>
        </div>
        {showChange ? (
          <div
            data-zxn-element-id="change"
            data-zxn-name="Change chip"
            style={{
              marginTop: 26, display: "inline-flex", alignItems: "center", gap: 12,
              padding: "12px 26px", borderRadius: 999, fontSize: 36, fontWeight: 700,
              background: change < 0 ? "rgba(255,111,97,.16)" : "rgba(46,196,182,.16)",
              color: change < 0 ? "#ff6f61" : "#2ec4b6",
              opacity: chip, transform: `translateY(${(1 - chip) * 16}px)`,
            }}
          >
            <span>{change < 0 ? "▼" : "▲"}</span>
            <span style={{ fontVariantNumeric: "tabular-nums" }}>{Math.abs(change)}%</span>
            <span style={{ fontWeight: 500, opacity: 0.75 }}>on last quarter</span>
          </div>
        ) : null}
      </div>
    </FullFrame>
  );
}

export const films = [defineFilm({
  id: "stat-card",
  title: "Stat card",
  width: 1920,
  height: 1080,
  frameRate: 30,
  frames: 120,
  component: StatCard,
  defaultProps: { label: "Renders this month", value: 1284, unit: "clips", change: 23, accent: "#7a6cff", showChange: true, corner: "left" },
  controls: {
    label: { type: "text", label: "Label" },
    value: { type: "number", label: "Value", min: 0, max: 100000, step: 1 },
    unit: { type: "text", label: "Unit" },
    change: { type: "number", label: "Change (%)", min: -100, max: 100, step: 1 },
    accent: { type: "color", label: "Accent" },
    showChange: { type: "boolean", label: "Show change" },
    corner: { type: "choice", label: "Corner", options: ["left", "right"] },
  },
})];
```

@video(stat-card)

Syv props, syv kontroller. `change` har fortegn, og filmen vælger både pil og farve ud fra det, så ét tal rummer hele idéen; en separat boolesk `isNegative` ville være en anden sandhedskilde, der skulle holdes i takt. `showChange` er boolesk, fordi den faktisk har to tilstande; `corner` er et valg, fordi »venstre eller højre« har navne.

## Egenskaber på kommandolinjen

CLI'et fletter en JSON-fil oven på standardværdierne, så samme film renderer hundrede kort fra hundrede filer:

```bash
zxn-motion render ./films.tsx stat-card ./out/q3.mp4 --props ./q3.json
```

```json
{ "label": "Renders in Q3", "value": 4102, "change": -6, "corner": "right" }
```

Kun de nøgler, du sender, tilsidesættes. Filen skal være et JSON-objekt, og en værdi, der ikke er JSON, giver en fejl frem for en automatisk typekonvertering.

## Næste skridt

- [Kompositions-API](/da/motion/composition-api): den fulde reference til film, tid og animationsmatematik.
- [Brug af pakker](/da/motion/using-packages): styr d3, GSAP og lignende ud fra billednummeret.
- [Film om brugergrænseflader](/da/motion/interface-films): scene, kamera og markør, hvor id'er får endnu en opgave.
- [Opskrifter](/da/motion/recipes): alle eksempler herfra i deres fulde længde.

