Aller au contenu
SuperScheduler

Spectacle vivantAurora Live (Entreprise fictive)

Planification des scènes d’un festival

Une balance mord sur la marge d’un concert. Zoomez à cinq minutes, raccourcissez-la et voyez les salles et équipes dont dépend l’artiste.

Échelle de temps
Démo avec données fictives
minutes / heures
Fonctionnalités Pro
DépendancesZoom continuÉchelle à la minutePanneau de détailThèmes
Aurora Live
ScènesScène AuroraChapiteauClubSallesLoge ALoge BSalle de presseÉquipesSon ASon BLumièreMachinistesLumen ArcBalance · Glass OrchardGlass OrchardNova TideNorthern HumBalance · Saltwater ChoirSaltwater ChoirBalance · Velvet StaticVelvet StaticBalance · Kilo BloomGlass OrchardNova TideNorthern HumSaltwater ChoirVelvet StaticOra LuxSaltwater ChoirLumen ArcNova TideService de jourService de nuitService de jourService de nuitService spectacleChangement de plateauChangement de plateausamedi 24 octobre16:3016:4517:0017:1517:3017:4518:0018:1518:3018:4519:0019:1519:3019:4520:0020:1520:3020:4521:0021:15

SuperScheduler Pro n’est chargé que lorsque vous le demandez.

La situation

Aurora Live est un festival fictif d’une journée avec trois scènes, deux loges, une salle de presse et quatre équipes techniques. Le programme est publié des semaines à l’avance ; ce que la production planifie encore le jour même, ce sont les balances, glissées entre les concerts, chacune avec une marge avant l’entrée en scène.

Dans un tableur, cinq minutes disparaissent : une balance qui finit dix minutes avant un concert semble correcte à l’échelle de l’heure. Le planning doit zoomer jusqu’à la minute, garder chaque balance reliée à son concert, refuser les déplacements absurdes (une balance sur une autre scène, un concert déjà publié) et montrer les salles et équipes qu’implique chaque artiste.

Ce que vous allez faireVous ouvrirez la fiche d’un artiste dans le panneau latéral, zoomerez sur des cellules de cinq minutes et raccourcirez une balance jusqu’à ce que son lien avec le concert redevienne neutre.

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

SuperScheduler fournit

  • Axe intrajournalier sans la nuit (heures ouvrées, samedi compris) et niveaux de zoom des heures aux cellules de 15 et 5 minutes, animés par control.zoom.
  • Lignes groupées pour les scènes, les salles et les équipes, et l’API de sélection de lignes qui les met en évidence.
  • Liens fin-début courbes entre chaque balance et son concert.
  • Glisser et redimensionner calés sur la cellule active, sans chevauchement par scène, messages de refus sur la carte de glissement, annuler/rétablir et déplacements au clavier.
  • Thème par propriétés CSS et règles hors couche sur la racine du contrôle (festival.css).

Le code de cet exemple décide

  • Quels blocs bougent (seulement les balances), sur quelle scène, et jusqu’où une balance peut être raccourcie.
  • La marge de 20 minutes entre une balance et son concert, le moment où son lien passe au rouge, et les liens dessinés (marges trop courtes et artiste sélectionné, ou tous).
  • Les salles et équipes liées à un artiste, et le panneau latéral qui saute à sa balance ou à son concert.
  • Les libellés d’en-tête à cinq minutes, la mission et tous les textes de cette page.

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 : trois niveaux de zoom, heures de nuit masquées, libellés d’en-tête à cinq minutes, mise en évidence des lignes via rows.selection, et les callbacks où les règles refusent un déplacement ou un redimensionnement.

scheduler.tsxtsx
/**
 * Aurora Live running order: stages, rooms and crews over one festival day, down to five minutes.
 *
 * Library: timeline with hidden night, zoom levels from hours to 5-minute cells, tree rows, row
 * selection (used to highlight related rows), dependency links, drag/resize with overlap
 * prevention, undo history, keyboard, and theming through CSS custom properties (./festival.css).
 * Application: which blocks can move, the line-check buffer before each set, the shortest check,
 * and which rows relate to an act. Those rules live in ./rules and run inside the callbacks below.
 */
