Zum Inhalt springen

Brennplan für Keramiköfen

Die Kegel-10-Stücke eines Mitglieds sind fertig. Brennen Sie sie im einzigen Ofen, der es kann, wenn jemand ausräumen kann, ohne das Energiebudget zu sprengen.

  • Keramikwerkstatt
  • Cone Ten
  • Externes Drag & Drop
  • React-Render-Slots
  • Ziehen und Dauer ändern
  • Verschieberegeln
  • Abhängigkeiten
  • Aktive Bereiche
  • Summenzeilen
  • Minimap
  • Theming
  • Demo mit synthetischen Daten
Quellcode laden.zip

Aufgabe

0 von 3 Schritten erledigt

Mittwoch, 10:15 Uhr. Das Steinzeug-Geschirr von Inés ist geschrüht und glasiert: Es braucht Kegel 10, reduzierend.

  1. Räumen Sie das Kegel-10-Geschirr von Inés in Dragon ein, den Gasofen. — Offen

    Ziehen Sie den Aufkleber INÉS vom Regal in die Zeile von Dragon, hinter den Brand, der dort abkühlt. Pip und Bess lehnen ab: Sie schaffen nur Kegel 6 und Kegel 8. Oder drücken Sie EINRÄUMEN am Aufkleber.

  2. Sorgen Sie dafür, dass jemand da ist, wenn ausgeräumt werden kann. — Offen

    Heute Nachmittag eingeräumt, kühlt er mitten in der Nacht ab. Ziehen Sie ihn auf Donnerstag 08:00 oder wählen Sie per Rechtsklick Nächster sauberer Start.

  3. Bringen Sie die Woche zurück unter ihr Energiebudget von 590 €. — Offen

    Der Schrühbrand des Mittwochskurses in Bess heizt mitten im Hochtarif von 17 bis 21 Uhr auf, während auch Pip aufheizt. Ziehen Sie ihn auf Donnerstag 08:00 oder wählen Sie per Rechtsklick Günstigster Start.

Etwa 30 Sekunden. Erledigte Schritte werden abgehakt.

Tastatur: Raster fokussieren, dann Pfeiltasten, Eingabe und Alt+Pfeile zum Verschieben.

Cone Ten
PipC6 · 85 LBessC8 · 220 LDragonC11 · 700 LEmberC06 · 60 LKaiBecher · 18NoorSchalen · 12NoorSchalen · 12MargitFliesen · 40LiorKrüge · 8LiorKrüge · 8OluVasen · 6Kurs Mi.Schalen · 32AdePlatten · 5SolTassen · 20RuthTeekannen · 7Kurs Sa.Teller · 26BrennerwartungLadenChawans · 8PiaVasen · 4MeiTeebecher · 6LadenTeebecher · 107031626171033300017181192035117003511910181870003511700StromkWh pro StundeMittwoch, 21. OktoberDonnerstag, 22. OktoberFreitag, 23. OktoberSamstag, 24. OktoberSonntag, 25. OktoberMontag, 26. OktoberDienstag, 27. OktoberMittwoch, 28. Oktober0004081216200004081216200004081216200004081216200004081216200004081216200004081216200004

Vorschau des ersten BildesLive: die echte Pro-Engine, in Ihrem BrowserEtwa 30 Sekunden. Erledigte Schritte werden abgehakt. Tastatur: Raster fokussieren, dann Pfeiltasten, Eingabe und Alt+Pfeile zum Verschieben.

Die Ausgangslage.

Cone Ten ist eine fiktive offene Keramikwerkstatt mit vier Öfen: zwei Elektroöfen für Schrühbrand und Glasurbrand bis Kegel 6, ein Gas-Herdwagenofen für den Reduktionsbrand bis Kegel 10 und eine Raku-Trommel. Mitglieder stellen ihre Rohware ins Regal, die Werkstatt brennt sie. Ein Brand ist kein Zeitblock, sondern eine Kurve: Das Programm heizt den Ofen bis zu seinem Kegel auf, hält dort und danach kühlt der Stein von selbst ab, bis der Ofen unter 200 °C geöffnet werden darf.

Genau diese Kurve muss sehen, wer plant. Die Abkühlung hängt an der Masse des Ofens und lässt sich nicht verkürzen; jemand muss da sein, um einzuräumen und auszuräumen; ein Schrühbrand muss einen Tag vor seinem Glasurbrand aus dem Ofen sein; und Strom kostet zwischen 17 und 21 Uhr zweieinhalbmal so viel, bei einer Vertragsleistung, die die Hauptsicherung auslöst, wenn beide Elektroöfen gleichzeitig aufheizen. Dieses Board zeichnet jeden Brand als eigene Temperaturkurve, lehnt ab, was die Öfen nicht können, und behält die Energierechnung im Blick.

Was Sie tun werden

Sie räumen eine Kegel-10-Charge in den Gasofen ein, verschieben sie so, dass jemand sie ausräumen kann, und bringen die Woche zurück ins Energiebudget, indem Sie einen Schrühbrand aus dem Hochtarif nehmen.

Wo die Bibliothek endet und Ihre Anwendung beginnt.

SuperScheduler liefert

  • Ereignisinhalte aus React-Render-Slots (renderEvent), mit demselben Markup als HTML-Fallback: Jeder Brand zeichnet seine Temperaturkurve über die exakte Spanne des Ereignisses, sodass Spitze und Ende der Abkühlung unter ihrer eigenen Uhrzeit im Kopf stehen.
  • Externes Ziehen (SuperScheduler.Scheduler.makeDraggable): Chargen wandern vom Regal in einen Ofen; onEventMoving passt den Schatten an das Programm des Ofens unter dem Zeiger an und lehnt mit args.message ab, was dieser Ofen nicht brennen kann.
  • Verschieben und Ändern der Dauer, Bild für Bild gesteuert: onEventMoving und onEventResizing runden das Einräumen auf die Viertelstunde, lassen die Endkante nur die Haltezeit ändern und verweigern eine kürzere Abkühlung; die Ziehkarte (dragCard.html) zeigt Einräumen, Spitze, Ausräumen und Kosten.
  • Zeitverankerte Bereiche (der Kegel auf seiner Spitze, die Temperatur zur Werkstattzeit, Warnstreifen über einer Überlast, eine Fahne an einem Ausräumen ohne Personal) und Hover-Bereiche (visibility: 'Hover') als große Pillen.
  • Eine fixierte Summenzeile, gezeichnet von onBeforeCellRender, drei Kopfzeilen aus onBeforeTimeHeaderRender (der Tag, die Öffnungszeiten als Kopfbereiche, die Uhrzeit), Schattierung außerhalb der Arbeitszeit, Trenner um Mitternacht und zur Werkstattzeit und das volle Fadenkreuz.
  • Ende-Anfang-Verknüpfungen, Zoomstufen von Stunden bis zum Monat, die Minimap, Rückgängig-Verlauf, Verschieben per Tastatur, die Hover-Karte (eventHover) und ein Kontextmenü im Look des Produkts.

