Zum Inhalt springen

Bauzeitenplanung

Regen stoppt eine Betonage. Verschieben Sie sie, ziehen Sie den Rohbau des nächsten Geschosses in einem Zug nach und halten Sie den Kran in seiner Schicht.

  • Bau
  • Plumbline
  • Ressourcenbaum
  • Abhängigkeiten
  • Summenzeilen
  • Mehrfachauswahl
  • Rechteckauswahl
  • Rückgängig und Wiederholen
  • Minimap
  • Fortschrittsbalken
  • Zoom-Regler
  • Demo mit synthetischen Daten
Quellcode laden.zip

Aufgabe

0 von 3 Schritten erledigt

Mittwoch, 21. Oktober, 07:00 Uhr: Von Donnerstag bis Montag ist Regen angesagt, und die Decke im 3. OG sollte morgen betoniert werden.

  1. Holen Sie die Betonage im 3. OG aus dem Regen: Di., 27. Okt. — Offen

    Ziehen Sie in 3. OG · Rohbau den roten Balken Betonage drei Arbeitstage nach rechts, auf Dienstag, 27. Oktober (das Wochenende wird übersprungen). Oder öffnen Sie sein Menü (Rechtsklick oder Umschalt+F10) und wählen Sie den ersten möglichen Tag.

  2. Verschieben Sie den ganzen Rohbau im 4. OG um drei Arbeitstage. — Offen

    Die Schalung im 4. OG beginnt jetzt, bevor die Decke im 3. OG ausgehärtet ist. Ziehen Sie über das leere Raster ein Fenster um die drei Balken im 4. OG (oder Strg/⌘-Klick auf jeden) und ziehen Sie dann einen davon: Sie bewegen sich gemeinsam. Oder wählen Sie Rohbau 4. OG auswählen im Menü eines Balkens und drücken Sie Verschieben im Seitenfeld.

  3. Beseitigen Sie die Überlastung des Turmdrehkrans. — Offen

    Die Betonage im 4. OG fällt jetzt auf Mi., 11. Nov., zusammen mit der Fassade und der Lieferung der Aufzugsschienen: 11 Kranstunden für eine 8-Stunden-Schicht. Ziehen Sie Aufzugsschienen in EG · Ausbau auf Do., 12. Nov., oder wählen Sie Nächster Tag mit freiem Kran in seinem Menü.

Etwa 30 Sekunden. Erledigte Schritte werden abgehakt.

Tastatur: Raster fokussieren, dann Pfeiltasten, Eingabe und Alt+Pfeile zum Verschieben.

Plumbline
4. OG▽ +12.80Rohbau3. OG▽ +9.60RohbauAusbau2. OG▽ +6.40RohbauAusbau1. OG▽ +3.20RohbauAusbauEG▽ ±0.00RohbauAusbauGründung▽ −1.20ErdreichErdarbeiten8 TInnenwände10 TAufz.1 TOberflächen20 TFassade4 THaustechnik8 TAbn.1 TInnenwände10 TFassade4 THaustechnik8 TAbn.1 TInnenwände10 TSchalung3 TBewehrung2 TBeton1 TFassade4 THaustechnik8 TSchalung3 TBewehrung2 TBeton1 T762433322844777762Turmdrehkran TK1h/Tag · Schicht 8 hKW43KW44KW45KW46KW47KW481920212223242526272829303112345678910111213141516171819202122232425

Vorschau des ersten BildesLive: die echte Pro-Engine, in Ihrem BrowserEtwa 30 Sekunden. Erledigte Schritte werden abgehakt. Tastatur: Raster fokussieren, dann Pfeiltasten, Eingabe und Alt+Pfeile zum Verschieben.

Die Ausgangslage.

Plumbline ist ein fiktiver Generalunternehmer, der Linden Yard, Haus C baut: sechs Geschosse auf einem Stahlbetonskelett. Der Bauzeitenplan ist ortsbezogen: Jedes Geschoss im Schnitt hat einen Rohbau (Schalung, Bewehrung, Betonage, danach die Fassade) und einen Ausbau (Haustechnik, Abnahme, Innenwände, Oberflächen), und je Gewerk zieht eine Kolonne von Geschoss zu Geschoss. Eine Decke braucht sieben Tage Aushärtung, bevor die Schalung des nächsten Geschosses darauf stehen darf.

Das Wetter ist der übliche Gegner. Verschiebt sich eine Betonage, verschiebt sich die Aushärtung, dann das Geschoss darüber, und der Puffer vor dem Richtfest ist weg, ohne dass es jemand merkt. Ein Balkenplan verbirgt diese Kette; dieser Bauzeitenplan berechnet den kritischen Pfad aus der Logik, zeichnet ihn mit Rotstift, lehnt ab, was die Baustelle nicht kann, und behält Turmdrehkran und Personal im Blick.

