LogísticaHarborworks (Negocio ficticio)
Planificación de atraques
Un buque llega doce horas tarde. Mueve su ventana de atraque y lleva con ella el remolcador y las grúas.
- Escala temporal
- Demo con datos sintéticos
- horas / días
- Capacidades Pro
- Eventos de varios díasÁrbol de recursosDependenciasZoom continuoMinimapa
Carga SuperScheduler Pro solo cuando lo pides.
La situación
Harborworks es una terminal ficticia con tres muelles, tres grúas pórtico, una grúa móvil y dos remolcadores. El planificador de atraques trabaja las 24 horas: cada escala necesita un muelle con eslora y calado suficientes, un remolcador que acerque el buque y grúas que alcancen ese muelle.
Los retrasos son lo normal. Cuando un buque comunica una nueva hora de llegada, su ventana se mueve y todo lo que depende de ella también: el remolque, las manos de estiba y a veces el buque siguiente. Un tablero que solo muestra barras esconde esa cascada; este dibuja los vínculos, marca lo que ya no encaja y mantiene a la vista la ocupación del muelle.
Qué vas a hacerMoverás un buque retrasado a su nueva hora de atraque, realinearás su remolcador y sus grúas con un solo comando y resolverás la grúa que coincide con un mantenimiento.
Dónde termina la librería y empieza tu aplicación
SuperScheduler aporta
- Árbol de recursos agrupado con remolcadores, muelles y grúas, y una cabecera de fila propia para cada equipo.
- Barras largas a la hora exacta (useEventBoxes: 'Never') que se arrastran por celdas completas con eventMoveByCell.
- Vínculos inicio-inicio y fin-inicio dibujados a partir de tus datos, con una clase CSS por vínculo.
- Niveles de zoom de horas a días, con gestos y un cambio animado.
- Minimapa con un selector de vista que desplaza y hace zoom, además de prevención de solapes, historial y movimientos con teclado.
El código de este ejemplo decide
- Qué buque cabe en qué muelle (eslora y calado) y qué grúa alcanza qué muelle: los rechazos en onEventMoving.
- Qué es un conflicto: una ventana antes de su ETB, una grúa fuera de su ventana, un remolque que no termina al atracar.
- El comando de realinear: una función pura que mueve las operaciones vinculadas y registra un único paso de deshacer.
- La serie de ocupación del muelle que alimenta el minimapa y el resumen del día con más carga.
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, barras a la hora exacta, vínculos construidos desde los datos y los callbacks donde las reglas de la terminal rechazan un movimiento.
/**
* Harborworks berth plan: tugs, berths and cranes over two weeks of vessel calls, round the clock.
*
* Library: grouped resource tree, long events at exact times, drag/resize, links, zoom levels,
* keyboard and undo history. Application: which ship fits which berth, which crane reaches which
* berth, and what counts as a conflict. Those rules live in ./rules and run in the callbacks below.
*/
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { format, plural } from '../../i18n/format'
import { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import {
BOARD,
assetMeta,
callById,
isAssetId,
readOperationFields,
toResources,
toneOf,
type OperationEvent,
} from './adapter'
import { INITIAL_VIEW_START, PLAN, type Operation, type VesselCall } from './model'
import { berthById, moveRefusal, type Issue, type MoveRefusal } from './rules'
import type { HarborText, ZoomId } from './text'
export interface HarborSchedulerProps {
readonly events: OperationEvent[]
/** The same events as business objects, for rules that look at other operations. */
readonly operations: readonly Operation[]
readonly calls: readonly VesselCall[]
readonly issues: readonly Issue[]
readonly text: HarborText
/** BCP 47 tag for labels, and the scheduler locale id. */
readonly tag: string
readonly schedulerLocale: string
/** Level on mount. Later changes go through control.zoom so the view animates from where it is. */
readonly initialZoom: ZoomId
readonly showLinks: boolean
readonly history: SchedulerHistory
readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
readonly onSelect: (id: string) => void
readonly onMoved: (id: string) => void
readonly onResized: (id: string, edge: 'start' | 'end') => void
readonly onRefused: (message: string) => void
readonly onZoomLevel: (level: ZoomId) => void
}
/** Four-hour watches by default; hours to plan a berthing, days to see the fortnight. */
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
{
id: 'hours',
properties: {
scale: 'Hour',
cellWidth: 40,
timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Hour' }],
},
},
{
id: 'watches',
properties: {
scale: 'CellDuration',
cellDuration: 240,
cellWidth: BOARD.cellWidth,
timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
},
},
{
id: 'days',
properties: {
scale: 'Day',
cellWidth: 150,
timeHeaders: [{ groupBy: 'Week' }, { groupBy: 'Day' }],
},
},
]
const ZOOM_IDS: readonly ZoomId[] = ['hours', 'watches', 'days']
/** A refusal message with the numbers of the ship and the berth it was dropped on. */
function refusalMessage(
reason: MoveRefusal,
call: VesselCall | null,
target: SuperScheduler.ResourceId,
text: HarborText,
tag: string,
): string {
const berth = berthById(String(target))
const number = (value: number) =>
new Intl.NumberFormat(tag, { maximumFractionDigits: 1 }).format(value)
return format(text.rules[reason], {
length: number(call?.length ?? 0),
draft: number(call?.draft ?? 0),
berthLength: number(berth?.length ?? 0),
berthDraft: number(berth?.draft ?? 0),
})
}
export function HarborScheduler(props: HarborSchedulerProps) {
const {
events,
operations,
calls,
issues,
text,
tag,
schedulerLocale,
initialZoom,
showLinks,
history,
controlRef,
} = props
const { onEventsChange, onSelect, onMoved, onResized, onRefused, onZoomLevel } = props
const resources = useMemo(() => toResources(text), [text])
// Header labels depend on the level's scale. A zoom animation lands on the new scale after the
// headers of its last frames were drawn, so each settled level gets a fresh hook: a new hook
// makes the control label its headers again.
const [level, setLevel] = useState<ZoomId>(initialZoom)
const timeHeaders = useMemo(() => ({ level, hook: createHeaderHook(tag) }), [level, tag]).hook
// A drop refused in onEventMoving never reaches onEventMove: announce the refusal of the last
// drag frame when the pointer is released (a frame that is allowed again clears it).
const refusal = useRef<string | null>(null)
const refuseOnRelease = useCallback(
(message: string | null) => {
if (message !== null && refusal.current === null) {
window.addEventListener(
'pointerup',
() => {
setTimeout(() => {
if (refusal.current !== null) onRefused(refusal.current)
refusal.current = null
}, 0)
},
{ once: true },
)
}
refusal.current = message
},
[onRefused],
)
// Links come from the business model: tugs finish before berthing, cranes start after it.
const links = useMemo<SuperScheduler.LinkData[]>(() => {
if (!showLinks) return []
const flagged = new Set(issues.map((issue) => issue.op))
const windows = new Map(
events.flatMap((event) =>
event.kind === 'berth' && event.call !== undefined ? [[event.call, event.id]] : [],
),
)
return events.flatMap((event): SuperScheduler.LinkData[] => {
const window = event.call === undefined ? undefined : windows.get(event.call)
if (window === undefined || (event.kind !== 'tug' && event.kind !== 'crane')) return []
const late = flagged.has(String(event.id)) ? { cssClass: 'ss-link--late' } : {}
return event.kind === 'tug'
? [
{
id: `${String(event.id)}->${String(window)}`,
from: event.id,
to: window,
type: 'FinishToStart',
...late,
},
]
: [
{
id: `${String(window)}->${String(event.id)}`,
from: window,
to: event.id,
type: 'StartToStart',
...late,
},
]
})
}, [events, issues, showLinks])
const config = useMemo<SchedulerProps>(() => {
const flagged = new Set(issues.map((issue) => issue.op))
return {
locale: schedulerLocale,
startDate: PLAN.start,
days: PLAN.days,
// ISO weeks in every locale, as the static preview draws them.
weekStarts: 1,
zoomLevels: ZOOM_LEVELS,
zoom: initialZoom,
zoomPosition: 'left',
// Exact times (a window from 06:30 stays at 06:30); drags move by whole cells.
useEventBoxes: 'Never',
eventMoveByCell: true,
height: '100%',
treeEnabled: true,
treePreventParentUsage: true,
rowHeaderWidth: BOARD.rowHeaderWidth,
rowHeaderWidthAutoFit: false,
eventHeight: BOARD.eventHeight,
rowMarginTop: BOARD.rowMarginTop,
rowMarginBottom: BOARD.rowMarginBottom,
allowEventOverlap: false,
durationBarVisible: false,
showToolTip: false,
eventHoverHandling: 'Disabled',
keyboardEnabled: true,
keyboardTarget: 'component',
keyboardMode: 'Full',
history,
controlRef,
onEventsChange,
onBeforeRowHeaderRender: (args) => {
const id = args.row.id
if (!isAssetId(id)) {
args.row.cssClass = 'ss-rh--group'
return
}
const name = SuperScheduler.Util.escapeHtml(args.row.name)
const meta = SuperScheduler.Util.escapeHtml(assetMeta(id, text))
args.row.html =
`<span class="flex min-w-0 items-baseline justify-between gap-2"><span class="truncate">${name}</span>` +
`<span class="shrink-0 text-[11px] text-ink-3 tabular-nums">${meta}</span></span>`
},
onBeforeEventRender: (args) => {
const fields = readOperationFields(args.data)
if (fields === null) return
const call = callById(fields.call)
const title = SuperScheduler.Util.escapeHtml(call?.vessel ?? text.maintenance)
const meta =
fields.kind === 'crane' && fields.moves !== undefined
? plural(text.moves, fields.moves, tag)
: fields.kind === 'berth' && call !== null
? call.id
: ''
const details =
meta === ''
? ''
: `<span class="ss-evt__meta">${SuperScheduler.Util.escapeHtml(meta)}</span>`
args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${title}</span>${details}</span>`
const classes = ['ss-ev', toneClass(toneOf(fields))]
if (fields.kind === 'maintenance') classes.push('ss-ev--hatched')
if (call?.planned === true) classes.push('ss-ev--ghost')
if (flagged.has(String(args.data.id))) classes.push('hw-conflict')
args.data.cssClass = classes.join(' ')
},
// Berth size, crane reach and asset kinds are terminal rules; overlap is enforced by the library.
onEventMoving: (args) => {
const fields = readOperationFields(args.e.data)
if (fields === null) return
const reason = moveRefusal(fields, String(args.resource), calls, operations)
const message =
reason === null
? null
: refusalMessage(reason, callById(fields.call), args.resource, text, tag)
if (message !== null) {
args.allowed = false
args.message = message
}
refuseOnRelease(message)
},
// Final check before the commit (also covers keyboard moves).
onEventMove: (args) => {
const fields = readOperationFields(args.e.data)
if (fields === null) return
const reason = moveRefusal(fields, String(args.newResource), calls, operations)
if (reason !== null) {
args.preventDefault()
onRefused(refusalMessage(reason, callById(fields.call), args.newResource, text, tag))
}
},
onEventMoved: (args) => onMoved(String(args.e.id())),
onEventResized: (args) => onResized(String(args.e.id()), args.what),
onEventClick: (args) => onSelect(String(args.e.id())),
onZoom: (args) => {
if (args.phase !== 'end') return
const next = ZOOM_IDS[args.level]
if (next === undefined) return
setLevel(next)
onZoomLevel(next)
},
}
}, [
history,
controlRef,
issues,
calls,
operations,
onEventsChange,
onMoved,
onRefused,
onResized,
onSelect,
onZoomLevel,
refuseOnRelease,
schedulerLocale,
tag,
text,
initialZoom,
])
// Imperative and once: a scroll prop would be re-applied on every update.
useEffect(() => {
controlRef.current?.scrollTo(INITIAL_VIEW_START, false, 'left')
}, [controlRef])
return (
<SuperSchedulerComponent
{...config}
onBeforeTimeHeaderRender={timeHeaders}
resources={resources}
events={events}
links={links}
/>
)
}Comparte este escenario
El enlace abre este ejemplo con la vista actual (escala y opciones). Nunca contiene tus cambios ni datos personales.