Aller au contenu
SuperScheduler

LogistiqueHarborworks (Entreprise fictive)

Planification des postes à quai

Un navire arrive avec douze heures de retard. Déplacez sa fenêtre d’accostage, puis entraînez son remorqueur et ses grues.

Échelle de temps
Démo avec données fictives
heures / jours
Fonctionnalités Pro
Événements sur plusieurs joursArborescence de ressourcesDépendancesZoom continuMinimap
Harborworks
Services maritimesRemorqueur Petrel70 tRemorqueur Alca60 tQuaiPoste 1340 m · 16 mPoste 2300 m · 14 mPoste 3220 m · 12 mGruesSTS 1Postes 1–2STS 2Postes 1–2STS 3Postes 1–2Grue mobile 4Poste 3Sella TraderFjord LanternMarisolSella TraderMarisolMaintenance de grueCape SolanoOrinoco StarCorvinaCape SolanoOrinoco StarAtlas MeridianMarisolKestrel BayAtlas MeridianKestrel BayAtlas MeridianKestrel BayAtlas MeridianMaintenance de grueFjord LanternOrinoco Starmercredi 21 octobrejeudi 22 octobrevendredi 23 octobresamedi 24 octobredimanche 25 octobre00: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 n’est chargé que lorsque vous le demandez.

La situation

Harborworks est un terminal fictif avec trois postes à quai, trois portiques, une grue mobile et deux remorqueurs. Le planificateur travaille en continu : chaque escale demande un poste assez long et assez profond pour le navire, un remorqueur pour l’amener à quai et des grues capables d’atteindre ce poste.

Les retards sont la règle. Quand un navire annonce une nouvelle heure d’arrivée, sa fenêtre se déplace, et tout ce qui en dépend doit suivre : le remorquage, les équipes de manutention, parfois le navire suivant. Un planning qui ne montre que des barres masque cette cascade ; celui-ci dessine les liens, signale ce qui ne tient plus et garde l’occupation du quai sous les yeux.

Ce que vous allez faireVous déplacerez un navire en retard vers sa nouvelle heure d’accostage, réalignerez son remorqueur et ses grues en une commande et réglerez la grue qui tombe sur une maintenance.

Où s’arrête la bibliothèque et où commence votre application

SuperScheduler fournit

  • Arborescence de ressources groupée : remorqueurs, postes à quai et grues, avec un en-tête de ligne propre à chaque équipement.
  • Barres longues à l’heure exacte (useEventBoxes: 'Never') déplacées par cellules entières avec eventMoveByCell.
  • Liens début-début et fin-début tracés à partir de vos données, avec une classe CSS par lien.
  • Niveaux de zoom des heures aux jours, avec gestes et bascule animée.
  • Minimap avec une sélection de vue qui défile et zoome, plus prévention des chevauchements, historique et déplacements au clavier.

Le code de cet exemple décide

  • Quel navire tient à quel poste (longueur et tirant d’eau) et quelle grue atteint quel poste : les refus dans onEventMoving.
  • Ce qu’est un conflit : une fenêtre avant son ETB, une grue hors de sa fenêtre, un remorquage qui ne finit pas à l’accostage.
  • La commande de réalignement : une fonction pure qui déplace les opérations liées et enregistre une seule étape d’annulation.
  • La série d’occupation du quai qui alimente la minimap, et le résumé du jour le plus chargé.

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, barres à l’heure exacte, liens construits depuis les données et callbacks où les règles du terminal refusent un dépôt.

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}
    />
  )
}
Télécharger le code de l’exemple (.zip)L’archive contient les fichiers de cet exemple et un README. Elle n’inclut ni SuperScheduler Pro ni aucune clé de téléchargement.

Partager ce scénario

Le lien ouvre cet exemple avec la vue actuelle (échelle et options). Il ne contient jamais vos modifications ni de données personnelles.

Un autre métier, un problème voisinOrdonnancement des ordres de fabricationLa maintenance a été avancée. Déplacez l’ordre et gardez ses opérations dans le bon ordre.Lire le cas métierPorts et logistique