Der Code dieses Beispiels entscheidet

  • Die Physik eines Brandes: das Aufheizen jedes Programms, die Haltezeit und die natürliche Abkühlung von der Kegelspitze bis 200 °C, bestimmt von der Wärmemasse jedes Ofens.
  • Was ein Ofen aufnimmt: seinen Kegel, seine Programme und sein Volumen; und was die Werkstatt braucht: jemanden, der zwischen 08:00 und 20:00 ein- und ausräumt, und einen Tag zwischen einem Schrühbrand und dem nächsten Brand.
  • Die Energiebilanz: jede Viertelstunde des Monats, der Strom zu seinem Tarif, Gas und Propan, die Vertragsleistung von 22 kW, die Rechnung der Woche und die Tagesreihe für die Minimap.
  • Die Befehle: nächster sauberer Start, günstigster Start, eine Stunde mehr Haltezeit, jetzt brennen und zurück ins Regal, jeweils als ein Rückgängig-Schritt gespeichert; ein später gelegter Schrühbrand nimmt seinen Glasurbrand mit.
  • Die Kurvenkarten, die Aufkleber im Regal, die Ofenköpfe mit dem, was jeder Ofen zur Werkstattzeit tut, und die Zahlen in den Leisten.

Der Code hinter dieser Demo.

Das sind die Dateien, die diese Seite ausführt, keine vereinfachte Kopie. Sie kompilieren gegen die öffentlichen Exporte von SuperScheduler Pro.

tsx.zip

Die Integration: Zoomstufen mit drei Kopfzeilen, rowHeaderColumns, die fixierte Energiezeile, Trenner, Arbeitszeiten, aus den Daten gebaute Verknüpfungen, renderEvent, die Bereiche jeder Karte, das Zieh-Urteil, das Kontextmenü und die Callbacks, in denen die Regeln der Werkstatt einen Brand ablehnen, anpassen oder verlängern.

/**
 * Cone Ten kiln board: four kilns over October, a frozen energy row on top, firings drawn as their
 * own temperature curves, and greenware dragged in from the shelf.
 *
 * Library: row-header columns written by onBeforeRowHeaderRender, a frozen summary row painted by
 * onBeforeCellRender, three time-header rows (day, open hours as header areas, hours) written by
 * onBeforeTimeHeaderRender, zoom levels from hours to the month, business-hours shading
 * (businessBeginsHour, businessEndsHour, cellsMarkBusiness), separators, the full crosshair,
 * events at exact times with React render slots (renderEvent, HTML fallback), time-anchored and
 * hover areas, external drag (SuperScheduler.Scheduler.makeDraggable, see ./shelf), drag and
 * resize steered frame by frame (onEventMoving, onEventResizing: refusals, the end fitted to the
 * kiln under the pointer), finish-to-start links, conflict highlighting, keyboard moves, undo
 * history, the event hover card and the drag card (super-scheduler/react-render), and a context
 * menu. Application: what a kiln can fire, the physics of a firing (ramp, hold, natural cooling),
 * staffed hours, bisque before glaze, the electricity tariff, the contract and the energy figures.
 * Those rules live in ./rules; ./board-paint and ./firing-card write the markup.
 */
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerEventsChangeArgs } from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { SuperSchedulerComponent, type SchedulerRenderProps } from 'super-scheduler/react-render'
import { format, plural } from '~/i18n/format'
import { DAY, HOUR, MINUTE, civil, parseCivil } from '../../scheduler/civil'
import { createEventMenu, type MenuEntry } from '../kit/context-menu'
import { formatSpan } from '../kit/format'
import { defaultHints, type HoverFact } from '../kit/hover-card'
import { KEYS } from '../kit/keys'
import { useDropRefusal } from '../kit/use-drop-refusal'
import { createDragCard } from '../kit/verdict'
import { skinClass, skinMenuTheme } from '../registry'
import type { DemoCommon } from '../types'
import {
  BOARD,
  ENERGY_ROW,
  firingLabel,
  isKilnRow,
  readFiringFields,
  toResources,
  type FiringFields,
  type KilnEvent,
} from './adapter'
import {
  LIMIT_SHARE,
  clock,
  coneHtml,
  coneLabel,
  createHeaderPainter,
  energyHeader,
  energyKey,
  energyPainter,
  kilnHtml,
  volumeHtml,
  when,
} from './board-paint'
import {
  FiringCard,
  cardHtml,
  cardSize,
  curvePaths,
  phaseOf,
  stepsHtml,
  useKilnHover,
  yOf,
  type CardParts,
  type KilnCardModel,
} from './firing-card'
import {
  CONE_PEAK,
  CONTRACT_KW,
  INITIAL_VIEW_START,
  KILNS,
  NOW,
  PLAN,
  PROGRAMS,
  SERVICES,
  SHELF,
  batchById,
  firingId,
  kilnById,
  type Firing,
  type FiringKind,
} from './model'
import {
  NOW_TICKS,
  cascadeOf,
  cheapestStart,
  extendHold,
  fireNow,
  firingCost,
  holdRefusal,
  isStaffed,
  kilnStateAt,
  moveRefusal,
  nextCleanStart,
  nextStaffed,
  ledgerWithout,
  overloadOf,
  overloadWith,
  partnerOf,
  placed,
  profileOf,
  snapHoldEnd,
  temperatureAt,
  unloadAt,
  earliestFinish,
  type Issue,
  type Ledger,
  type Placement,
  type Refusal,
} from './rules'
import type { ConeTenText, ZoomId } from './text'
import 'super-scheduler/styles.css'
import '../../scheduler/skin.css'

const SLUG = 'ceramics-kilns'
const STEP = 15 * MINUTE

/** What the board asks the demo to do (context menu, hover buttons, keyboard). */
export type FiringAction = 'details' | 'hold' | 'fire-now' | 'next' | 'cheapest' | 'shelf'

export interface ConeTenSchedulerProps {
  readonly events: KilnEvent[]
  /** The same firings as business objects, for rules that look at the others. */
  readonly firings: readonly Firing[]
  readonly ledger: Ledger
  readonly issues: readonly Issue[]
  readonly text: ConeTenText
  readonly common: DemoCommon
  /** BCP 47 tag for labels, and the scheduler locale id. */
  readonly tag: string
  readonly schedulerLocale: string
  /** Level on mount. Later changes go through control.zoom so the view animates from where it is. */
  readonly initialZoom: ZoomId
  /** The firing shown in the side sheet: it gets the selection frame. */
  readonly selected: string | null
  readonly history: SchedulerHistory
  readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  readonly onSelect: (id: string) => void
  /** `external`: the batch came from the shelf. */
  readonly onMoved: (id: string, external: boolean) => void
  readonly onResized: (id: string) => void
  readonly onRefused: (id: string, message: string) => void
  /** A bisque moved later and its finish follows: one state change, one undo step. */
  readonly onCascade: (firing: Firing, placement: Placement, follower: Firing) => void
  readonly onAction: (action: FiringAction, id: string) => void
  readonly onZoomLevel: (level: ZoomId) => void
  /** Narrow stages keep the kiln column only. */
  readonly narrow: boolean
}

