Zum Inhalt springen

Nachtplanung am Observatorium

Um 23:40 trifft eine Supernova-Meldung ein. Nehmen Sie vor ihrem Untergang ein Spektrum auf und sichern Sie den Transit, den sie verdrängt.

  • Astronomie
  • Nightglass
  • Ausgeblendete Zeit
  • React-Render-Slots
  • Verschieberegeln
  • Gesperrte Zeiten
  • Summenzeilen
  • Minimap
  • Stufenloser Zoom
  • Fadenkreuz
  • Theming
  • Demo mit synthetischen Daten
Quellcode laden.zip

Aufgabe

0 von 3 Schritten erledigt

Mittwoch, 23:40. Die vor einer Stunde entdeckte SN 2026qf braucht heute Nacht ein Klassifikationsspektrum. Um 01:24 sinkt sie unter 30°.

  1. Geben Sie der Meldung SN 2026qf 40 Minuten am Albor mit 2,5 m Öffnung. — Offen

    Ziehen Sie sie aus der Meldungswarteschlange direkt nach unten auf Albor 2,5 m, mit Beginn zwischen 23:50 und 00:40. Die Drag-Karte zeigt die Höhe zur vorgeschlagenen Zeit; in späteren Zeitfenstern sinkt das Ziel unter 30°.

  2. Verschieben Sie den verdrängten Transit von WASP-18 b auf das robotische Vigía mit 0,6 m Öffnung. — Offen

    Ein Transit behält seine Zeit: Ziehen Sie ihn direkt nach unten auf Vigía 0,6 m (Cumbre ist für technische Arbeiten belegt), oder öffnen Sie sein Menü und wählen Sie Auf Vigía 0,6 m verschieben, gleiche Zeit.

  3. Schalten Sie die Konsole für den Rest der Nacht in den Rotlichtmodus. — Offen

    Drücken Sie R, während die Tafel den Fokus hat, oder nutzen Sie den Schalter Rotlicht in der oberen Leiste.

Etwa 30 Sekunden. Erledigte Schritte werden abgehakt.

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

Nightglass
MeldungenHauptkuppelAlbor2.5 mOstkuppelCumbre1.2 mSchiebedachVigía0.6 mFaro0.3 mLampenspektrenThArNGC 7793NG-26B-021 · R 40 000NGC 1365NG-26B-014 · R 40 000WASP-18NG-26B-033 · RNGC 1097NG-26B-014 · R 40 000NGC 2440NG-26B-021 · R 40 000LampenspektrenThArNGC 7793NG-26B-021 · R 40 000NGC 1316NG-26B-014 · R 40 000NGC 1097NG-26B-014 · R 40 000Himmelsflatsg r iNGC 7293NG-26B-052 · Hα [O III]TechnikJustierung des Sekundärspiegels30 DoradusNG-26B-058 · g r iNGC 2440NG-26B-052 · Hα [O III]Himmelsflatsg r iNGC 7293NG-26B-052 · Hα [O III]NGC 55NG-26B-040 · V IHimmelsflatsB VNGC 300NG-26B-040 · VR DoradusNG-26B-063 · B VHimmelsflatsB VNGC 300NG-26B-040 · VR DoradusNG-26B-063 · B VHimmelsflatsg rField S17NG-26B-071 · g rField S15NG-26B-071 · g rField S16NG-26B-071 · g rHimmelsflatsg rField S17NG-26B-071 · g rField S14NG-26B-071 · g rSN 2026qfTOO-26B-003 · R 40 00081%89%MondPhase · Unterg.Mittwoch, 21. OktoberDonnerstag, 22. Oktober2021222300010203040506072021222300

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.

Nightglass ist der Nachtplan von Cerro Alto, einem fiktiven Observatorium auf 2.400 m mit vier Teleskopen in drei Schutzbauten. Der Plan reicht vom Sonnenuntergang bis zum Sonnenaufgang; Tagesstunden erscheinen nicht auf der Tafel. Die Dämmerung gehört zu den Daten: Himmelsflats brauchen sie, wissenschaftliche Beobachtungen warten auf ihr Ende.

Ob ein Zeitfenster passt, entscheidet der Himmel. Ein Ziel muss über 30° bleiben (Luftmasse 2), der Mond hellt den Himmel um lichtschwache Ziele auf, und eine feuchte Wetterfront kann alle Kuppeln schließen. Wird eine neue Supernova gemeldet, hat die Nachtassistenz nur wenige Minuten, um vor ihrem Untergang ein Teleskop zu finden und die verdrängten Beobachtungen neu zuzuweisen.

Was Sie tun werden

Sie planen ein Spektrum des Gelegenheitsziels ein, solange die Supernova noch über 30° steht, verschieben den verdrängten Transit zur selben Zeit auf ein anderes Teleskop und schalten die Konsole in den Rotlichtmodus.

Wo die Bibliothek endet und Ihre Anwendung beginnt.