import { useEffect, useMemo, useState, type RefObject } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type {
  SchedulerBeforeTimeHeaderRenderArgs,
  SchedulerEventsChangeArgs,
  SchedulerProps,
} from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { formatCivil } from '../../scheduler/civil'
import { headerLabel, usesTwelveHours } from '../../scheduler/header-format'
import { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import { KIND_TONES, readSlotFields, slotLabel, toResources, type SlotEvent } from './adapter'
import {
  FESTIVAL_DAY,
  INITIAL_VIEW_START,
  OPEN_HOURS,
  type ActId,
  type RowId,
  type Slot,
} from './model'
import { checkTooShort, moveProblem, type MoveProblem } from './rules'
import type { AuroraText, ZoomId } from './text'
import './festival.css'

export interface AuroraSchedulerProps {
  readonly events: SlotEvent[]
  readonly slots: readonly Slot[]
  /** Acts whose line check ends too close to their set. */
  readonly shortBuffers: readonly ActId[]
  /** Rows that relate to the act in the side panel (highlighted). */
  readonly relatedRows: readonly RowId[]
  readonly text: AuroraText
  /** 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
  /** Every check-to-set link, or only those of the selected act and of short buffers. */
  readonly showAllLinks: boolean
  readonly selectedAct: ActId | null
  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) => void
  readonly onRefused: (message: string) => void
  readonly onZoomLevel: (level: ZoomId) => void
}

const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  {
    id: 'hours',
    properties: {
      scale: 'Hour',
      cellWidth: 80,
      timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Hour' }],
    },
  },
  {
    id: 'quarter',
    properties: {
      scale: 'CellDuration',
      cellDuration: 15,
      cellWidth: 64,
      timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
    },
  },
  {
    id: 'five',
    properties: {
      scale: 'CellDuration',
      cellDuration: 5,
      cellWidth: 26,
      timeHeaders: [{ groupBy: 'Hour' }, { groupBy: 'Cell' }],
    },
  },
]

const ZOOM_IDS: readonly ZoomId[] = ['hours', 'quarter', 'five']

/**
 * Time headers: the shared Intl labels, except at five minutes where the hour sits above and the
 * minutes (":05") below, so 26 px cells stay readable.
 */
function festivalHeaders(tag: string): (args: SchedulerBeforeTimeHeaderRenderArgs) => void {
  const shared = createHeaderHook(tag)
  return (args) => {
    if (args.control.cellDuration !== 5) {
      shared(args)
      return
    }
    const ticks = args.header.start.getTime()
    const minutes = String(args.header.start.getMinutes()).padStart(2, '0')
    args.header.html =
      args.header.level === 0
        ? SuperScheduler.Util.escapeHtml(headerLabel(ticks, 'hour', tag, 'group'))
        : `:${minutes}`
  }
}