/**
 * Three day-wide rows: the day, its open hours, its clock hours. The hours are areas of a day cell
 * (every hour, three or six, as the density allows), so the level-of-detail planner never has to
 * coarsen an hour row into days at the week's 8 px per hour.
 */
const HOURLY: SuperScheduler.TimeHeaderData[] = [
  { groupBy: 'Day', height: BOARD.header.day },
  { groupBy: 'Day', height: BOARD.header.band },
  { groupBy: 'Day', height: BOARD.header.hour },
]

/** Hours to plan a load to the quarter, the week by default, the month to see the energy. */
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  { id: 'hours', properties: { scale: 'Hour', cellWidth: 30, timeHeaders: HOURLY } },
  { id: 'week', properties: { scale: 'Hour', cellWidth: BOARD.hourWidth, timeHeaders: HOURLY } },
  {
    id: 'month',
    properties: {
      scale: 'Day',
      cellWidth: 40,
      timeHeaders: [
        { groupBy: 'Week', height: BOARD.header.day },
        { groupBy: 'Day', height: BOARD.header.band },
        { groupBy: 'Day', height: BOARD.header.hour, format: 'd' },
      ],
    },
  },
]
const ZOOM_IDS: readonly ZoomId[] = ['hours', 'week', 'month']

/** Pixels per hour at the control's current scale. */
function pxPerHour(control: SuperScheduler.Scheduler | SuperScheduler.SchedulerApi | null): number {
  if (control === null) return BOARD.hourWidth
  const width = control.cellWidth ?? BOARD.hourWidth
  return control.scale === 'Day' ? width / 24 : width
}

/** The big pills that appear over a booked firing (areas with visibility 'Hover'). */
function pill(
  label: string,
  tip: string,
  right: number,
  width: number,
  run: () => void,
  primary = false,
): SuperScheduler.AreaData {
  return {
    right,
    // At the foot of the cooling tail: the middle of the card stays free to grab and drag.
    bottom: 7,
    width,
    height: 24,
    visibility: 'Hover',
    action: 'None',
    cssClass: primary ? 'ck-pill ck-pill--primary' : 'ck-pill',
    html: `<span>${SuperScheduler.Util.escapeHtml(label)}</span>`,
    toolTip: tip,
    onClick: (args) => {
      args.preventDefault()
      run()
    },
  }
}

/** Rough width of a card's owner label (13 px Geist 900, uppercase) with its inset. */
function labelWidth(owner: string): number {
  return owner.length * 9.6 + 14
}

/**
 * True when the cone can be flagged on the peak: the owner reads in the sky over the ramp, so the
 * ramp must be wider than the label. Otherwise the card carries the cone next to the owner.
 */
function badgeFits(firing: Firing, owner: string, width: number): boolean {
  const profile = profileOf(firing)
  if (profile === null || width < 90) return false
  const rampPx = ((profile.peakAt - profile.start) / (profile.end - profile.start)) * width
  return rampPx >= labelWidth(owner)
}

/** The draft of a firing being dragged: the one on the board, or a shelf batch coming in. */
function draftOf(id: string, fields: FiringFields, firings: readonly Firing[]): Firing | null {
  const current = firings.find((candidate) => candidate.id === id)
  if (current !== undefined) return current
  if (fields.kind === 'service' || fields.batch === undefined) return null
  const kiln = KILNS.find((candidate) => candidate.kinds.includes(fields.kind as FiringKind))
  if (kiln === undefined) return null
  return {
    id,
    batch: fields.batch,
    kind: fields.kind,
    kiln: kiln.id,
    start: NOW,
    end: civil(unloadAt(fields.kind, kiln, NOW_TICKS, PROGRAMS[fields.kind].hold)),
  }
}

/**
 * Where a dragged firing would land: its load rounded to the quarter hour (a month cell keeps its
 * time of day), and its unload fitted to the program on the kiln under the pointer, with the hold
 * it had. The scheduler's shadow follows this geometry.
 */
function fit(
  firing: Firing,
  target: string,
  rawStart: number,
  dayCells: boolean,
): Placement | null {
  const kiln = kilnById(target)
  if (kiln === null) return null
  let start = Math.round(rawStart / STEP) * STEP
  if (dayCells) {
    const original = parseCivil(firing.start)
    start = start - (((start % DAY) + DAY) % DAY) + (((original % DAY) + DAY) % DAY)
  }
  return placed(firing, kiln, start)
}

/** A refusal as one complete sentence with the batch's and the kiln's numbers. */
export function refusalText(
  reason: Refusal,
  firing: Pick<Firing, 'batch' | 'kind'>,
  target: string,
  start: number,
  firings: readonly Firing[],
  text: ConeTenText,
  tag: string,
): string {
  const kiln = kilnById(target)
  const batch = batchById(firing.batch)
  // Temperatures and litres read without grouping: 1285 °C.
  const number = new Intl.NumberFormat(tag, { useGrouping: false })
  const cone = PROGRAMS[firing.kind].cone
  const earliest = earliestFinish(firing, firings)
  if (reason === 'kind') {
    if (firing.kind === 'raku') return text.rules.raku
    return kiln === null ? text.rules['not-a-kiln'] : text.rules.kind[kiln.id]
  }
  return format(text.rules[reason], {
    batch: batch === null ? firing.batch : firingLabel(firing, text),
    kiln: kiln === null ? '' : text.kilns[kiln.id],
    cone,
    peak: number.format(CONE_PEAK[cone]),
    max: kiln === null ? '' : kiln.maxCone,
    maxPeak: kiln === null ? '' : number.format(CONE_PEAK[kiln.maxCone]),
    volume: number.format(batch?.volume ?? 0),
    capacity: number.format(kiln?.volume ?? 0),
    time: when(reason === 'past' ? NOW_TICKS : start, tag),
    earliest: earliest === null ? '' : when(nextStaffed(earliest), tag),
  })
}

/** The status chip of a firing on the board, its flags first. */
function statusOf(
  firing: Firing,
  issues: readonly Issue[],
  text: ConeTenText,
): KilnCardModel['status'] {
  const own = issues.find((issue) => issue.firing === firing.id)
  if (own !== undefined) return { label: text.issues[own.kind], tone: 'danger' }
  const phase = phaseOf(firing, NOW_TICKS)
  if (phase === 'firing') return { label: text.status.firing, tone: 'warning' }
  if (phase === 'cooling') return { label: text.status.cooling, tone: 'success' }
  if (phase === 'fired') return { label: text.status.fired }
  return { label: text.status.booked, tone: 'success' }
}

