Aller au contenu

Planification des nuits d’observation

Une alerte de supernova arrive à 23:40. Obtenez son spectre avant son coucher, tout en préservant le transit qu’elle déplace.

  • Astronomie
  • Nightglass
  • Temps masqué
  • Slots de rendu React
  • Règles de déplacement
  • Créneaux bloqués
  • Lignes de synthèse
  • Minimap
  • Zoom continu
  • Réticule
  • Thèmes
  • Démo avec données fictives

Mission

0 étapes faites sur 3

Mercredi, 23:40. SN 2026qf, découverte il y a une heure, nécessite un spectre de classification cette nuit. Elle passe sous 30° à 01:24.

  1. Accordez 40 minutes à l’alerte SN 2026qf sur Albor, le télescope de 2,5 m. — À faire

    Glissez-la depuis la file d’alertes directement vers le bas, sur Albor 2,5 m, avec un début entre 23:50 et 00:40. La carte de glisser montre sa hauteur à l’heure proposée ; les créneaux plus tardifs passent sous 30°.

  2. Déplacez le transit de WASP-18 b remplacé par l’alerte sur Vigía, le télescope robotisé de 0,6 m. — À faire

    Un transit conserve son horaire : glissez-le directement vers le bas, sur Vigía 0,6 m (Cumbre est réservé aux travaux techniques), ou ouvrez son menu et choisissez Déplacer sur Vigía 0,6 m, à la même heure.

  3. Activez le mode lumière rouge de la console pour le reste de la nuit. — À faire

    Appuyez sur R lorsque le tableau a le focus, ou utilisez le bouton Lumière rouge dans la barre supérieure.

Environ 30 secondes. Les étapes se cochent au fur et à mesure.

Clavier : placez le focus sur la grille, puis flèches, Entrée et Alt+flèches pour déplacer.

Nightglass
File d’alertesDôme principalAlbor2.5 mDôme estCumbre1.2 mToit coulissantVigía0.6 mFaro0.3 mSpectres de lampeThArNGC 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 000Spectres de lampeThArNGC 7793NG-26B-021 · R 40 000NGC 1316NG-26B-014 · R 40 000NGC 1097NG-26B-014 · R 40 000Flats de cielg r iNGC 7293NG-26B-052 · Hα [O III]Travaux techniquesAlignement du miroir secondaire30 DoradusNG-26B-058 · g r iNGC 2440NG-26B-052 · Hα [O III]Flats de cielg r iNGC 7293NG-26B-052 · Hα [O III]NGC 55NG-26B-040 · V IFlats de cielB VNGC 300NG-26B-040 · VR DoradusNG-26B-063 · B VFlats de cielB VNGC 300NG-26B-040 · VR DoradusNG-26B-063 · B VFlats de cielg rField S17NG-26B-071 · g rField S15NG-26B-071 · g rField S16NG-26B-071 · g rFlats de cielg rField S17NG-26B-071 · g rField S14NG-26B-071 · g rSN 2026qfTOO-26B-003 · R 40 00081%89%Lunephase · couchermercredi 21 octobrejeudi 22 octobre2021222300010203040506072021222300

Aperçu de la première imageEn direct : le vrai moteur Pro, dans votre navigateurEnviron 30 secondes. Les étapes se cochent au fur et à mesure. Clavier : placez le focus sur la grille, puis flèches, Entrée et Alt+flèches pour déplacer.

La situation.

Nightglass est le planning nocturne de Cerro Alto, un observatoire fictif à 2 400 m d’altitude avec quatre télescopes sous trois abris. Le planning va du coucher au lever du Soleil ; les heures diurnes n’apparaissent pas sur le tableau. Le crépuscule fait partie des données : les flats de ciel en ont besoin, les observations scientifiques attendent qu’il se termine.