Was Sie tun werden

Sie holen eine Betonage aus dem Regen, verschieben den Rohbau eines Geschosses in einem Zug, sobald die Aushärtungsregel ihn markiert, und beseitigen die Kranüberlastung, die die Verschiebung erzeugt.

Wo die Bibliothek endet und Ihre Anwendung beginnt.

SuperScheduler liefert

  • Ein dreistufiger Ressourcenbaum (Haus C, seine Geschosse, deren Bereiche) mit rowHeaderColumns und zusammengeführten Elternzeilen; die Zeilenköpfe zeichnen den Gebäudeschnitt mit Kote und Decke jedes Geschosses.
  • Eine fixierte Zeile oben (frozen: 'top', cellsDisabled), die onBeforeCellRender mit den Kranstunden pro Tag füllt; Zellen schraffiert für Wochenenden, aushärtende Decken und Regen, und gesperrt während der Weihnachtspause.
  • Drei Zeitkopfzeilen aus onBeforeTimeHeaderRender: Wochen als Maßkette, Tage und die Wetterprognose, mit einem Kopfbereich an der Baustellenuhr; Trennlinien für die Baustellenuhr und den letzten Krantag.
  • Meilensteine, PercentComplete-Fortschrittsbalken als Materialschraffuren, Ende-Anfang-Verknüpfungen mit Stil aus onBeforeLinkRender und Verknüpfungs-Bubble, und zeitverankerte Bereiche über den Tagen, an denen ein Vorgang zu früh beginnt.
  • Mehrfachauswahl per Klick, Strg/Cmd-Klick oder einem über das Raster gezogenen Fenster (rectangleSelectHandling, onGridMouseDown), allowMultiMove mit jedem Mitglied pro Frame in onEventMoving neu geschrieben, und eventMoveSkipNonBusiness.
  • Der Zoom-Slider von super-scheduler/zoom-ui als Maßstabsleiste des Plans, die Minimap von super-scheduler/minimap als Übersichtsplan, Rückgängig-Verlauf, Tastaturbewegungen, die Hover-Karte, die Drag-Karte und ein Kontextmenü im Look des Produkts.

Der Code dieses Beispiels entscheidet

  • Der Baustellenkalender (Wochenenden, Weihnachtspause), die Aushärtungsregel, Ende-Anfang-Logik, kein Betonieren bei Regen, eine Kolonne je Gewerk und feste Termine: Ablehnungen in onEventMoving und onEventMove, mit Grund und genanntem Tag.
  • Die Methode des kritischen Pfads: späteste Termine rückwärts von den festen Terminen über Arbeitstage, Gesamtpuffer je Vorgang, die kritischen Vorgänge und die Verknüpfungen, die sie treiben.
  • Kranstunden und Personal auf der Baustelle pro Tag, die Befehle (erster möglicher Tag, nächster Tag mit freiem Kran, Auswahl um Arbeitstage verschieben) und die Indexbuchstaben aus dem Verlauf.
  • Der Plan selbst: Schraffuren je Gewerk, Maßlinien, die Schnittlinie an der Baustellenuhr, Revisionswolken und das Schriftfeld.

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.

tsx.zip

Die Integration: der Baum und seine Spalten, die fixierte Kranzeile, drei Kopfzeilen, Trennlinien, Meilensteine und Fortschrittsbalken, Verknüpfungen im Stil des kritischen Pfads mit ihrer Bubble, Fensterauswahl, Mehrfachverschiebung mit pro Frame neu geschriebenen Mitgliedern, das Drag-Urteil, die Hover-Karte und das Kontextmenü, und die Callbacks, in denen die Baustellenregeln eine Verschiebung ablehnen.

/**
 * Plumbline programme board: Linden Yard, Block C, drawn as its section. Levels from the roof
 * down, the faces of each level as rows, fifteen weeks of days on a drafting grid, the critical
 * path in red pencil and the tower crane booked day by day on top.
 *
 * Library: a three-level resource tree with rowHeaderColumns (merged parent rows), a frozen
 * summary row painted by onBeforeCellRender, three time-header rows written by
 * onBeforeTimeHeaderRender (a dimension chain of weeks, days, the forecast) with header areas,
 * disabled cells, separators, milestones, PercentComplete bars drawn as material hatches,
 * finish-to-start links styled by onBeforeLinkRender with a link bubble, multi-select (click,
 * Ctrl/Cmd+click, a window drawn over the grid with rectangleSelectHandling), allowMultiMove with
 * members rewritten per frame, eventMoveSkipNonBusiness, conflict highlighting, the zoom slider
 * (super-scheduler/zoom-ui), keyboard and undo history, the event hover card and the drag card
 * (super-scheduler/react-render) and a context menu. Application: the site calendar, curing,
 * logic, rain, crews and fixed dates, the critical path and float, crane hours and every figure.
 * Those rules live in ./rules; ./paint writes the markup.
 */