/** What the hover card and the sheet say about a firing. */
export function firingFacts(
  firing: Firing,
  issues: readonly Issue[],
  ledger: Ledger,
  text: ConeTenText,
  tag: string,
): { facts: HoverFact[]; rule: KilnCardModel['rule'] } {
  const profile = profileOf(firing)
  const batch = batchById(firing.batch)
  const number = new Intl.NumberFormat(tag)
  const degrees = new Intl.NumberFormat(tag, { useGrouping: false })
  const money = new Intl.NumberFormat(tag, {
    style: 'currency',
    currency: 'EUR',
    maximumFractionDigits: 0,
  })
  if (profile === null) return { facts: [], rule: undefined }
  const cost = firingCost(firing)
  const own = new Set(
    issues.filter((issue) => issue.firing === firing.id).map((issue) => issue.kind),
  )
  const facts: HoverFact[] = [
    {
      label: text.fields.batch,
      value:
        batch === null
          ? firing.batch
          : `${text.pieces[batch.piece]} · ${plural(text.count, batch.count, tag)} · ${number.format(batch.volume)} L`,
    },
    { label: text.fields.load, value: when(profile.start, tag) },
    {
      label: text.fields.peak,
      value: format(text.values.peak, {
        temperature: degrees.format(profile.peak),
        time: when(profile.peakAt, tag),
      }),
    },
    {
      label: text.fields.unload,
      value: when(profile.end, tag),
      alert: own.has('unload-closed'),
    },
    {
      label: text.fields.energy,
      value:
        profile.kiln.energy === 'electric'
          ? format(text.values.electric, {
              kwh: number.format(Math.round(cost.kwh)),
              euros: money.format(cost.euros),
            })
          : format(text.values.fuel, {
              kwh: number.format(Math.round(cost.kwh)),
              euros: money.format(cost.euros),
              fuel: text.fuels[profile.kiln.energy],
            }),
      alert: own.has('power'),
    },
  ]
  let rule: KilnCardModel['rule']
  if (own.has('unload-closed'))
    rule = {
      state: 'blocked',
      text: format(text.card.unloadRule, {
        time: when(profile.end, tag),
        next: when(nextStaffed(profile.end), tag),
      }),
    }
  else if (own.has('power')) {
    const overload = overloadOf(firing, ledger)
    rule = {
      state: 'blocked',
      text: format(text.card.powerRule, {
        kw: number.format(Math.round(overload?.kw ?? 0)),
        contract: number.format(CONTRACT_KW),
      }),
    }
  } else if (profile.end <= NOW_TICKS) rule = { state: 'ok', text: text.card.firedRule }
  else if (profile.start < NOW_TICKS) rule = { state: 'warn', text: text.card.lockedRule }
  else rule = { state: 'ok', text: text.card.okRule }
  return { facts, rule }
}

/** Ramp, hold and cooling of a firing, for the hover card and the sheet. */
export function phasesOf(firing: Firing, text: ConeTenText): KilnCardModel['phases'] {
  const profile = profileOf(firing)
  if (profile === null) return undefined
  return [
    { id: 'ramp', label: text.fields.ramp, minutes: profile.ramp },
    { id: 'hold', label: text.fields.hold, minutes: profile.hold },
    ...(profile.cool > 0
      ? [{ id: 'cool' as const, label: text.fields.cooling, minutes: profile.cool }]
      : []),
  ]
}

/** Menu and sheet labels of the commands that move a firing: where it would go, or why not. */
export function commandLabels(
  firing: Firing,
  firings: readonly Firing[],
  text: ConeTenText,
  tag: string,
): {
  readonly hold: { label: string; enabled: boolean }
  readonly fireNow: { label: string; enabled: boolean }
  readonly next: { label: string; enabled: boolean }
  readonly cheapest: { label: string; enabled: boolean }
  readonly shelf: { label: string; enabled: boolean }
} {
  const locked = parseCivil(firing.start) < NOW_TICKS
  const hold = extendHold(firing.id, firings)
  const now = fireNow(firing.id, firings)
  const next = locked ? null : nextCleanStart(firing.id, firings)
  const kiln = kilnById(firing.kiln)
  const cheapest = locked || kiln?.energy !== 'electric' ? null : cheapestStart(firing.id, firings)
  const money = new Intl.NumberFormat(tag, {
    style: 'currency',
    currency: 'EUR',
    maximumFractionDigits: 0,
  })
  const follower = firing.kind === 'bisque' ? partnerOf(firing, firings) : null
  return {
    hold: {
      label:
        typeof hold === 'string'
          ? hold === 'max-hold'
            ? text.rules.maxHold
            : hold === 'kiln-busy'
              ? text.rules.kilnBusy
              : text.actions.hold
          : text.actions.hold,
      enabled: typeof hold !== 'string',
    },
    fireNow: { label: text.actions.fireNow, enabled: now !== null },
    next:
      next === null
        ? { label: text.actions.noNext, enabled: false }
        : next.start === firing.start
          ? { label: text.actions.atNext, enabled: false }
          : {
              label: format(text.actions.next, { time: when(parseCivil(next.start), tag) }),
              enabled: true,
            },
    cheapest:
      cheapest === null
        ? { label: text.actions.noCheapest, enabled: false }
        : cheapest.start === firing.start
          ? { label: text.actions.atCheapest, enabled: false }
          : {
              label: format(text.actions.cheapest, {
                time: when(parseCivil(cheapest.start), tag),
                euros: money.format(firingCost({ ...firing, ...cheapest }).euros),
              }),
              enabled: true,
            },
    shelf: {
      label: follower !== null ? text.actions.shelfBlocked : text.actions.shelf,
      enabled: !locked && follower === null,
    },
  }
}

/** The parts of each card for one set of flags, cached per firing state and width. */
function cardPartsFor(
  flagged: ReadonlyMap<string, ReadonlySet<Issue['kind']>>,
  text: ConeTenText,
  tag: string,
): (firing: Firing, width: number) => CardParts {
  const cache = new Map<string, CardParts>()
  return (firing, width) => {
    const size = cardSize(width)
    const flags = flagged.get(firing.id)
    const key = `${firing.id}|${firing.kiln}|${firing.start}|${firing.end}|${Math.round(width / 8)}|${flags === undefined ? '' : [...flags].join(',')}`
    const cached = cache.get(key)
    if (cached !== undefined) return cached
    const batch = batchById(firing.batch)
    const owner = batch === null ? firing.batch : text.owners[batch.owner]
    const hours = (parseCivil(firing.end) - parseCivil(firing.start)) / HOUR
    const parts: CardParts = {
      kind: firing.kind,
      owner,
      coneInline: size !== 'tiny' && !badgeFits(firing, owner, width),
      detail:
        batch === null
          ? ''
          : `${text.pieces[batch.piece]} · ${new Intl.NumberFormat(tag).format(batch.count)}`,
      cone: coneLabel(PROGRAMS[firing.kind].cone),
      phase: phaseOf(firing, NOW_TICKS),
      size,
      flagged: flags !== undefined,
      paths: curvePaths(firing, hours > 0 ? width / hours : BOARD.hourWidth),
    }
    cache.set(key, parts)
    return parts
  }
}