SuperScheduler liefert

  • Ausgeblendete Zeit mit onIncludeTimeCell: Auf der Achse stehen nur Nächte, jeweils von einer halben Stunde vor Sonnenuntergang bis nach Sonnenaufgang, direkt nebeneinander, mit einem Separator an jeder Nachtgrenze und einem an der aktuellen Nachtzeit.
  • Zellen von zehn Minuten und zoomLevels vom Detail in Zehn-Minuten-Schritten bis zur ganzen Beobachtungsserie, stufenlose Zoomgeste und Schieberegler aus zoom-ui (createZoomSlider); Verschieben, Größenänderungen und Tastaturschritte folgen den Zellen.
  • Drei Zeitkopfzeilen aus onBeforeTimeHeaderRender: die Nacht (eine Zeile mit groupBy: 'None' und einem Kopfbereich je Nacht), die Stunden und die Himmelszeile mit Dämmerungsbändern und Markierungen für Sonnenuntergang, Dunkelheit und Sonnenaufgang.
  • onBeforeCellRender färbt jede Zelle nach der Sonnenhöhe ein und sperrt die Stunden, in denen die Vorhersage einen Schutzbau schließt (schraffiert; Blöcke können dort nicht abgelegt werden).
  • Eine fixierte Mondzeile (frozen: 'top'), deren Events für jede Nacht Mondphase, Untergangszeit und Wahrscheinlichkeit für klaren Himmel zeigen, über einem Baum aus Schutzbauten und Teleskopen mit vier rowHeaderColumns.
  • React-Render-Slots (renderEvent aus super-scheduler/react-render) zeichnen die Höhenkurve jedes Blocks; Hover-Karte (eventHover) und Drag-Karte zeigen das Ziel über die ganze Nacht und zur vorgeschlagenen Zeit.
  • Begründete Ablehnungen in onEventMoving, args.allowOverlap für Meldungen, die Beobachtungen aus der Warteschlange verdrängen, moveHDisabled für einen Transit und moveVDisabled für Kalibrierungen, crosshairType: 'Full', Rückgängig-Verlauf, Tastaturbewegungen und ein Kontextmenü im Skin des Produkts.
  • Die Minimap (super-scheduler/minimap) über die ganze Beobachtungsserie, mit festem Bereich sowie einer Datenreihe und Farbtönen, die die Anwendung berechnet.

Der Code dieses Beispiels entscheidet

  • Der Himmel: näherungsweise Positionen von Sonne und Mond, Sternzeit, Höhen, Luftmasse und Dämmerung am Standort als reine Funktionen.
  • Was ein Teleskop darf: das benötigte Instrument je Modus, Ziele über 30°, wissenschaftliche Beobachtungen nach der nautischen Dämmerung, Himmelsflats in der Dämmerung, Sperrzeiten aus der Wettervorhersage und kein Beginn in der Vergangenheit.
  • Was eine Meldung verdrängt, welche Blöcke im Konflikt stehen, das früheste Zeitfenster eines Blocks und das Teleskop, das einen Transit zur selben Zeit übernehmen kann.
  • Belegte Dunkelzeit je Nacht und wetterbedingte Ausfälle als Grundlage der Minimap-Farbtöne, die Nachtuhr mit UT und Sternzeit sowie der Rotlichtmodus.

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: die reine Nachtzeitachse (onIncludeTimeCell), Zoomstufen auf Zehn-Minuten-Zellen, die drei Kopfzeilen, die fixierte Mondzeile, rowHeaderColumns, Separatoren, die React-Blockkarten, die Hover-Karte, die Drag-Karte mit der Höhe zur vorgeschlagenen Zeit, das Kontextmenü und die Callbacks, in denen die Himmelsbedingungen ein Ablegen ablehnen oder einer Meldung Vorrang geben.

/**
 * Nightglass night plan: four telescopes under three enclosures over a run of seven nights, in
 * ten-minute cells, with daytime taken off the axis.
 *
 * Library: hidden time (onIncludeTimeCell keeps the nights only), zoom levels on one cell scale
 * from 10-minute detail to the whole run, three time-header rows written by
 * onBeforeTimeHeaderRender (a 'None' row with one area per night, the hours, the sky), cells
 * shaded and disabled by onBeforeCellRender, a frozen Moon row with its own events, a resource
 * tree with four rowHeaderColumns, React cards (renderEvent) over HTML fallbacks, separators at
 * night boundaries and at the night clock, the full crosshair, drag and resize rules with the drag
 * card, per-frame overlap for alerts, moveHDisabled / moveVDisabled locks, the hover card
 * (eventHover), keyboard and undo history, and a context menu. Application: the sky (./astro),
 * what the night allows and what an alert pre-empts (./rules), and what the board paints from it
 * (./sky-paint, ./block-card).
 */
