Zum Inhalt springen
SuperScheduler

LogistikHarborworks (Fiktives Unternehmen)

Liegeplatzplanung im Hafen

Ein Schiff kommt zwölf Stunden zu spät. Verschieben Sie sein Liegefenster und nehmen Sie Schlepper und Kräne mit.

Zeitskala
Demo mit synthetischen Daten
Stunden / Tage
Pro-Funktionen
Mehrtägige EreignisseRessourcenbaumAbhängigkeitenStufenloser ZoomMinimap
Harborworks
Nautische DiensteSchlepper Petrel70 t ZugSchlepper Alca60 t ZugKajeLiegeplatz 1340 m · 16 mLiegeplatz 2300 m · 14 mLiegeplatz 3220 m · 12 mKräneSTS 1LP 1–2STS 2LP 1–2STS 3LP 1–2Mobilkran 4LP 3Sella TraderFjord LanternMarisolSella TraderMarisolKranwartungCape SolanoOrinoco StarCorvinaCape SolanoOrinoco StarAtlas MeridianMarisolKestrel BayAtlas MeridianKestrel BayAtlas MeridianKestrel BayAtlas MeridianKranwartungFjord LanternOrinoco StarMittwoch, 21. OktoberDonnerstag, 22. OktoberFreitag, 23. OktoberSamstag, 24. OktoberSonntag, 25. Oktober00:0004:0008:0012:0016:0020:0000:0004:0008:0012:0016:0020:0000:0004:0008:0012:0016:0020:0000:0004:0008:0012:0016:0020:0000:0004:0008:0012:0016:00

SuperScheduler Pro wird erst geladen, wenn Sie es anfordern.

Die Ausgangslage

Harborworks ist ein fiktives Terminal mit drei Liegeplätzen, drei Containerbrücken, einem Mobilkran und zwei Schleppern. Die Liegeplatzplanung läuft rund um die Uhr: Jeder Anlauf braucht einen Liegeplatz mit genug Länge und Tiefgang, einen Schlepper, der das Schiff an die Kaje bringt, und Kräne, die diesen Liegeplatz erreichen.

Verspätungen sind der Normalfall. Meldet ein Schiff eine neue Ankunftszeit, wandert sein Fenster, und alles, was daran hängt, muss mit: der Schleppereinsatz, die Kranschichten, manchmal das nächste Schiff. Eine Tafel, die nur Balken zeigt, verbirgt diese Kaskade; diese zeichnet die Verknüpfungen, markiert, was nicht mehr passt, und behält die Kaiauslastung im Blick.

Was Sie tun werdenSie verschieben ein verspätetes Schiff auf seine neue Liegezeit, richten Schlepper und Kräne mit einem Befehl neu aus und lösen den Kran, der mit einer Wartung kollidiert.

Wo die Bibliothek endet und Ihre Anwendung beginnt

SuperScheduler liefert

  • Gruppierter Ressourcenbaum aus Schleppern, Liegeplätzen und Kränen, mit eigenem Zeilenkopf je Gerät.
  • Lange Balken auf die Minute genau (useEventBoxes: 'Never'), die mit eventMoveByCell zellenweise verschoben werden.
  • Start-Start- und Ende-Start-Verknüpfungen aus Ihren Daten, je Verknüpfung per CSS-Klasse gestaltet.
  • Zoomstufen von Stunden bis Tagen, mit Gesten und animiertem Wechsel.
  • Minimap mit Ansichtsrahmen zum Verschieben und Zoomen, dazu Überlappungsschutz, Rückgängig und Wiederholen sowie Tastaturbedienung.

Der Code dieses Beispiels entscheidet

  • Welches Schiff an welchen Liegeplatz passt (Länge und Tiefgang) und welcher Kran welchen Liegeplatz erreicht: Ablehnungen in onEventMoving.
  • Was ein Konflikt ist: ein Fenster vor der ETB, ein Kran außerhalb des Fensters, ein Schlepper, der nicht zum Anlegen fertig ist.
  • Der Befehl zum Ausrichten: eine reine Funktion, die verknüpfte Einsätze verschiebt und einen einzigen Eintrag im Verlauf speichert.
  • Die Auslastungsreihe der Kaje für die Minimap und die Zusammenfassung des stärksten Tages.

Der Code hinter dieser Demo

Das sind die Dateien, die diese Seite ausführt, keine vereinfachte Kopie. Sie kompilieren gegen die öffentlichen Exporte von SuperScheduler Pro.

Die Integration: Zoomstufen, minutengenaue Balken, Verknüpfungen aus den Daten und die Callbacks, in denen Terminalregeln ein Ablegen ablehnen.

scheduler.tsxtsx
/**
 * 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}
    />
  )
}
Quellcode des Beispiels herunterladen (.zip)Das Archiv enthält die Dateien dieses Beispiels und eine README. SuperScheduler Pro und Download-Schlüssel sind nicht enthalten.

Dieses Szenario teilen

Der Link öffnet dieses Beispiel mit der aktuellen Ansicht (Zeitskala und Optionen). Er enthält nie Ihre Änderungen oder persönliche Daten.

Eine andere Branche, ein verwandtes ProblemPlanung von FertigungsaufträgenDie Wartung wurde vorgezogen. Verschieben Sie den Auftrag und halten Sie die Arbeitsfolge ein.Zum AnwendungsfallHäfen und Logistik