export function ConeTenScheduler(props: ConeTenSchedulerProps) {
  const {
    events,
    firings,
    ledger,
    issues,
    text,
    common,
    tag,
    schedulerLocale,
    initialZoom,
    selected,
    history,
    controlRef,
    narrow,
  } = props
  const {
    onEventsChange,
    onSelect,
    onMoved,
    onResized,
    onRefused,
    onCascade,
    onAction,
    onZoomLevel,
  } = props

  const resources = useMemo(() => toResources(text), [text])
  const board = useRef<HTMLDivElement>(null)
  // Hover buttons call the latest handler without rebuilding every card's areas.
  const action = useRef(onAction)
  useEffect(() => {
    action.current = onAction
  }, [onAction])

  // The level on screen: a settled zoom re-labels headers and resizes cards and pills.
  const [level, setLevel] = useState<ZoomId>(initialZoom)
  const density = level === 'hours' ? 30 : level === 'month' ? 40 / 24 : BOARD.hourWidth

  // A refused drop (cone, program, volume, the clock, staffed hours, glaze too early) is said on
  // release; the library refuses overlaps itself.
  const refusal = useDropRefusal(onRefused)

  const flagged = useMemo(() => {
    const map = new Map<string, Set<Issue['kind']>>()
    for (const issue of issues) {
      const set = map.get(issue.firing) ?? new Set()
      set.add(issue.kind)
      map.set(issue.firing, set)
    }
    return map
  }, [issues])

  // Links come from the business model: a batch's bisque finishes before its finish firing loads.
  const links = useMemo<SuperScheduler.LinkData[]>(
    () =>
      firings.flatMap((firing): SuperScheduler.LinkData[] => {
        if (firing.kind !== 'bisque') return []
        const next = partnerOf(firing, firings)
        if (next === null) return []
        const past = parseCivil(next.end) <= NOW_TICKS
        return [
          {
            id: `${firing.id}->${next.id}`,
            from: firing.id,
            to: next.id,
            type: 'FinishToStart',
            width: 2,
            style: 'dashed',
            cssClass: past ? 'ck-link ck-link--past' : 'ck-link',
          },
        ]
      }),
    [firings],
  )

  // Hover card: the batch, the program's stages, the energy and the rule that applies.
  const card = useCallback(
    (event: SuperScheduler.Event): KilnCardModel | null => {
      const id = String(event.id())
      // The firing in the side sheet needs no card on top of it.
      if (id === selected) return null
      const firing = firings.find((candidate) => candidate.id === id)
      if (firing === undefined) {
        const service = SERVICES.find((candidate) => candidate.id === id)
        if (service === undefined) return null
        return {
          id,
          kind: 'service',
          eyebrow: `${text.kinds.service} · ${text.kilns[service.kiln]}`,
          title: text.service.title,
          status: { label: text.status.service },
          facts: [
            { label: text.fields.load, value: when(parseCivil(service.start), tag) },
            { label: text.fields.unload, value: when(parseCivil(service.end), tag) },
          ],
          rule: { state: 'blocked', text: text.card.serviceRule },
        }
      }
      const { facts, rule } = firingFacts(firing, issues, ledger, text, tag)
      const program = PROGRAMS[firing.kind]
      return {
        id,
        kind: firing.kind,
        eyebrow: `${text.kinds[firing.kind]} · ${format(text.cone, { cone: program.cone })} · ${text.kilns[firing.kiln]}`,
        title: firingLabel(firing, text),
        status: statusOf(firing, issues, text),
        phases: phasesOf(firing, text),
        phasesLabel: text.card.phases,
        facts,
        rule,
        hints: defaultHints(common, { move: parseCivil(firing.start) >= NOW_TICKS }),
      }
    },
    [firings, issues, ledger, selected, text, common, tag],
  )
  const { eventHover, closeHover, suppressHover } = useKilnHover(card, tag)

  // Drag verdict: the studio's refusal, or what the drop would mean: load, peak and unload of the
  // program on the kiln under the pointer, its cost, and the warnings (nobody to unload, the
  // contract, the glaze firing that follows).
  const dragCard = useMemo<SuperScheduler.DragCardOptions>(() => {
    const draftFor = (info: SuperScheduler.DragCardInfo): Firing | null => {
      const id = info.eventId === null ? null : String(info.eventId)
      if (id === null) return null
      const current = firings.find((candidate) => candidate.id === id)
      if (current !== undefined) return current
      const shelf = SHELF.find((entry) => firingId(entry.batch, entry.kind) === id)
      return shelf === undefined
        ? null
        : draftOf(id, { kind: shelf.kind, batch: shelf.batch }, firings)
    }
    const proposed = (info: SuperScheduler.DragCardInfo): Firing | null => {
      const draft = draftFor(info)
      if (draft === null || info.resource === null || !isKilnRow(info.resource)) return null
      return {
        ...draft,
        kiln: info.resource,
        start: civil(info.start.getTime()),
        end: civil(info.end.getTime()),
      }
    }
    // The load of every other firing, built once per dragged firing and plan.
    const ledgers = new Map<string, Ledger>()
    const othersOf = (id: string) => {
      let others = ledgers.get(id)
      if (others === undefined) {
        others = ledgerWithout(id, firings)
        ledgers.set(id, others)
      }
      return others
    }
    const number = new Intl.NumberFormat(tag, { useGrouping: false })
    const money = new Intl.NumberFormat(tag, {
      style: 'currency',
      currency: 'EUR',
      maximumFractionDigits: 0,
    })
    const base = createDragCard({
      tag,
      common,
      rowName: (id) => (isKilnRow(id) ? text.kilns[id] : null),
      warn: (info) => {
        const next = proposed(info)
        if (next === null) return null
        const profile = profileOf(next)
        if (profile === null) return null
        if (!isStaffed(profile.end)) {
          const open = nextStaffed(profile.end)
          return format(text.verdict.unloadClosed, {
            time: when(profile.end, tag),
            next: when(open, tag),
            wait: formatSpan(open - profile.end, tag),
          })
        }
        if (profile.kiln.energy === 'electric') {
          const overload = overloadWith(next, othersOf(next.id))
          if (overload !== null)
            return format(text.verdict.power, {
              kw: number.format(Math.round(overload.kw)),
              contract: number.format(CONTRACT_KW),
            })
        }
        if (info.mode === 'move' && next.kind === 'bisque' && !info.external) {
          const current = firings.find((candidate) => candidate.id === next.id)
          if (current !== undefined) {
            const follower = cascadeOf(
              current,
              { kiln: next.kiln, start: next.start, end: next.end },
              firings,
            )
            if (follower !== null && follower !== 'blocked')
              return format(text.verdict.cascade, {
                batch: firingLabel(follower, text),
                time: when(parseCivil(follower.start), tag),
              })
          }
        }
        if (info.mode === 'resize')
          return format(text.verdict.hold, { hold: formatSpan(profile.hold * MINUTE, tag) })
        return null
      },
    })
    return {
      ...base,
      html: (info) => {
        const html = base.html?.(info) ?? null
        if (html === null || info.forbidden || info.overlapping) return html
        const next = proposed(info)
        const profile = next === null ? null : profileOf(next)
        if (next === null || profile === null) return html
        const cost = firingCost(next)
        const steps = stepsHtml({
          load: `${text.verdict.load} ${when(profile.start, tag)}`,
          peak: `${text.verdict.peak} ${number.format(profile.peak)}°`,
          unload: `${text.verdict.unload} ${when(profile.end, tag)}`,
          unloadOk: isStaffed(profile.end),
          cost: format(text.verdict.cost, { euros: money.format(cost.euros) }),
        })
        return `${html.slice(0, -'</div>'.length)}${steps}</div>`
      },
    }
  }, [firings, text, common, tag])

  // Context menu: rebuilt when the plan changes, so its labels name the real slot.
  const contextMenu = useMemo(
    () =>
      createEventMenu({
        theme: skinMenuTheme(SLUG),
        title: (event) => format(common.kit.menuTitle, { name: event.text() }),
        onOpenChange: (open) => suppressHover(open),
        entries: (event) => {
          const id = String(event.id())
          const firing = firings.find((candidate) => candidate.id === id)
          if (firing === undefined) return []
          const labels = commandLabels(firing, firings, text, tag)
          const issueOf = issues.find((issue) => issue.firing === id)
          const entries: MenuEntry[] = [
            {
              label: common.kit.openDetails,
              icon: 'details',
              keys: KEYS.details,
              run: () => onAction('details', id),
            },
          ]
          if (parseCivil(firing.start) < NOW_TICKS) return entries
          entries.push(
            {
              label: labels.next.label,
              icon: 'slot',
              primary: issueOf?.kind === 'unload-closed',
              disabled: !labels.next.enabled,
              run: () => onAction('next', id),
            },
            {
              label: labels.cheapest.label,
              icon: 'after',
              primary: issueOf?.kind === 'power',
              disabled: !labels.cheapest.enabled,
              run: () => onAction('cheapest', id),
            },
            {
              label: labels.hold.label,
              icon: 'pending',
              disabled: !labels.hold.enabled,
              run: () => onAction('hold', id),
            },
            {
              label: labels.fireNow.label,
              icon: 'confirm',
              disabled: !labels.fireNow.enabled,
              run: () => onAction('fire-now', id),
            },
            'separator',
            {
              label: labels.shelf.label,
              icon: 'layers',
              disabled: !labels.shelf.enabled,
              run: () => onAction('shelf', id),
            },
          )
          return entries
        },
      }),
    [firings, issues, common, text, tag, onAction, suppressHover],
  )

  // Time headers, written once per cell and level.
  const headerPaint = useMemo(() => createHeaderPainter(text, tag), [text, tag])
  // The energy row, from the ledger in state, cached per cell.
  const paintEnergy = useMemo(() => energyPainter(ledger, text, tag), [ledger, text, tag])

  // Cards: their parts, cached per firing state and width.
  const partsOf = useMemo(() => cardPartsFor(flagged, text, tag), [flagged, text, tag])

  const config = useMemo<SchedulerRenderProps>(() => {
    const escape = SuperScheduler.Util.escapeHtml
    const byId = new Map(firings.map((firing) => [firing.id, firing]))
    const services = new Map(SERVICES.map((service) => [service.id, service]))
    const states = new Map(
      KILNS.map((kiln) => [kiln.id, kilnStateAt(kiln.id, firings, SERVICES, NOW_TICKS)]),
    )
    // Temperatures read without grouping: 1285 °C.
    const number = new Intl.NumberFormat(tag, { useGrouping: false })
    const areaCache = new Map<string, SuperScheduler.AreaData[]>()

    const dayCells = () => controlRef.current?.scale === 'Day'
    const messageFor = (reason: Refusal, firing: Firing, target: string, start: number) =>
      refusalText(reason, firing, target, start, firings, text, tag)

    /** Areas of a card: peak badge, temperature now, flags, and the hover pills of a booked firing. */
    const areasOf = (firing: Firing, width: number, phase: string): SuperScheduler.AreaData[] => {
      const flags = flagged.get(firing.id)
      const key = `${firing.id}|${firing.kiln}|${firing.start}|${firing.end}|${Math.round(width / 8)}|${flags === undefined ? '' : [...flags].join(',')}`
      const cached = areaCache.get(key)
      if (cached !== undefined) return cached
      const profile = profileOf(firing)
      const areas: SuperScheduler.AreaData[] = []
      if (profile === null) return areas
      // The cone, flagged on the peak (right of it: the owner reads in the sky over the ramp).
      const batch = batchById(firing.batch)
      if (badgeFits(firing, batch === null ? firing.batch : text.owners[batch.owner], width))
        areas.push({
          start: civil(profile.peakAt),
          offsetX: 3,
          top: 3,
          width: 38,
          height: 17,
          cssClass: 'ck-peak',
          html: `<span>${escape(coneLabel(PROGRAMS[firing.kind].cone))}</span>`,
          toolTip: `${number.format(profile.peak)} °C · ${when(profile.peakAt, tag)}`,
        })
      // The temperature at the studio clock, on the curve, for a firing in the kiln now.
      if ((phase === 'firing' || phase === 'cooling') && width >= 54) {
        const temperature = Math.round(temperatureAt(firing, NOW_TICKS))
        areas.push({
          start: NOW,
          offsetX: -22,
          width: 44,
          height: 18,
          top: `calc(${Math.round(yOf(temperature) * 10) / 10}% - 9px)`,
          cssClass: 'ck-now-temp',
          html: `<span>${escape(number.format(temperature))}°</span>`,
        })
      }
      // Over the contract: hazard stripes under the stretch of the draw that trips the breaker.
      if (flags?.has('power') === true) {
        const overload = overloadOf(firing, ledger)
        if (overload !== null)
          areas.push({
            start: civil(Math.max(overload.from, profile.start)),
            end: civil(Math.min(overload.to, profile.holdEnd)),
            bottom: 0,
            height: 9,
            cssClass: 'ck-hazard',
            toolTip: text.issues.power,
          })
      }
      // Nobody to unload: a sticker on the cold end of the card.
      if (flags?.has('unload-closed') === true && width >= 120)
        areas.push({
          right: 7,
          // Mid-height, in the sky over the cold end of the curve.
          top: 'calc(50% - 16px)',
          // Sized to its text (9.5 px Geist 900, uppercase), inside the card.
          width: Math.min(width - 8, format(text.card.flag, { time: '00:00' }).length * 6.9 + 18),
          height: 22,
          cssClass: 'ck-flag',
          html: `<span>${escape(format(text.card.flag, { time: clock(profile.end, tag) }))}</span>`,
          toolTip: format(text.card.unloadRule, {
            time: when(profile.end, tag),
            next: when(nextStaffed(profile.end), tag),
          }),
        })
      // Big pills over a booked firing: open it, one more hour of hold, fire it now.
      if (phase === 'booked') {
        const id = firing.id
        if (width >= 110)
          areas.push(
            pill(text.actions.details, common.kit.openDetails, 5, 52, () =>
              action.current('details', id),
            ),
          )
        if (width >= 176)
          areas.push(
            pill(text.actions.holdShort, text.actions.hold, 62, 46, () =>
              action.current('hold', id),
            ),
          )
        if (width >= 262)
          areas.push(
            pill(
              text.actions.fireNowShort,
              text.actions.fireNow,
              113,
              84,
              () => action.current('fire-now', id),
              true,
            ),
          )
      }
      areaCache.set(key, areas)
      return areas
    }

    return {
      locale: schedulerLocale,
      cssClass: skinClass(SLUG),
      startDate: PLAN.start,
      days: PLAN.days,
      weekStarts: 1,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // Exact times: a load at 16:30 stays at 16:30; onEventMoving rounds to the quarter hour.
      useEventBoxes: 'Never',
      height: '100%',
      // The studio is staffed 08:00 to 20:00 every day: the rest is shaded as closed.
      businessBeginsHour: 8,
      businessEndsHour: 20,
      businessWeekends: true,
      cellsMarkBusiness: true,
      rowHeaderColumns: narrow
        ? [{ text: text.columns.kiln, width: BOARD.narrowColumn, nonresizable: true }]
        : [
            { text: text.columns.kiln, width: BOARD.kilnColumn, nonresizable: true },
            { text: text.columns.cone, width: BOARD.coneColumn, nonresizable: true },
            { text: text.columns.volume, width: BOARD.volumeColumn, nonresizable: true },
          ],
      rowHeaderWidth: narrow ? BOARD.narrowColumn : BOARD.rowHeaderWidth,
      rowHeaderWidthAutoFit: false,
      eventHeight: narrow ? BOARD.narrowEventHeight : BOARD.eventHeight,
      rowMarginTop: BOARD.rowMarginTop,
      rowMarginBottom: BOARD.rowMarginBottom,
      allowEventOverlap: false,
      conflictHighlight: true,
      durationBarVisible: false,
      // The curve is drawn to the time axis: no label may slide when the viewport cuts a firing.
      floatingEvents: false,
      linkShape: 'RightAngled',
      linkLayer: 'Below',
      crosshairType: 'Full',
      // Midnights as heavy rules, and the studio clock above the cards.
      separators: [
        ...Array.from({ length: PLAN.days + 1 }, (_, index) => ({
          location: civil(parseCivil(PLAN.start) + index * DAY),
          layer: 'BelowEvents' as const,
          width: 2,
          cssClass: 'ck-sep-day',
        })),
        {
          location: NOW,
          layer: 'AboveEvents',
          width: 3,
          cssClass: 'ck-sep-now',
          toolTip: format(text.header.nowTip, { time: when(NOW_TICKS, tag) }),
        },
      ],
      showToolTip: false,
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      history,
      controlRef,
      onEventsChange,

      onBeforeCornerRender: (args) => {
        args.html = `<span class="ck-corner" aria-hidden="true"><b>${escape(text.brand)}</b><span>${escape(text.header.corner)}</span></span>`
      },

      onBeforeTimeHeaderRender: (args) => {
        headerPaint(args.header, pxPerHour(args.control))
      },

      onBeforeRowHeaderRender: (args) => {
        const row = args.row
        const [first, second, third] = row.columns
        if (first === undefined) return
        if (row.id === ENERGY_ROW) {
          row.cssClass = 'ck-row ck-row--energy'
          first.html = energyHeader(text, level === 'month')
          if (second !== undefined) second.html = energyKey(text, tag, 'peak')
          if (third !== undefined) third.html = energyKey(text, tag, 'contract')
          return
        }
        const kiln = isKilnRow(row.id) ? kilnById(row.id) : null
        if (kiln === null) return
        const status = states.get(kiln.id) ?? { state: 'idle' as const, temperature: 20 }
        row.cssClass = `ck-row ck-row--${kiln.id}`
        first.html = kilnHtml(kiln, status, text, tag, narrow)
        if (second !== undefined) second.html = coneHtml(kiln, tag)
        if (third !== undefined) third.html = volumeHtml(kiln, tag)
      },

      onBeforeCellRender: (args) => {
        const cell = args.cell
        if (cell.resource !== ENERGY_ROW) return
        const painted = paintEnergy(
          cell.start.getTime(),
          cell.end.getTime(),
          args.control.cellWidth ?? BOARD.hourWidth,
        )
        cell.properties.html = painted.html
        cell.properties.cssClass = painted.css
      },

      onBeforeEventRender: (args) => {
        const data = args.data
        const id = String(data.id)
        const service = services.get(id)
        const width = ((data.end.getTime() - data.start.getTime()) / HOUR) * pxPerHour(args.control)
        if (service !== undefined) {
          data.html = cardHtml({
            kind: 'service',
            owner: text.service.title,
            detail: '',
            cone: '',
            coneInline: false,
            phase: 'booked',
            size: cardSize(width),
            flagged: false,
            paths: null,
          })
          data.cssClass = 'ck-ev ck-ev--service'
          data.ariaLabel = `${text.service.title}, ${text.kilns[service.kiln]}`
          return
        }
        const firing = byId.get(id)
        if (firing === undefined) return
        const parts = partsOf(firing, width)
        data.html = cardHtml(parts)
        const classes = ['ck-ev', `ck-ev--${firing.kind}`, `ck-ev--${parts.phase}`]
        const flags = flagged.get(id)
        if (flags !== undefined) classes.push('ck-ev--flag')
        if (id === selected) classes.push('ck-ev--selected')
        data.cssClass = classes.join(' ')
        data.areas = areasOf(firing, width, parts.phase)
        const profile = profileOf(firing)
        data.ariaLabel =
          `${String(data.text)}, ${text.kinds[firing.kind]}, ${format(text.cone, { cone: PROGRAMS[firing.kind].cone })}` +
          (profile === null
            ? ''
            : `, ${text.fields.load} ${when(profile.start, tag)}, ${text.fields.unload} ${when(profile.end, tag)}`) +
          (flags === undefined ? '' : `, ${[...flags].map((kind) => text.issues[kind]).join(', ')}`)
      },

      // Every frame of a drag (on the board or in from the shelf): round the load, fit the unload
      // to the kiln under the pointer, and refuse what the studio does not allow.
      onEventMoving: (args) => {
        const fields = readFiringFields(args.e.data)
        if (fields === null || fields.kind === 'service') return
        const id = String(args.e.id())
        const draft = draftOf(id, fields, firings)
        if (draft === null) return
        const target = String(args.resource)
        const placement = fit(draft, target, args.start.getTime(), dayCells())
        if (placement !== null) {
          args.start = new SuperScheduler.Date(parseCivil(placement.start))
          args.end = new SuperScheduler.Date(parseCivil(placement.end))
        }
        const start = args.start.getTime()
        const reason = moveRefusal(draft, target, start, firings)
        if (reason !== null) {
          const message = messageFor(reason, draft, target, start)
          args.allowed = false
          args.message = message
          refusal.track(id, message)
          return
        }
        refusal.track(
          id,
          args.conflicts[0] === undefined
            ? null
            : format(common.kit.overlaps, { name: args.conflicts[0].text() }),
        )
      },

      // Final check before the commit (also keyboard moves): the same rules, and a bisque moved
      // later takes its finish firing along in one step.
      onEventMove: (args) => {
        const fields = readFiringFields(args.e.data)
        if (fields === null || fields.kind === 'service') return
        const id = String(args.e.id())
        const draft = draftOf(id, fields, firings)
        if (draft === null) return
        const target = String(args.newResource)
        const placement = fit(draft, target, args.newStart.getTime(), dayCells())
        const start = placement === null ? args.newStart.getTime() : parseCivil(placement.start)
        const reason = moveRefusal(draft, target, start, firings)
        if (reason !== null || placement === null) {
          args.preventDefault()
          refusal.clear()
          onRefused(id, messageFor(reason ?? 'not-a-kiln', draft, target, start))
          return
        }
        args.newStart = new SuperScheduler.Date(parseCivil(placement.start))
        args.newEnd = new SuperScheduler.Date(parseCivil(placement.end))
        if (args.external) return
        const follower = cascadeOf(draft, placement, firings)
        if (follower === 'blocked') {
          args.preventDefault()
          refusal.clear()
          onRefused(id, text.rules.cascade)
        } else if (follower !== null) {
          args.preventDefault()
          refusal.clear()
          onCascade(draft, placement, follower)
        }
      },

      onEventMoved: (args) => {
        refusal.clear()
        onMoved(String(args.e.id()), args.external)
      },

      // The end edge changes the hold; cooling is the kiln's and cannot be shortened, and the ramp
      // is the program's: the start edge does not move.
      onEventResizing: (args) => {
        const id = String(args.e.id())
        const firing = byId.get(id)
        if (firing === undefined) return
        if (args.what === 'start') {
          args.allowed = false
          args.message = text.rules.ramp
          refusal.track(id, text.rules.ramp)
          return
        }
        const end = snapHoldEnd(firing, args.end.getTime())
        args.end = new SuperScheduler.Date(end)
        const problem = holdRefusal(firing, end)
        if (problem === null) {
          refusal.track(id, null)
          return
        }
        const profile = profileOf(firing)
        const message =
          problem === 'short'
            ? format(text.rules.short, {
                peak: number.format(profile?.peak ?? 0),
                kiln: text.kilns[firing.kiln],
                cool: formatSpan((profile?.cool ?? 0) * MINUTE, tag),
                time: when(
                  parseCivil(firing.start) +
                    ((profile?.ramp ?? 0) + PROGRAMS[firing.kind].minHold + (profile?.cool ?? 0)) *
                      MINUTE,
                  tag,
                ),
              })
            : format(text.rules.long, {
                max: formatSpan(PROGRAMS[firing.kind].maxHold * MINUTE, tag),
              })
        args.allowed = false
        args.message = message
        refusal.track(id, message)
      },

      onEventResize: (args) => {
        const id = String(args.e.id())
        const firing = byId.get(id)
        if (firing === undefined) return
        const end = snapHoldEnd(firing, args.newEnd.getTime())
        if (args.what === 'start' || holdRefusal(firing, end) !== null) {
          args.preventDefault()
          refusal.clear()
          onRefused(id, args.what === 'start' ? text.rules.ramp : text.rules.maxHold)
          return
        }
        args.newStart = new SuperScheduler.Date(parseCivil(firing.start))
        args.newEnd = new SuperScheduler.Date(end)
      },

      onEventResized: (args) => {
        refusal.clear()
        onResized(String(args.e.id()))
      },

      // A click (or Enter) docks the firing into the side sheet; the hover card steps aside.
      onEventClick: (args) => {
        closeHover()
        onSelect(String(args.e.id()))
      },

      onZoom: (args) => {
        if (args.phase !== 'end') return
        const next = ZOOM_IDS[args.level]
        if (next === undefined) return
        setLevel(next)
        onZoomLevel(next)
      },
    }
  }, [
    firings,
    ledger,
    flagged,
    selected,
    narrow,
    level,
    history,
    controlRef,
    headerPaint,
    paintEnergy,
    partsOf,
    refusal,
    closeHover,
    common,
    text,
    tag,
    schedulerLocale,
    initialZoom,
    onEventsChange,
    onMoved,
    onResized,
    onRefused,
    onCascade,
    onSelect,
    onZoomLevel,
  ])

  // React cards: the same parts as the HTML fallback, the curve redrawn by React.
  const renderEvent = useMemo<NonNullable<SchedulerRenderProps['renderEvent']>>(
    () =>
      ({ e, data, width }) => {
        const firing = firings.find((candidate) => candidate.id === String(e.id()))
        if (firing === undefined) {
          const service = SERVICES.find((candidate) => candidate.id === String(e.id()))
          return service === undefined ? (
            e.text()
          ) : (
            <FiringCard
              parts={{
                kind: 'service',
                owner: text.service.title,
                detail: '',
                cone: '',
                coneInline: false,
                phase: 'booked',
                size: cardSize(width),
                flagged: false,
                paths: null,
              }}
            />
          )
        }
        const actual = ((data.end.getTime() - data.start.getTime()) / HOUR) * density
        return <FiringCard parts={partsOf(firing, Math.max(width, actual))} />
      },
    [firings, partsOf, density, text],
  )

  // Imperative and once: a scroll prop would be re-applied on every update.
  useEffect(() => {
    controlRef.current?.scrollTo(INITIAL_VIEW_START, false, 'left')
  }, [controlRef])

  // The contract and the daily budget as lines in the energy row: CSSOM, from the model.
  useEffect(() => {
    const element = board.current
    if (element === null) return
    element.style.setProperty('--ck-limit-hour', `${Math.round(LIMIT_SHARE.hour * 1000) / 10}%`)
    element.style.setProperty('--ck-limit-day', `${Math.round(LIMIT_SHARE.day * 1000) / 10}%`)
  }, [])

  return (
    <div ref={board} className="ck-board-host h-full [&>div]:h-full">
      <SuperSchedulerComponent
        {...config}
        resources={resources}
        events={events}
        links={links}
        renderEvent={renderEvent}
        eventHover={eventHover}
        dragCard={dragCard}
        contextMenu={contextMenu}
      />
    </div>
  )
}

Das Archiv enthält die Dateien dieses Beispiels und eine README. SuperScheduler Pro und Download-Schlüssel sind nicht enthalten.