Observatory night planning
A supernova alert arrives at 23:40. Give it a spectrum before it sets, without losing the transit it displaces.
- Astronomy
- Nightglass
- Hidden time
- React render slots
- Move rules
- Blocked time
- Summary rows
- Minimap
- Continuous zoom
- Crosshair
- Theming
- Demo with synthetic data
Mission
0 of 3 steps doneWednesday, 23:40. SN 2026qf, found an hour ago, needs a classification spectrum tonight. It sinks below 30° at 01:24.
Give the SN 2026qf alert 40 minutes on the Albor 2.5 m. — To do
Drag it from the alert queue straight down onto Albor 2.5 m, starting between 23:50 and 00:40. The drag card draws its altitude at the proposed time; later slots sink below 30°.
Move the pre-empted WASP-18 b transit to the robotic Vigía 0.6 m. — To do
A transit keeps its time: drag it straight down to Vigía 0.6 m (Cumbre is in engineering), or open its menu and choose Move to Vigía 0.6 m.
Switch the console to red light for the rest of the night. — To do
Press R while the board has focus, or use the Red light toggle in the top bar.
About 30 seconds. Steps tick off as you complete them.
Keyboard: focus the grid, then use arrow keys, Enter and Alt+arrows to move.
Preview of the first frameLive: the real Pro engine, running in your browserAbout 30 seconds. Steps tick off as you complete them. Keyboard: focus the grid, then use arrow keys, Enter and Alt+arrows to move.
The situation.
Nightglass is the night plan of Cerro Alto, a fictional observatory at 2,400 m with four telescopes under three enclosures. The plan runs from sunset to sunrise and daytime does not exist on the board. Twilight is data: sky flats need it, science waits for it to end.
What makes a slot good is the sky. A target must stay above 30° (airmass 2), the Moon brightens the sky around faint targets, and a humid front can close every dome. When a fresh supernova is reported, the night assistant has minutes to find it a telescope before it sets, and whatever it displaces needs a new home.
What you will do
You will place a target-of-opportunity spectrum while the supernova is still above 30°, move the transit it pre-empts to another telescope at the same time, and switch the console to red light.
Where the library ends and your application begins.
SuperScheduler provides
- Hidden time with onIncludeTimeCell: only nights exist on the axis, each from half an hour before sunset to after sunrise, side by side, with a separator at every night boundary and one at the night clock.
- Ten-minute cells and zoomLevels from 10-minute detail to the whole run, the continuous zoom gesture and the zoom-ui slider (createZoomSlider); moves, resizes and keyboard steps follow the cells.
- Three time-header rows written by onBeforeTimeHeaderRender: the night (a groupBy: 'None' row with one header area per night), the hours, and the sky row with its twilight bands and the sunset, darkness and sunrise marks.
- onBeforeCellRender shades every cell by the altitude of the Sun and disables the hours a forecast closes an enclosure (hatched, and drops there are refused).
- A frozen Moon row (frozen: 'top') whose events carry each night's phase, setting time and clear-sky chance, above a tree of enclosures and telescopes with four rowHeaderColumns.
- React render slots (renderEvent from super-scheduler/react-render) draw the altitude curve of every block; the hover card (eventHover) and the drag card show the target over the whole night and at the proposed time.
- Refusals with reasons in onEventMoving, args.allowOverlap for alerts that pre-empt queue time, moveHDisabled for a transit and moveVDisabled for calibrations, crosshairType: 'Full', undo history, keyboard moves and a context menu in the product's skin.
- The minimap (super-scheduler/minimap) over the whole run, with a fixed range and a series and tones computed by the application.
This example’s code decides
- The sky: low-precision Sun and Moon positions, sidereal time, altitudes, airmass and twilight for the site, as pure functions.
- What a telescope may do: the instrument a mode needs, targets above 30°, science after nautical twilight, sky flats in twilight, closures from the weather forecast and nothing in the past.
- What an alert pre-empts, which blocks are in conflict, the earliest slot of a block and the telescope that can take a transit at the same time.
- Dark time allocated per night and the weather loss behind the minimap tones, the night clock with UT and sidereal time, and red-light mode.
The code behind this demo.
These are the files this page runs, not a simplified copy. They compile against the public SuperScheduler Pro exports.
The integration: the night-only timeline (onIncludeTimeCell), zoom levels on ten-minute cells, the three header rows, the frozen Moon row, rowHeaderColumns, separators, the React block cards, the hover card, the drag card with the altitude at the proposed time, the context menu, and the callbacks where the sky refuses a drop or lets an alert pre-empt.
/**
* 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}
/>
)
}The archive contains this example’s files and a README. It does not include SuperScheduler Pro or any download key.