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 3Mercredi, 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.
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°.
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.
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.
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.
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.