Zum Inhalt springen

Gleisbelegung im Bahnhof

Ein InterCity hat sechs Minuten Verspätung. Bringen Sie seine 140 Umsteiger in die S3, ohne die S3 zu verspäten.

  • Bahn
  • Perron
  • Minutenraster
  • Abhängigkeiten
  • Verschieberegeln
  • Summenzeilen
  • Fadenkreuz
  • Stufenloser Zoom
  • Gespeicherte Ansichten
  • Minimap
  • Theming
  • Demo mit synthetischen Daten
Quellcode laden.zip

Aufgabe

0 von 3 Schritten erledigt

Mittwoch, 07:42. IC 712 aus Kaltenhof meldet sechs Minuten Verspätung: Er kommt um 08:02 statt 07:56 auf Gleis 2 an. 140 seiner Reisenden steigen in die S3 nach Hagenmoor um.

  1. Die Verspätung von IC 712 in den Plan übernehmen. — Offen

    Ziehen Sie IC 712 (Gleis 2, rot schraffiert) sechs Minuten nach rechts, oder klicken Sie mit rechts darauf und wählen Sie Verspätung übernehmen +6′. Sein Anschluss an die S3 wird rot.

  2. Seine Reisenden in die S3 bringen, ohne die S3 zu verspäten. — Offen

    Ziehen Sie die S3 (Gleis 7, ab 08:07) senkrecht nach oben auf Gleis 4 in Halle A: Innerhalb einer Halle braucht man 4 Minuten, und ein Gleiswechsel behält die Zeiten des Zuges.

  3. Den letzten roten Anschluss lösen. — Offen

    RE 4811 kommt jetzt in der anderen Halle an, 2 Minuten zu knapp. Klicken Sie auf die S3 und drücken Sie Warten auf RE 4811 +2′, oder ziehen Sie ihren rechten Rand zwei Minuten später.

Etwa 30 Sekunden. Erledigte Schritte werden abgehakt.

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

Perron
Halle AGleis 1420 mGleis 2420 mGleis 3320 mGleis 4320 mHalle BGleis 5400 mGleis 6400 mGleis 7240 mGleis 8240 mIC 712SeewaldeIC 714SeewaldeIC 716SeewaldeRE 4903KranzbachRE 4905KranzbachIC 713KaltenhofIC 715KaltenhofIC 717KaltenhofRE 4902von KranzbachRE 4904von KranzbachRE 4906von KranzbachRE 4714von ArnsfeldRE 4715ArnsfeldRE 4716von ArnsfeldRE 4717ArnsfeldRE 4718von ArnsfeldRE 4719ArnsfeldRangierfahrtRE 4812TalbrunnRE 4814TalbrunnRE 4816TalbrunnS1RotheimS1LohwaldS1RotheimS1LohwaldS1RotheimS3von HagenmoorS3HagenmoorS3von HagenmoorS3von HagenmoorS3HagenmoorS3HagenmoorS3von HagenmoorS3von HagenmoorS3HagenmoorS3HagenmoorS3HagenmoorRE 4811MirlachRE 4813MirlachS3von HagenmoorS3HagenmoorS3von HagenmoorRangierfahrtS5von GrauholzS5Grauholz2/85/85/83/82/85/85/82/82/85/8Belegte GleiseSpitze je 15 Min.0708093045153045153045

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.

Perron ist ein fiktives Werkzeug für die Gleisbelegung in Brennholt Hbf, einem erfundenen Durchgangsbahnhof mit acht Bahnsteiggleisen in zwei Hallen. Die Züge treffen sich zur vollen Stunde, damit man in alle Richtungen umsteigen kann: Der Fahrplan verspricht diese Anschlüsse, die Fahrdienstleitung hält sie, Minute für Minute.

Eine Verspätung betrifft nie nur einen Zug. Sie verschiebt Ankunft und Abfahrt, die Umsteigezeit aller Anschlussreisenden und manchmal den nächsten Zug auf demselben Gleis. Ein Plan, der nur Balken zeichnet, verbirgt das; dieser zeichnet jeden Anschluss mit seiner Umsteigezeit, weiß, dass man über den Bahnsteig 2 Minuten und zwischen den Hallen 7 braucht, und weist einen 400-m-Zug auf einem 320-m-Gleis ab.

Was Sie tun werden

Sie übernehmen eine gemeldete Verspätung in den Plan, verlegen einen Anschlusszug in die andere Halle, ohne seine Zeiten zu ändern, und halten eine Abfahrt für die Reisenden zurück, die sie sonst verpassen.

Wo die Bibliothek endet und Ihre Anwendung beginnt.

