Saltar al contenido

Planificación de noches de observación

Llega una alerta de supernova a las 23:40. Obtén su espectro antes de que se ponga y conserva el tránsito al que desplaza.

  • Astronomía
  • Nightglass
  • Tiempo oculto
  • Slots de renderizado React
  • Reglas de movimiento
  • Tiempo bloqueado
  • Filas de resumen
  • Minimapa
  • Zoom continuo
  • Cruceta
  • Temas
  • Demo con datos sintéticos

Misión

0 de 3 pasos hechos

Miércoles, 23:40. SN 2026qf, descubierta hace una hora, necesita un espectro de clasificación esta noche. Baja de 30° a las 01:24.

  1. Asigna 40 minutos a la alerta SN 2026qf en el Albor de 2,5 m. — Pendiente

    Arrástrala desde la cola de alertas directamente hacia abajo, al Albor de 2,5 m, con inicio entre las 23:50 y las 00:40. La tarjeta de arrastre muestra su altura a la hora propuesta; las franjas posteriores bajan de 30°.

  2. Mueve el tránsito desplazado de WASP-18 b al Vigía robótico de 0,6 m. — Pendiente

    Un tránsito conserva su horario: arrástralo directamente hacia abajo, al Vigía de 0,6 m (Cumbre está en trabajos técnicos), o abre su menú y elige Mover a Vigía 0,6 m, a la misma hora.

  3. Activa el modo de luz roja de la consola para el resto de la noche. — Pendiente

    Pulsa R con el foco en el tablero o usa el interruptor Luz roja de la barra superior.

Unos 30 segundos. Los pasos se marcan a medida que los completas.

Teclado: enfoca la rejilla y usa las flechas, Intro y Alt+flechas para mover.

Nightglass
Cola de alertasCúpula principalAlbor2.5 mCúpula esteCumbre1.2 mTecho corredizoVigía0.6 mFaro0.3 mEspectros de lámparaThArNGC 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 000Espectros de lámparaThArNGC 7793NG-26B-021 · R 40 000NGC 1316NG-26B-014 · R 40 000NGC 1097NG-26B-014 · R 40 000Flats de cielog r iNGC 7293NG-26B-052 · Hα [O III]Trabajos técnicosAlineación del espejo secundario30 DoradusNG-26B-058 · g r iNGC 2440NG-26B-052 · Hα [O III]Flats de cielog r iNGC 7293NG-26B-052 · Hα [O III]NGC 55NG-26B-040 · V IFlats de cieloB VNGC 300NG-26B-040 · VR DoradusNG-26B-063 · B VFlats de cieloB VNGC 300NG-26B-040 · VR DoradusNG-26B-063 · B VFlats de cielog rField S17NG-26B-071 · g rField S15NG-26B-071 · g rField S16NG-26B-071 · g rFlats de cielog rField S17NG-26B-071 · g rField S14NG-26B-071 · g rSN 2026qfTOO-26B-003 · R 40 00081%89%Lunafase · puestamiércoles, 21 de octubrejueves, 22 de octubre2021222300010203040506072021222300

Vista previa del primer fotogramaEn vivo: el motor Pro real, funcionando en tu navegadorUnos 30 segundos. Los pasos se marcan a medida que los completas. Teclado: enfoca la rejilla y usa las flechas, Intro y Alt+flechas para mover.

La situación.

Nightglass es el plan de observación nocturna de Cerro Alto, un observatorio ficticio a 2400 m con cuatro telescopios en tres recintos. El plan va de la puesta a la salida del Sol y las horas diurnas no aparecen en el tablero. El crepúsculo forma parte de los datos: los flats de cielo lo necesitan; las observaciones científicas esperan a que termine.

El cielo determina si una franja es adecuada. Un objetivo debe mantenerse por encima de 30° (masa de aire 2), la Luna aumenta el brillo del cielo alrededor de los objetivos débiles y un frente húmedo puede obligar a cerrar todas las cúpulas. Cuando se detecta una nueva supernova, el asistente de noche tiene minutos para encontrarle un telescopio antes de su puesta y reasignar las observaciones que desplace.

