Planification des voies en gare
Un InterCity a six minutes de retard. Gardez ses 140 voyageurs dans le S3 sans retarder le S3.
- Ferroviaire
- Perron
- Échelle à la minute
- Dépendances
- Règles de déplacement
- Lignes de synthèse
- Réticule
- Zoom continu
- Vues enregistrées
- Minimap
- Thèmes
- Démo avec données fictives
Mission
0 étapes faites sur 3Mercredi, 07:42. L’IC 712 en provenance de Kaltenhof annonce six minutes de retard : il arrivera voie 2 à 08:02 au lieu de 07:56. 140 de ses voyageurs ont une correspondance pour le S3 vers Hagenmoor.
Reporter le retard de l’IC 712 dans le plan. — À faire
Faites glisser l’IC 712 (voie 2, hachuré de rouge) de six minutes vers la droite, ou faites un clic droit dessus et choisissez Reporter le retard +6′. Sa correspondance avec le S3 passe au rouge.
Garder ses voyageurs dans le S3 sans retarder le S3. — À faire
Faites glisser le S3 (voie 7, départ 08:07) tout droit vers le haut, sur la voie 4 du hall A : dans un même hall il faut 4 minutes, et un changement de voie garde les horaires du train.
Résoudre la dernière correspondance rouge. — À faire
Le RE 4811 arrive maintenant dans l’autre hall, avec 2 minutes de trop peu. Cliquez sur le S3 et appuyez sur Attendre le RE 4811 +2′, ou faites glisser son bord droit deux minutes plus tard.
Environ 30 secondes. Les étapes se cochent au fur et à mesure.
Clavier : placez le focus sur la grille, puis flèches, Entrée et Alt+flèches pour déplacer.
Aperçu de la première imageEn direct : le vrai moteur Pro, dans votre navigateurEnviron 30 secondes. Les étapes se cochent au fur et à mesure. Clavier : placez le focus sur la grille, puis flèches, Entrée et Alt+flèches pour déplacer.
La situation.
Perron est un outil fictif de planification des voies à Brennholt Central, une gare de passage inventée avec huit voies à quai réparties dans deux halls. Les trains se croisent autour de l’heure pleine pour que l’on puisse changer dans toutes les directions : l’horaire promet ces correspondances, le régulateur des voies les tient, minute par minute.
Un retard ne concerne jamais un seul train. Il décale l’arrivée, le départ, le temps de correspondance de tous ceux qui changent et parfois le train suivant sur la même voie. Un plan qui ne dessine que des barres le cache ; celui-ci dessine chaque correspondance avec son temps, sait qu’il faut 2 minutes pour traverser le quai et 7 pour changer de hall, et refuse un train de 400 m sur une voie de 320 m.
Ce que vous allez faire
Vous reporterez un retard annoncé dans le plan, changerez un train en correspondance de hall sans toucher à ses horaires et retiendrez un départ pour les voyageurs qui le manqueraient.
Où s’arrête la bibliothèque et où commence votre application.
SuperScheduler fournit
- Une échelle en minutes (scale: Minute) avec trois niveaux de zoom jusqu’aux heures, le geste de zoom continu et les touches de zoom ; les heures de nuit quittent l’axe via onIncludeTimeCell.
- Deux lignes d’en-tête écrites par onBeforeTimeHeaderRender : les chiffres des heures avec les trains de cette heure et l’horloge de la gare comme zones d’en-tête, et une règle des minutes que suit le réticule (crosshairType: Full).
- Un arbre de ressources groupé avec rowHeaderColumns (voie et son état, longueur et secteurs, accès de plain-pied) et une ligne de synthèse figée (frozen: top) que onBeforeCellRender remplit avec les voies occupées par quart d’heure.
- Des cartes à l’heure exacte (useEventBoxes: Never) avec une zone ancrée dans le temps sur les minutes où un train en retard ne peut pas encore être là, des boutons au survol (visibility: Hover) et des cellules désactivées pour les travaux.
- Des liens issus de vos données, un par correspondance (type StartToFinish : de l’arrivée du train d’apport au départ), avec une classe par état et une bulle chacun (linkBubble, bubbleHtml) ; survoler un train met ses propres liens en avant (onEventMouseEnter).
- onEventMoving avec args.allowed, args.message et args.start et args.end ajustés, pour qu’un changement de voie garde les horaires du train ; conflictHighlight ; l’historique d’annulation ; la carte au survol et la carte de glisser-déposer (super-scheduler/react-render) ; un menu contextuel aux couleurs du produit.
- getViewState et applyViewState de super-scheduler/views comme vues enregistrées, et la mini-carte (super-scheduler/minimap) alimentée par les trains par heure.
Le code de cet exemple décide
- Quelle voie reçoit quel train : longueur du quai contre longueur du train, deux minutes d’espacement, travaux, horaire (jamais partir en avance) et rames qui font demi-tour là où elles sont.
- Ce qu’il faut à une correspondance : 2 minutes sur un même quai, 4 dans un hall, 7 entre les halls, et les voyageurs qu’elle porte.
- Les commandes, chacune une étape d’annulation : reporter un retard annoncé (avec le demi-tour qui suit), changer de voie vers la meilleure voie libre, retenir un départ, abandonner une correspondance.
- Les chiffres : le tableau des départs, les correspondances manquées et les voyageurs touchés, les retards à reporter, les voies occupées et les trains par heure.
- Le style suisse : panneaux de voie, affichage à palettes, horloge de la gare, pictogrammes et cartes noires qui réservent le rouge aux retards et aux correspondances manquées.
Le code de cette démo.
Ce sont les fichiers exécutés par cette page, pas une copie simplifiée. Ils compilent avec les exports publics de SuperScheduler Pro.
L’intégration : niveaux de zoom en minutes et en heures, la journée d’exploitation via onIncludeTimeCell, les deux lignes d’en-tête, rowHeaderColumns, la ligne figée des voies occupées, les travaux en cellules désactivées, les liens de correspondance avec leurs bulles, le séparateur à l’horloge de la gare, le verdict de glisser-déposer qui réévalue chaque correspondance à la position proposée, le menu contextuel et les callbacks où les règles de la gare refusent un déplacement ou gardent ses horaires.
/**
* 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>
)
}L’archive contient les fichiers de cet exemple et un README. Elle n’inclut ni SuperScheduler Pro ni aucune clé de téléchargement.