SuperScheduler liefert

  • Eine Minutenskala (scale: Minute) mit drei Zoomstufen bis zu Stunden, die stufenlose Zoomgeste und Zoomtasten; die Nachtstunden verlassen die Achse über onIncludeTimeCell.
  • Zwei Zeitkopfzeilen aus onBeforeTimeHeaderRender: die Stundenziffern mit den Zügen dieser Stunde und der Bahnhofsuhr als Kopfbereiche, darunter ein Minutenlineal, dem das Fadenkreuz (crosshairType: Full) folgt.
  • Ein gruppierter Ressourcenbaum mit rowHeaderColumns (Gleis und Zustand, Länge und Sektoren, stufenloser Zugang) und eine fixierte Summenzeile (frozen: top), die onBeforeCellRender mit den belegten Gleisen je Viertelstunde füllt.
  • Karten zu exakten Zeiten (useEventBoxes: Never) mit einem zeitverankerten Bereich über den Minuten, in denen ein verspäteter Zug noch nicht da sein kann, Hover-Schaltflächen (visibility: Hover) und gesperrte Zellen für Bauarbeiten.
  • Verknüpfungen aus Ihren Daten, eine je Anschluss (Typ StartToFinish: von der Ankunft des Zubringers bis zur Abfahrt), mit einer Klasse je Zustand und je einer Verknüpfungsblase (linkBubble, bubbleHtml); ein Zug unter dem Zeiger holt seine eigenen Verknüpfungen nach vorn (onEventMouseEnter).
  • onEventMoving mit args.allowed, args.message und angepassten args.start und args.end, sodass ein Gleiswechsel die Zeiten des Zuges behält; conflictHighlight; Undo-Verlauf; die Hover-Karte und die Drag-Karte (super-scheduler/react-render); ein Kontextmenü im Skin des Produkts.
  • getViewState und applyViewState aus super-scheduler/views als gespeicherte Ansichten, und die Minimap (super-scheduler/minimap) mit den Zügen pro Stunde.

Der Code dieses Beispiels entscheidet

  • Welches Gleis welchen Zug aufnimmt: Bahnsteiglänge gegen Zuglänge, zwei Minuten Zugfolgeabstand, Bauarbeiten, der Fahrplan (nie zu früh abfahren) und Garnituren, die dort wenden, wo sie stehen.
  • Was ein Anschluss braucht: 2 Minuten über einen Bahnsteig, 4 innerhalb einer Halle, 7 zwischen den Hallen, und die Reisenden, die er trägt.
  • Die Befehle, jeder ein Undo-Schritt: gemeldete Verspätung übernehmen (samt der Wende dahinter), auf das beste freie Gleis verlegen, eine Abfahrt zurückhalten, einen Anschluss aufgeben.
  • Die Kennzahlen: die Abfahrtstafel, verpasste Anschlüsse und betroffene Reisende, noch offene Verspätungen, belegte Gleise und Züge pro Stunde.
  • Der Skin im Schweizer Stil: Gleisschilder, Fallblattanzeige, Bahnhofsuhr, Piktogramme und schwarze Karten, die Rot für Verspätungen und verpasste Anschlüsse aufsparen.

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 in Minuten und Stunden, der Betriebstag über onIncludeTimeCell, die beiden Kopfzeilen, rowHeaderColumns, die fixierte Zeile der belegten Gleise, Bauarbeiten als gesperrte Zellen, Anschluss-Verknüpfungen mit Blasen, der Separator zur Bahnhofsuhr, das Drag-Urteil, das jeden Anschluss an der vorgeschlagenen Stelle neu bewertet, das Kontextmenü und die Callbacks, in denen Bahnhofsregeln eine Verschiebung ablehnen oder ihre Zeiten behalten.

/**
 * Perron platform plan: eight platform tracks in two halls over one operating day, minute by
 * minute, with the connections between trains drawn as links.
 *
 * Library: a grouped resource tree with row-header columns, a frozen summary row painted by
 * onBeforeCellRender, a minute scale with zoom levels (minutes to hours) and the continuous zoom
 * gesture, hidden night hours (onIncludeTimeCell), two time-header rows written by
 * onBeforeTimeHeaderRender (the hour numerals with header areas, a minute ruler), disabled cells
 * for track works, exact-time cards with time-anchored and hover areas, drag and resize with
 * per-frame verdicts and adjusted times (onEventMoving), conflict highlighting, links with their
 * own classes and link bubbles, a separator at the station clock, the full crosshair, keyboard
 * moves and zoom keys, undo history, the event hover card and the drag card
 * (super-scheduler/react-render) and a context menu. Application: platform lengths, headways,
 * the timetable, turnarounds, change times between tracks, which connections hold, the delay,
 * hold, release and re-platform commands, and every figure. Those rules live in ./rules and run in
 * the callbacks below; ./paint writes the markup.
 */
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'
import { SchedulerDate, 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 { HOUR, civil, parseCivil } from '../../scheduler/civil'
import { createEventMenu, type MenuEntry } from '../kit/context-menu'
import { useEventHover, type HoverCardModel } 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,
  SUMMARY_ID,
  hallTracks,
  isHallId,
  isTrackId,
  readTrainFields,
  toResources,
  trainName,
  type TrainEvent,
} from './adapter'
import { DAY, INITIAL_VIEW_START, NOW, TIMELINE, type Connection, type Occupation } from './model'
import {
  PICTOGRAM,
  accessHtml,
  cardHtml,
  clock,
  gridCell,
  hallHtml,
  hourHeaderPainter,
  lengthHtml,
  rulerHeader,
  summaryPainter,
  trackHtml,
  trackState,
} from './paint'
import {
  bestTrack,
  connectionsIf,
  expectedArrival,
  holdFor,
  isLocked,
  latenessOf,
  moveRefusal,
  nextAlternative,
  partnerOf,
  passengersAffected,
  serviceById,
  trackById,
  trainsPerBucket,
  type ConnectionState,
  type Placement,
  type Plan,
  type Refusal,
} from './rules'
import { VIEW_IDS, type PerronText, type ViewId } from './text'
import { connectionItems, connectionsHtml, trackLabel, trainCard, transferText } from './train-card'

