Saltar al contenido

Planificación de vías en estación

Un InterCity llega seis minutos tarde. Mantén a sus 140 viajeros en el S3 sin retrasar el S3.

  • Ferrocarril
  • Perron
  • Escala por minutos
  • Dependencias
  • Reglas de movimiento
  • Filas de resumen
  • Cruceta
  • Zoom continuo
  • Vistas guardadas
  • Minimapa
  • Temas
  • Demo con datos sintéticos

Misión

0 de 3 pasos hechos

Miércoles, 07:42. El IC 712 procedente de Kaltenhof comunica seis minutos de retraso: llegará a la vía 2 a las 08:02, no a las 07:56. 140 de sus viajeros enlazan con el S3 a Hagenmoor.

  1. Lleva el retraso del IC 712 al plan. — Pendiente

    Arrastra el IC 712 (vía 2, rayado en rojo) seis minutos a la derecha, o haz clic derecho sobre él y elige Aplicar retraso +6′. Su enlace con el S3 se pone en rojo.

  2. Mantén a sus viajeros en el S3 sin retrasar el S3. — Pendiente

    Arrastra el S3 (vía 7, sale 08:07) en vertical hasta la vía 4 de la nave A: dentro de una nave bastan 4 minutos, y cambiar de vía conserva las horas del tren.

  3. Resuelve el último enlace en rojo. — Pendiente

    El RE 4811 llega ahora a la otra nave, con 2 minutos de menos. Haz clic en el S3 y pulsa Esperar al RE 4811 +2′, o arrastra su borde derecho dos minutos más tarde.

Unos 30 segundos. Los pasos se marcan a medida que los completas.

Teclado: enfoca la rejilla y usa las flechas, Intro y Alt+flechas para mover.

Perron
Nave AVía 1420 mVía 2420 mVía 3320 mVía 4320 mNave BVía 5400 mVía 6400 mVía 7240 mVía 8240 mIC 712SeewaldeIC 714SeewaldeIC 716SeewaldeRE 4903KranzbachRE 4905KranzbachIC 713KaltenhofIC 715KaltenhofIC 717KaltenhofRE 4902desde KranzbachRE 4904desde KranzbachRE 4906desde KranzbachRE 4714desde ArnsfeldRE 4715ArnsfeldRE 4716desde ArnsfeldRE 4717ArnsfeldRE 4718desde ArnsfeldRE 4719ArnsfeldManiobraRE 4812TalbrunnRE 4814TalbrunnRE 4816TalbrunnS1RotheimS1LohwaldS1RotheimS1LohwaldS1RotheimS3desde HagenmoorS3HagenmoorS3desde HagenmoorS3desde HagenmoorS3HagenmoorS3HagenmoorS3desde HagenmoorS3desde HagenmoorS3HagenmoorS3HagenmoorS3HagenmoorRE 4811MirlachRE 4813MirlachS3desde HagenmoorS3HagenmoorS3desde HagenmoorManiobraS5desde GrauholzS5Grauholz2/85/85/83/82/85/85/82/82/85/8Vías ocupadasmáximo por 15 min0708093045153045153045

Vista previa del primer fotogramaEn vivo: el motor Pro real, funcionando en tu navegadorUnos 30 segundos. Los pasos se marcan a medida que los completas. Teclado: enfoca la rejilla y usa las flechas, Intro y Alt+flechas para mover.

La situación.

Perron es una herramienta ficticia de planificación de vías en Brennholt Central, una estación de paso inventada con ocho vías con andén repartidas en dos naves. Los trenes coinciden en torno a la hora en punto para que se pueda transbordar en todas las direcciones: el horario promete esos enlaces y el regulador de vías los mantiene, minuto a minuto.