Qué vas a hacer

Programarás un espectro de un objetivo de oportunidad mientras la supernova siga por encima de 30°, moverás el tránsito desplazado a otro telescopio a la misma hora y activarás el modo de luz roja de la consola.

Dónde termina la librería y empieza tu aplicación.

SuperScheduler aporta

  • Tiempo oculto con onIncludeTimeCell: el eje solo muestra noches, cada una desde media hora antes de la puesta del Sol hasta después de su salida, una junto a otra, con un separador entre noches y otro en la hora actual.
  • Celdas de diez minutos y zoomLevels desde el detalle de diez minutos hasta toda la campaña, el gesto de zoom continuo y el control deslizante de zoom-ui (createZoomSlider); los movimientos, los cambios de duración y los pasos con teclado siguen las celdas.
  • Tres filas de cabecera de tiempo dibujadas por onBeforeTimeHeaderRender: la noche (una fila groupBy: 'None' con un área de cabecera por noche), las horas y la fila del cielo con sus bandas de crepúsculo y las marcas de puesta del Sol, oscuridad y salida del Sol.
  • onBeforeCellRender sombrea cada celda según la altura del Sol y desactiva las horas en las que la previsión obliga a cerrar un recinto (con un rayado y sin permitir soltar bloques).
  • Una fila fija de la Luna (frozen: 'top') cuyos eventos muestran la fase, la hora de puesta y la probabilidad de cielo despejado de cada noche, sobre un árbol de recintos y telescopios con cuatro rowHeaderColumns.
  • Los puntos de renderizado React (renderEvent de super-scheduler/react-render) dibujan la curva de altura de cada bloque; la tarjeta al pasar el ratón (eventHover) y la tarjeta de arrastre muestran el objetivo durante toda la noche y a la hora propuesta.
  • Rechazos con sus motivos en onEventMoving, args.allowOverlap para alertas que desplazan observaciones de la cola, moveHDisabled para un tránsito y moveVDisabled para calibraciones, crosshairType: 'Full', historial de deshacer, movimientos con teclado y un menú contextual con la piel del producto.
  • El minimapa (super-scheduler/minimap) de toda la campaña, con un rango fijo y una serie y tonos calculados por la aplicación.

El código de este ejemplo decide

  • El cielo: posiciones del Sol y la Luna de baja precisión, tiempo sidéreo, alturas, masa de aire y crepúsculo para el observatorio, como funciones puras.
  • Qué puede hacer un telescopio: el instrumento que necesita cada modo, objetivos por encima de 30°, observaciones científicas después del crepúsculo náutico, flats de cielo durante el crepúsculo, cierres según la previsión meteorológica y ningún inicio en el pasado.
  • Qué desplaza una alerta, qué bloques están en conflicto, la primera franja posible de un bloque y el telescopio que puede acoger un tránsito a la misma hora.
  • El tiempo de oscuridad asignado por noche y las pérdidas por meteorología que determinan los tonos del minimapa, el reloj nocturno con UT y tiempo sidéreo y el modo de luz roja.

El código de esta demo.

Son los archivos que ejecuta esta página, no una copia simplificada. Compilan contra los exports públicos de SuperScheduler Pro.

tsx.zip

La integración: el eje de tiempo solo nocturno (onIncludeTimeCell), niveles de zoom sobre celdas de diez minutos, las tres filas de cabecera, la fila fija de la Luna, rowHeaderColumns, separadores, las tarjetas React de los bloques, la tarjeta al pasar el ratón, la tarjeta de arrastre con la altura a la hora propuesta, el menú contextual y los callbacks donde las condiciones del cielo rechazan un movimiento o permiten que una alerta desplace otra observación.

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

El archivo contiene los ficheros de este ejemplo y un README. No incluye SuperScheduler Pro ni ninguna clave de descarga.