C’est le ciel qui détermine si un créneau convient. Une cible doit rester au-dessus de 30° (masse d’air 2), la Lune augmente la luminosité du ciel autour des cibles faibles et un front humide peut imposer la fermeture de tous les dômes. Lorsqu’une nouvelle supernova est signalée, l’assistant de nuit n’a que quelques minutes pour lui trouver un télescope avant son coucher et réaffecter les observations qu’elle déplace.

Ce que vous allez faire

Vous programmerez un spectre de cible d’opportunité tant que la supernova reste au-dessus de 30°, déplacerez le transit qu’elle remplace sur un autre télescope à la même heure et activerez le mode lumière rouge de la console.

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

SuperScheduler fournit

  • Temps masqué avec onIncludeTimeCell : seules les nuits figurent sur l’axe, chacune commençant une demi-heure avant le coucher du Soleil et se terminant après son lever, côte à côte, avec un séparateur entre les nuits et un autre à l’heure courante.
  • Cellules de dix minutes et zoomLevels du détail à dix minutes à l’ensemble de la campagne, geste de zoom continu et curseur de zoom-ui (createZoomSlider) ; déplacements, redimensionnements et pas au clavier suivent les cellules.
  • Trois lignes d’en-tête de temps dessinées par onBeforeTimeHeaderRender : la nuit (une ligne groupBy: 'None' avec une zone d’en-tête par nuit), les heures et la ligne du ciel avec ses bandes de crépuscule et ses repères de coucher du Soleil, d’obscurité et de lever du Soleil.
  • onBeforeCellRender colore chaque cellule selon la hauteur du Soleil et désactive les heures où les prévisions imposent la fermeture d’un abri (hachures et refus des dépôts de blocs).
  • Une ligne de la Lune figée (frozen: 'top') dont les événements portent la phase, l’heure de coucher et la probabilité de ciel dégagé de chaque nuit, au-dessus d’un arbre d’abris et de télescopes avec quatre rowHeaderColumns.
  • Les points de rendu React (renderEvent de super-scheduler/react-render) dessinent la courbe de hauteur de chaque bloc ; la carte au survol (eventHover) et la carte de glisser montrent la cible sur toute la nuit et à l’heure proposée.
  • Refus motivés dans onEventMoving, args.allowOverlap pour les alertes prioritaires qui remplacent des observations en file, moveHDisabled pour un transit et moveVDisabled pour les calibrations, crosshairType: 'Full', historique d’annulation, déplacements au clavier et menu contextuel à l’habillage du produit.
  • La minicarte (super-scheduler/minimap) sur toute la campagne, avec une plage fixe, une série et des teintes calculées par l’application.

Le code de cet exemple décide

  • Le ciel : positions du Soleil et de la Lune à précision réduite, temps sidéral, hauteurs, masse d’air et crépuscule du site, sous forme de fonctions pures.
  • Ce qu’un télescope peut faire : l’instrument requis par chaque mode, des cibles au-dessus de 30°, des observations scientifiques après le crépuscule nautique, des flats de ciel au crépuscule, des fermetures selon les prévisions météo et aucun début dans le passé.
  • Ce qu’une alerte remplace, les blocs en conflit, le premier créneau possible d’un bloc et le télescope qui peut accueillir un transit à la même heure.
  • Le temps de nuit noire alloué par nuit et les pertes dues à la météo qui déterminent les teintes de la minicarte, l’horloge nocturne avec UT et temps sidéral, et le mode lumière rouge.

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.

tsx.zip

L’intégration : l’axe de temps limité aux nuits (onIncludeTimeCell), les niveaux de zoom sur des cellules de dix minutes, les trois lignes d’en-tête, la ligne de la Lune figée, rowHeaderColumns, les séparateurs, les cartes React des blocs, la carte au survol, la carte de glisser avec la hauteur à l’heure proposée, le menu contextuel et les callbacks où les conditions du ciel refusent un dépôt ou permettent à une alerte de prendre la priorité.

/**
 * 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}
    />
  )
}

L’archive contient les fichiers de cet exemple et un README. Elle n’inclut ni SuperScheduler Pro ni aucune clé de téléchargement.