export function AuroraScheduler(props: AuroraSchedulerProps) {
  const {
    events,
    slots,
    shortBuffers,
    relatedRows,
    selectedAct,
    text,
    tag,
    schedulerLocale,
    initialZoom,
    showAllLinks,
    history,
    controlRef,
  } = props
  const { onEventsChange, onSelect, onMoved, onResized, onRefused, onZoomLevel } = props

  const resources = useMemo(() => toResources(text), [text])
  // Narrower row headers on phones, chosen once on mount.
  const rowHeaderWidth = useMemo(
    () => (window.matchMedia('(max-width: 639px)').matches ? 116 : 176),
    [],
  )
  // Header labels follow the active level. An animated zoom renders the new level's headers before
  // control.cellDuration reports it, so the hook is rebuilt (headers repainted) once a zoom lands.
  const [headerHook, setHeaderHook] = useState(() => festivalHeaders(tag))

  // Each act's line check leads to its set: a finish-to-start link, red when the buffer is short.
  // Morning checks would cross a whole stage row, so by default only the selected act and the
  // short buffers show theirs.
  const links = useMemo<SuperScheduler.LinkData[]>(() => {
    const short = new Set(shortBuffers)
    return slots.flatMap((check) => {
      const set =
        check.kind === 'check'
          ? slots.find((slot) => slot.kind === 'set' && slot.act === check.act)
          : undefined
      if (set === undefined || check.act === undefined) return []
      if (!showAllLinks && !short.has(check.act) && check.act !== selectedAct) return []
      return [
        {
          id: `${check.act}-buffer`,
          from: check.id,
          to: set.id,
          type: 'FinishToStart' as const,
          ...(short.has(check.act) ? { cssClass: 'ss-link--late' } : {}),
        },
      ]
    })
  }, [slots, shortBuffers, showAllLinks, selectedAct])

  const config = useMemo<SchedulerProps>(() => {
    const short = new Set<string>(shortBuffers)
    const related = new Set<string>(relatedRows)
    const pattern = usesTwelveHours(tag) ? 'h:mm tt' : 'HH:mm'
    const clock = (value: string) => formatCivil(value, tag, { hour: 'numeric', minute: '2-digit' })
    const refusal = (problem: MoveProblem) =>
      problem === 'published'
        ? text.rules.published
        : problem === 'follows'
          ? text.rules.follows
          : text.rules.wrongStage

    return {
      locale: schedulerLocale,
      startDate: FESTIVAL_DAY,
      days: 1,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // The site opens at noon: the morning stays hidden. A Saturday is a working day here.
      businessBeginsHour: OPEN_HOURS.begin,
      businessEndsHour: OPEN_HOURS.end,
      businessWeekends: true,
      showNonBusiness: false,
      useEventBoxes: 'Never',
      height: '100%',
      cssClass: 'al-board',
      treeEnabled: true,
      treePreventParentUsage: true,
      rowHeaderWidth,
      rowHeaderWidthAutoFit: false,
      eventHeight: 32,
      rowMarginTop: 4,
      rowMarginBottom: 4,
      allowEventOverlap: false,
      linkLayer: 'Below',
      timeRangeSelectedHandling: 'Disabled',
      // Minute-level work: the drag card and header marker show clock times, not dates.
      dragCard: {
        duration: false,
        dateFormat: pattern,
        movingDateFormat: pattern,
        markerFormat: pattern,
      },
      durationBarVisible: false,
      showToolTip: false,
      eventHoverHandling: 'Disabled',
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      history,
      controlRef,
      onEventsChange,

      onBeforeTimeHeaderRender: headerHook,

      onBeforeRowHeaderRender: (args) => {
        args.row.cssClass = args.row.data.children !== undefined ? 'ss-rh--group' : ''
      },

      // The selected act's rows are also tinted across the grid (headers come from rows.selection).
      onBeforeCellRender: (args) => {
        if (related.has(String(args.cell.resource))) args.cell.properties.cssClass = 'al-related'
      },

      onBeforeEventRender: (args) => {
        const fields = readSlotFields(args.data)
        if (fields === null) return
        const label = SuperScheduler.Util.escapeHtml(slotLabel(fields, text))
        const time =
          fields.kind === 'set'
            ? `<span class="ss-evt__meta">${SuperScheduler.Util.escapeHtml(clock(args.data.start.value))}</span>`
            : ''
        args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${label}</span>${time}</span>`
        const classes = ['ss-ev', toneClass(KIND_TONES[fields.kind])]
        if (fields.kind === 'set') classes.push('al-set')
        if (fields.kind === 'check') classes.push('ss-ev--ghost')
        if (fields.kind === 'changeover') classes.push('ss-ev--hatched')
        if (fields.kind === 'set' && fields.act !== undefined && short.has(fields.act))
          classes.push('ss-ev--late')
        args.data.cssClass = classes.join(' ')
      },

      // Only line checks move, and only on their act's stage; overlap is refused by the library.
      onEventMoving: (args) => {
        const fields = readSlotFields(args.e.data)
        const problem = fields === null ? null : moveProblem(fields, String(args.resource))
        if (problem !== null) {
          args.allowed = false
          args.message = refusal(problem)
        }
      },

      // Final check before the commit (also covers keyboard moves).
      onEventMove: (args) => {
        const fields = readSlotFields(args.e.data)
        const problem = fields === null ? null : moveProblem(fields, String(args.newResource))
        if (problem !== null) {
          args.preventDefault()
          onRefused(refusal(problem))
        }
      },

      onEventResizing: (args) => {
        if (checkTooShort(args.start.value, args.end.value)) {
          args.allowed = false
          args.message = text.rules.tooShort
        }
      },

      onEventResize: (args) => {
        if (checkTooShort(args.newStart.value, args.newEnd.value)) {
          args.preventDefault()
          onRefused(text.rules.tooShort)
        }
      },

      onEventMoved: (args) => onMoved(String(args.e.id())),
      onEventResized: (args) => onResized(String(args.e.id())),
      onEventClick: (args) => onSelect(String(args.e.id())),

      onZoom: (args) => {
        if (args.phase !== 'end') return
        setHeaderHook(() => festivalHeaders(tag))
        const level = ZOOM_IDS[args.level]
        if (level !== undefined) onZoomLevel(level)
      },
    }
  }, [
    history,
    controlRef,
    shortBuffers,
    relatedRows,
    rowHeaderWidth,
    onEventsChange,
    onMoved,
    onRefused,
    onResized,
    onSelect,
    onZoomLevel,
    schedulerLocale,
    headerHook,
    tag,
    text,
    initialZoom,
  ])

  // Highlighted rows are the control's row selection, driven by the act in the side panel.
  useEffect(() => {
    const control = controlRef.current
    if (control === null || control.disposed()) return
    control.rows.selection.clear()
    for (const id of relatedRows) {
      const row = control.rows.find(id)
      if (row !== null) control.rows.selection.add(row)
    }
  }, [controlRef, relatedRows])

  // 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} 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 voisinRéservation de courts dans un club sportifEn pleine matinée, le filet d’un court de padel lâche. Déplacez le stage, fermez le court et gardez chaque coach dans son service.Lire le cas métierLieux et événements