import { useCallback, useEffect, useMemo, type RefObject } from 'react'
import { 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 { createZoomSlider } from 'super-scheduler/zoom-ui'
import { format, plural } from '~/i18n/format'
import { DAY, civil, parseCivil } from '../../scheduler/civil'
import { createEventMenu, type MenuEntry } from '../kit/context-menu'
import { useEventHover } 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,
  BUILDING_ID,
  CRANE_ROW,
  ROW_HEADER_WIDTH,
  activityName,
  isLevelRowId,
  isRowId,
  levelOfRow,
  openingScroll,
  readActivityFields,
  toResources,
  type ActivityEvent,
} from './adapter'
import {
  activityCard,
  craneDayOf,
  firstValidOf,
  refusalText,
  type ActivityAction,
  type PlanView,
} from './card'
import {
  CRANE_DISMANTLED,
  CRANE_HOURS,
  INITIAL_VIEW_START,
  NOW,
  PLAN,
  ROWS,
  type Activity,
} from './model'
import {
  DAY_LETTERS_FROM,
  DAY_NUMBERS_FROM,
  buildingHtml,
  cardHtml,
  cellPainter,
  craneCell,
  craneHeaderHtml,
  dayHeader,
  doneHtml,
  faceHtml,
  keyDay,
  levelHtml,
  milestoneHtml,
  shortDay,
  tradeHtml,
  weatherHeader,
  weekHeader,
  type HeaderPaint,
} from './paint'
import {
  addWorkingDays,
  brokenSuccessors,
  craneHours,
  curingWindows,
  dayOf,
  duration,
  frameOf,
  groupRefusal,
  isMovable,
  isWorkingDay,
  linkId,
  nextWorkingStart,
  placementRefusal,
  pourOf,
  rowProgress,
  shiftStart,
  signedWorkingDays,
  slabState,
  tradeWork,
  workAt,
  type Span,
} from './rules'
import type { LogicView, PlumblineText } from './text'

const SLUG = 'construction-site'
const NOW_TICKS = parseCivil(NOW)
const PLAN_FROM = parseCivil(PLAN.start)

export interface PlumblineSchedulerProps {
  readonly events: ActivityEvent[]
  readonly view: PlanView
  readonly text: PlumblineText
  readonly common: DemoCommon
  /** BCP 47 tag for labels, and the scheduler locale id. */
  readonly tag: string
  readonly schedulerLocale: string
  readonly logic: LogicView
  /** Narrow stages keep the location column only. */
  readonly narrow: boolean
  readonly history: SchedulerHistory
  readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
  /** Where the product bar keeps the zoom slider (the drawing's scale bar). */
  readonly zoomHost: RefObject<HTMLDivElement | null>
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  /** A click (or Enter) on an activity: its sheet opens. */
  readonly onOpen: (id: string) => void
  readonly onMoved: (ids: readonly string[]) => void
  readonly onRefused: (id: string, message: string) => void
  readonly onAction: (action: ActivityAction, id: string) => void
  /** The settled cell width after a zoom, for the title block's scale. */
  readonly onScale: (cellWidth: number) => void
}

/** The last working day at or before `ticks` (a keyboard move to the left over a weekend). */
function previousWorkingStart(ticks: number): number {
  let day = dayOf(ticks)
  while (!isWorkingDay(day)) day -= DAY
  return day
}

/** onBeforeTimeHeaderRender: weeks, days and the forecast, cached per cell and width bucket. */
function createTimeHeaderHook(text: PlumblineText, tag: string) {
  const cache = new Map<string, HeaderPaint>()
  const nowLabel = format(text.chrome.now, { day: shortDay(NOW_TICKS, tag), time: '07:00' })
  return (args: SuperScheduler.SchedulerBeforeTimeHeaderRenderArgs) => {
    const header = args.header
    const start = header.start.getTime()
    const span = header.end.getTime() - start
    const width = args.control.cellWidth ?? BOARD.cellWidth
    const bucket = width >= DAY_LETTERS_FROM ? 'w' : width >= DAY_NUMBERS_FROM ? 'm' : 'n'
    const key = `${header.level}:${start}:${span}:${bucket}`
    let paint = cache.get(key)
    if (paint === undefined) {
      paint =
        header.level === 0
          ? weekHeader(start, span, text, tag)
          : header.level === 1
            ? dayHeader(start, span, width, tag)
            : weatherHeader(start, span, width, text, nowLabel)
      cache.set(key, paint)
    }
    header.html = paint.html
    header.cssClass = paint.cssClass
    header.areas = paint.areas
    header.toolTip = paint.toolTip
  }
}