import { useCallback, useEffect, useMemo, useState, type RefObject } from 'react'
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { SuperSchedulerComponent } from 'super-scheduler/react-render'
import { format } from '../../i18n/format'
import { parseCivil } from '../../scheduler/civil'
import { toneClass } from '../../scheduler/tones'
import { createEventMenu, type MenuEntry } from '../kit/context-menu'
import { defaultHints } 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,
  MOON_ROW,
  blockTitle,
  enclosureMembers,
  isEnclosureRow,
  isRowId,
  lookOf,
  readBlockFields,
  toResources,
  toneOf,
  type BlockEvent,
} from './adapter'
import {
  BlockCard,
  MoonCard,
  chartModel,
  chartSvg,
  useObservationHover,
  altitudeSummary,
  cardHtml,
  cardParts,
  cardSize,
  moonHtml,
  moonParts,
  type HoverFact,
  type ObservationCardModel,
} from './block-card'
import { crossing, targetAltitude } from './astro'
import {
  NIGHTS,
  NOW,
  PLAN,
  SITE,
  QUEUE_ID,
  SLOT_MINUTES,
  TONIGHT,
  isTelescopeId,
  programmeById,
  targetById,
  telescopeById,
  type Block,
} from './model'
import {
  altitudeProfile,
  blockStatus,
  earliestSlot,
  isLocked,
  modeOf,
  moonWarning,
  civilOf,
  nightAt,
  nightSky,
  overlapping,
  preemptible,
  refusalOf,
  rehome,
  warningsOf,
  type Issue,
  type Refusal,
} from './rules'
import {
  cellPainter,
  clock,
  cornerHtml,
  degrees,
  domeHtml,
  formatDec,
  formatRa,
  hourHeader,
  hoursMinutes,
  isNightCell,
  nightAreas,
  nightSpans,
  pixelOf,
  seeingHtml,
  skyAreas,
  telescopeHtml,
  telescopeName,
} from './sky-paint'
import type { NightglassText, ZoomId } from './text'

export type Layout = 'wide' | 'medium' | 'narrow'

/** What the board asks the demo to do (context menu, keyboard, sheet). */
export type BlockAction = 'details' | 'earliest' | 'rehome' | 'red-light'

export interface NightglassSchedulerProps {
  readonly events: BlockEvent[]
  /** The same events as business objects, for rules that look at other blocks. */
  readonly blocks: readonly Block[]
  readonly issues: readonly Issue[]
  readonly text: NightglassText
  readonly common: DemoCommon
  /** 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
  /** The block shown in the side sheet: it gets the selection ring. */
  readonly selected: string | null
  /** Red-light mode: a class on the root, which the hover card copies; the menu gets its own theme. */
  readonly red: 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: (id: string, message: string) => void
  readonly onAction: (action: BlockAction, id: string) => void
  readonly onZoomLevel: (level: ZoomId) => void
  /**
   * Wide stages show four row-header columns; medium ones fold the instrument into the
   * telescope column and keep the dome; phones keep the telescope column only.
   */
  readonly layout: Layout
}

/** One cell scale, three widths: zooming changes only the pixels per ten minutes. */
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  { id: 'minutes', properties: { cellWidth: BOARD.zoom.minutes } },
  { id: 'night', properties: { cellWidth: BOARD.zoom.night } },
  { id: 'run', properties: { cellWidth: BOARD.zoom.run } },
]
const ZOOM_IDS: readonly ZoomId[] = ['minutes', 'night', 'run']

/** The night, its hours and its sky. */
const TIME_HEADERS: SuperScheduler.TimeHeaderData[] = [
  { groupBy: 'None', height: BOARD.header.night },
  { groupBy: 'Hour', height: BOARD.header.hours, format: 'HH' },
  { groupBy: 'None', height: BOARD.header.sky },
]

/** Night boundaries and the night clock: fixed instants of the scenario. */
function separators(text: NightglassText, tag: string): SuperScheduler.SeparatorData[] {
  return [
    ...NIGHTS.slice(1).map((night) => ({
      location: new Date(nightSky(night).window.start).toISOString().slice(0, 19),
      layer: 'BelowEvents' as const,
      width: 1,
      cssClass: 'ng-sep-night',
    })),
    {
      location: NOW,
      layer: 'AboveEvents' as const,
      width: 1,
      cssClass: 'ng-now',
      toolTip: `${text.chrome.clock} ${clock(parseCivil(NOW), tag)}`,
    },
  ]
}

/** A refusal as a complete sentence, with the sky's numbers. */
export function refusalMessage(refusal: Refusal, text: NightglassText, tag: string): string {
  const time = (ticks: number) => clock(ticks, tag)
  const target = (id: string) => targetById(id)?.name ?? id
  switch (refusal.kind) {
    case 'done':
    case 'running':
    case 'engineering':
    case 'queue':
      return text.rules[refusal.kind]
    case 'past':
      return format(text.rules.past, { time: time(parseCivil(NOW)) })
    case 'instrument':
      return format(text.rules.instrument[refusal.mode], {
        telescope: telescopeName(refusal.telescope, text, tag),
      })
    case 'sets':
    case 'rises':
    case 'low':
      return format(text.rules[refusal.kind], {
        target: target(refusal.target),
        time: time(refusal.at),
        edge: time(refusal.edge),
        altitude: degrees(refusal.altitude),
      })
    case 'bright':
      return format(refusal.dawn ? text.rules.brightDawn : text.rules.brightDusk, {
        time: time(refusal.limit),
        edge: time(refusal.edge),
        sun: degrees(refusal.sun),
      })
    case 'twilight':
      return format(text.rules.twilight, { edge: time(refusal.edge), sun: degrees(refusal.sun) })
    case 'closed':
      return format(
        refusal.closure.cause === 'humidity' ? text.rules.closedHumidity : text.rules.closedWind,
        {
          enclosure: text.enclosures[refusal.closure.enclosure],
          start: time(refusal.closure.start),
          end: time(refusal.closure.end),
          value: String(refusal.closure.value),
        },
      )
    case 'pre-empt':
      return format(text.rules.preempt, { name: refusal.blocker })
  }
}