const SLUG = 'rail-platforms'
const NOW_TICKS = parseCivil(NOW)
const DAY_START = parseCivil(DAY.start)
const DAY_END = parseCivil(DAY.end)

/** What the board asks the demo to do (context menu, hover buttons, keyboard). */
export type TrainAction = 'details' | 'propagate' | 'replatform' | 'hold' | 'release' | 'restore'

/** Two header rows at every level: the hour with its numeral, then the minute ruler. */
const HEADERS: SuperScheduler.TimeHeaderData[] = [
  // An empty declared format tells the level-of-detail planner the labels take no room (the hook
  // writes its own markup), so it never coarsens the hours or the minute ruler.
  { groupBy: 'Hour', height: BOARD.header.hour, format: '' },
  { groupBy: 'Cell', height: BOARD.header.ruler, format: '' },
]

/** Minutes for the peak, minutes at a glance for the morning, hours for the operating day. */
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  {
    id: 'rush',
    properties: { scale: 'Minute', cellWidth: BOARD.cellWidth.rush, timeHeaders: HEADERS },
  },
  {
    id: 'morning',
    properties: { scale: 'Minute', cellWidth: BOARD.cellWidth.morning, timeHeaders: HEADERS },
  },
  {
    id: 'day',
    properties: { scale: 'Hour', cellWidth: BOARD.cellWidth.day, timeHeaders: HEADERS },
  },
]

/** The station is closed from 01:00 to 05:00: those hours leave the axis. */
function includeOperatingDay(args: SuperScheduler.SchedulerIncludeTimeCellArgs): void {
  const start = args.cell.start.getTime()
  args.cell.visible = start >= DAY_START && start < DAY_END
}

/**
 * Brings one train's links forward (`pr-traced`) and lets the others step back, or clears it.
 * Class writes on hover enter and leave only.
 */
function trace(root: HTMLElement | null, id: string | null): void {
  if (root === null) return
  for (const element of root.querySelectorAll('.pr-traced')) element.classList.remove('pr-traced')
  root.toggleAttribute('data-tracing', id !== null)
  if (id === null) return
  for (const element of root.querySelectorAll(`.pr-l-${CSS.escape(id)}`))
    element.classList.add('pr-traced')
}

/** Cell content (the summary bands, the works label) shows down to 2 px per minute. */
const LOD = { cellContent: 2 } as const

/** A hover button on a card (`visibility: 'Hover'`): the press is the area's, not a drag. */
function hoverAction(
  icon: 'propagate' | 'hold',
  label: string,
  run: () => void,
): SuperScheduler.AreaData {
  return {
    right: 3,
    top: 'calc(50% - 12px)',
    width: 24,
    height: 24,
    visibility: 'Hover',
    action: 'None',
    cssClass: 'pr-act',
    html: PICTOGRAM[icon],
    toolTip: label,
    onClick: (args) => {
      args.preventDefault()
      run()
    },
  }
}

/** A refusal as one complete sentence, with the numbers of the train and the track. */
export function refusalMessage(
  refusal: Refusal,
  id: string,
  placement: Pick<Placement, 'track'>,
  text: PerronText,
  tag: string,
): string {
  const service = serviceById(id)
  const track = trackById(placement.track)
  const other = refusal.other === undefined ? null : serviceById(refusal.other)
  return format(text.rules[refusal.reason], {
    train: service === null ? id : trainName(service, text),
    other: other === null ? '' : trainName(other, text),
    length: String(service?.length ?? 0),
    track: trackLabel(placement.track, text),
    trackLength: String(track?.length ?? 0),
    minutes: String(refusal.minutes ?? 0),
    time: refusal.at === undefined ? '' : clock(parseCivil(refusal.at), tag),
  })
}

/** Menu and sheet label for propagating a reported delay. */
export function propagateLabel(
  occupation: Occupation,
  text: PerronText,
): { label: string; enabled: boolean } {
  const service = serviceById(occupation.id)
  const pending = service === null ? 0 : latenessOf(service, occupation).pending
  return pending > 0 && !isLocked(occupation)
    ? { label: format(text.actions.propagate, { minutes: String(pending) }), enabled: true }
    : { label: text.actions.noDelay, enabled: false }
}

/** Menu and sheet label for moving a train to the best other platform at the same times. */
export function replatformLabel(
  occupation: Occupation,
  connections: readonly Connection[],
  plan: Plan,
  text: PerronText,
): { label: string; enabled: boolean } {
  const choice = isLocked(occupation) ? null : bestTrack(occupation.id, connections, plan)
  return choice === null
    ? { label: text.actions.noTrack, enabled: false }
    : {
        label: format(text.actions.replatform, { track: trackLabel(choice.track, text) }),
        enabled: true,
      }
}