Un retraso nunca afecta a un solo tren. Mueve la llegada, la salida, el tiempo de transbordo de todos los que enlazan y a veces el siguiente tren de la misma vía. Un plan que solo dibuja barras lo esconde; este dibuja cada enlace con su tiempo de transbordo, sabe que cruzar el andén lleva 2 minutos y cambiar de nave 7, y rechaza un tren de 400 m en una vía de 320 m.

Qué vas a hacer

Llevarás al plan un retraso comunicado, cambiarás de nave un tren de enlace sin tocar sus horas y retendrás una salida para los viajeros que la perderían.

Dónde termina la librería y empieza tu aplicación.

SuperScheduler aporta

  • Una escala de minutos (scale: Minute) con tres niveles de zoom hasta horas, el gesto de zoom continuo y las teclas de zoom; las horas de la noche salen del eje con onIncludeTimeCell.
  • Dos filas de cabecera escritas por onBeforeTimeHeaderRender: los números de las horas con los trenes de esa hora y el reloj de la estación como áreas de cabecera, y una regla de minutos que sigue la cruceta (crosshairType: Full).
  • Un árbol de recursos agrupado con rowHeaderColumns (vía y su estado, longitud y sectores, acceso sin escalones) y una fila de resumen fija (frozen: top) que onBeforeCellRender rellena con las vías ocupadas por cuarto de hora.
  • Tarjetas a la hora exacta (useEventBoxes: Never) con un área anclada en el tiempo sobre los minutos en que un tren retrasado aún no puede estar, botones al pasar el ratón (visibility: Hover) y celdas desactivadas para las obras.
  • Enlaces desde tus datos, uno por conexión (tipo StartToFinish: de la llegada del tren de aporte a la salida), con una clase por estado y una burbuja cada uno (linkBubble, bubbleHtml); señalar un tren pone delante sus propios enlaces (onEventMouseEnter).
  • onEventMoving con args.allowed, args.message y args.start y args.end ajustados, para que un cambio de vía conserve las horas del tren; conflictHighlight; historial de deshacer; la tarjeta al pasar el ratón y la tarjeta de arrastre (super-scheduler/react-render); un menú contextual con la piel del producto.
  • getViewState y applyViewState de super-scheduler/views como vistas guardadas, y el minimapa (super-scheduler/minimap) alimentado con los trenes por hora.

El código de este ejemplo decide

  • Qué vía recibe qué tren: longitud del andén frente a longitud del tren, dos minutos de intervalo, obras, el horario (nunca salir antes) y composiciones que dan la vuelta donde están.
  • Lo que necesita un enlace: 2 minutos en un mismo andén, 4 dentro de una nave, 7 entre naves, y los viajeros que lleva.
  • Los comandos, cada uno un paso de deshacer: aplicar un retraso comunicado (y la vuelta que lo sigue), cambiar a la mejor vía libre, retener una salida, renunciar a un enlace.
  • Las cifras: el panel de salidas, los enlaces perdidos y los viajeros afectados, los retrasos por aplicar, las vías ocupadas y los trenes por hora.
  • La piel de estilo suizo: rótulos de vía, panel de paletas, reloj de la estación, pictogramas y tarjetas negras que reservan el rojo para retrasos y enlaces perdidos.

El código de esta demo.

Son los archivos que ejecuta esta página, no una copia simplificada. Compilan contra los exports públicos de SuperScheduler Pro.

tsx.zip

La integración: niveles de zoom en minutos y horas, la jornada de servicio con onIncludeTimeCell, las dos filas de cabecera, rowHeaderColumns, la fila fija de vías ocupadas, las obras como celdas desactivadas, los enlaces con sus burbujas, el separador en el reloj de la estación, el veredicto de arrastre que reevalúa cada enlace en la posición propuesta, el menú contextual y los callbacks donde las reglas de la estación rechazan un movimiento o conservan sus horas.

/**
 * 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>
  )
}

El archivo contiene los ficheros de este ejemplo y un README. No incluye SuperScheduler Pro ni ninguna clave de descarga.