/** Menu label of the earliest slot: where it would go, or why it stays. */
export function earliestLabel(
  block: Block,
  blocks: readonly Block[],
  text: NightglassText,
  tag: string,
): { label: string; enabled: boolean } {
  const slot = earliestSlot(block.id, blocks)
  if (slot === null) return { label: text.actions.noSlot, enabled: false }
  if (slot.row === block.row && slot.start === parseCivil(block.start))
    return { label: text.actions.atEarliest, enabled: false }
  return {
    label: format(text.actions.earliest, {
      time: clock(slot.start, tag),
      telescope: slot.row === QUEUE_ID ? text.queue.row : telescopeName(slot.row, text, tag),
    }),
    enabled: true,
  }
}

/** Menu label of the move to another telescope at the same time. */
export function rehomeLabel(
  block: Block,
  blocks: readonly Block[],
  text: NightglassText,
  tag: string,
): { label: string; enabled: boolean } {
  const slot = rehome(block.id, blocks)
  return slot === null || slot.row === QUEUE_ID
    ? { label: text.actions.noRehome, enabled: false }
    : {
        label: format(text.actions.rehome, { telescope: telescopeName(slot.row, text, tag) }),
        enabled: true,
      }
}

/** The hover card and side-sheet facts of a block, from the model and the rules. */
export function observationModel(
  block: Block,
  issues: readonly Issue[],
  text: NightglassText,
  common: DemoCommon,
  tag: string,
): ObservationCardModel {
  const target = targetById(block.target)
  const programme = block.programme
  const start = parseCivil(block.start)
  const end = parseCivil(block.end)
  const night = nightAt(start) ?? nightAt(end - 1)
  const status = block.row === QUEUE_ID ? 'waiting' : blockStatus(block)
  const own = issues.find((issue) => issue.block === block.id)
  const mode = modeOf(block)
  const where = block.row === QUEUE_ID ? text.queue.row : telescopeName(block.row, text, tag)
  const facts: HoverFact[] = []
  let rule: ObservationCardModel['rule'] = null
  if (target !== null) {
    const profile = altitudeProfile(target, start, end)
    const summary = altitudeSummary(profile)
    const moon = moonWarning(target.id, start, end)
    facts.push(
      {
        label: text.fields.window,
        value: format(text.values.window, {
          start: clock(start, tag),
          end: clock(end, tag),
          span: hoursMinutes((end - start) / 60_000),
        }),
        mono: true,
      },
      {
        label: text.fields.coordinates,
        value: format(text.values.coordinates, {
          ra: formatRa(target.ra),
          dec: formatDec(target.dec),
        }),
        mono: true,
      },
      {
        label: text.fields.programme,
        value:
          programme === undefined
            ? '—'
            : format(text.values.programme, {
                id: programme,
                pi: programmeById(programme)?.pi ?? '',
              }),
        mono: true,
      },
      { label: text.fields.setup, value: block.setup ?? '—', mono: true },
      {
        label: text.fields.altitude,
        value: format(text.values.altitude, summary),
        mono: true,
        alert: profile.min < 30,
      },
    )
    if (moon !== null)
      facts.push({
        label: text.fields.moon,
        value: format(text.values.moon, {
          value: new Intl.NumberFormat(tag, { style: 'percent' }).format(moon.fraction),
          separation: String(moon.separation),
        }),
        alert: true,
      })
    rule =
      own?.kind === 'pre-empted'
        ? { state: 'blocked', text: text.status['pre-empted'] }
        : own?.kind === 'weather'
          ? { state: 'blocked', text: text.status.weather }
          : moon !== null
            ? {
                state: 'warn',
                text: format(text.warn.moon, {
                  value: new Intl.NumberFormat(tag, { style: 'percent' }).format(moon.fraction),
                  separation: String(moon.separation),
                }),
              }
            : {
                state: 'ok',
                text: format(text.warn.clear, { min: summary.min, airmass: summary.airmass }),
              }
  } else {
    facts.push(
      { label: text.fields.telescope, value: where },
      { label: text.fields.starts, value: clock(start, tag), mono: true },
      { label: text.fields.ends, value: clock(end, tag), mono: true },
    )
    if (block.setup !== undefined)
      facts.push({ label: text.fields.setup, value: block.setup, mono: true })
  }
  const statusLabel =
    own !== undefined
      ? { label: text.status[own.kind], tone: 'danger' as const }
      : status === 'running'
        ? { label: text.status.running, tone: 'success' as const }
        : status === 'waiting'
          ? { label: text.status.waiting, tone: 'warning' as const }
          : { label: block.kind === 'calibration' ? text.status.calibration : text.status[status] }
  return {
    id: block.id,
    tone: toneOf(block),
    eyebrow: `${mode === null ? text.kinds[block.kind] : text.modes[mode]} · ${where}`,
    title: blockTitle(block, text),
    status: statusLabel,
    chart:
      night === null || target === null
        ? null
        : { night, target: target.id, start, end, label: text.actions.chart },
    facts,
    rule,
    hints: defaultHints(common, { move: !isLocked(block) }),
  }
}

