Brennplan für Keramiköfen
Die Kegel-10-Stücke eines Mitglieds sind fertig. Brennen Sie sie im einzigen Ofen, der es kann, wenn jemand ausräumen kann, ohne das Energiebudget zu sprengen.
- Keramikwerkstatt
- Cone Ten
- Externes Drag & Drop
- React-Render-Slots
- Ziehen und Dauer ändern
- Verschieberegeln
- Abhängigkeiten
- Aktive Bereiche
- Summenzeilen
- Minimap
- Theming
- Demo mit synthetischen Daten
Aufgabe
0 von 3 Schritten erledigtMittwoch, 10:15 Uhr. Das Steinzeug-Geschirr von Inés ist geschrüht und glasiert: Es braucht Kegel 10, reduzierend.
Räumen Sie das Kegel-10-Geschirr von Inés in Dragon ein, den Gasofen. — Offen
Ziehen Sie den Aufkleber INÉS vom Regal in die Zeile von Dragon, hinter den Brand, der dort abkühlt. Pip und Bess lehnen ab: Sie schaffen nur Kegel 6 und Kegel 8. Oder drücken Sie EINRÄUMEN am Aufkleber.
Sorgen Sie dafür, dass jemand da ist, wenn ausgeräumt werden kann. — Offen
Heute Nachmittag eingeräumt, kühlt er mitten in der Nacht ab. Ziehen Sie ihn auf Donnerstag 08:00 oder wählen Sie per Rechtsklick Nächster sauberer Start.
Bringen Sie die Woche zurück unter ihr Energiebudget von 590 €. — Offen
Der Schrühbrand des Mittwochskurses in Bess heizt mitten im Hochtarif von 17 bis 21 Uhr auf, während auch Pip aufheizt. Ziehen Sie ihn auf Donnerstag 08:00 oder wählen Sie per Rechtsklick Günstigster Start.
Etwa 30 Sekunden. Erledigte Schritte werden abgehakt.
Tastatur: Raster fokussieren, dann Pfeiltasten, Eingabe und Alt+Pfeile zum Verschieben.
Vorschau des ersten BildesLive: die echte Pro-Engine, in Ihrem BrowserEtwa 30 Sekunden. Erledigte Schritte werden abgehakt. Tastatur: Raster fokussieren, dann Pfeiltasten, Eingabe und Alt+Pfeile zum Verschieben.
Die Ausgangslage.
Cone Ten ist eine fiktive offene Keramikwerkstatt mit vier Öfen: zwei Elektroöfen für Schrühbrand und Glasurbrand bis Kegel 6, ein Gas-Herdwagenofen für den Reduktionsbrand bis Kegel 10 und eine Raku-Trommel. Mitglieder stellen ihre Rohware ins Regal, die Werkstatt brennt sie. Ein Brand ist kein Zeitblock, sondern eine Kurve: Das Programm heizt den Ofen bis zu seinem Kegel auf, hält dort und danach kühlt der Stein von selbst ab, bis der Ofen unter 200 °C geöffnet werden darf.
Genau diese Kurve muss sehen, wer plant. Die Abkühlung hängt an der Masse des Ofens und lässt sich nicht verkürzen; jemand muss da sein, um einzuräumen und auszuräumen; ein Schrühbrand muss einen Tag vor seinem Glasurbrand aus dem Ofen sein; und Strom kostet zwischen 17 und 21 Uhr zweieinhalbmal so viel, bei einer Vertragsleistung, die die Hauptsicherung auslöst, wenn beide Elektroöfen gleichzeitig aufheizen. Dieses Board zeichnet jeden Brand als eigene Temperaturkurve, lehnt ab, was die Öfen nicht können, und behält die Energierechnung im Blick.
Was Sie tun werden
Sie räumen eine Kegel-10-Charge in den Gasofen ein, verschieben sie so, dass jemand sie ausräumen kann, und bringen die Woche zurück ins Energiebudget, indem Sie einen Schrühbrand aus dem Hochtarif nehmen.
Wo die Bibliothek endet und Ihre Anwendung beginnt.
SuperScheduler liefert
- Ereignisinhalte aus React-Render-Slots (renderEvent), mit demselben Markup als HTML-Fallback: Jeder Brand zeichnet seine Temperaturkurve über die exakte Spanne des Ereignisses, sodass Spitze und Ende der Abkühlung unter ihrer eigenen Uhrzeit im Kopf stehen.
- Externes Ziehen (SuperScheduler.Scheduler.makeDraggable): Chargen wandern vom Regal in einen Ofen; onEventMoving passt den Schatten an das Programm des Ofens unter dem Zeiger an und lehnt mit args.message ab, was dieser Ofen nicht brennen kann.
- Verschieben und Ändern der Dauer, Bild für Bild gesteuert: onEventMoving und onEventResizing runden das Einräumen auf die Viertelstunde, lassen die Endkante nur die Haltezeit ändern und verweigern eine kürzere Abkühlung; die Ziehkarte (dragCard.html) zeigt Einräumen, Spitze, Ausräumen und Kosten.
- Zeitverankerte Bereiche (der Kegel auf seiner Spitze, die Temperatur zur Werkstattzeit, Warnstreifen über einer Überlast, eine Fahne an einem Ausräumen ohne Personal) und Hover-Bereiche (visibility: 'Hover') als große Pillen.
- Eine fixierte Summenzeile, gezeichnet von onBeforeCellRender, drei Kopfzeilen aus onBeforeTimeHeaderRender (der Tag, die Öffnungszeiten als Kopfbereiche, die Uhrzeit), Schattierung außerhalb der Arbeitszeit, Trenner um Mitternacht und zur Werkstattzeit und das volle Fadenkreuz.
- Ende-Anfang-Verknüpfungen, Zoomstufen von Stunden bis zum Monat, die Minimap, Rückgängig-Verlauf, Verschieben per Tastatur, die Hover-Karte (eventHover) und ein Kontextmenü im Look des Produkts.
Der Code dieses Beispiels entscheidet
- Die Physik eines Brandes: das Aufheizen jedes Programms, die Haltezeit und die natürliche Abkühlung von der Kegelspitze bis 200 °C, bestimmt von der Wärmemasse jedes Ofens.
- Was ein Ofen aufnimmt: seinen Kegel, seine Programme und sein Volumen; und was die Werkstatt braucht: jemanden, der zwischen 08:00 und 20:00 ein- und ausräumt, und einen Tag zwischen einem Schrühbrand und dem nächsten Brand.
- Die Energiebilanz: jede Viertelstunde des Monats, der Strom zu seinem Tarif, Gas und Propan, die Vertragsleistung von 22 kW, die Rechnung der Woche und die Tagesreihe für die Minimap.
- Die Befehle: nächster sauberer Start, günstigster Start, eine Stunde mehr Haltezeit, jetzt brennen und zurück ins Regal, jeweils als ein Rückgängig-Schritt gespeichert; ein später gelegter Schrühbrand nimmt seinen Glasurbrand mit.
- Die Kurvenkarten, die Aufkleber im Regal, die Ofenköpfe mit dem, was jeder Ofen zur Werkstattzeit tut, und die Zahlen in den Leisten.
Der Code hinter dieser Demo.
Das sind die Dateien, die diese Seite ausführt, keine vereinfachte Kopie. Sie kompilieren gegen die öffentlichen Exporte von SuperScheduler Pro.
Die Integration: Zoomstufen mit drei Kopfzeilen, rowHeaderColumns, die fixierte Energiezeile, Trenner, Arbeitszeiten, aus den Daten gebaute Verknüpfungen, renderEvent, die Bereiche jeder Karte, das Zieh-Urteil, das Kontextmenü und die Callbacks, in denen die Regeln der Werkstatt einen Brand ablehnen, anpassen oder verlängern.
/**
* Cone Ten kiln board: four kilns over October, a frozen energy row on top, firings drawn as their
* own temperature curves, and greenware dragged in from the shelf.
*
* Library: row-header columns written by onBeforeRowHeaderRender, a frozen summary row painted by
* onBeforeCellRender, three time-header rows (day, open hours as header areas, hours) written by
* onBeforeTimeHeaderRender, zoom levels from hours to the month, business-hours shading
* (businessBeginsHour, businessEndsHour, cellsMarkBusiness), separators, the full crosshair,
* events at exact times with React render slots (renderEvent, HTML fallback), time-anchored and
* hover areas, external drag (SuperScheduler.Scheduler.makeDraggable, see ./shelf), drag and
* resize steered frame by frame (onEventMoving, onEventResizing: refusals, the end fitted to the
* kiln under the pointer), finish-to-start links, conflict highlighting, keyboard moves, undo
* history, the event hover card and the drag card (super-scheduler/react-render), and a context
* menu. Application: what a kiln can fire, the physics of a firing (ramp, hold, natural cooling),
* staffed hours, bisque before glaze, the electricity tariff, the contract and the energy figures.
* Those rules live in ./rules; ./board-paint and ./firing-card write the markup.
*/
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerEventsChangeArgs } from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { SuperSchedulerComponent, type SchedulerRenderProps } from 'super-scheduler/react-render'
import { format, plural } from '~/i18n/format'
import { DAY, HOUR, MINUTE, civil, parseCivil } from '../../scheduler/civil'
import { createEventMenu, type MenuEntry } from '../kit/context-menu'
import { formatSpan } from '../kit/format'
import { defaultHints, type HoverFact } 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,
ENERGY_ROW,
firingLabel,
isKilnRow,
readFiringFields,
toResources,
type FiringFields,
type KilnEvent,
} from './adapter'
import {
LIMIT_SHARE,
clock,
coneHtml,
coneLabel,
createHeaderPainter,
energyHeader,
energyKey,
energyPainter,
kilnHtml,
volumeHtml,
when,
} from './board-paint'
import {
FiringCard,
cardHtml,
cardSize,
curvePaths,
phaseOf,
stepsHtml,
useKilnHover,
yOf,
type CardParts,
type KilnCardModel,
} from './firing-card'
import {
CONE_PEAK,
CONTRACT_KW,
INITIAL_VIEW_START,
KILNS,
NOW,
PLAN,
PROGRAMS,
SERVICES,
SHELF,
batchById,
firingId,
kilnById,
type Firing,
type FiringKind,
} from './model'
import {
NOW_TICKS,
cascadeOf,
cheapestStart,
extendHold,
fireNow,
firingCost,
holdRefusal,
isStaffed,
kilnStateAt,
moveRefusal,
nextCleanStart,
nextStaffed,
ledgerWithout,
overloadOf,
overloadWith,
partnerOf,
placed,
profileOf,
snapHoldEnd,
temperatureAt,
unloadAt,
earliestFinish,
type Issue,
type Ledger,
type Placement,
type Refusal,
} from './rules'
import type { ConeTenText, ZoomId } from './text'
import 'super-scheduler/styles.css'
import '../../scheduler/skin.css'
const SLUG = 'ceramics-kilns'
const STEP = 15 * MINUTE
/** What the board asks the demo to do (context menu, hover buttons, keyboard). */
export type FiringAction = 'details' | 'hold' | 'fire-now' | 'next' | 'cheapest' | 'shelf'
export interface ConeTenSchedulerProps {
readonly events: KilnEvent[]
/** The same firings as business objects, for rules that look at the others. */
readonly firings: readonly Firing[]
readonly ledger: Ledger
readonly issues: readonly Issue[]
readonly text: ConeTenText
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 firing shown in the side sheet: it gets the selection frame. */
readonly selected: string | null
readonly history: SchedulerHistory
readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
readonly onSelect: (id: string) => void
/** `external`: the batch came from the shelf. */
readonly onMoved: (id: string, external: boolean) => void
readonly onResized: (id: string) => void
readonly onRefused: (id: string, message: string) => void
/** A bisque moved later and its finish follows: one state change, one undo step. */
readonly onCascade: (firing: Firing, placement: Placement, follower: Firing) => void
readonly onAction: (action: FiringAction, id: string) => void
readonly onZoomLevel: (level: ZoomId) => void
/** Narrow stages keep the kiln column only. */
readonly narrow: boolean
}
/**
* Three day-wide rows: the day, its open hours, its clock hours. The hours are areas of a day cell
* (every hour, three or six, as the density allows), so the level-of-detail planner never has to
* coarsen an hour row into days at the week's 8 px per hour.
*/
const HOURLY: SuperScheduler.TimeHeaderData[] = [
{ groupBy: 'Day', height: BOARD.header.day },
{ groupBy: 'Day', height: BOARD.header.band },
{ groupBy: 'Day', height: BOARD.header.hour },
]
/** Hours to plan a load to the quarter, the week by default, the month to see the energy. */
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
{ id: 'hours', properties: { scale: 'Hour', cellWidth: 30, timeHeaders: HOURLY } },
{ id: 'week', properties: { scale: 'Hour', cellWidth: BOARD.hourWidth, timeHeaders: HOURLY } },
{
id: 'month',
properties: {
scale: 'Day',
cellWidth: 40,
timeHeaders: [
{ groupBy: 'Week', height: BOARD.header.day },
{ groupBy: 'Day', height: BOARD.header.band },
{ groupBy: 'Day', height: BOARD.header.hour, format: 'd' },
],
},
},
]
const ZOOM_IDS: readonly ZoomId[] = ['hours', 'week', 'month']
/** Pixels per hour at the control's current scale. */
function pxPerHour(control: SuperScheduler.Scheduler | SuperScheduler.SchedulerApi | null): number {
if (control === null) return BOARD.hourWidth
const width = control.cellWidth ?? BOARD.hourWidth
return control.scale === 'Day' ? width / 24 : width
}
/** The big pills that appear over a booked firing (areas with visibility 'Hover'). */
function pill(
label: string,
tip: string,
right: number,
width: number,
run: () => void,
primary = false,
): SuperScheduler.AreaData {
return {
right,
// At the foot of the cooling tail: the middle of the card stays free to grab and drag.
bottom: 7,
width,
height: 24,
visibility: 'Hover',
action: 'None',
cssClass: primary ? 'ck-pill ck-pill--primary' : 'ck-pill',
html: `<span>${SuperScheduler.Util.escapeHtml(label)}</span>`,
toolTip: tip,
onClick: (args) => {
args.preventDefault()
run()
},
}
}
/** Rough width of a card's owner label (13 px Geist 900, uppercase) with its inset. */
function labelWidth(owner: string): number {
return owner.length * 9.6 + 14
}
/**
* True when the cone can be flagged on the peak: the owner reads in the sky over the ramp, so the
* ramp must be wider than the label. Otherwise the card carries the cone next to the owner.
*/
function badgeFits(firing: Firing, owner: string, width: number): boolean {
const profile = profileOf(firing)
if (profile === null || width < 90) return false
const rampPx = ((profile.peakAt - profile.start) / (profile.end - profile.start)) * width
return rampPx >= labelWidth(owner)
}
/** The draft of a firing being dragged: the one on the board, or a shelf batch coming in. */
function draftOf(id: string, fields: FiringFields, firings: readonly Firing[]): Firing | null {
const current = firings.find((candidate) => candidate.id === id)
if (current !== undefined) return current
if (fields.kind === 'service' || fields.batch === undefined) return null
const kiln = KILNS.find((candidate) => candidate.kinds.includes(fields.kind as FiringKind))
if (kiln === undefined) return null
return {
id,
batch: fields.batch,
kind: fields.kind,
kiln: kiln.id,
start: NOW,
end: civil(unloadAt(fields.kind, kiln, NOW_TICKS, PROGRAMS[fields.kind].hold)),
}
}
/**
* Where a dragged firing would land: its load rounded to the quarter hour (a month cell keeps its
* time of day), and its unload fitted to the program on the kiln under the pointer, with the hold
* it had. The scheduler's shadow follows this geometry.
*/
function fit(
firing: Firing,
target: string,
rawStart: number,
dayCells: boolean,
): Placement | null {
const kiln = kilnById(target)
if (kiln === null) return null
let start = Math.round(rawStart / STEP) * STEP
if (dayCells) {
const original = parseCivil(firing.start)
start = start - (((start % DAY) + DAY) % DAY) + (((original % DAY) + DAY) % DAY)
}
return placed(firing, kiln, start)
}
/** A refusal as one complete sentence with the batch's and the kiln's numbers. */
export function refusalText(
reason: Refusal,
firing: Pick<Firing, 'batch' | 'kind'>,
target: string,
start: number,
firings: readonly Firing[],
text: ConeTenText,
tag: string,
): string {
const kiln = kilnById(target)
const batch = batchById(firing.batch)
// Temperatures and litres read without grouping: 1285 °C.
const number = new Intl.NumberFormat(tag, { useGrouping: false })
const cone = PROGRAMS[firing.kind].cone
const earliest = earliestFinish(firing, firings)
if (reason === 'kind') {
if (firing.kind === 'raku') return text.rules.raku
return kiln === null ? text.rules['not-a-kiln'] : text.rules.kind[kiln.id]
}
return format(text.rules[reason], {
batch: batch === null ? firing.batch : firingLabel(firing, text),
kiln: kiln === null ? '' : text.kilns[kiln.id],
cone,
peak: number.format(CONE_PEAK[cone]),
max: kiln === null ? '' : kiln.maxCone,
maxPeak: kiln === null ? '' : number.format(CONE_PEAK[kiln.maxCone]),
volume: number.format(batch?.volume ?? 0),
capacity: number.format(kiln?.volume ?? 0),
time: when(reason === 'past' ? NOW_TICKS : start, tag),
earliest: earliest === null ? '' : when(nextStaffed(earliest), tag),
})
}
/** The status chip of a firing on the board, its flags first. */
function statusOf(
firing: Firing,
issues: readonly Issue[],
text: ConeTenText,
): KilnCardModel['status'] {
const own = issues.find((issue) => issue.firing === firing.id)
if (own !== undefined) return { label: text.issues[own.kind], tone: 'danger' }
const phase = phaseOf(firing, NOW_TICKS)
if (phase === 'firing') return { label: text.status.firing, tone: 'warning' }
if (phase === 'cooling') return { label: text.status.cooling, tone: 'success' }
if (phase === 'fired') return { label: text.status.fired }
return { label: text.status.booked, tone: 'success' }
}
/** What the hover card and the sheet say about a firing. */
export function firingFacts(
firing: Firing,
issues: readonly Issue[],
ledger: Ledger,
text: ConeTenText,
tag: string,
): { facts: HoverFact[]; rule: KilnCardModel['rule'] } {
const profile = profileOf(firing)
const batch = batchById(firing.batch)
const number = new Intl.NumberFormat(tag)
const degrees = new Intl.NumberFormat(tag, { useGrouping: false })
const money = new Intl.NumberFormat(tag, {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
})
if (profile === null) return { facts: [], rule: undefined }
const cost = firingCost(firing)
const own = new Set(
issues.filter((issue) => issue.firing === firing.id).map((issue) => issue.kind),
)
const facts: HoverFact[] = [
{
label: text.fields.batch,
value:
batch === null
? firing.batch
: `${text.pieces[batch.piece]} · ${plural(text.count, batch.count, tag)} · ${number.format(batch.volume)} L`,
},
{ label: text.fields.load, value: when(profile.start, tag) },
{
label: text.fields.peak,
value: format(text.values.peak, {
temperature: degrees.format(profile.peak),
time: when(profile.peakAt, tag),
}),
},
{
label: text.fields.unload,
value: when(profile.end, tag),
alert: own.has('unload-closed'),
},
{
label: text.fields.energy,
value:
profile.kiln.energy === 'electric'
? format(text.values.electric, {
kwh: number.format(Math.round(cost.kwh)),
euros: money.format(cost.euros),
})
: format(text.values.fuel, {
kwh: number.format(Math.round(cost.kwh)),
euros: money.format(cost.euros),
fuel: text.fuels[profile.kiln.energy],
}),
alert: own.has('power'),
},
]
let rule: KilnCardModel['rule']
if (own.has('unload-closed'))
rule = {
state: 'blocked',
text: format(text.card.unloadRule, {
time: when(profile.end, tag),
next: when(nextStaffed(profile.end), tag),
}),
}
else if (own.has('power')) {
const overload = overloadOf(firing, ledger)
rule = {
state: 'blocked',
text: format(text.card.powerRule, {
kw: number.format(Math.round(overload?.kw ?? 0)),
contract: number.format(CONTRACT_KW),
}),
}
} else if (profile.end <= NOW_TICKS) rule = { state: 'ok', text: text.card.firedRule }
else if (profile.start < NOW_TICKS) rule = { state: 'warn', text: text.card.lockedRule }
else rule = { state: 'ok', text: text.card.okRule }
return { facts, rule }
}
/** Ramp, hold and cooling of a firing, for the hover card and the sheet. */
export function phasesOf(firing: Firing, text: ConeTenText): KilnCardModel['phases'] {
const profile = profileOf(firing)
if (profile === null) return undefined
return [
{ id: 'ramp', label: text.fields.ramp, minutes: profile.ramp },
{ id: 'hold', label: text.fields.hold, minutes: profile.hold },
...(profile.cool > 0
? [{ id: 'cool' as const, label: text.fields.cooling, minutes: profile.cool }]
: []),
]
}
/** Menu and sheet labels of the commands that move a firing: where it would go, or why not. */
export function commandLabels(
firing: Firing,
firings: readonly Firing[],
text: ConeTenText,
tag: string,
): {
readonly hold: { label: string; enabled: boolean }
readonly fireNow: { label: string; enabled: boolean }
readonly next: { label: string; enabled: boolean }
readonly cheapest: { label: string; enabled: boolean }
readonly shelf: { label: string; enabled: boolean }
} {
const locked = parseCivil(firing.start) < NOW_TICKS
const hold = extendHold(firing.id, firings)
const now = fireNow(firing.id, firings)
const next = locked ? null : nextCleanStart(firing.id, firings)
const kiln = kilnById(firing.kiln)
const cheapest = locked || kiln?.energy !== 'electric' ? null : cheapestStart(firing.id, firings)
const money = new Intl.NumberFormat(tag, {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
})
const follower = firing.kind === 'bisque' ? partnerOf(firing, firings) : null
return {
hold: {
label:
typeof hold === 'string'
? hold === 'max-hold'
? text.rules.maxHold
: hold === 'kiln-busy'
? text.rules.kilnBusy
: text.actions.hold
: text.actions.hold,
enabled: typeof hold !== 'string',
},
fireNow: { label: text.actions.fireNow, enabled: now !== null },
next:
next === null
? { label: text.actions.noNext, enabled: false }
: next.start === firing.start
? { label: text.actions.atNext, enabled: false }
: {
label: format(text.actions.next, { time: when(parseCivil(next.start), tag) }),
enabled: true,
},
cheapest:
cheapest === null
? { label: text.actions.noCheapest, enabled: false }
: cheapest.start === firing.start
? { label: text.actions.atCheapest, enabled: false }
: {
label: format(text.actions.cheapest, {
time: when(parseCivil(cheapest.start), tag),
euros: money.format(firingCost({ ...firing, ...cheapest }).euros),
}),
enabled: true,
},
shelf: {
label: follower !== null ? text.actions.shelfBlocked : text.actions.shelf,
enabled: !locked && follower === null,
},
}
}
/** The parts of each card for one set of flags, cached per firing state and width. */
function cardPartsFor(
flagged: ReadonlyMap<string, ReadonlySet<Issue['kind']>>,
text: ConeTenText,
tag: string,
): (firing: Firing, width: number) => CardParts {
const cache = new Map<string, CardParts>()
return (firing, width) => {
const size = cardSize(width)
const flags = flagged.get(firing.id)
const key = `${firing.id}|${firing.kiln}|${firing.start}|${firing.end}|${Math.round(width / 8)}|${flags === undefined ? '' : [...flags].join(',')}`
const cached = cache.get(key)
if (cached !== undefined) return cached
const batch = batchById(firing.batch)
const owner = batch === null ? firing.batch : text.owners[batch.owner]
const hours = (parseCivil(firing.end) - parseCivil(firing.start)) / HOUR
const parts: CardParts = {
kind: firing.kind,
owner,
coneInline: size !== 'tiny' && !badgeFits(firing, owner, width),
detail:
batch === null
? ''
: `${text.pieces[batch.piece]} · ${new Intl.NumberFormat(tag).format(batch.count)}`,
cone: coneLabel(PROGRAMS[firing.kind].cone),
phase: phaseOf(firing, NOW_TICKS),
size,
flagged: flags !== undefined,
paths: curvePaths(firing, hours > 0 ? width / hours : BOARD.hourWidth),
}
cache.set(key, parts)
return parts
}
}
export function ConeTenScheduler(props: ConeTenSchedulerProps) {
const {
events,
firings,
ledger,
issues,
text,
common,
tag,
schedulerLocale,
initialZoom,
selected,
history,
controlRef,
narrow,
} = props
const {
onEventsChange,
onSelect,
onMoved,
onResized,
onRefused,
onCascade,
onAction,
onZoomLevel,
} = props
const resources = useMemo(() => toResources(text), [text])
const board = useRef<HTMLDivElement>(null)
// Hover buttons call the latest handler without rebuilding every card's areas.
const action = useRef(onAction)
useEffect(() => {
action.current = onAction
}, [onAction])
// The level on screen: a settled zoom re-labels headers and resizes cards and pills.
const [level, setLevel] = useState<ZoomId>(initialZoom)
const density = level === 'hours' ? 30 : level === 'month' ? 40 / 24 : BOARD.hourWidth
// A refused drop (cone, program, volume, the clock, staffed hours, glaze too early) is said on
// release; the library refuses overlaps itself.
const refusal = useDropRefusal(onRefused)
const flagged = useMemo(() => {
const map = new Map<string, Set<Issue['kind']>>()
for (const issue of issues) {
const set = map.get(issue.firing) ?? new Set()
set.add(issue.kind)
map.set(issue.firing, set)
}
return map
}, [issues])
// Links come from the business model: a batch's bisque finishes before its finish firing loads.
const links = useMemo<SuperScheduler.LinkData[]>(
() =>
firings.flatMap((firing): SuperScheduler.LinkData[] => {
if (firing.kind !== 'bisque') return []
const next = partnerOf(firing, firings)
if (next === null) return []
const past = parseCivil(next.end) <= NOW_TICKS
return [
{
id: `${firing.id}->${next.id}`,
from: firing.id,
to: next.id,
type: 'FinishToStart',
width: 2,
style: 'dashed',
cssClass: past ? 'ck-link ck-link--past' : 'ck-link',
},
]
}),
[firings],
)
// Hover card: the batch, the program's stages, the energy and the rule that applies.
const card = useCallback(
(event: SuperScheduler.Event): KilnCardModel | null => {
const id = String(event.id())
// The firing in the side sheet needs no card on top of it.
if (id === selected) return null
const firing = firings.find((candidate) => candidate.id === id)
if (firing === undefined) {
const service = SERVICES.find((candidate) => candidate.id === id)
if (service === undefined) return null
return {
id,
kind: 'service',
eyebrow: `${text.kinds.service} · ${text.kilns[service.kiln]}`,
title: text.service.title,
status: { label: text.status.service },
facts: [
{ label: text.fields.load, value: when(parseCivil(service.start), tag) },
{ label: text.fields.unload, value: when(parseCivil(service.end), tag) },
],
rule: { state: 'blocked', text: text.card.serviceRule },
}
}
const { facts, rule } = firingFacts(firing, issues, ledger, text, tag)
const program = PROGRAMS[firing.kind]
return {
id,
kind: firing.kind,
eyebrow: `${text.kinds[firing.kind]} · ${format(text.cone, { cone: program.cone })} · ${text.kilns[firing.kiln]}`,
title: firingLabel(firing, text),
status: statusOf(firing, issues, text),
phases: phasesOf(firing, text),
phasesLabel: text.card.phases,
facts,
rule,
hints: defaultHints(common, { move: parseCivil(firing.start) >= NOW_TICKS }),
}
},
[firings, issues, ledger, selected, text, common, tag],
)
const { eventHover, closeHover, suppressHover } = useKilnHover(card, tag)
// Drag verdict: the studio's refusal, or what the drop would mean: load, peak and unload of the
// program on the kiln under the pointer, its cost, and the warnings (nobody to unload, the
// contract, the glaze firing that follows).
const dragCard = useMemo<SuperScheduler.DragCardOptions>(() => {
const draftFor = (info: SuperScheduler.DragCardInfo): Firing | null => {
const id = info.eventId === null ? null : String(info.eventId)
if (id === null) return null
const current = firings.find((candidate) => candidate.id === id)
if (current !== undefined) return current
const shelf = SHELF.find((entry) => firingId(entry.batch, entry.kind) === id)
return shelf === undefined
? null
: draftOf(id, { kind: shelf.kind, batch: shelf.batch }, firings)
}
const proposed = (info: SuperScheduler.DragCardInfo): Firing | null => {
const draft = draftFor(info)
if (draft === null || info.resource === null || !isKilnRow(info.resource)) return null
return {
...draft,
kiln: info.resource,
start: civil(info.start.getTime()),
end: civil(info.end.getTime()),
}
}
// The load of every other firing, built once per dragged firing and plan.
const ledgers = new Map<string, Ledger>()
const othersOf = (id: string) => {
let others = ledgers.get(id)
if (others === undefined) {
others = ledgerWithout(id, firings)
ledgers.set(id, others)
}
return others
}
const number = new Intl.NumberFormat(tag, { useGrouping: false })
const money = new Intl.NumberFormat(tag, {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
})
const base = createDragCard({
tag,
common,
rowName: (id) => (isKilnRow(id) ? text.kilns[id] : null),
warn: (info) => {
const next = proposed(info)
if (next === null) return null
const profile = profileOf(next)
if (profile === null) return null
if (!isStaffed(profile.end)) {
const open = nextStaffed(profile.end)
return format(text.verdict.unloadClosed, {
time: when(profile.end, tag),
next: when(open, tag),
wait: formatSpan(open - profile.end, tag),
})
}
if (profile.kiln.energy === 'electric') {
const overload = overloadWith(next, othersOf(next.id))
if (overload !== null)
return format(text.verdict.power, {
kw: number.format(Math.round(overload.kw)),
contract: number.format(CONTRACT_KW),
})
}
if (info.mode === 'move' && next.kind === 'bisque' && !info.external) {
const current = firings.find((candidate) => candidate.id === next.id)
if (current !== undefined) {
const follower = cascadeOf(
current,
{ kiln: next.kiln, start: next.start, end: next.end },
firings,
)
if (follower !== null && follower !== 'blocked')
return format(text.verdict.cascade, {
batch: firingLabel(follower, text),
time: when(parseCivil(follower.start), tag),
})
}
}
if (info.mode === 'resize')
return format(text.verdict.hold, { hold: formatSpan(profile.hold * MINUTE, tag) })
return null
},
})
return {
...base,
html: (info) => {
const html = base.html?.(info) ?? null
if (html === null || info.forbidden || info.overlapping) return html
const next = proposed(info)
const profile = next === null ? null : profileOf(next)
if (next === null || profile === null) return html
const cost = firingCost(next)
const steps = stepsHtml({
load: `${text.verdict.load} ${when(profile.start, tag)}`,
peak: `${text.verdict.peak} ${number.format(profile.peak)}°`,
unload: `${text.verdict.unload} ${when(profile.end, tag)}`,
unloadOk: isStaffed(profile.end),
cost: format(text.verdict.cost, { euros: money.format(cost.euros) }),
})
return `${html.slice(0, -'</div>'.length)}${steps}</div>`
},
}
}, [firings, text, common, tag])
// Context menu: rebuilt when the plan changes, so its labels name the real slot.
const contextMenu = useMemo(
() =>
createEventMenu({
theme: skinMenuTheme(SLUG),
title: (event) => format(common.kit.menuTitle, { name: event.text() }),
onOpenChange: (open) => suppressHover(open),
entries: (event) => {
const id = String(event.id())
const firing = firings.find((candidate) => candidate.id === id)
if (firing === undefined) return []
const labels = commandLabels(firing, firings, text, tag)
const issueOf = issues.find((issue) => issue.firing === id)
const entries: MenuEntry[] = [
{
label: common.kit.openDetails,
icon: 'details',
keys: KEYS.details,
run: () => onAction('details', id),
},
]
if (parseCivil(firing.start) < NOW_TICKS) return entries
entries.push(
{
label: labels.next.label,
icon: 'slot',
primary: issueOf?.kind === 'unload-closed',
disabled: !labels.next.enabled,
run: () => onAction('next', id),
},
{
label: labels.cheapest.label,
icon: 'after',
primary: issueOf?.kind === 'power',
disabled: !labels.cheapest.enabled,
run: () => onAction('cheapest', id),
},
{
label: labels.hold.label,
icon: 'pending',
disabled: !labels.hold.enabled,
run: () => onAction('hold', id),
},
{
label: labels.fireNow.label,
icon: 'confirm',
disabled: !labels.fireNow.enabled,
run: () => onAction('fire-now', id),
},
'separator',
{
label: labels.shelf.label,
icon: 'layers',
disabled: !labels.shelf.enabled,
run: () => onAction('shelf', id),
},
)
return entries
},
}),
[firings, issues, common, text, tag, onAction, suppressHover],
)
// Time headers, written once per cell and level.
const headerPaint = useMemo(() => createHeaderPainter(text, tag), [text, tag])
// The energy row, from the ledger in state, cached per cell.
const paintEnergy = useMemo(() => energyPainter(ledger, text, tag), [ledger, text, tag])
// Cards: their parts, cached per firing state and width.
const partsOf = useMemo(() => cardPartsFor(flagged, text, tag), [flagged, text, tag])
const config = useMemo<SchedulerRenderProps>(() => {
const escape = SuperScheduler.Util.escapeHtml
const byId = new Map(firings.map((firing) => [firing.id, firing]))
const services = new Map(SERVICES.map((service) => [service.id, service]))
const states = new Map(
KILNS.map((kiln) => [kiln.id, kilnStateAt(kiln.id, firings, SERVICES, NOW_TICKS)]),
)
// Temperatures read without grouping: 1285 °C.
const number = new Intl.NumberFormat(tag, { useGrouping: false })
const areaCache = new Map<string, SuperScheduler.AreaData[]>()
const dayCells = () => controlRef.current?.scale === 'Day'
const messageFor = (reason: Refusal, firing: Firing, target: string, start: number) =>
refusalText(reason, firing, target, start, firings, text, tag)
/** Areas of a card: peak badge, temperature now, flags, and the hover pills of a booked firing. */
const areasOf = (firing: Firing, width: number, phase: string): SuperScheduler.AreaData[] => {
const flags = flagged.get(firing.id)
const key = `${firing.id}|${firing.kiln}|${firing.start}|${firing.end}|${Math.round(width / 8)}|${flags === undefined ? '' : [...flags].join(',')}`
const cached = areaCache.get(key)
if (cached !== undefined) return cached
const profile = profileOf(firing)
const areas: SuperScheduler.AreaData[] = []
if (profile === null) return areas
// The cone, flagged on the peak (right of it: the owner reads in the sky over the ramp).
const batch = batchById(firing.batch)
if (badgeFits(firing, batch === null ? firing.batch : text.owners[batch.owner], width))
areas.push({
start: civil(profile.peakAt),
offsetX: 3,
top: 3,
width: 38,
height: 17,
cssClass: 'ck-peak',
html: `<span>${escape(coneLabel(PROGRAMS[firing.kind].cone))}</span>`,
toolTip: `${number.format(profile.peak)} °C · ${when(profile.peakAt, tag)}`,
})
// The temperature at the studio clock, on the curve, for a firing in the kiln now.
if ((phase === 'firing' || phase === 'cooling') && width >= 54) {
const temperature = Math.round(temperatureAt(firing, NOW_TICKS))
areas.push({
start: NOW,
offsetX: -22,
width: 44,
height: 18,
top: `calc(${Math.round(yOf(temperature) * 10) / 10}% - 9px)`,
cssClass: 'ck-now-temp',
html: `<span>${escape(number.format(temperature))}°</span>`,
})
}
// Over the contract: hazard stripes under the stretch of the draw that trips the breaker.
if (flags?.has('power') === true) {
const overload = overloadOf(firing, ledger)
if (overload !== null)
areas.push({
start: civil(Math.max(overload.from, profile.start)),
end: civil(Math.min(overload.to, profile.holdEnd)),
bottom: 0,
height: 9,
cssClass: 'ck-hazard',
toolTip: text.issues.power,
})
}
// Nobody to unload: a sticker on the cold end of the card.
if (flags?.has('unload-closed') === true && width >= 120)
areas.push({
right: 7,
// Mid-height, in the sky over the cold end of the curve.
top: 'calc(50% - 16px)',
// Sized to its text (9.5 px Geist 900, uppercase), inside the card.
width: Math.min(width - 8, format(text.card.flag, { time: '00:00' }).length * 6.9 + 18),
height: 22,
cssClass: 'ck-flag',
html: `<span>${escape(format(text.card.flag, { time: clock(profile.end, tag) }))}</span>`,
toolTip: format(text.card.unloadRule, {
time: when(profile.end, tag),
next: when(nextStaffed(profile.end), tag),
}),
})
// Big pills over a booked firing: open it, one more hour of hold, fire it now.
if (phase === 'booked') {
const id = firing.id
if (width >= 110)
areas.push(
pill(text.actions.details, common.kit.openDetails, 5, 52, () =>
action.current('details', id),
),
)
if (width >= 176)
areas.push(
pill(text.actions.holdShort, text.actions.hold, 62, 46, () =>
action.current('hold', id),
),
)
if (width >= 262)
areas.push(
pill(
text.actions.fireNowShort,
text.actions.fireNow,
113,
84,
() => action.current('fire-now', id),
true,
),
)
}
areaCache.set(key, areas)
return areas
}
return {
locale: schedulerLocale,
cssClass: skinClass(SLUG),
startDate: PLAN.start,
days: PLAN.days,
weekStarts: 1,
zoomLevels: ZOOM_LEVELS,
zoom: initialZoom,
zoomPosition: 'left',
// Exact times: a load at 16:30 stays at 16:30; onEventMoving rounds to the quarter hour.
useEventBoxes: 'Never',
height: '100%',
// The studio is staffed 08:00 to 20:00 every day: the rest is shaded as closed.
businessBeginsHour: 8,
businessEndsHour: 20,
businessWeekends: true,
cellsMarkBusiness: true,
rowHeaderColumns: narrow
? [{ text: text.columns.kiln, width: BOARD.narrowColumn, nonresizable: true }]
: [
{ text: text.columns.kiln, width: BOARD.kilnColumn, nonresizable: true },
{ text: text.columns.cone, width: BOARD.coneColumn, nonresizable: true },
{ text: text.columns.volume, width: BOARD.volumeColumn, nonresizable: true },
],
rowHeaderWidth: narrow ? BOARD.narrowColumn : BOARD.rowHeaderWidth,
rowHeaderWidthAutoFit: false,
eventHeight: narrow ? BOARD.narrowEventHeight : BOARD.eventHeight,
rowMarginTop: BOARD.rowMarginTop,
rowMarginBottom: BOARD.rowMarginBottom,
allowEventOverlap: false,
conflictHighlight: true,
durationBarVisible: false,
// The curve is drawn to the time axis: no label may slide when the viewport cuts a firing.
floatingEvents: false,
linkShape: 'RightAngled',
linkLayer: 'Below',
crosshairType: 'Full',
// Midnights as heavy rules, and the studio clock above the cards.
separators: [
...Array.from({ length: PLAN.days + 1 }, (_, index) => ({
location: civil(parseCivil(PLAN.start) + index * DAY),
layer: 'BelowEvents' as const,
width: 2,
cssClass: 'ck-sep-day',
})),
{
location: NOW,
layer: 'AboveEvents',
width: 3,
cssClass: 'ck-sep-now',
toolTip: format(text.header.nowTip, { time: when(NOW_TICKS, tag) }),
},
],
showToolTip: false,
keyboardEnabled: true,
keyboardTarget: 'component',
keyboardMode: 'Full',
history,
controlRef,
onEventsChange,
onBeforeCornerRender: (args) => {
args.html = `<span class="ck-corner" aria-hidden="true"><b>${escape(text.brand)}</b><span>${escape(text.header.corner)}</span></span>`
},
onBeforeTimeHeaderRender: (args) => {
headerPaint(args.header, pxPerHour(args.control))
},
onBeforeRowHeaderRender: (args) => {
const row = args.row
const [first, second, third] = row.columns
if (first === undefined) return
if (row.id === ENERGY_ROW) {
row.cssClass = 'ck-row ck-row--energy'
first.html = energyHeader(text, level === 'month')
if (second !== undefined) second.html = energyKey(text, tag, 'peak')
if (third !== undefined) third.html = energyKey(text, tag, 'contract')
return
}
const kiln = isKilnRow(row.id) ? kilnById(row.id) : null
if (kiln === null) return
const status = states.get(kiln.id) ?? { state: 'idle' as const, temperature: 20 }
row.cssClass = `ck-row ck-row--${kiln.id}`
first.html = kilnHtml(kiln, status, text, tag, narrow)
if (second !== undefined) second.html = coneHtml(kiln, tag)
if (third !== undefined) third.html = volumeHtml(kiln, tag)
},
onBeforeCellRender: (args) => {
const cell = args.cell
if (cell.resource !== ENERGY_ROW) return
const painted = paintEnergy(
cell.start.getTime(),
cell.end.getTime(),
args.control.cellWidth ?? BOARD.hourWidth,
)
cell.properties.html = painted.html
cell.properties.cssClass = painted.css
},
onBeforeEventRender: (args) => {
const data = args.data
const id = String(data.id)
const service = services.get(id)
const width = ((data.end.getTime() - data.start.getTime()) / HOUR) * pxPerHour(args.control)
if (service !== undefined) {
data.html = cardHtml({
kind: 'service',
owner: text.service.title,
detail: '',
cone: '',
coneInline: false,
phase: 'booked',
size: cardSize(width),
flagged: false,
paths: null,
})
data.cssClass = 'ck-ev ck-ev--service'
data.ariaLabel = `${text.service.title}, ${text.kilns[service.kiln]}`
return
}
const firing = byId.get(id)
if (firing === undefined) return
const parts = partsOf(firing, width)
data.html = cardHtml(parts)
const classes = ['ck-ev', `ck-ev--${firing.kind}`, `ck-ev--${parts.phase}`]
const flags = flagged.get(id)
if (flags !== undefined) classes.push('ck-ev--flag')
if (id === selected) classes.push('ck-ev--selected')
data.cssClass = classes.join(' ')
data.areas = areasOf(firing, width, parts.phase)
const profile = profileOf(firing)
data.ariaLabel =
`${String(data.text)}, ${text.kinds[firing.kind]}, ${format(text.cone, { cone: PROGRAMS[firing.kind].cone })}` +
(profile === null
? ''
: `, ${text.fields.load} ${when(profile.start, tag)}, ${text.fields.unload} ${when(profile.end, tag)}`) +
(flags === undefined ? '' : `, ${[...flags].map((kind) => text.issues[kind]).join(', ')}`)
},
// Every frame of a drag (on the board or in from the shelf): round the load, fit the unload
// to the kiln under the pointer, and refuse what the studio does not allow.
onEventMoving: (args) => {
const fields = readFiringFields(args.e.data)
if (fields === null || fields.kind === 'service') return
const id = String(args.e.id())
const draft = draftOf(id, fields, firings)
if (draft === null) return
const target = String(args.resource)
const placement = fit(draft, target, args.start.getTime(), dayCells())
if (placement !== null) {
args.start = new SuperScheduler.Date(parseCivil(placement.start))
args.end = new SuperScheduler.Date(parseCivil(placement.end))
}
const start = args.start.getTime()
const reason = moveRefusal(draft, target, start, firings)
if (reason !== null) {
const message = messageFor(reason, draft, target, start)
args.allowed = false
args.message = message
refusal.track(id, message)
return
}
refusal.track(
id,
args.conflicts[0] === undefined
? null
: format(common.kit.overlaps, { name: args.conflicts[0].text() }),
)
},
// Final check before the commit (also keyboard moves): the same rules, and a bisque moved
// later takes its finish firing along in one step.
onEventMove: (args) => {
const fields = readFiringFields(args.e.data)
if (fields === null || fields.kind === 'service') return
const id = String(args.e.id())
const draft = draftOf(id, fields, firings)
if (draft === null) return
const target = String(args.newResource)
const placement = fit(draft, target, args.newStart.getTime(), dayCells())
const start = placement === null ? args.newStart.getTime() : parseCivil(placement.start)
const reason = moveRefusal(draft, target, start, firings)
if (reason !== null || placement === null) {
args.preventDefault()
refusal.clear()
onRefused(id, messageFor(reason ?? 'not-a-kiln', draft, target, start))
return
}
args.newStart = new SuperScheduler.Date(parseCivil(placement.start))
args.newEnd = new SuperScheduler.Date(parseCivil(placement.end))
if (args.external) return
const follower = cascadeOf(draft, placement, firings)
if (follower === 'blocked') {
args.preventDefault()
refusal.clear()
onRefused(id, text.rules.cascade)
} else if (follower !== null) {
args.preventDefault()
refusal.clear()
onCascade(draft, placement, follower)
}
},
onEventMoved: (args) => {
refusal.clear()
onMoved(String(args.e.id()), args.external)
},
// The end edge changes the hold; cooling is the kiln's and cannot be shortened, and the ramp
// is the program's: the start edge does not move.
onEventResizing: (args) => {
const id = String(args.e.id())
const firing = byId.get(id)
if (firing === undefined) return
if (args.what === 'start') {
args.allowed = false
args.message = text.rules.ramp
refusal.track(id, text.rules.ramp)
return
}
const end = snapHoldEnd(firing, args.end.getTime())
args.end = new SuperScheduler.Date(end)
const problem = holdRefusal(firing, end)
if (problem === null) {
refusal.track(id, null)
return
}
const profile = profileOf(firing)
const message =
problem === 'short'
? format(text.rules.short, {
peak: number.format(profile?.peak ?? 0),
kiln: text.kilns[firing.kiln],
cool: formatSpan((profile?.cool ?? 0) * MINUTE, tag),
time: when(
parseCivil(firing.start) +
((profile?.ramp ?? 0) + PROGRAMS[firing.kind].minHold + (profile?.cool ?? 0)) *
MINUTE,
tag,
),
})
: format(text.rules.long, {
max: formatSpan(PROGRAMS[firing.kind].maxHold * MINUTE, tag),
})
args.allowed = false
args.message = message
refusal.track(id, message)
},
onEventResize: (args) => {
const id = String(args.e.id())
const firing = byId.get(id)
if (firing === undefined) return
const end = snapHoldEnd(firing, args.newEnd.getTime())
if (args.what === 'start' || holdRefusal(firing, end) !== null) {
args.preventDefault()
refusal.clear()
onRefused(id, args.what === 'start' ? text.rules.ramp : text.rules.maxHold)
return
}
args.newStart = new SuperScheduler.Date(parseCivil(firing.start))
args.newEnd = new SuperScheduler.Date(end)
},
onEventResized: (args) => {
refusal.clear()
onResized(String(args.e.id()))
},
// A click (or Enter) docks the firing into the side sheet; the hover card steps aside.
onEventClick: (args) => {
closeHover()
onSelect(String(args.e.id()))
},
onZoom: (args) => {
if (args.phase !== 'end') return
const next = ZOOM_IDS[args.level]
if (next === undefined) return
setLevel(next)
onZoomLevel(next)
},
}
}, [
firings,
ledger,
flagged,
selected,
narrow,
level,
history,
controlRef,
headerPaint,
paintEnergy,
partsOf,
refusal,
closeHover,
common,
text,
tag,
schedulerLocale,
initialZoom,
onEventsChange,
onMoved,
onResized,
onRefused,
onCascade,
onSelect,
onZoomLevel,
])
// React cards: the same parts as the HTML fallback, the curve redrawn by React.
const renderEvent = useMemo<NonNullable<SchedulerRenderProps['renderEvent']>>(
() =>
({ e, data, width }) => {
const firing = firings.find((candidate) => candidate.id === String(e.id()))
if (firing === undefined) {
const service = SERVICES.find((candidate) => candidate.id === String(e.id()))
return service === undefined ? (
e.text()
) : (
<FiringCard
parts={{
kind: 'service',
owner: text.service.title,
detail: '',
cone: '',
coneInline: false,
phase: 'booked',
size: cardSize(width),
flagged: false,
paths: null,
}}
/>
)
}
const actual = ((data.end.getTime() - data.start.getTime()) / HOUR) * density
return <FiringCard parts={partsOf(firing, Math.max(width, actual))} />
},
[firings, partsOf, density, text],
)
// Imperative and once: a scroll prop would be re-applied on every update.
useEffect(() => {
controlRef.current?.scrollTo(INITIAL_VIEW_START, false, 'left')
}, [controlRef])
// The contract and the daily budget as lines in the energy row: CSSOM, from the model.
useEffect(() => {
const element = board.current
if (element === null) return
element.style.setProperty('--ck-limit-hour', `${Math.round(LIMIT_SHARE.hour * 1000) / 10}%`)
element.style.setProperty('--ck-limit-day', `${Math.round(LIMIT_SHARE.day * 1000) / 10}%`)
}, [])
return (
<div ref={board} className="ck-board-host h-full [&>div]:h-full">
<SuperSchedulerComponent
{...config}
resources={resources}
events={events}
links={links}
renderEvent={renderEvent}
eventHover={eventHover}
dragCard={dragCard}
contextMenu={contextMenu}
/>
</div>
)
}Das Archiv enthält die Dateien dieses Beispiels und eine README. SuperScheduler Pro und Download-Schlüssel sind nicht enthalten.