/**
 * Menu and sheet label for holding a departure for a late feeder, said from the departure ('in':
 * hold for RE 4811) or from the feeder ('out': hold S3 30807).
 */
export function holdLabel(
  state: ConnectionState,
  plan: Plan,
  text: PerronText,
  tag: string,
  side: 'in' | 'out' = 'in',
): { label: string; enabled: boolean } {
  const result = holdFor(state.connection, plan)
  const departing = serviceById(state.connection.to)
  const feeder = serviceById(state.connection.from)
  const template = side === 'in' ? text.actions.hold : text.actions.holdOut
  const values = {
    train: departing === null ? '' : trainName(departing, text),
    feeder: feeder === null ? '' : trainName(feeder, text),
  }
  if (result === null)
    return { label: format(template, { ...values, minutes: '0' }), enabled: false }
  return result.ok
    ? { label: format(template, { ...values, minutes: String(result.minutes) }), enabled: true }
    : {
        label: refusalMessage(
          result.refusal,
          state.connection.to,
          { track: state.toTrack },
          text,
          tag,
        ),
        enabled: false,
      }
}

/** Menu and sheet label for releasing a connection: where its passengers go instead. */
export function releaseLabel(
  state: ConnectionState,
  plan: Plan,
  text: PerronText,
  tag: string,
): string {
  const next = nextAlternative(state.connection.to, plan)
  const service = next === null ? null : serviceById(next.id)
  return format(text.actions.release, {
    count: String(state.connection.passengers),
    train: service === null ? '—' : trainName(service, text),
    time: next === null ? '—' : clock(parseCivil(next.end), tag),
  })
}

export interface PerronSchedulerProps {
  readonly events: TrainEvent[]
  readonly plan: Plan
  readonly states: readonly ConnectionState[]
  readonly connections: readonly Connection[]
  readonly text: PerronText
  readonly common: DemoCommon
  /** BCP 47 tag for labels (24-hour), and the scheduler locale id. */
  readonly tag: string
  readonly schedulerLocale: string
  readonly initialView: ViewId
  /** The train shown in the side sheet: it gets the selection ring. */
  readonly selected: string | null
  /** Phones keep the track column only. */
  readonly narrow: boolean
  readonly history: SchedulerHistory
  readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  readonly onSelect: (id: string) => void
  readonly onMoved: (id: string, across: boolean) => void
  readonly onResized: (id: string) => void
  readonly onRefused: (id: string, message: string) => void
  readonly onAction: (action: TrainAction, id: string, connection?: string) => void
  readonly onZoomLevel: (level: ViewId) => void
}