/** Beside a waiting alert: its programme and when the target sinks below 30° tonight. */
function alertSide(block: Block, text: NightglassText, tag: string): string {
  const escape = SuperScheduler.Util.escapeHtml
  const target = targetById(block.target)
  const night = nightAt(parseCivil(block.start))
  if (target === null || night === null) return ''
  const { window } = nightSky(night)
  const sets = crossing(
    (ticks) => targetAltitude(target, ticks, SITE),
    30,
    'down',
    parseCivil(block.start),
    window.end,
    5 * 60_000,
  )
  const programme = programmeById(block.programme)
  return (
    `<span class="ng-side"><b>${escape(target.name)}</b>` +
    `<span>${escape([programme?.id, block.setup].filter(Boolean).join(' · '))}</span>` +
    (sets === null
      ? ''
      : `<span class="ng-side__sets">${escape(format(text.card.setsAt, { time: clock(sets, tag) }))}</span>`) +
    `</span>`
  )
}

export function NightglassScheduler(props: NightglassSchedulerProps) {
  const {
    events,
    blocks,
    issues,
    text,
    common,
    tag,
    schedulerLocale,
    initialZoom,
    selected,
    red,
    history,
    controlRef,
    layout,
  } = props
  const narrow = layout === 'narrow'
  const { onEventsChange, onSelect, onMoved, onResized, onRefused, onAction, onZoomLevel } = props

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

  // Header labels depend on the cell width; a settled zoom level gets a fresh hook so the control
  // labels its headers again (the hook reads args.control.cellWidth while painting).
  const [level, setLevel] = useState<ZoomId>(initialZoom)
  const timeHeaders = useMemo(() => {
    const hook = (args: SuperScheduler.SchedulerBeforeTimeHeaderRenderArgs) => {
      const header = args.header
      const width = args.control.cellWidth ?? BOARD.zoom.night
      const start = header.start.getTime()
      const end = header.end.getTime()
      if (header.level === 0) {
        header.html = ''
        header.cssClass = 'ng-th-nights'
        header.areas = nightAreas(width, text, tag)
        header.toolTip = ''
        return
      }
      if (header.level === 1) {
        const painted = hourHeader(start, end, width, text, tag)
        header.html = painted.html
        header.toolTip = painted.tip
        header.cssClass = 'ng-th-hour'
        return
      }
      header.html = ''
      header.areas = skyAreas(width, text, tag)
      header.cssClass = 'ng-th-sky'
      header.toolTip = ''
    }
    return { level, hook }
  }, [level, tag, text]).hook

  // A refused drop (sky, instrument, closure, the night log) is said on release.
  const refusal = useDropRefusal(onRefused)

  const paintCell = useMemo(() => cellPainter(text, tag), [text, tag])

  // Hover card: the target over its whole night, the facts and the rule that applies.
  const card = useCallback(
    (event: SuperScheduler.Event) => {
      const block = blocks.find((candidate) => candidate.id === String(event.id()))
      return block === undefined ? null : observationModel(block, issues, text, common, tag)
    },
    [blocks, issues, text, common, tag],
  )
  const { eventHover, closeHover, suppressHover } = useObservationHover(card, tag)

  // Drag verdict: the night's refusal or warnings, then the target's altitude at the proposed
  // time, with the part under 30° in red.
  const dragCard = useMemo<SuperScheduler.DragCardOptions>(() => {
    const proposed = (info: SuperScheduler.DragCardInfo) => {
      const block = blocks.find((candidate) => candidate.id === String(info.eventId))
      if (block === undefined || info.resource === null || !isRowId(info.resource)) return null
      return {
        block,
        placement: { row: info.resource, start: info.start.getTime(), end: info.end.getTime() },
      }
    }
    const base = createDragCard({
      tag,
      common,
      rowName: (id) =>
        id === QUEUE_ID ? text.queue.row : isTelescopeId(id) ? telescopeName(id, text, tag) : null,
      warn: (info) => {
        const next = proposed(info)
        if (next === null) return null
        const warnings = warningsOf(next.block, next.placement, blocks)
        const lines = warnings.map((warning) =>
          warning.kind === 'moon'
            ? format(text.warn.moon, {
                value: new Intl.NumberFormat(tag, { style: 'percent' }).format(warning.fraction),
                separation: String(warning.separation),
              })
            : format(text.warn.preempts, {
                names: warning.blocks
                  .map((id) => {
                    const other = blocks.find((candidate) => candidate.id === id)
                    return other === undefined ? id : blockTitle(other, text)
                  })
                  .join(', '),
              }),
        )
        return lines.length === 0 ? null : lines.join(' ')
      },
    })
    return {
      ...base,
      // The header marker names the minute: a night plan is read to the minute, not the day.
      markerFormat: 'HH:mm',
      html: (info) => {
        const html = base.html?.(info) ?? null
        const next = proposed(info)
        const target = targetById(next?.block.target)
        if (html === null || next === null || target === null) return html
        const profile = altitudeProfile(target, next.placement.start, next.placement.end)
        const night = nightAt(next.placement.start) ?? nightAt(next.placement.end - 1)
        if (night === null) return html
        const low = profile.min < 30
        const caption = format(text.warn.clear, altitudeSummary(profile))
        const chart =
          `<div class="ng-vd-alt" data-low="${low}">` +
          `<span class="ng-vd-alt__label">${text.warn.chart}</span>` +
          chartSvg(
            chartModel(night, target.id, next.placement.start, next.placement.end, tag, {
              width: 248,
              height: 64,
            }),
            low,
          ) +
          (low ? '' : `<span class="ng-vd-alt__caption">${caption}</span>`) +
          `</div>`
        return `${html.slice(0, -'</div>'.length)}${chart}</div>`
      },
    }
  }, [blocks, text, common, tag])

  // Context menu: rebuilt when the plan changes, so its labels name the real consequence. Red
  // light gets its own theme (the menu lives on <body>, outside the board).
  const contextMenu = useMemo(
    () =>
      createEventMenu({
        theme: red ? 'ng-red-menu' : skinMenuTheme('observatory-nights'),
        title: (event) => format(common.kit.menuTitle, { name: event.text() }),
        onOpenChange: (open) => suppressHover(open),
        entries: (event) => {
          const id = String(event.id())
          const block = blocks.find((candidate) => candidate.id === id)
          if (block === undefined) return []
          const entries: MenuEntry[] = [
            {
              label: common.kit.openDetails,
              icon: 'details',
              keys: KEYS.details,
              run: () => onAction('details', id),
            },
          ]
          if (!isLocked(block) && block.timeCritical !== true && block.kind !== 'calibration') {
            const earliest = earliestLabel(block, blocks, text, tag)
            entries.push({
              label: earliest.label,
              icon: 'slot',
              primary: block.kind === 'too' && block.row === QUEUE_ID,
              disabled: !earliest.enabled,
              run: () => onAction('earliest', id),
            })
          }
          if (block.kind === 'science' && !isLocked(block)) {
            const move = rehomeLabel(block, blocks, text, tag)
            entries.push({
              label: move.label,
              icon: 'handover',
              primary: issues.some((issue) => issue.block === id && issue.kind === 'pre-empted'),
              disabled: !move.enabled,
              run: () => onAction('rehome', id),
            })
          }
          entries.push('separator', {
            label: red ? text.actions.redLightOff : text.actions.redLightOn,
            icon: 'focus',
            keys: ['R'],
            run: () => onAction('red-light', id),
          })
          return entries
        },
      }),
    [blocks, issues, red, common, text, tag, onAction, suppressHover],
  )

  // React cards: the same parts as the HTML fallback, sized by the rendered width.
  const renderEvent = useMemo<NonNullable<SchedulerProps['renderEvent']>>(
    () =>
      ({ e, data, width, lod }) => {
        const fields = readBlockFields(data)
        if (fields === null) return e.text()
        if (fields.kind === 'moon')
          return fields.night === undefined ? null : (
            <MoonCard parts={moonParts(fields.night, text, tag, width)} />
          )
        const block = blocks.find((candidate) => candidate.id === String(e.id()))
        if (block === undefined) return e.text()
        const moon = moonWarning(block.target, parseCivil(block.start), parseCivil(block.end))
        return (
          <BlockCard
            parts={cardParts(
              block,
              issues,
              moon?.separation ?? null,
              text,
              lookOf(fields),
              blockTitle(block, text),
              cardSize(width, lod),
            )}
          />
        )
      },
    [blocks, issues, text, tag],
  )

  const config = useMemo<SchedulerProps>(() => {
    const escape = SuperScheduler.Util.escapeHtml
    const targetName = (id: string | undefined) => targetById(id)?.name ?? ''
    const columns =
      layout === 'narrow'
        ? [{ text: text.columns.telescope, width: BOARD.narrowColumn }]
        : layout === 'medium'
          ? [
              { text: text.columns.telescope, width: BOARD.columns.telescope },
              { text: text.columns.dome, width: BOARD.columns.dome },
            ]
          : [
              { text: text.columns.telescope, width: BOARD.columns.telescope },
              { text: text.columns.dome, width: BOARD.columns.dome },
              { text: text.columns.seeing, width: BOARD.columns.seeing },
            ]
    const headerWidth = columns.reduce((sum, column) => sum + column.width, 0)
    const sep = separators(text, tag)
    return {
      locale: schedulerLocale,
      startDate: PLAN.start,
      days: PLAN.days,
      scale: 'CellDuration',
      cellDuration: SLOT_MINUTES,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // Pinch and Ctrl+wheel between the whole run and 10-minute detail; the slider follows.
      zoomGesture: { min: 2, max: 48, wheel: 'ctrl' },
      timeHeaders: TIME_HEADERS,
      // Daytime does not exist for this product: only cells inside a night's window are kept.
      onIncludeTimeCell: (args) => {
        args.cell.visible = isNightCell(args.cell.start.getTime(), args.cell.end.getTime())
      },
      useEventBoxes: 'Never',
      height: '100%',
      cssClass: `${skinClass('observatory-nights')} ng-board${red ? ' ng-red' : ''}`,
      treeEnabled: true,
      treePreventParentUsage: true,
      treeIndent: BOARD.treeIndent,
      rowHeaderColumns: columns,
      rowHeaderWidth: headerWidth,
      rowHeaderWidthAutoFit: false,
      // The corner above the column titles names the run and keys the night and hour rows.
      onBeforeCornerRender: (args) => {
        args.html = cornerHtml(text, narrow)
      },
      eventHeight: BOARD.eventHeight,
      rowMarginTop: BOARD.rowMargin,
      rowMarginBottom: BOARD.rowMargin,
      allowEventOverlap: false,
      conflictHighlight: true,
      durationBarVisible: false,
      crosshairType: 'Full',
      separators: sep,
      showToolTip: false,
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      keyboardOptions: { zoomKeys: true },
      history,
      controlRef,
      onEventsChange,

      onBeforeRowHeaderRender: (args) => {
        const row = args.row
        const id = row.id
        const cells = row.columns
        const first = cells[0]
        if (first === undefined) return
        const name = String(row.name)
        for (const cell of cells.slice(1)) cell.html = ''
        if (id === MOON_ROW) {
          row.cssClass = 'ng-row ng-row--moon'
          first.html = `<span class="ng-rh"><span class="ng-rh__name">${escape(name)}</span><span class="ng-rh__meta">${escape(text.moon.meta)}</span></span>`
          return
        }
        if (id === QUEUE_ID) {
          row.cssClass = 'ng-row ng-row--queue'
          const waiting = blocks.filter((block) => block.row === QUEUE_ID).length
          first.html =
            `<span class="ng-rh"><span class="ng-rh__name">${escape(name)}</span>` +
            `<span class="ng-rh__meta"><span class="ng-count" data-zero="${waiting === 0}">${waiting}</span>${escape(text.queue.meta)}</span></span>`
          return
        }
        if (isEnclosureRow(id)) {
          row.cssClass = 'ss-rh--group ng-row ng-row--group'
          row.html = `<span class="ng-group">${escape(name)}<span class="ng-group__count">${enclosureMembers(id).length}</span></span>`
          first.html = row.html
          return
        }
        const telescope = telescopeById(String(id))
        if (telescope === null) return
        row.cssClass = `ng-row ng-row--telescope ng-row--${telescope.id}`
        first.html = telescopeHtml(telescope, text, tag, layout !== 'narrow')
        const [, dome, seeing] = cells
        if (dome !== undefined) dome.html = domeHtml(telescope, blocks, text, targetName)
        if (seeing !== undefined) seeing.html = seeingHtml(telescope, text, tag)
      },

      onBeforeCellRender: (args) => {
        const cell = args.cell
        const resource = String(cell.resource)
        if (resource === MOON_ROW) {
          cell.properties.cssClass = 'ng-cell ng-cell--moon'
          return
        }
        const row = telescopeById(resource)?.id ?? (resource === QUEUE_ID ? 'queue' : 'other')
        const painted = paintCell(cell.start.getTime(), cell.end.getTime(), row)
        cell.properties.cssClass = painted.css
        if (painted.disabled) {
          cell.properties.disabled = true
          cell.properties.bubbleHtml = painted.title
        }
      },

      onBeforeEventRender: (args) => {
        const data = args.data
        const fields = readBlockFields(data)
        if (fields === null) return
        if (fields.kind === 'moon') {
          const night = fields.night
          if (night === undefined) return
          const sky = nightSky(night)
          // A night narrower than 300 px keeps the phase and the moonset tick. The settled zoom level
          // (not the control's width mid-gesture) decides, and changing it repaints these events.
          const cellWidth = BOARD.zoom[level]
          const wide = cellWidth * 72 >= 300
          const parts = moonParts(night, text, tag, wide ? 400 : 120)
          data.html = moonHtml(parts)
          data.cssClass = 'ng-ev ng-ev--moon'
          // Moonset, marked where it happens on the axis (a time-anchored area of the event).
          const set = sky.moon.set
          // On a narrow night the time is written only where it fits before the next night starts.
          const span = nightSpans(cellWidth).find((candidate) => candidate.night === night)
          const fits =
            set !== null &&
            (wide || (span !== undefined && pixelOf(set, cellWidth) - span.left + 58 <= span.width))
          data.areas =
            set === null
              ? []
              : [
                  {
                    start: civilOf(set),
                    width: 58,
                    top: 0,
                    bottom: 0,
                    cssClass: 'ng-moonset',
                    html: fits
                      ? `<span aria-hidden="true">${SuperScheduler.Util.escapeHtml(clock(set, tag))}</span>`
                      : '',
                    toolTip: format(text.header.moonset, { time: clock(set, tag) }),
                  },
                ]
          data.ariaLabel = format(text.moon.aria, {
            night: new Date(parseCivil(night)).toISOString().slice(5, 10),
            phase: sky.moon.waxing ? text.moon.waxing : text.moon.waning,
            value: parts.lit,
            clear: parts.clear,
          })
          return
        }
        const block = blocks.find((candidate) => candidate.id === String(data.id))
        if (block === undefined) return
        const look = lookOf(fields)
        const moon = moonWarning(block.target, parseCivil(block.start), parseCivil(block.end))
        const own = issues.filter((issue) => issue.block === block.id)
        // The fallback is the full card; React replaces it with the same markup sized to the block.
        data.html = cardHtml(
          cardParts(
            block,
            issues,
            moon?.separation ?? null,
            text,
            look,
            blockTitle(block, text),
            'full',
          ),
        )
        const status = block.row === QUEUE_ID ? 'planned' : blockStatus(block)
        const classes = ['ng-ev', `ng-ev--${look}`, toneClass(toneOf(fields))]
        if (status !== 'planned') classes.push(`ng-${status}`)
        for (const issue of own) classes.push(`ng-${issue.kind}`)
        if (moon !== null) classes.push('ng-moonwarn')
        if (block.timeCritical === true) classes.push('ng-critical')
        if (block.id === selected) classes.push('ng-selected')
        data.cssClass = classes.join(' ')
        // An alert waiting in the queue says what it is beside itself: the block is only 40 min.
        if (block.row === QUEUE_ID) data.htmlRight = alertSide(block, text, tag)
        const where = block.row === QUEUE_ID ? text.queue.row : telescopeName(block.row, text, tag)
        data.ariaLabel = [
          blockTitle(block, text),
          text.kinds[block.kind],
          where,
          `${clock(parseCivil(block.start), tag)}–${clock(parseCivil(block.end), tag)}`,
          ...own.map((issue) => text.status[issue.kind]),
        ].join(', ')
      },

      // The sky, the instruments, the weather and the night log decide; alerts may pre-empt.
      onEventMoving: (args) => {
        const id = String(args.e.id())
        const block = blocks.find((candidate) => candidate.id === id)
        if (block === undefined || !isRowId(args.resource)) return
        const placement = {
          row: args.resource,
          start: args.start.getTime(),
          end: args.end.getTime(),
        }
        const reason = refusalOf(block, placement, blocks)
        if (reason !== null) {
          const message = refusalMessage(reason, text, tag)
          args.allowed = false
          args.message = message
          refusal.track(id, message)
          return
        }
        if (block.kind === 'too' && placement.row !== QUEUE_ID) {
          const others = overlapping(block.id, placement, blocks)
          if (others.length > 0 && others.every((other) => preemptible(other)))
            args.allowOverlap = true
        }
        const first = args.conflicts[0]
        refusal.track(
          id,
          args.allowOverlap === true || first === undefined
            ? null
            : format(common.kit.overlaps, { name: first.text() }),
        )
      },

      onEventResizing: (args) => {
        const id = String(args.e.id())
        const block = blocks.find((candidate) => candidate.id === id)
        if (block === undefined || !isRowId(block.row)) return
        const reason = refusalOf(
          block,
          { row: block.row, start: args.start.getTime(), end: args.end.getTime() },
          blocks,
        )
        if (reason !== null) {
          const message = refusalMessage(reason, text, tag)
          args.allowed = false
          args.message = message
          refusal.track(id, message)
          return
        }
        const first = args.conflicts[0]
        refusal.track(
          id,
          first === undefined ? null : format(common.kit.overlaps, { name: first.text() }),
        )
      },

      // Final check before the commit (also covers keyboard moves).
      onEventMove: (args) => {
        const id = String(args.e.id())
        const block = blocks.find((candidate) => candidate.id === id)
        if (block === undefined || !isRowId(args.newResource)) {
          args.preventDefault()
          return
        }
        const reason = refusalOf(
          block,
          { row: args.newResource, start: args.newStart.getTime(), end: args.newEnd.getTime() },
          blocks,
        )
        if (reason !== null) {
          args.preventDefault()
          refusal.clear()
          onRefused(id, refusalMessage(reason, text, tag))
        }
      },

      onEventMoved: (args) => {
        refusal.clear()
        onMoved(String(args.e.id()))
      },
      onEventResized: (args) => {
        refusal.clear()
        onResized(String(args.e.id()), args.what)
      },
      // A click (or Enter) docks the block into the side sheet.
      onEventClick: (args) => {
        if (String(args.e.id()).startsWith('moon-')) return
        closeHover()
        onSelect(String(args.e.id()))
      },
      // R toggles red light while the board has focus.
      onKeyDown: (args) => {
        const key = args.originalEvent
        if (key.key !== 'r' && key.key !== 'R') return
        if (key.metaKey || key.ctrlKey || key.altKey) return
        args.preventDefault()
        onAction('red-light', '')
      },

      onZoom: (args) => {
        if (args.phase !== 'end') return
        const next = ZOOM_IDS[args.level]
        if (next === undefined) return
        setLevel(next)
        onZoomLevel(next)
      },
    }
  }, [
    history,
    controlRef,
    issues,
    blocks,
    selected,
    red,
    paintCell,
    common,
    refusal,
    closeHover,
    onEventsChange,
    onMoved,
    onRefused,
    onResized,
    onSelect,
    onAction,
    onZoomLevel,
    schedulerLocale,
    tag,
    text,
    initialZoom,
    level,
    layout,
    narrow,
  ])

  // Imperative and once: open on tonight's window (on a phone, on the night clock).
  useEffect(() => {
    controlRef.current?.scrollTo(
      narrow ? NOW : new Date(nightSky(TONIGHT).window.start).toISOString().slice(0, 19),
      false,
      narrow ? 'middle' : 'left',
    )
    // The layout at mount decides the opening view; a later resize keeps wherever the user is.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [controlRef])

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

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