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 hechosMié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.
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.
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.
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.
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.
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.