export function PerronScheduler(props: PerronSchedulerProps) {
  const { events, plan, states, connections, text, common, tag, schedulerLocale } = props
  const { initialView, selected, narrow, history, controlRef } = props
  const { onEventsChange, onSelect, onMoved, onResized, onRefused, onAction, onZoomLevel } = props

  const resources = useMemo(() => toResources(text), [text])
  const refusal = useDropRefusal(onRefused)
  const board = useRef<HTMLDivElement>(null)
  // Whether the last committed move changed track, decided in onEventMove (before the commit).
  const across = useRef(new Map<string, boolean>())
  const [level, setLevel] = useState<ViewId>(initialView)

  // Trains per hour for the hour numerals, from the plan in state.
  const paintHour = useMemo(() => {
    const hours = Math.round((DAY_END - DAY_START) / HOUR)
    const counts = trainsPerBucket(plan.list, DAY_START, HOUR, hours)
    return hourHeaderPainter(
      (start) => counts[Math.floor((start - DAY_START) / HOUR)] ?? 0,
      text,
      tag,
    )
  }, [plan, text, tag])
  // A fresh hook per settled level and per plan: the control labels its headers again.
  const timeHeaders = useMemo(() => {
    const hook = (args: SuperScheduler.SchedulerBeforeTimeHeaderRenderArgs) => {
      const header = args.header
      const start = header.start.getTime()
      const end = header.end.getTime()
      if (header.level === 1) {
        const ruler = rulerHeader(start, end, text, tag)
        header.html = ''
        header.cssClass = ruler.cssClass
        header.areas = ruler.areas
        return
      }
      const cellWidth = args.control.cellWidth ?? BOARD.cellWidth.rush
      // Hour-scale cells are hours; minute-scale widths are per minute.
      const perMinute =
        end - start >= HOUR && args.control.scale === 'Hour' ? cellWidth / 60 : cellWidth
      const painted = paintHour(start, end, perMinute)
      header.html = painted.html
      header.areas = painted.areas
      header.cssClass = start < NOW_TICKS - HOUR + 1 ? 'pr-th pr-th--past' : 'pr-th'
      header.toolTip = ''
    }
    return { level, hook }
  }, [level, paintHour, text, tag]).hook

  const paintSummary = useMemo(() => summaryPainter(plan, text, tag), [plan, text, tag])

  // Links come from the timetable: each connection, from the feeder's arrival to the departure.
  // (A set that turns around stands on one track: its two cards touch, no link needed.)
  const links = useMemo<SuperScheduler.LinkData[]>(() => {
    const escape = SuperScheduler.Util.escapeHtml
    // Connections whose train has left at the station clock are history: they are not drawn. The
    // operating day in hours keeps the missed ones only (the others would be a hatch of lines).
    const live = states.filter((state) => {
      const departure = plan.byId.get(state.connection.to)
      if (departure === undefined || parseCivil(departure.end) <= NOW_TICKS) return false
      return level !== 'day' || state.status === 'broken'
    })
    const out: SuperScheduler.LinkData[] = live.map((state) => {
      const feeder = serviceById(state.connection.from)
      return {
        id: state.connection.id,
        from: state.connection.from,
        to: state.connection.to,
        type: 'StartToFinish',
        // Each end's class lets a hovered or selected train pick out its own connections.
        cssClass: `pr-link pr-link--${state.status} pr-l-${state.connection.from} pr-l-${state.connection.to}${
          selected === state.connection.from || selected === state.connection.to
            ? ' pr-link--sel'
            : ''
        }`,
        bubbleHtml:
          `<span class="pr-bubble" data-status="${state.status}"><b>${escape(feeder === null ? '' : trainName(feeder, text))}</b>` +
          `<span>${escape(state.status === 'released' ? text.transfer.released : transferText(state, text))}</span>` +
          `<span>${escape(plural(text.transfer.passengers, state.connection.passengers, tag))}</span></span>`,
      }
    })
    return out
  }, [states, plan, selected, level, text, tag])

  const linkBubble = useMemo(
    () =>
      new SuperScheduler.Bubble({
        theme: skinMenuTheme(SLUG),
        position: 'Mouse',
        showAfter: 120,
        hideOnClick: true,
      }),
    [],
  )

  // Hover card: route, times against the timetable, fit and every connection.
  const card = useCallback(
    (event: SuperScheduler.Event): HoverCardModel | null => {
      const occupation = plan.byId.get(String(event.id()))
      return occupation === undefined
        ? null
        : trainCard(occupation, states, plan, text, common, tag)
    },
    [plan, states, text, common, tag],
  )
  const { eventHover, closeHover, suppressHover } = useEventHover(card)

  // Drag verdict: the station's refusal, the axis of the move, and the connections it leaves.
  const dragCard = useMemo<SuperScheduler.DragCardOptions>(() => {
    const proposed = (info: SuperScheduler.DragCardInfo) => {
      const current = plan.byId.get(String(info.eventId))
      if (current === undefined || info.resource === null || !isTrackId(info.resource)) return null
      const placement: Placement = {
        track: info.resource,
        start: civil(info.start.getTime()),
        end: civil(info.end.getTime()),
      }
      return { current, placement }
    }
    const base = createDragCard({
      tag,
      common,
      rowName: (id) => (isTrackId(id) ? trackLabel(id, text) : null),
      warn: (info) => {
        const next = proposed(info)
        if (next === null) return null
        const broken = connectionsIf(next.current.id, next.placement, connections, plan).filter(
          (state) => state.status === 'broken',
        )
        if (broken.length > 0)
          return plural(text.announce.broken, broken.length, tag, {
            passengers: String(passengersAffected(broken)),
          })
        const track = trackById(next.placement.track)
        return track !== null && !track.stepFree && next.placement.track !== next.current.track
          ? format(text.card.stairs, { track: trackLabel(track.id, text) })
          : null
      },
    })
    return {
      ...base,
      // The header marker follows the minute, as the ruler does.
      markerFormat: 'HH:mm',
      html: (info) => {
        const html = base.html?.(info) ?? null
        if (html === null || info.forbidden || info.overlapping) return html
        const next = proposed(info)
        if (next === null) return html
        const across = next.placement.track !== next.current.track
        const mode = across
          ? text.card.kept
          : format(text.card.retimed, {
              minutes: `${(parseCivil(next.placement.start) - parseCivil(next.current.start)) / 60_000 >= 0 ? '+' : '−'}${Math.abs(Math.round((parseCivil(next.placement.start) - parseCivil(next.current.start)) / 60_000))}`,
            })
        const items = connectionItems(
          next.current.id,
          connectionsIf(next.current.id, next.placement, connections, plan),
          text,
        )
        const extra =
          `<div class="pr-vd-mode" data-axis="${across ? 'track' : 'time'}">${SuperScheduler.Util.escapeHtml(mode)}</div>` +
          connectionsHtml(items, text)
        return `${html.slice(0, -'</div>'.length)}${extra}</div>`
      },
    }
  }, [plan, connections, text, common, tag])

  // Context menu: rebuilt when the plan changes, so its labels name the real consequence.
  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 occupation = plan.byId.get(id)
          const service = serviceById(id)
          if (occupation === undefined || service === null) return []
          const entries: MenuEntry[] = [
            {
              label: common.kit.openDetails,
              icon: 'details',
              keys: KEYS.details,
              run: () => onAction('details', id),
            },
          ]
          if (service.category === null) return entries
          const propagate = propagateLabel(occupation, text)
          entries.push({
            label: propagate.label,
            icon: 'slot',
            primary: propagate.enabled,
            disabled: !propagate.enabled,
            run: () => onAction('propagate', id),
          })
          const replatform = replatformLabel(occupation, connections, plan, text)
          entries.push({
            label: replatform.label,
            icon: 'handover',
            disabled: !replatform.enabled,
            run: () => onAction('replatform', id),
          })
          const incoming = states.filter(
            (state) => state.connection.to === id && state.status !== 'ok',
          )
          const outgoing = states.filter(
            (state) => state.connection.from === id && state.status === 'broken',
          )
          if (incoming.length + outgoing.length > 0) entries.push('separator')
          for (const state of outgoing) {
            const hold = holdLabel(state, plan, text, tag, 'out')
            entries.push(
              {
                label: hold.label,
                icon: 'pending',
                primary: hold.enabled,
                disabled: !hold.enabled,
                run: () => onAction('hold', id, state.connection.id),
              },
              {
                label: releaseLabel(state, plan, text, tag),
                icon: 'after',
                run: () => onAction('release', id, state.connection.id),
              },
            )
          }
          for (const state of incoming) {
            if (state.status === 'released') {
              const feeder = serviceById(state.connection.from)
              entries.push({
                label: format(text.actions.restore, {
                  train: feeder === null ? '' : trainName(feeder, text),
                }),
                icon: 'link',
                run: () => onAction('restore', id, state.connection.id),
              })
              continue
            }
            const hold = holdLabel(state, plan, text, tag)
            entries.push(
              {
                label: hold.label,
                icon: 'pending',
                primary: hold.enabled,
                disabled: !hold.enabled,
                run: () => onAction('hold', id, state.connection.id),
              },
              {
                label: releaseLabel(state, plan, text, tag),
                icon: 'after',
                run: () => onAction('release', id, state.connection.id),
              },
            )
          }
          return entries
        },
      }),
    [plan, states, connections, common, text, tag, onAction, suppressHover],
  )

  const config = useMemo<SchedulerRenderProps>(() => {
    const escape = SuperScheduler.Util.escapeHtml
    const brokenIn = new Set(
      states.filter((state) => state.status === 'broken').map((state) => state.connection.to),
    )
    const brokenOut = new Set(
      states.filter((state) => state.status === 'broken').map((state) => state.connection.from),
    )
    const cards = new Map<
      string,
      { html: string; areas: SuperScheduler.AreaData[]; label: string }
    >()
    const dates = new Map<string, SchedulerDate>()
    const dateOf = (value: string) => {
      let date = dates.get(value)
      if (date === undefined) {
        date = new SchedulerDate(value)
        dates.set(value, date)
      }
      return date
    }
    const hallMeta = (hall: 'hall-a' | 'hall-b') => {
      const tracks = hallTracks(hall)
      return format(text.hallMeta, {
        from: tracks[0]?.slice(1) ?? '',
        to: tracks[tracks.length - 1]?.slice(1) ?? '',
      })
    }
    /** Re-platforming keeps the timetable: a move to another track snaps back to the train's times. */
    const lockAxis = (id: string, resource: SuperScheduler.ResourceId): Occupation | null => {
      const current = plan.byId.get(id)
      return current !== undefined && String(resource) !== current.track ? current : null
    }

    return {
      locale: schedulerLocale,
      cssClass: skinClass(SLUG),
      startDate: TIMELINE.start,
      days: TIMELINE.days,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialView,
      zoomPosition: 'left',
      onIncludeTimeCell: includeOperatingDay,
      cellsMarkBusiness: false,
      useEventBoxes: 'Never',
      height: '100%',
      treeEnabled: true,
      treePreventParentUsage: true,
      rowHeaderColumns: narrow
        ? [{ text: text.columns.track, width: BOARD.narrowColumn }]
        : [
            { text: text.columns.track, width: BOARD.trackColumn },
            { text: text.columns.length, width: BOARD.lengthColumn },
            { text: text.columns.access, width: BOARD.accessColumn },
          ],
      rowHeaderColumnsMergeParents: true,
      rowHeaderWidth: narrow ? BOARD.narrowColumn : BOARD.rowHeaderWidth,
      rowHeaderWidthAutoFit: false,
      treeIndent: BOARD.treeIndent,
      eventHeight: BOARD.eventHeight,
      rowMarginTop: BOARD.rowMarginTop,
      rowMarginBottom: BOARD.rowMarginBottom,
      allowEventOverlap: false,
      conflictHighlight: true,
      durationBarVisible: false,
      linkShape: 'RightAngled',
      linkLayer: 'Above',
      linkBubble,
      crosshairType: 'Full',
      lod: LOD,
      // The station clock: a fixed instant of the scenario, not the visitor's clock.
      separators: [
        {
          location: NOW,
          layer: 'AboveEvents',
          width: 2,
          cssClass: 'pr-now',
          toolTip: `${text.chrome.clock} ${clock(NOW_TICKS, tag)}`,
        },
      ],
      showToolTip: false,
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      keyboardOptions: { zoomKeys: true },
      history,
      controlRef,
      onEventsChange,

      // The access column is titled by its pictogram (its name stays for assistive technology).
      onBeforeRowHeaderColumnRender: (args) => {
        if (args.column.data.text !== text.columns.access) return
        args.column.html =
          `<span class="pr-colhead-access" title="${escape(text.columns.access)}">${PICTOGRAM.stepFree}` +
          `<span class="sr-only">${escape(text.columns.access)}</span></span>`
      },

      onBeforeCornerRender: (args) => {
        args.html = narrow
          ? ''
          : `<span class="pr-corner" aria-hidden="true">${escape(text.chrome.station)}</span>`
      },

      onBeforeRowHeaderRender: (args) => {
        const row = args.row
        const [first, second, third] = row.columns
        const id = row.id
        if (id === SUMMARY_ID) {
          row.cssClass = 'pr-row pr-row--summary'
          row.html =
            `<span class="pr-sumhead"><b>${escape(text.summary.row)}</b>` +
            `<span>${escape(text.summary.rowMeta[level])}</span></span>`
          if (first !== undefined) first.html = row.html
          if (second !== undefined) second.html = ''
          if (third !== undefined) third.html = ''
          return
        }
        if (isHallId(id)) {
          row.cssClass = 'ss-rh--group pr-row pr-row--hall'
          row.html = hallHtml(String(row.name), hallMeta(id))
          if (first !== undefined) first.html = row.html
          return
        }
        const track = isTrackId(id) ? trackById(id) : null
        if (track === null || first === undefined) return
        row.cssClass = `pr-row pr-row--track pr-row--${track.hall}`
        first.html = trackHtml(track, String(row.name), trackState(track, plan, text, tag), narrow)
        if (second !== undefined) second.html = lengthHtml(track, text)
        if (third !== undefined) third.html = accessHtml(track, text)
      },

      onBeforeCellRender: (args) => {
        const cell = args.cell
        const start = cell.start.getTime()
        const end = cell.end.getTime()
        const painted =
          cell.resource === SUMMARY_ID
            ? paintSummary(start, end)
            : gridCell(String(cell.resource), start, end, text, tag)
        cell.properties.cssClass = painted.css
        if (painted.html !== undefined) cell.properties.html = painted.html
        if (painted.disabled === true) cell.properties.disabled = true
      },

      onBeforeEventRender: (args) => {
        const data = args.data
        const id = String(data.id)
        const occupation = plan.byId.get(id)
        const service = serviceById(id)
        if (occupation === undefined || service === null || readTrainFields(data) === null) return
        const lateness = latenessOf(service, occupation)
        const broken = brokenIn.has(id)
        const isSelected = id === selected
        const key = `${id}|${occupation.track}|${occupation.start}|${occupation.end}|${broken ? 'b' : ''}|${brokenOut.has(id) ? 'o' : ''}`
        let painted = cards.get(key)
        if (painted === undefined) {
          const areas: SuperScheduler.AreaData[] = []
          const expected = expectedArrival(service)
          if (lateness.pending > 0 && expected !== null)
            areas.push({
              start: occupation.start,
              end: civil(Math.min(parseCivil(occupation.end), expected)),
              top: 0,
              bottom: 0,
              cssClass: 'pr-pending',
              toolTip: format(text.card.pendingRule, {
                minutes: lateness.pending,
                time: clock(expected, tag),
              }),
            })
          if (!isLocked(occupation)) {
            const propagate = propagateLabel(occupation, text)
            if (propagate.enabled)
              areas.push(hoverAction('propagate', propagate.label, () => onAction('propagate', id)))
            else if (broken) {
              const state = states.find(
                (candidate) => candidate.connection.to === id && candidate.status === 'broken',
              )
              const hold = state === undefined ? null : holdLabel(state, plan, text, tag)
              if (state !== undefined && hold?.enabled === true)
                areas.push(
                  hoverAction('hold', hold.label, () => onAction('hold', id, state.connection.id)),
                )
            }
          }
          const late = Math.max(lateness.arrival, lateness.departure, lateness.pending)
          const label = [
            trainName(service, text),
            service.to ?? service.from ?? '',
            trackLabel(occupation.track, text),
            `${clock(parseCivil(occupation.start), tag)}–${clock(parseCivil(occupation.end), tag)}`,
            late > 0 ? format(text.board.late, { minutes: late }) : '',
            broken ? text.status.broken : '',
          ]
            .filter((part) => part !== '')
            .join(', ')
          painted = { html: cardHtml(occupation, service, text, tag), areas, label }
          cards.set(key, painted)
        }
        data.html = painted.html
        data.areas = painted.areas
        data.ariaLabel = painted.label
        const classes = [
          'pr-ev',
          `pr-ev--${service.category === null ? 'shunt' : service.category.toLowerCase()}`,
        ]
        const end = parseCivil(occupation.end)
        if (end <= NOW_TICKS) classes.push('pr-ev--past')
        else if (isLocked(occupation)) classes.push('pr-ev--standing')
        if (lateness.pending > 0) classes.push('pr-ev--pending')
        // A set that turns around: the inbound card opens into the outbound one.
        const partner = partnerOf(service)
        const partnerPlan = partner === null ? undefined : plan.byId.get(partner.id)
        if (partnerPlan !== undefined && partnerPlan.track === occupation.track) {
          if (service.kind === 'terminating' && partnerPlan.start === occupation.end)
            classes.push('pr-ev--set-in')
          if (service.kind === 'originating' && partnerPlan.end === occupation.start)
            classes.push('pr-ev--set-out')
        }
        if (broken) classes.push('pr-ev--broken')
        if (isSelected) classes.push('pr-ev--selected')
        data.cssClass = classes.join(' ')
      },

      // Platform length, headway, works, timetable and turnarounds are station rules; a move to
      // another track keeps the train's times (re-platforming), a move along it retimes it.
      onEventMoving: (args) => {
        const id = String(args.e.id())
        const kept = lockAxis(id, args.resource)
        if (kept !== null) {
          args.start = dateOf(kept.start)
          args.end = dateOf(kept.end)
        }
        if (!isTrackId(args.resource)) return
        const placement: Placement = {
          track: args.resource,
          start: civil(args.start.getTime()),
          end: civil(args.end.getTime()),
        }
        const reason = moveRefusal(id, placement, plan)
        if (reason === null) {
          refusal.track(id, null)
          return
        }
        const message = refusalMessage(reason, id, placement, text, tag)
        args.allowed = false
        args.message = message
        refusal.track(id, message)
      },

      onEventResizing: (args) => {
        const id = String(args.e.id())
        const current = plan.byId.get(id)
        if (current === undefined) return
        const placement: Placement = {
          track: current.track,
          start: civil(args.start.getTime()),
          end: civil(args.end.getTime()),
        }
        const reason = moveRefusal(id, placement, plan)
        if (reason === null) {
          refusal.track(id, null)
          return
        }
        const message = refusalMessage(reason, id, placement, text, tag)
        args.allowed = false
        args.message = message
        refusal.track(id, message)
      },

      // Final check before the commit (also covers keyboard moves, Alt + arrows).
      onEventMove: (args) => {
        const id = String(args.e.id())
        const kept = lockAxis(id, args.newResource)
        if (kept !== null) {
          args.newStart = dateOf(kept.start)
          args.newEnd = dateOf(kept.end)
        }
        if (!isTrackId(args.newResource)) {
          args.preventDefault()
          return
        }
        const placement: Placement = {
          track: args.newResource,
          start: civil(args.newStart.getTime()),
          end: civil(args.newEnd.getTime()),
        }
        const reason = moveRefusal(id, placement, plan)
        if (reason !== null) {
          args.preventDefault()
          refusal.clear()
          onRefused(id, refusalMessage(reason, id, placement, text, tag))
          return
        }
        across.current.set(id, kept !== null)
      },

      onEventResize: (args) => {
        const id = String(args.e.id())
        const current = plan.byId.get(id)
        if (current === undefined) return
        const placement: Placement = {
          track: current.track,
          start: civil(args.newStart.getTime()),
          end: civil(args.newEnd.getTime()),
        }
        const reason = moveRefusal(id, placement, plan)
        if (reason !== null) {
          args.preventDefault()
          refusal.clear()
          onRefused(id, refusalMessage(reason, id, placement, text, tag))
        }
      },

      onEventMoved: (args) => {
        refusal.clear()
        const id = String(args.e.id())
        onMoved(id, across.current.get(id) === true)
      },
      onEventResized: (args) => {
        refusal.clear()
        onResized(String(args.e.id()))
      },
      // A click (or Enter) docks the train into the side sheet; the hover card steps aside.
      onEventClick: (args) => {
        closeHover()
        onSelect(String(args.e.id()))
      },
      // Pointing at a train picks out its connections: classes on enter and leave, never per frame.
      onEventMouseEnter: (args) => trace(board.current, String(args.e.id())),
      onEventMouseLeave: () => trace(board.current, null),

      onZoom: (args) => {
        if (args.phase !== 'end') return
        const next = VIEW_IDS[args.level]
        if (next === undefined) return
        setLevel(next)
        onZoomLevel(next)
      },
    }
  }, [
    plan,
    states,
    selected,
    narrow,
    level,
    history,
    controlRef,
    linkBubble,
    paintSummary,
    refusal,
    closeHover,
    onEventsChange,
    onMoved,
    onRefused,
    onResized,
    onSelect,
    onAction,
    onZoomLevel,
    schedulerLocale,
    initialView,
    text,
    tag,
  ])

  // Imperative and once: a scroll prop would be re-applied on every update. Phones open at the
  // station clock, wider stages at the start of the 07:00 node.
  useEffect(() => {
    controlRef.current?.scrollTo(
      narrow ? civil(NOW_TICKS - 14 * 60_000) : INITIAL_VIEW_START,
      false,
      'left',
    )
    // Only on mount: later width changes keep the visitor's position.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [controlRef])

  return (
    <div ref={board} className="pr-board-host h-full [&>div]:h-full">
      <SuperSchedulerComponent
        {...config}
        onBeforeTimeHeaderRender={timeHeaders}
        resources={resources}
        events={events}
        links={links}
        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.