Railway platform planning
An InterCity runs six minutes late. Keep its 140 passengers on the S3 without delaying the S3.
- Rail
- Perron
- Minute scale
- Dependencies
- Move rules
- Summary rows
- Crosshair
- Continuous zoom
- Saved views
- Minimap
- Theming
- Demo with synthetic data
Mission
0 of 3 steps doneWednesday, 07:42. IC 712 from Kaltenhof reports a six-minute delay: it will arrive on Track 2 at 08:02, not 07:56. 140 of its passengers change to the S3 to Hagenmoor.
Put IC 712’s delay on the board. — To do
Drag IC 712 (Track 2, hatched red) six minutes to the right, or right-click it and choose Propagate delay +6′. Its change to the S3 turns red.
Keep its passengers on the S3 without delaying the S3. — To do
Drag the S3 (Track 7, leaves 08:07) straight up to Track 4 in Hall A: changing within a hall takes 4 minutes, and moving to another track keeps the train’s times.
Clear the last red connection. — To do
RE 4811 now arrives in the other hall, 2 minutes short. Click the S3 and press Hold for RE 4811 +2′, or drag its right edge two minutes later.
About 30 seconds. Steps tick off as you complete them.
Keyboard: focus the grid, then use arrow keys, Enter and Alt+arrows to move.
Preview of the first frameLive: the real Pro engine, running in your browserAbout 30 seconds. Steps tick off as you complete them. Keyboard: focus the grid, then use arrow keys, Enter and Alt+arrows to move.
The situation.
Perron is a fictional platform planning tool at Brennholt Central, an invented through station with eight platform tracks in two halls. Trains meet around the full hour, so passengers can change in every direction: the timetable promises those connections, and the platform dispatcher keeps them, minute by minute.
A delay is never one train. It moves the arrival, the departure, the change times of everyone who connects and sometimes the next train on the same track. A board that only draws bars hides that; this one draws every connection with its change time, knows that crossing the platform takes 2 minutes and changing halls takes 7, and refuses a 400 m train on a 320 m track.
What you will do
You will put a reported delay on the board, move a connecting train to another hall without changing its times, and hold a departure for the passengers who would otherwise miss it.
Where the library ends and your application begins.
SuperScheduler provides
- A minute scale (scale: Minute) with three zoom levels down to hours, the continuous zoom gesture and zoom keys; the night hours leave the axis through onIncludeTimeCell.
- Two time-header rows written by onBeforeTimeHeaderRender: the hour numerals with the trains of that hour and the station clock as header areas, and a minute ruler that the crosshair (crosshairType: Full) follows.
- A grouped resource tree with rowHeaderColumns (track and its state, length and sectors, step-free access) and a frozen summary row (frozen: top) that onBeforeCellRender paints with the tracks in use per quarter hour.
- Cards at exact times (useEventBoxes: Never) with a time-anchored area over minutes a late train cannot be there yet, hover buttons (visibility: Hover), and disabled cells for track works.
- Links from your data, one per connection (type StartToFinish: from the feeder’s arrival to the departure), with a class per state and a link bubble each (linkBubble, bubbleHtml); pointing at a train brings its own links forward (onEventMouseEnter).
- onEventMoving with args.allowed, args.message and adjusted args.start and args.end, so a move to another track keeps the train’s times; conflictHighlight; undo history; the event hover card and the drag card (super-scheduler/react-render); a context menu in the product’s skin.
- getViewState and applyViewState from super-scheduler/views as saved views, and the minimap (super-scheduler/minimap) fed with trains per hour.
This example’s code decides
- Which track takes which train: platform length against train length, two minutes of headway, track works, the timetable (never leave early), and sets that turn around where they stand.
- What a connection needs: 2 minutes across one platform, 4 within a hall, 7 between halls, and the passengers it carries.
- The commands, each one undo step: propagate a reported delay (and the turnaround behind it), re-platform to the best free track, hold a departure, release a connection.
- The figures: the departure board, broken connections and passengers affected, delays still to apply, tracks in use and trains per hour.
- The Swiss-style skin: platform signs, split-flap departures, the station clock, pictograms and black cards that keep red for delays and broken changes.
The code behind this demo.
These are the files this page runs, not a simplified copy. They compile against the public SuperScheduler Pro exports.
The integration: minute and hour zoom levels, the operating day through onIncludeTimeCell, the two header rows, rowHeaderColumns, the frozen tracks-in-use row, works as disabled cells, connection links with bubbles, the separator at the station clock, the drag verdict that re-evaluates every connection at the proposed position, the context menu, and the callbacks where station rules refuse a move or keep its times.
/**
* 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>
)
}The archive contains this example’s files and a README. It does not include SuperScheduler Pro or any download key.