Planning de chantier
La pluie arrête un coulage. Décalez-le, déplacez d’un geste la structure du niveau suivant et gardez la grue à tour dans son poste.
- Construction
- Plumbline
- Arborescence de ressources
- Dépendances
- Lignes de synthèse
- Multisélection
- Sélection rectangulaire
- Annuler et rétablir
- Minimap
- Barres de progression
- Curseur de zoom
- Démo avec données fictives
Mission
0 étapes faites sur 3Mercredi 21 octobre, 7 h : de la pluie est annoncée de jeudi à lundi, et la dalle du R+3 devait être coulée demain.
Sortez le coulage du R+3 de la pluie : mar. 27 oct. — À faire
Sur Niveau R+3 · Gros œuvre, faites glisser la barre rouge Coulage de trois jours ouvrés vers la droite, au mardi 27 octobre (le week-end est sauté). Ou ouvrez son menu (clic droit ou Maj+F10) et choisissez le premier jour possible.
Décalez toute la structure du R+4 de trois jours ouvrés. — À faire
Le coffrage du R+4 commence maintenant avant la fin de la cure de la dalle du R+3. Glissez sur la grille vide pour tracer une fenêtre sur les trois barres du R+4 (ou Ctrl/⌘+clic sur chacune), puis faites-en glisser une : elles bougent ensemble. Ou choisissez Sélectionner la structure du R+4 dans le menu d’une barre et appuyez sur Décaler dans le panneau.
Supprimez la surcharge de la grue à tour. — À faire
Le coulage du R+4 tombe maintenant le mer. 11 nov. avec la façade et la livraison des rails d’ascenseur : 11 heures de grue pour un poste de 8. Faites glisser Rails ascenseur sur Rez-de-chaussée · Second œuvre au jeu. 12 nov., ou choisissez Jour suivant avec grue libre dans son menu.
Environ 30 secondes. Les étapes se cochent au fur et à mesure.
Clavier : placez le focus sur la grille, puis flèches, Entrée et Alt+flèches pour déplacer.
Aperçu de la première imageEn direct : le vrai moteur Pro, dans votre navigateurEnviron 30 secondes. Les étapes se cochent au fur et à mesure. Clavier : placez le focus sur la grille, puis flèches, Entrée et Alt+flèches pour déplacer.
La situation.
Plumbline est une entreprise générale fictive qui construit Linden Yard, bâtiment C : six niveaux sur une structure en béton armé. Son planning est par zones : chaque niveau de la coupe a un gros œuvre (coffrage, ferraillage, coulage, puis la façade) et un second œuvre (réseaux, contrôle, cloisons, finitions), et une équipe par corps d’état monte d’un niveau à l’autre. Une dalle doit faire sept jours de cure avant que le coffrage du niveau supérieur ne s’y appuie.
La météo est l’ennemi habituel. Quand un coulage glisse, la cure glisse, le niveau au-dessus glisse, et la marge qui protégeait la fin du gros œuvre disparaît sans que personne ne le voie. Un diagramme à barres cache cette cascade ; ce planning calcule le chemin critique à partir de la logique, le trace au crayon rouge, refuse ce que le chantier ne peut pas faire et garde la grue à tour et l’effectif en vue.
Ce que vous allez faire
Vous sortirez un coulage de la pluie, déplacerez d’un geste la structure d’un niveau quand la règle de cure la signale, et supprimerez la surcharge de grue que crée le décalage.
Où s’arrête la bibliothèque et où commence votre application.
SuperScheduler fournit
- Un arbre de ressources à trois niveaux (bâtiment C, ses niveaux, leurs zones) avec rowHeaderColumns et lignes parentes fusionnées ; les en-têtes dessinent la coupe du bâtiment avec la cote et la dalle de chaque niveau.
- Une ligne figée en haut (frozen: 'top', cellsDisabled) que onBeforeCellRender remplit avec les heures de grue à tour par jour ; des cellules hachurées pour les week-ends, les dalles en cure et la pluie, et désactivées pendant la fermeture de Noël.
- Trois lignes d’en-tête de temps écrites par onBeforeTimeHeaderRender : les semaines en chaîne de cotes, les jours et la météo, avec une zone d’en-tête à l’heure du chantier ; des séparateurs pour l’heure du chantier et le dernier jour de la grue.
- Des jalons, des barres de progression PercentComplete dessinées en hachures de matériau, des liens fin-début stylés par onBeforeLinkRender avec une bulle de lien, et des zones ancrées dans le temps sur les jours où une activité commence trop tôt.
- La multisélection par clic, Ctrl/Cmd+clic ou une fenêtre tracée sur la grille (rectangleSelectHandling, onGridMouseDown), allowMultiMove avec chaque membre réécrit à chaque image dans onEventMoving, et eventMoveSkipNonBusiness.
- Le zoom slider de super-scheduler/zoom-ui en échelle graphique du plan, la minimap de super-scheduler/minimap en plan de repérage, l’historique d’annulation, les déplacements au clavier, la carte de survol, la carte de glisser et un menu contextuel aux couleurs du produit.
Le code de cet exemple décide
- Le calendrier du chantier (week-ends, fermeture de Noël), la règle de cure, la logique fin-début, pas de coulage sous la pluie, une équipe par corps d’état et les dates fixes : des refus dans onEventMoving et onEventMove, avec la raison et le jour qu’ils citent.
- La méthode du chemin critique : dates au plus tard à rebours depuis les dates fixes en jours ouvrés, marge totale par activité, les activités critiques et les liens qui les pilotent.
- Les heures de grue à tour et l’effectif sur chantier par jour, les commandes (premier jour possible, jour suivant avec grue libre, décaler une sélection en jours ouvrés) et les lettres d’indice tirées de l’historique.
- Le plan lui-même : hachures par corps d’état, lignes de cote, le trait de coupe à l’heure du chantier, les nuages de révision et le cartouche.
Le code de cette démo.
Ce sont les fichiers exécutés par cette page, pas une copie simplifiée. Ils compilent avec les exports publics de SuperScheduler Pro.
L’intégration : l’arbre et ses colonnes, la ligne figée de la grue, trois lignes d’en-tête, les séparateurs, les jalons et barres de progression, les liens stylés selon le chemin critique avec leur bulle, la sélection par fenêtre, le déplacement multiple avec les membres réécrits à chaque image, le verdict de glisser, la carte de survol et le menu contextuel, et les callbacks où les règles du chantier refusent un déplacement.
/**
* 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}
/>
)
}L’archive contient les fichiers de cet exemple et un README. Elle n’inclut ni SuperScheduler Pro ni aucune clé de téléchargement.