export function PlumblineScheduler(props: PlumblineSchedulerProps) {
  const { events, view, text, common, tag, schedulerLocale, logic, narrow, history } = props
  const { controlRef, zoomHost } = props
  const { onEventsChange, onOpen, onMoved, onRefused, onAction, onScale } = props

  const resources = useMemo(() => toResources(text), [text])
  const refusal = useDropRefusal(onRefused)

  const name = useCallback((activity: Activity) => activityName(activity, text), [text])

  // Hover card: a drawing callout with the logic on both sides, the float and the revision.
  const card = useCallback(
    (event: SuperScheduler.Event) => {
      const activity = view.net.byId.get(String(event.id()))
      return activity === undefined ? null : activityCard(activity, view, text, common, tag)
    },
    [view, text, common, tag],
  )
  const { eventHover, closeHover, suppressHover } = useEventHover(card)

  // Drag verdict: the site's refusal, or the successors the drop would push out of their logic.
  const dragCard = useMemo(
    () =>
      createDragCard({
        tag,
        common,
        days: true,
        rowName: (id) => {
          const row = ROWS.find((candidate) => candidate.id === id)
          return row === undefined
            ? null
            : `${text.levelShort[row.level]} · ${text.faces[row.face]}`
        },
        warn: (info) => {
          const activity = view.net.byId.get(String(info.eventId))
          if (activity === undefined) return null
          const span = { start: info.start.getTime(), end: info.end.getTime() }
          const broken = brokenSuccessors(activity, span, view.net)
          if (broken.length === 0) return null
          const names = broken
            .flatMap((item) => {
              const next = view.net.byId.get(item.id)
              return next === undefined ? [] : [`${name(next)} (${shortDay(item.until, tag)})`]
            })
            .join(', ')
          return plural(text.card.pushes, broken.length, tag, { names })
        },
      }),
    [view, text, common, tag, name],
  )

  // Context menu: rebuilt when the plan changes, so its labels name the real day.
  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 activity = view.net.byId.get(id)
          if (activity === undefined) return []
          const entries: MenuEntry[] = [
            {
              label: common.kit.openDetails,
              icon: 'details',
              keys: KEYS.details,
              run: () => onAction('details', id),
            },
          ]
          if (view.issueOf.has(id) && isMovable(activity)) {
            const first = firstValidOf(activity, view)
            entries.push({
              label:
                first === null
                  ? text.actions.noValid
                  : format(text.actions.firstValid, { date: shortDay(first, tag) }),
              icon: 'slot',
              primary: true,
              disabled: first === null,
              run: () => onAction('first-valid', id),
            })
          }
          if (activity.trade === 'lift' && isMovable(activity)) {
            const day = craneDayOf(activity, view)
            entries.push({
              label:
                day === null
                  ? text.actions.noCraneDay
                  : format(text.actions.craneDay, { date: shortDay(day, tag) }),
              icon: 'after',
              primary: !view.issueOf.has(id),
              disabled: day === null,
              run: () => onAction('crane-day', id),
            })
          }
          const row = ROWS.find((candidate) => candidate.id === activity.row)
          const frame = frameOf(view.activities, activity.row).filter(isMovable)
          const trade = activity.trade === null ? [] : tradeWork(view.activities, activity.trade)
          const selections: MenuEntry[] = []
          if (row !== undefined && frame.some((member) => member.id === id) && frame.length > 1)
            selections.push({
              label: format(text.actions.selectFrame, {
                level: text.levelShort[row.level],
                count: String(frame.length),
              }),
              icon: 'focus',
              run: () => onAction('select-frame', id),
            })
          if (activity.trade !== null && trade.length > 1)
            selections.push({
              label: format(text.actions.selectTrade, {
                trade: text.trades[activity.trade],
                count: String(trade.length),
              }),
              icon: 'layers',
              run: () => onAction('select-trade', id),
            })
          if (selections.length > 0) entries.push('separator', ...selections)
          entries.push('separator', {
            label: logic === 'all' ? text.actions.showCritical : text.actions.showAll,
            icon: 'link',
            run: () => onAction('toggle-logic', id),
          })
          return entries
        },
      }),
    [view, logic, common, text, tag, suppressHover, onAction],
  )

  // The logic from the model; the critical path in red pencil, broken links as clashes. Abutting
  // bars of one face read as a sequence already (like a dimension chain): their link is drawn only
  // when it breaks. The bubble (bubbleHtml) is part of each link's data: lag and the float left.
  const links = useMemo<SuperScheduler.LinkData[]>(() => {
    const escape = SuperScheduler.Util.escapeHtml
    const broken = new Set(
      view.issues.flatMap((issue) =>
        issue.other === undefined ? [] : [`${issue.other}->${issue.activity}`],
      ),
    )
    return view.net.links.flatMap((link): SuperScheduler.LinkData[] => {
      const id = linkId(link)
      const critical = view.plan.criticalLinks.has(id)
      const clash = broken.has(id)
      if (logic === 'critical' && !critical && !clash) return []
      const from = view.net.byId.get(link.from)
      const to = view.net.byId.get(link.to)
      if (from === undefined || to === undefined) return []
      if (!clash && link.lag === 0 && from.row === to.row && from.end === to.start) return []
      const required = parseCivil(from.end) + link.lag * DAY
      const gap = signedWorkingDays(nextWorkingStart(required), parseCivil(to.start))
      const lagText =
        link.lag > 0 ? format(text.card.curedOn, { date: shortDay(required, tag) }) : ''
      const gapText =
        gap < 0
          ? format(text.card.negative, { count: String(-gap) })
          : gap === 0
            ? text.card.critical
            : format(text.card.float, { count: String(gap) })
      return [
        {
          id,
          from: link.from,
          to: link.to,
          type: 'FinishToStart',
          cssClass: clash
            ? 'pl-link pl-link--clash'
            : critical
              ? 'pl-link pl-link--critical'
              : 'pl-link',
          bubbleHtml:
            `<span class="pl-lb"><b>${escape(name(from))} → ${escape(name(to))}</b>` +
            (lagText === '' ? '' : `<span>${escape(lagText)}</span>`) +
            `<span class="pl-lb__gap" data-gap="${gap < 0 ? 'clash' : gap === 0 ? 'zero' : 'free'}">${escape(gapText)}</span></span>`,
        },
      ]
    })
  }, [view, logic, text, tag, name])

  // The link bubble shows a link's lag and the float it leaves (one instance, the menu's skin).
  const linkBubble = useMemo(
    () =>
      new SuperScheduler.Bubble({
        theme: skinMenuTheme(SLUG),
        showAfter: 120,
        position: 'Mouse',
        hideOnClick: true,
      }),
    [],
  )

  // The separators: the site clock as a section cut, and the day the tower crane comes down.
  const separators = useMemo<SuperScheduler.SeparatorData[]>(
    () => [
      {
        location: `${CRANE_DISMANTLED}T00:00:00`,
        layer: 'BelowEvents',
        width: 1,
        cssClass: 'pl-sep-crane',
        toolTip: format(text.kpi.toppingTip, { date: shortDay(parseCivil(CRANE_DISMANTLED), tag) }),
      },
      {
        location: NOW,
        layer: 'AboveEvents',
        width: 2,
        cssClass: 'pl-sep-now',
        toolTip: format(text.chrome.now, {
          day: shortDay(NOW_TICKS, tag),
          time: new Intl.DateTimeFormat(tag, {
            hour: '2-digit',
            minute: '2-digit',
            hourCycle: 'h23',
            timeZone: 'UTC',
          }).format(new Date(NOW_TICKS)),
        }),
      },
    ],
    [text, tag],
  )

  // Header markup per cell, cached for the board's life (the hook runs for every header cell).
  const timeHeaders = useMemo(() => createTimeHeaderHook(text, tag), [text, tag])

  const columns = useMemo<SuperScheduler.RowHeaderColumnData[]>(
    () =>
      narrow
        ? [{ text: text.columns.location, width: BOARD.narrowColumn, nonresizable: true }]
        : [
            { text: text.columns.location, width: BOARD.locationColumn, nonresizable: true },
            { text: text.columns.trade, width: BOARD.tradeColumn, nonresizable: true },
            { text: text.columns.done, width: BOARD.doneColumn, nonresizable: true },
          ],
    [narrow, text],
  )

  const config = useMemo<SchedulerRenderProps>(() => {
    const escape = SuperScheduler.Util.escapeHtml
    const activities = view.activities
    const byId = view.net.byId
    // Row figures, once per plan: progress, the trade at work, critical faces, slabs.
    const progress = new Map(ROWS.map((row) => [row.id, rowProgress(activities, row.id)]))
    const overall =
      ROWS.reduce((sum, row) => sum + (progress.get(row.id) ?? 0), 0) / Math.max(1, ROWS.length)
    const criticalRows = new Set(
      activities.flatMap((activity) => (view.plan.critical.has(activity.id) ? [activity.row] : [])),
    )
    const paintCell = cellPainter(curingWindows(activities))
    const crane = craneHours(activities, PLAN_FROM, PLAN.days)
    const cards = new Map<string, string>()

    /** Members of a multi-move, each shifted by the dragged activity's working days. */
    const groupSpans = (
      master: Activity,
      start: number,
      items: readonly SuperScheduler.SchedulerMultiMoveItem[],
    ) => {
      const delta = signedWorkingDays(parseCivil(master.start), start)
      const spans = new Map<string, Span>()
      const members: Activity[] = []
      for (const item of items) {
        const member = byId.get(String(item.event.id()))
        if (member === undefined) continue
        const from = shiftStart(parseCivil(member.start), delta)
        spans.set(member.id, { start: from, end: addWorkingDays(from, duration(member)) })
        members.push(member)
      }
      return { delta, spans, members }
    }

    return {
      locale: schedulerLocale,
      cssClass: skinClass(SLUG),
      startDate: PLAN.start,
      days: PLAN.days,
      // ISO weeks in every locale: the week row is a dimension chain from Monday to Monday.
      weekStarts: 1,
      scale: 'Day',
      cellWidth: BOARD.cellWidth,
      zoomGesture: { min: BOARD.zoom.min, max: BOARD.zoom.max, scales: false },
      // The drawing keeps its lettering down to a narrow day; it simplifies further out.
      lod: { zoomedOut: 20, eventFull: 26, eventText: 20, cellContent: 12, dayNumber: 13 },
      timeHeaders: [
        { groupBy: 'Week', height: BOARD.header.week },
        { groupBy: 'Day', height: BOARD.header.day },
        { groupBy: 'Day', height: BOARD.header.weather },
      ],
      // Weekends are the site's: drawn by the cells, and skipped by moves.
      cellsMarkBusiness: false,
      businessWeekends: false,
      eventMoveSkipNonBusiness: true,
      useEventBoxes: 'Never',
      height: '100%',
      treeEnabled: true,
      treePreventParentUsage: true,
      treeIndent: narrow ? 4 : BOARD.treeIndent,
      rowHeaderColumns: columns,
      rowHeaderColumnsMergeParents: true,
      rowHeaderWidth: narrow ? BOARD.narrowColumn : ROW_HEADER_WIDTH,
      rowHeaderWidthAutoFit: false,
      eventHeight: BOARD.eventHeight,
      rowMarginTop: BOARD.rowMargin,
      rowMarginBottom: BOARD.rowMargin,
      allowEventOverlap: false,
      conflictHighlight: true,
      // Progress fills the trade's hatch from the left: the bar spans the whole card.
      durationBarVisible: true,
      durationBarMode: 'PercentComplete',
      durationBarHeight: 3,
      // Selection: a click, Ctrl/Cmd+click, or a window drawn over the empty grid (CAD style).
      eventClickHandling: 'Select',
      allowMultiSelect: true,
      allowMultiMove: true,
      rectangleSelectHandling: 'EventSelect',
      timeRangeSelectedHandling: 'Disabled',
      linkShape: 'RightAngled',
      linkLayer: 'Below',
      linkBubble,
      crosshairType: 'Full',
      separators,
      showToolTip: false,
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      history,
      controlRef,
      onEventsChange,

      // The corner names the drawing: section A–A of Block C.
      onBeforeCornerRender: (args) => {
        args.html =
          `<span class="pl-corner"><b>${escape(text.section)}</b>` +
          `<span>${escape(text.building)}</span></span>`
      },

      onBeforeRowHeaderRender: (args) => {
        const row = args.row
        const id = row.id
        const [first, second, third] = row.columns
        if (id === CRANE_ROW) {
          row.cssClass = 'pl-row pl-row--crane'
          if (first !== undefined) first.html = craneHeaderHtml(text)
          if (second !== undefined) second.html = ''
          if (third !== undefined)
            third.html = `<span class="pl-cap" aria-hidden="true"><i></i>${CRANE_HOURS} h</span>`
          return
        }
        if (id === BUILDING_ID) {
          row.cssClass = 'pl-row pl-row--building'
          row.html = buildingHtml(text, overall)
          if (first !== undefined) first.html = row.html
          return
        }
        if (isLevelRowId(id)) {
          const level = levelOfRow(id)
          row.cssClass = 'pl-row pl-row--level'
          row.html = levelHtml(level, slabState(pourOf(activities, level)), text, tag)
          if (first !== undefined) first.html = row.html
          return
        }
        if (!isRowId(id)) return
        const critical = criticalRows.has(id)
        row.cssClass = 'pl-row pl-row--face'
        if (first !== undefined) first.html = faceHtml(String(row.name), critical)
        if (second !== undefined) second.html = tradeHtml(workAt(activities, id), text, tag)
        if (third !== undefined) third.html = doneHtml(progress.get(id) ?? 0)
      },

      onBeforeCellRender: (args) => {
        const cell = args.cell
        const day = cell.start.getTime()
        const wide = (args.control.cellWidth ?? BOARD.cellWidth) >= 26
        if (cell.resource === CRANE_ROW) {
          const hours = crane[Math.round((day - PLAN_FROM) / DAY)] ?? 0
          const painted = craneCell(hours, wide, text)
          cell.properties.cssClass = painted.css
          cell.properties.html = painted.html
          return
        }
        if (cell.resource === BUILDING_ID || isLevelRowId(cell.resource)) {
          cell.properties.cssClass = isWorkingDay(day) ? 'pl-c-slab' : 'pl-c-slab pl-c-off'
          return
        }
        if (!isRowId(cell.resource)) return
        const painted = paintCell(cell.resource, day, wide, text)
        if (painted.css !== '') cell.properties.cssClass = painted.css
        if (painted.html !== '') cell.properties.html = painted.html
        if (painted.disabled) cell.properties.disabled = true
      },

      onBeforeEventRender: (args) => {
        const data = args.data
        const fields = readActivityFields(data)
        const id = String(data.id)
        const activity = byId.get(id)
        if (fields === null || activity === undefined) return
        const issue = view.issueOf.get(id) ?? null
        const critical = view.plan.critical.has(id)
        const revision = view.revisionOf.get(id) ?? null
        const classes = ['pl-ev', `pl-k-${activity.kind}`]
        if (activity.trade !== null) classes.push(`pl-t-${activity.trade}`)
        if (critical) classes.push('pl-ev--critical')
        if (issue !== null) classes.push('pl-ev--clash')
        if (revision !== null) classes.push('pl-ev--revised')
        if (activity.complete >= 100) classes.push('pl-ev--built')
        else if (activity.complete > 0) classes.push('pl-ev--running')
        data.cssClass = classes.join(' ')
        const label = activityName(activity, text)

        if (activity.kind === 'milestone') {
          // A key date: a diamond at the end of its day, its name and day beside it.
          data['type'] = 'Milestone'
          data['width'] = 18
          data.html = ''
          data.barHidden = true
          const note = issue === null ? null : text.card.conflict
          data.htmlRight =
            (args.control.cellWidth ?? BOARD.cellWidth) >= DAY_NUMBERS_FROM
              ? milestoneHtml(label, keyDay(parseCivil(activity.start), tag), note)
              : ''
          data.ariaLabel = `${label}, ${keyDay(parseCivil(activity.start), tag)}`
          return
        }

        const days = duration(activity)
        const width = Math.round(
          ((parseCivil(activity.end) - parseCivil(activity.start)) / DAY) *
            (args.control.cellWidth ?? BOARD.cellWidth),
        )
        const bucket =
          width >= 110
            ? 110
            : width >= 96
              ? 96
              : width >= 64
                ? 64
                : width >= 58
                  ? 58
                  : width >= 30
                    ? 30
                    : 0
        const key = `${id}|${bucket}|${revision ?? ''}|${activity.complete}`
        let html = cards.get(key)
        if (html === undefined) {
          html = cardHtml(activity, label, days, { critical, issue, revision, width: bucket }, text)
          cards.set(key, html)
        }
        data.html = html
        data.complete = activity.complete
        if (activity.kind !== 'work') data.barHidden = true
        // The days an activity starts too early: hatched in red pencil, from its start.
        const areas: SuperScheduler.AreaData[] = []
        if (issue !== null) {
          const end =
            issue.kind === 'rain'
              ? activity.end
              : civil(Math.min(issue.until, parseCivil(activity.end)))
          areas.push({
            start: activity.start,
            end,
            top: 0,
            bottom: 0,
            cssClass: 'pl-clash',
            action: 'Default',
          })
        }
        data.areas = areas
        const state =
          issue !== null ? `, ${text.card.conflict}` : critical ? `, ${text.card.critical}` : ''
        data.ariaLabel = `${label}, ${shortDay(parseCivil(activity.start), tag)}, ${plural(text.days, days, tag)}${state}`
      },

      // The critical path is drawn heavier than the leader lines of the rest of the logic.
      onBeforeLinkRender: (args) => {
        if ((args.data.cssClass ?? '').includes('critical')) args.data.width = 2
      },

      // A plain drag over the empty grid draws a selection window, as in a drawing program.
      onGridMouseDown: (args) => {
        if (args.button === 'left') args.action = 'RectangleSelect'
      },

      // Every frame: the site's rules for the dragged activity or the whole selection moved
      // together (each member shifted by the same working days, rewritten here).
      onEventMoving: (args) => {
        const id = String(args.e.id())
        const activity = byId.get(id)
        if (activity === undefined) return
        if (args.multimove.length > 1) {
          const group = groupSpans(activity, args.start.getTime(), args.multimove)
          for (const item of args.multimove) {
            const span = group.spans.get(String(item.event.id()))
            if (span === undefined) continue
            item.start = new SuperScheduler.Date(span.start)
            item.end = new SuperScheduler.Date(span.end)
          }
          const refused = groupRefusal(group.members, group.spans, view.net)
          if (refused !== null) {
            const member = byId.get(refused.activity)
            const message = `${member === undefined ? '' : `${name(member)}: `}${refusalText(refused, view, text, tag)}`
            args.allowed = false
            args.message = message
            refusal.track(id, message)
            return
          }
          refusal.track(id, null)
          return
        }
        const refused = placementRefusal(
          activity,
          { start: args.start.getTime(), end: args.end.getTime() },
          view.net,
        )
        if (refused !== null) {
          const message = refusalText(refused, view, text, tag)
          args.allowed = false
          args.message = message
          refusal.track(id, message)
          return
        }
        refusal.track(id, null)
      },

      // Final check before the commit; keyboard moves (Alt+arrows) arrive here without a drag and
      // are kept on working days in the direction they travel.
      onEventMove: (args) => {
        const id = String(args.e.id())
        const activity = byId.get(id)
        if (activity === undefined) return
        if (args.multimove.length > 1) {
          const group = groupSpans(activity, args.newStart.getTime(), args.multimove)
          const refused = groupRefusal(group.members, group.spans, view.net)
          if (refused === null) return
          args.preventDefault()
          refusal.clear()
          onRefused(id, refusalText(refused, view, text, tag))
          return
        }
        const before = parseCivil(activity.start)
        let start = args.newStart.getTime()
        if (!isWorkingDay(dayOf(start)))
          start = start >= before ? nextWorkingStart(start) : previousWorkingStart(start)
        const end = addWorkingDays(start, duration(activity))
        if (start !== args.newStart.getTime() || end !== args.newEnd.getTime()) {
          args.newStart = new SuperScheduler.Date(start)
          args.newEnd = new SuperScheduler.Date(end)
        }
        const refused = placementRefusal(activity, { start, end }, view.net)
        if (refused === null) return
        args.preventDefault()
        refusal.clear()
        onRefused(id, refusalText(refused, view, text, tag))
      },

      onEventMoved: (args) => {
        refusal.clear()
        const ids =
          args.multimove.length > 0
            ? args.multimove.map((item) => String(item.event.id()))
            : [String(args.e.id())]
        onMoved(ids)
      },

      // A click (or Enter) selects the activity and docks its sheet; the hover card steps aside.
      onEventClick: (args) => {
        closeHover()
        if (args.ctrl || args.meta) return
        onOpen(String(args.e.id()))
      },

      onZoom: (args) => {
        if (args.phase === 'end') onScale(args.cellWidth)
      },
    }
  }, [
    view,
    columns,
    narrow,
    text,
    tag,
    schedulerLocale,
    history,
    controlRef,
    separators,
    linkBubble,
    refusal,
    name,
    closeHover,
    onEventsChange,
    onMoved,
    onOpen,
    onRefused,
    onScale,
  ])

  // Imperative and once: a scroll prop would be re-applied on every update. The board opens on
  // the week of the slip, scrolled down to Level 4.
  useEffect(() => {
    const control = controlRef.current
    control?.scrollTo(INITIAL_VIEW_START, false, 'left')
    control?.setScrollY(openingScroll())
  }, [controlRef])

  // The zoom slider lives in the product bar, styled as the drawing's scale bar.
  useEffect(() => {
    const control = controlRef.current
    const host = zoomHost.current
    if (control === null || host === null) return
    const slider = createZoomSlider(control, host, {
      min: BOARD.zoom.min,
      max: BOARD.zoom.max,
      scale: 'log',
      detents: BOARD.zoom.detents,
      label: text.chrome.scale,
    })
    return () => slider.dispose()
  }, [controlRef, zoomHost, text])

  return (
    <SuperSchedulerComponent
      {...config}
      onBeforeTimeHeaderRender={timeHeaders}
      resources={resources}
      events={events}
      links={links}
      eventHover={eventHover}
      dragCard={dragCard}
      contextMenu={contextMenu}
    />
  )
}

Das Archiv enthält die Dateien dieses Beispiels und eine README. SuperScheduler Pro und Download-Schlüssel sind nicht enthalten.