Planificación de obra
La lluvia para un hormigonado. Retrásalo, mueve de una vez la estructura de la planta siguiente y mantén la grúa torre dentro de su turno.
- Construcción
- Plumbline
- Árbol de recursos
- Dependencias
- Filas de resumen
- Multiselección
- Selección rectangular
- Deshacer y rehacer
- Minimapa
- Barras de progreso
- Control de zoom
- Demo con datos sintéticos
Misión
0 de 3 pasos hechosMiércoles 21 de octubre, 07:00: se espera lluvia de jueves a lunes y el forjado de la P3 debía hormigonarse mañana.
Saca el hormigonado de la P3 de la lluvia: mar 27 oct. — Pendiente
En Planta 3 · Obra gruesa, arrastra la barra roja Hormigonado tres días laborables a la derecha, hasta el martes 27 de octubre (el fin de semana se salta). O abre su menú (clic derecho o Mayús+F10) y elige el primer día posible.
Retrasa toda la estructura de la P4 tres días laborables. — Pendiente
El encofrado de la P4 empieza ahora antes de que cure el forjado de la P3. Arrastra sobre la cuadrícula vacía para trazar una ventana sobre las tres barras de la P4 (o Ctrl/⌘+clic en cada una) y arrastra una: se mueven juntas. O elige Seleccionar la estructura de P4 en el menú de una barra y pulsa Desplazar en el panel.
Despeja la sobrecarga de la grúa torre. — Pendiente
El hormigonado de la P4 cae ahora el miér 11 nov junto con la fachada y la entrega de las guías del ascensor: 11 horas de grúa para un turno de 8. Arrastra Guías ascensor de Planta baja · Interiores al jue 12 nov, o elige Siguiente día con grúa libre en su menú.
Unos 30 segundos. Los pasos se marcan a medida que los completas.
Teclado: enfoca la rejilla y usa las flechas, Intro y Alt+flechas para mover.
Vista previa del primer fotogramaEn vivo: el motor Pro real, funcionando en tu navegadorUnos 30 segundos. Los pasos se marcan a medida que los completas. Teclado: enfoca la rejilla y usa las flechas, Intro y Alt+flechas para mover.
La situación.
Plumbline es una constructora ficticia que levanta Linden Yard, Bloque C: seis plantas sobre estructura de hormigón armado. Su planificación es por zonas: cada planta de la sección tiene una obra gruesa (encofrado, ferralla, hormigonado y después la fachada) y unos interiores (instalaciones, inspección, tabiquería, acabados), y una cuadrilla por oficio sube de planta en planta. Un forjado necesita siete días de curado antes de que el encofrado de la planta superior se apoye en él.
El tiempo es el enemigo habitual. Cuando un hormigonado se retrasa, se retrasa el curado, se retrasa la planta de arriba y la holgura que protegía el fin de estructura desaparece sin que nadie lo note. Un diagrama de barras esconde esa cascada; esta planificación calcula el camino crítico a partir de la lógica, lo dibuja con lápiz rojo, rechaza lo que la obra no puede hacer y mantiene a la vista la grúa torre y la mano de obra.
Qué vas a hacer
Sacarás un hormigonado de la lluvia, moverás de una vez la estructura de una planta cuando la regla de curado la marque y despejarás la sobrecarga de grúa que provoca el retraso.
Dónde termina la librería y empieza tu aplicación.
SuperScheduler aporta
- Un árbol de recursos de tres niveles (Bloque C, sus plantas y sus zonas) con rowHeaderColumns y filas padre fusionadas; las cabeceras dibujan la sección del edificio con la cota y el forjado de cada planta.
- Una fila fija arriba (frozen: 'top', cellsDisabled) que onBeforeCellRender llena con las horas de grúa torre por día; celdas rayadas para fines de semana, forjados en curado y lluvia, y deshabilitadas durante el cierre de Navidad.
- Tres filas de cabecera de tiempo escritas por onBeforeTimeHeaderRender: las semanas como cadena de cotas, los días y la previsión, con un área de cabecera en el reloj de obra; separadores para el reloj de obra y el último día de la grúa.
- Hitos, barras de progreso PercentComplete dibujadas como tramas de material, vínculos fin-inicio con estilo de onBeforeLinkRender y burbuja de vínculo, y áreas ancladas al tiempo sobre los días en que una actividad empieza demasiado pronto.
- Selección múltiple con clic, Ctrl/Cmd+clic o una ventana trazada sobre la cuadrícula (rectangleSelectHandling, onGridMouseDown), allowMultiMove con cada miembro reescrito en cada fotograma de onEventMoving, y eventMoveSkipNonBusiness.
- El zoom slider de super-scheduler/zoom-ui como escala gráfica del plano, el minimapa de super-scheduler/minimap como plano de situación, historial de deshacer, movimientos con teclado, la tarjeta flotante del evento, la tarjeta de arrastre y un menú contextual con la piel del producto.
El código de este ejemplo decide
- El calendario de obra (fines de semana, cierre de Navidad), la regla de curado, la lógica fin-inicio, nada de hormigonar con lluvia, una cuadrilla por oficio y las fechas fijas: rechazos en onEventMoving y onEventMove, con el motivo y el día que citan.
- El método del camino crítico: fechas tardías hacia atrás desde las fechas fijas en días laborables, holgura total por actividad, las actividades críticas y los vínculos que las mandan.
- Las horas de grúa torre y los operarios en obra por día, los comandos (primer día posible, siguiente día con grúa libre, desplazar una selección en días laborables) y las letras de revisión que salen del historial.
- El propio plano: tramas por oficio, líneas de cota, el corte de sección en el reloj de obra, las nubes de revisión y el cajetín.
El código de esta demo.
Son los archivos que ejecuta esta página, no una copia simplificada. Compilan contra los exports públicos de SuperScheduler Pro.
La integración: el árbol y sus columnas, la fila fija de la grúa, tres filas de cabecera, separadores, hitos y barras de progreso, vínculos con el estilo del camino crítico y su burbuja, la selección por ventana, el desplazamiento múltiple con los miembros reescritos en cada fotograma, el veredicto de arrastre, la tarjeta flotante y el menú contextual, y los callbacks donde las reglas de la obra rechazan un movimiento.
/**
* Plumbline programme board: Linden Yard, Block C, drawn as its section. Levels from the roof
* down, the faces of each level as rows, fifteen weeks of days on a drafting grid, the critical
* path in red pencil and the tower crane booked day by day on top.
*
* Library: a three-level resource tree with rowHeaderColumns (merged parent rows), a frozen
* summary row painted by onBeforeCellRender, three time-header rows written by
* onBeforeTimeHeaderRender (a dimension chain of weeks, days, the forecast) with header areas,
* disabled cells, separators, milestones, PercentComplete bars drawn as material hatches,
* finish-to-start links styled by onBeforeLinkRender with a link bubble, multi-select (click,
* Ctrl/Cmd+click, a window drawn over the grid with rectangleSelectHandling), allowMultiMove with
* members rewritten per frame, eventMoveSkipNonBusiness, conflict highlighting, the zoom slider
* (super-scheduler/zoom-ui), keyboard and undo history, the event hover card and the drag card
* (super-scheduler/react-render) and a context menu. Application: the site calendar, curing,
* logic, rain, crews and fixed dates, the critical path and float, crane hours and every figure.
* Those rules live in ./rules; ./paint writes the markup.
*/
import { useCallback, useEffect, useMemo, 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 { createZoomSlider } from 'super-scheduler/zoom-ui'
import { format, plural } from '~/i18n/format'
import { DAY, civil, parseCivil } from '../../scheduler/civil'
import { createEventMenu, type MenuEntry } from '../kit/context-menu'
import { useEventHover } 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,
BUILDING_ID,
CRANE_ROW,
ROW_HEADER_WIDTH,
activityName,
isLevelRowId,
isRowId,
levelOfRow,
openingScroll,
readActivityFields,
toResources,
type ActivityEvent,
} from './adapter'
import {
activityCard,
craneDayOf,
firstValidOf,
refusalText,
type ActivityAction,
type PlanView,
} from './card'
import {
CRANE_DISMANTLED,
CRANE_HOURS,
INITIAL_VIEW_START,
NOW,
PLAN,
ROWS,
type Activity,
} from './model'
import {
DAY_LETTERS_FROM,
DAY_NUMBERS_FROM,
buildingHtml,
cardHtml,
cellPainter,
craneCell,
craneHeaderHtml,
dayHeader,
doneHtml,
faceHtml,
keyDay,
levelHtml,
milestoneHtml,
shortDay,
tradeHtml,
weatherHeader,
weekHeader,
type HeaderPaint,
} from './paint'
import {
addWorkingDays,
brokenSuccessors,
craneHours,
curingWindows,
dayOf,
duration,
frameOf,
groupRefusal,
isMovable,
isWorkingDay,
linkId,
nextWorkingStart,
placementRefusal,
pourOf,
rowProgress,
shiftStart,
signedWorkingDays,
slabState,
tradeWork,
workAt,
type Span,
} from './rules'
import type { LogicView, PlumblineText } from './text'
const SLUG = 'construction-site'
const NOW_TICKS = parseCivil(NOW)
const PLAN_FROM = parseCivil(PLAN.start)
export interface PlumblineSchedulerProps {
readonly events: ActivityEvent[]
readonly view: PlanView
readonly text: PlumblineText
readonly common: DemoCommon
/** BCP 47 tag for labels, and the scheduler locale id. */
readonly tag: string
readonly schedulerLocale: string
readonly logic: LogicView
/** Narrow stages keep the location column only. */
readonly narrow: boolean
readonly history: SchedulerHistory
readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
/** Where the product bar keeps the zoom slider (the drawing's scale bar). */
readonly zoomHost: RefObject<HTMLDivElement | null>
readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
/** A click (or Enter) on an activity: its sheet opens. */
readonly onOpen: (id: string) => void
readonly onMoved: (ids: readonly string[]) => void
readonly onRefused: (id: string, message: string) => void
readonly onAction: (action: ActivityAction, id: string) => void
/** The settled cell width after a zoom, for the title block's scale. */
readonly onScale: (cellWidth: number) => void
}
/** The last working day at or before `ticks` (a keyboard move to the left over a weekend). */
function previousWorkingStart(ticks: number): number {
let day = dayOf(ticks)
while (!isWorkingDay(day)) day -= DAY
return day
}
/** onBeforeTimeHeaderRender: weeks, days and the forecast, cached per cell and width bucket. */
function createTimeHeaderHook(text: PlumblineText, tag: string) {
const cache = new Map<string, HeaderPaint>()
const nowLabel = format(text.chrome.now, { day: shortDay(NOW_TICKS, tag), time: '07:00' })
return (args: SuperScheduler.SchedulerBeforeTimeHeaderRenderArgs) => {
const header = args.header
const start = header.start.getTime()
const span = header.end.getTime() - start
const width = args.control.cellWidth ?? BOARD.cellWidth
const bucket = width >= DAY_LETTERS_FROM ? 'w' : width >= DAY_NUMBERS_FROM ? 'm' : 'n'
const key = `${header.level}:${start}:${span}:${bucket}`
let paint = cache.get(key)
if (paint === undefined) {
paint =
header.level === 0
? weekHeader(start, span, text, tag)
: header.level === 1
? dayHeader(start, span, width, tag)
: weatherHeader(start, span, width, text, nowLabel)
cache.set(key, paint)
}
header.html = paint.html
header.cssClass = paint.cssClass
header.areas = paint.areas
header.toolTip = paint.toolTip
}
}
export function PlumblineScheduler(props: PlumblineSchedulerProps) {
const { events, view, text, common, tag, schedulerLocale, logic, narrow, history } = props
const { controlRef, zoomHost } = props
const { onEventsChange, onOpen, onMoved, onRefused, onAction, onScale } = props
const resources = useMemo(() => toResources(text), [text])
const refusal = useDropRefusal(onRefused)
const name = useCallback((activity: Activity) => activityName(activity, text), [text])
// Hover card: a drawing callout with the logic on both sides, the float and the revision.
const card = useCallback(
(event: SuperScheduler.Event) => {
const activity = view.net.byId.get(String(event.id()))
return activity === undefined ? null : activityCard(activity, view, text, common, tag)
},
[view, text, common, tag],
)
const { eventHover, closeHover, suppressHover } = useEventHover(card)
// Drag verdict: the site's refusal, or the successors the drop would push out of their logic.
const dragCard = useMemo(
() =>
createDragCard({
tag,
common,
days: true,
rowName: (id) => {
const row = ROWS.find((candidate) => candidate.id === id)
return row === undefined
? null
: `${text.levelShort[row.level]} · ${text.faces[row.face]}`
},
warn: (info) => {
const activity = view.net.byId.get(String(info.eventId))
if (activity === undefined) return null
const span = { start: info.start.getTime(), end: info.end.getTime() }
const broken = brokenSuccessors(activity, span, view.net)
if (broken.length === 0) return null
const names = broken
.flatMap((item) => {
const next = view.net.byId.get(item.id)
return next === undefined ? [] : [`${name(next)} (${shortDay(item.until, tag)})`]
})
.join(', ')
return plural(text.card.pushes, broken.length, tag, { names })
},
}),
[view, text, common, tag, name],
)
// Context menu: rebuilt when the plan changes, so its labels name the real day.
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 activity = view.net.byId.get(id)
if (activity === undefined) return []
const entries: MenuEntry[] = [
{
label: common.kit.openDetails,
icon: 'details',
keys: KEYS.details,
run: () => onAction('details', id),
},
]
if (view.issueOf.has(id) && isMovable(activity)) {
const first = firstValidOf(activity, view)
entries.push({
label:
first === null
? text.actions.noValid
: format(text.actions.firstValid, { date: shortDay(first, tag) }),
icon: 'slot',
primary: true,
disabled: first === null,
run: () => onAction('first-valid', id),
})
}
if (activity.trade === 'lift' && isMovable(activity)) {
const day = craneDayOf(activity, view)
entries.push({
label:
day === null
? text.actions.noCraneDay
: format(text.actions.craneDay, { date: shortDay(day, tag) }),
icon: 'after',
primary: !view.issueOf.has(id),
disabled: day === null,
run: () => onAction('crane-day', id),
})
}
const row = ROWS.find((candidate) => candidate.id === activity.row)
const frame = frameOf(view.activities, activity.row).filter(isMovable)
const trade = activity.trade === null ? [] : tradeWork(view.activities, activity.trade)
const selections: MenuEntry[] = []
if (row !== undefined && frame.some((member) => member.id === id) && frame.length > 1)
selections.push({
label: format(text.actions.selectFrame, {
level: text.levelShort[row.level],
count: String(frame.length),
}),
icon: 'focus',
run: () => onAction('select-frame', id),
})
if (activity.trade !== null && trade.length > 1)
selections.push({
label: format(text.actions.selectTrade, {
trade: text.trades[activity.trade],
count: String(trade.length),
}),
icon: 'layers',
run: () => onAction('select-trade', id),
})
if (selections.length > 0) entries.push('separator', ...selections)
entries.push('separator', {
label: logic === 'all' ? text.actions.showCritical : text.actions.showAll,
icon: 'link',
run: () => onAction('toggle-logic', id),
})
return entries
},
}),
[view, logic, common, text, tag, suppressHover, onAction],
)
// The logic from the model; the critical path in red pencil, broken links as clashes. Abutting
// bars of one face read as a sequence already (like a dimension chain): their link is drawn only
// when it breaks. The bubble (bubbleHtml) is part of each link's data: lag and the float left.
const links = useMemo<SuperScheduler.LinkData[]>(() => {
const escape = SuperScheduler.Util.escapeHtml
const broken = new Set(
view.issues.flatMap((issue) =>
issue.other === undefined ? [] : [`${issue.other}->${issue.activity}`],
),
)
return view.net.links.flatMap((link): SuperScheduler.LinkData[] => {
const id = linkId(link)
const critical = view.plan.criticalLinks.has(id)
const clash = broken.has(id)
if (logic === 'critical' && !critical && !clash) return []
const from = view.net.byId.get(link.from)
const to = view.net.byId.get(link.to)
if (from === undefined || to === undefined) return []
if (!clash && link.lag === 0 && from.row === to.row && from.end === to.start) return []
const required = parseCivil(from.end) + link.lag * DAY
const gap = signedWorkingDays(nextWorkingStart(required), parseCivil(to.start))
const lagText =
link.lag > 0 ? format(text.card.curedOn, { date: shortDay(required, tag) }) : ''
const gapText =
gap < 0
? format(text.card.negative, { count: String(-gap) })
: gap === 0
? text.card.critical
: format(text.card.float, { count: String(gap) })
return [
{
id,
from: link.from,
to: link.to,
type: 'FinishToStart',
cssClass: clash
? 'pl-link pl-link--clash'
: critical
? 'pl-link pl-link--critical'
: 'pl-link',
bubbleHtml:
`<span class="pl-lb"><b>${escape(name(from))} → ${escape(name(to))}</b>` +
(lagText === '' ? '' : `<span>${escape(lagText)}</span>`) +
`<span class="pl-lb__gap" data-gap="${gap < 0 ? 'clash' : gap === 0 ? 'zero' : 'free'}">${escape(gapText)}</span></span>`,
},
]
})
}, [view, logic, text, tag, name])
// The link bubble shows a link's lag and the float it leaves (one instance, the menu's skin).
const linkBubble = useMemo(
() =>
new SuperScheduler.Bubble({
theme: skinMenuTheme(SLUG),
showAfter: 120,
position: 'Mouse',
hideOnClick: true,
}),
[],
)
// The separators: the site clock as a section cut, and the day the tower crane comes down.
const separators = useMemo<SuperScheduler.SeparatorData[]>(
() => [
{
location: `${CRANE_DISMANTLED}T00:00:00`,
layer: 'BelowEvents',
width: 1,
cssClass: 'pl-sep-crane',
toolTip: format(text.kpi.toppingTip, { date: shortDay(parseCivil(CRANE_DISMANTLED), tag) }),
},
{
location: NOW,
layer: 'AboveEvents',
width: 2,
cssClass: 'pl-sep-now',
toolTip: format(text.chrome.now, {
day: shortDay(NOW_TICKS, tag),
time: new Intl.DateTimeFormat(tag, {
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
timeZone: 'UTC',
}).format(new Date(NOW_TICKS)),
}),
},
],
[text, tag],
)
// Header markup per cell, cached for the board's life (the hook runs for every header cell).
const timeHeaders = useMemo(() => createTimeHeaderHook(text, tag), [text, tag])
const columns = useMemo<SuperScheduler.RowHeaderColumnData[]>(
() =>
narrow
? [{ text: text.columns.location, width: BOARD.narrowColumn, nonresizable: true }]
: [
{ text: text.columns.location, width: BOARD.locationColumn, nonresizable: true },
{ text: text.columns.trade, width: BOARD.tradeColumn, nonresizable: true },
{ text: text.columns.done, width: BOARD.doneColumn, nonresizable: true },
],
[narrow, text],
)
const config = useMemo<SchedulerRenderProps>(() => {
const escape = SuperScheduler.Util.escapeHtml
const activities = view.activities
const byId = view.net.byId
// Row figures, once per plan: progress, the trade at work, critical faces, slabs.
const progress = new Map(ROWS.map((row) => [row.id, rowProgress(activities, row.id)]))
const overall =
ROWS.reduce((sum, row) => sum + (progress.get(row.id) ?? 0), 0) / Math.max(1, ROWS.length)
const criticalRows = new Set(
activities.flatMap((activity) => (view.plan.critical.has(activity.id) ? [activity.row] : [])),
)
const paintCell = cellPainter(curingWindows(activities))
const crane = craneHours(activities, PLAN_FROM, PLAN.days)
const cards = new Map<string, string>()
/** Members of a multi-move, each shifted by the dragged activity's working days. */
const groupSpans = (
master: Activity,
start: number,
items: readonly SuperScheduler.SchedulerMultiMoveItem[],
) => {
const delta = signedWorkingDays(parseCivil(master.start), start)
const spans = new Map<string, Span>()
const members: Activity[] = []
for (const item of items) {
const member = byId.get(String(item.event.id()))
if (member === undefined) continue
const from = shiftStart(parseCivil(member.start), delta)
spans.set(member.id, { start: from, end: addWorkingDays(from, duration(member)) })
members.push(member)
}
return { delta, spans, members }
}
return {
locale: schedulerLocale,
cssClass: skinClass(SLUG),
startDate: PLAN.start,
days: PLAN.days,
// ISO weeks in every locale: the week row is a dimension chain from Monday to Monday.
weekStarts: 1,
scale: 'Day',
cellWidth: BOARD.cellWidth,
zoomGesture: { min: BOARD.zoom.min, max: BOARD.zoom.max, scales: false },
// The drawing keeps its lettering down to a narrow day; it simplifies further out.
lod: { zoomedOut: 20, eventFull: 26, eventText: 20, cellContent: 12, dayNumber: 13 },
timeHeaders: [
{ groupBy: 'Week', height: BOARD.header.week },
{ groupBy: 'Day', height: BOARD.header.day },
{ groupBy: 'Day', height: BOARD.header.weather },
],
// Weekends are the site's: drawn by the cells, and skipped by moves.
cellsMarkBusiness: false,
businessWeekends: false,
eventMoveSkipNonBusiness: true,
useEventBoxes: 'Never',
height: '100%',
treeEnabled: true,
treePreventParentUsage: true,
treeIndent: narrow ? 4 : BOARD.treeIndent,
rowHeaderColumns: columns,
rowHeaderColumnsMergeParents: true,
rowHeaderWidth: narrow ? BOARD.narrowColumn : ROW_HEADER_WIDTH,
rowHeaderWidthAutoFit: false,
eventHeight: BOARD.eventHeight,
rowMarginTop: BOARD.rowMargin,
rowMarginBottom: BOARD.rowMargin,
allowEventOverlap: false,
conflictHighlight: true,
// Progress fills the trade's hatch from the left: the bar spans the whole card.
durationBarVisible: true,
durationBarMode: 'PercentComplete',
durationBarHeight: 3,
// Selection: a click, Ctrl/Cmd+click, or a window drawn over the empty grid (CAD style).
eventClickHandling: 'Select',
allowMultiSelect: true,
allowMultiMove: true,
rectangleSelectHandling: 'EventSelect',
timeRangeSelectedHandling: 'Disabled',
linkShape: 'RightAngled',
linkLayer: 'Below',
linkBubble,
crosshairType: 'Full',
separators,
showToolTip: false,
keyboardEnabled: true,
keyboardTarget: 'component',
keyboardMode: 'Full',
history,
controlRef,
onEventsChange,
// The corner names the drawing: section A–A of Block C.
onBeforeCornerRender: (args) => {
args.html =
`<span class="pl-corner"><b>${escape(text.section)}</b>` +
`<span>${escape(text.building)}</span></span>`
},
onBeforeRowHeaderRender: (args) => {
const row = args.row
const id = row.id
const [first, second, third] = row.columns
if (id === CRANE_ROW) {
row.cssClass = 'pl-row pl-row--crane'
if (first !== undefined) first.html = craneHeaderHtml(text)
if (second !== undefined) second.html = ''
if (third !== undefined)
third.html = `<span class="pl-cap" aria-hidden="true"><i></i>${CRANE_HOURS} h</span>`
return
}
if (id === BUILDING_ID) {
row.cssClass = 'pl-row pl-row--building'
row.html = buildingHtml(text, overall)
if (first !== undefined) first.html = row.html
return
}
if (isLevelRowId(id)) {
const level = levelOfRow(id)
row.cssClass = 'pl-row pl-row--level'
row.html = levelHtml(level, slabState(pourOf(activities, level)), text, tag)
if (first !== undefined) first.html = row.html
return
}
if (!isRowId(id)) return
const critical = criticalRows.has(id)
row.cssClass = 'pl-row pl-row--face'
if (first !== undefined) first.html = faceHtml(String(row.name), critical)
if (second !== undefined) second.html = tradeHtml(workAt(activities, id), text, tag)
if (third !== undefined) third.html = doneHtml(progress.get(id) ?? 0)
},
onBeforeCellRender: (args) => {
const cell = args.cell
const day = cell.start.getTime()
const wide = (args.control.cellWidth ?? BOARD.cellWidth) >= 26
if (cell.resource === CRANE_ROW) {
const hours = crane[Math.round((day - PLAN_FROM) / DAY)] ?? 0
const painted = craneCell(hours, wide, text)
cell.properties.cssClass = painted.css
cell.properties.html = painted.html
return
}
if (cell.resource === BUILDING_ID || isLevelRowId(cell.resource)) {
cell.properties.cssClass = isWorkingDay(day) ? 'pl-c-slab' : 'pl-c-slab pl-c-off'
return
}
if (!isRowId(cell.resource)) return
const painted = paintCell(cell.resource, day, wide, text)
if (painted.css !== '') cell.properties.cssClass = painted.css
if (painted.html !== '') cell.properties.html = painted.html
if (painted.disabled) cell.properties.disabled = true
},
onBeforeEventRender: (args) => {
const data = args.data
const fields = readActivityFields(data)
const id = String(data.id)
const activity = byId.get(id)
if (fields === null || activity === undefined) return
const issue = view.issueOf.get(id) ?? null
const critical = view.plan.critical.has(id)
const revision = view.revisionOf.get(id) ?? null
const classes = ['pl-ev', `pl-k-${activity.kind}`]
if (activity.trade !== null) classes.push(`pl-t-${activity.trade}`)
if (critical) classes.push('pl-ev--critical')
if (issue !== null) classes.push('pl-ev--clash')
if (revision !== null) classes.push('pl-ev--revised')
if (activity.complete >= 100) classes.push('pl-ev--built')
else if (activity.complete > 0) classes.push('pl-ev--running')
data.cssClass = classes.join(' ')
const label = activityName(activity, text)
if (activity.kind === 'milestone') {
// A key date: a diamond at the end of its day, its name and day beside it.
data['type'] = 'Milestone'
data['width'] = 18
data.html = ''
data.barHidden = true
const note = issue === null ? null : text.card.conflict
data.htmlRight =
(args.control.cellWidth ?? BOARD.cellWidth) >= DAY_NUMBERS_FROM
? milestoneHtml(label, keyDay(parseCivil(activity.start), tag), note)
: ''
data.ariaLabel = `${label}, ${keyDay(parseCivil(activity.start), tag)}`
return
}
const days = duration(activity)
const width = Math.round(
((parseCivil(activity.end) - parseCivil(activity.start)) / DAY) *
(args.control.cellWidth ?? BOARD.cellWidth),
)
const bucket =
width >= 110
? 110
: width >= 96
? 96
: width >= 64
? 64
: width >= 58
? 58
: width >= 30
? 30
: 0
const key = `${id}|${bucket}|${revision ?? ''}|${activity.complete}`
let html = cards.get(key)
if (html === undefined) {
html = cardHtml(activity, label, days, { critical, issue, revision, width: bucket }, text)
cards.set(key, html)
}
data.html = html
data.complete = activity.complete
if (activity.kind !== 'work') data.barHidden = true
// The days an activity starts too early: hatched in red pencil, from its start.
const areas: SuperScheduler.AreaData[] = []
if (issue !== null) {
const end =
issue.kind === 'rain'
? activity.end
: civil(Math.min(issue.until, parseCivil(activity.end)))
areas.push({
start: activity.start,
end,
top: 0,
bottom: 0,
cssClass: 'pl-clash',
action: 'Default',
})
}
data.areas = areas
const state =
issue !== null ? `, ${text.card.conflict}` : critical ? `, ${text.card.critical}` : ''
data.ariaLabel = `${label}, ${shortDay(parseCivil(activity.start), tag)}, ${plural(text.days, days, tag)}${state}`
},
// The critical path is drawn heavier than the leader lines of the rest of the logic.
onBeforeLinkRender: (args) => {
if ((args.data.cssClass ?? '').includes('critical')) args.data.width = 2
},
// A plain drag over the empty grid draws a selection window, as in a drawing program.
onGridMouseDown: (args) => {
if (args.button === 'left') args.action = 'RectangleSelect'
},
// Every frame: the site's rules for the dragged activity or the whole selection moved
// together (each member shifted by the same working days, rewritten here).
onEventMoving: (args) => {
const id = String(args.e.id())
const activity = byId.get(id)
if (activity === undefined) return
if (args.multimove.length > 1) {
const group = groupSpans(activity, args.start.getTime(), args.multimove)
for (const item of args.multimove) {
const span = group.spans.get(String(item.event.id()))
if (span === undefined) continue
item.start = new SuperScheduler.Date(span.start)
item.end = new SuperScheduler.Date(span.end)
}
const refused = groupRefusal(group.members, group.spans, view.net)
if (refused !== null) {
const member = byId.get(refused.activity)
const message = `${member === undefined ? '' : `${name(member)}: `}${refusalText(refused, view, text, tag)}`
args.allowed = false
args.message = message
refusal.track(id, message)
return
}
refusal.track(id, null)
return
}
const refused = placementRefusal(
activity,
{ start: args.start.getTime(), end: args.end.getTime() },
view.net,
)
if (refused !== null) {
const message = refusalText(refused, view, text, tag)
args.allowed = false
args.message = message
refusal.track(id, message)
return
}
refusal.track(id, null)
},
// Final check before the commit; keyboard moves (Alt+arrows) arrive here without a drag and
// are kept on working days in the direction they travel.
onEventMove: (args) => {
const id = String(args.e.id())
const activity = byId.get(id)
if (activity === undefined) return
if (args.multimove.length > 1) {
const group = groupSpans(activity, args.newStart.getTime(), args.multimove)
const refused = groupRefusal(group.members, group.spans, view.net)
if (refused === null) return
args.preventDefault()
refusal.clear()
onRefused(id, refusalText(refused, view, text, tag))
return
}
const before = parseCivil(activity.start)
let start = args.newStart.getTime()
if (!isWorkingDay(dayOf(start)))
start = start >= before ? nextWorkingStart(start) : previousWorkingStart(start)
const end = addWorkingDays(start, duration(activity))
if (start !== args.newStart.getTime() || end !== args.newEnd.getTime()) {
args.newStart = new SuperScheduler.Date(start)
args.newEnd = new SuperScheduler.Date(end)
}
const refused = placementRefusal(activity, { start, end }, view.net)
if (refused === null) return
args.preventDefault()
refusal.clear()
onRefused(id, refusalText(refused, view, text, tag))
},
onEventMoved: (args) => {
refusal.clear()
const ids =
args.multimove.length > 0
? args.multimove.map((item) => String(item.event.id()))
: [String(args.e.id())]
onMoved(ids)
},
// A click (or Enter) selects the activity and docks its sheet; the hover card steps aside.
onEventClick: (args) => {
closeHover()
if (args.ctrl || args.meta) return
onOpen(String(args.e.id()))
},
onZoom: (args) => {
if (args.phase === 'end') onScale(args.cellWidth)
},
}
}, [
view,
columns,
narrow,
text,
tag,
schedulerLocale,
history,
controlRef,
separators,
linkBubble,
refusal,
name,
closeHover,
onEventsChange,
onMoved,
onOpen,
onRefused,
onScale,
])
// Imperative and once: a scroll prop would be re-applied on every update. The board opens on
// the week of the slip, scrolled down to Level 4.
useEffect(() => {
const control = controlRef.current
control?.scrollTo(INITIAL_VIEW_START, false, 'left')
control?.setScrollY(openingScroll())
}, [controlRef])
// The zoom slider lives in the product bar, styled as the drawing's scale bar.
useEffect(() => {
const control = controlRef.current
const host = zoomHost.current
if (control === null || host === null) return
const slider = createZoomSlider(control, host, {
min: BOARD.zoom.min,
max: BOARD.zoom.max,
scale: 'log',
detents: BOARD.zoom.detents,
label: text.chrome.scale,
})
return () => slider.dispose()
}, [controlRef, zoomHost, text])
return (
<SuperSchedulerComponent
{...config}
onBeforeTimeHeaderRender={timeHeaders}
resources={resources}
events={events}
links={links}
eventHover={eventHover}
dragCard={dragCard}
contextMenu={contextMenu}
/>
)
}El archivo contiene los ficheros de este ejemplo y un README. No incluye SuperScheduler Pro ni ninguna clave de descarga.