@umriss-ui/schedule 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +5 -0
- package/dist/schedule.css +1 -1
- package/dist/schedule.js +1194 -847
- package/docs/llms-full.md +3451 -0
- package/package.json +6 -5
|
@@ -0,0 +1,3451 @@
|
|
|
1
|
+
# @umriss-ui/schedule 0.2.0
|
|
2
|
+
|
|
3
|
+
> The umriss schedule - subtasks on lanes over time, with transports, findings and controlled editing.
|
|
4
|
+
|
|
5
|
+
Install with `pnpm add @umriss-ui/schedule`. This text is generated from the package's demo (https://romanhaendler.github.io/umriss-ui/schedule/): every page with its import line, its examples - the source exactly as it runs, with the package name where the demo imports its own source - its props tables generated from the code, and why it is built the way it is. The pages index stands in https://romanhaendler.github.io/umriss-ui/schedule/llms.txt.
|
|
6
|
+
|
|
7
|
+
## Drawing
|
|
8
|
+
|
|
9
|
+
What the schedule shows: lanes over time, the work on them, and everything a bar says besides its colour.
|
|
10
|
+
|
|
11
|
+
### First schedule
|
|
12
|
+
|
|
13
|
+
Subtasks on lanes over time, with a day band above and adaptive time ticks below - the whole picture in thirty lines.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { Schedule, Lane, Subtasks, Transports } from "@umriss-ui/schedule";
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/schedule
|
|
20
|
+
|
|
21
|
+
#### Examples
|
|
22
|
+
|
|
23
|
+
##### A day in the plant
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
|
|
27
|
+
import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
|
|
28
|
+
|
|
29
|
+
/* The schedule draws what it is given: lanes in the order they are declared,
|
|
30
|
+
subtasks in the colour of their task, transports from one subtask's end to
|
|
31
|
+
the next one's start. The day band above names the day, the fine band below
|
|
32
|
+
steps from the hour down to the quarter hour as the time scale is zoomed.
|
|
33
|
+
|
|
34
|
+
Two findings stand in this plan on purpose: two orders claim the mill at
|
|
35
|
+
10:00, and the bracket cannot reach the paint shop in time. They are drawn,
|
|
36
|
+
offset and marked, and nothing moves them.
|
|
37
|
+
|
|
38
|
+
Drag the background to pan in both directions. The wheel scrolls the lanes
|
|
39
|
+
and, once they are at their end, the page; Ctrl or ⌘ with the wheel, or a
|
|
40
|
+
pinch, zooms; Shift with the wheel pans through time. `Transports` is declared
|
|
41
|
+
before `Subtasks`, so its lines run beneath the bars. */
|
|
42
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
43
|
+
const min = (n: number) => n * 60_000;
|
|
44
|
+
|
|
45
|
+
const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
const STATIONS = [
|
|
49
|
+
{ id: "saw", label: "Saw 1" },
|
|
50
|
+
{ id: "mill", label: "Mill" },
|
|
51
|
+
{ id: "press", label: "Press 2" },
|
|
52
|
+
{ id: "paint", label: "Paint shop" },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const ORDERS: readonly Task[] = [
|
|
56
|
+
{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
|
|
57
|
+
{ id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
|
|
58
|
+
{ id: "a-2044", name: "A-2044 Flange", color: "light-dark(#7c3aed, #a98bfa)" },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const STEPS: readonly Subtask[] = [
|
|
62
|
+
{ id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6), to: at(7), setup: min(15), teardown: min(10) },
|
|
63
|
+
{ id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
|
|
64
|
+
{ id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
|
|
65
|
+
{ id: "a-2043-2", task: "a-2043", lane: "mill", from: at(10), to: at(11, 30), setup: min(15) },
|
|
66
|
+
{ id: "a-2043-3", task: "a-2043", lane: "paint", from: at(12), to: at(14), setup: min(20), teardown: min(20) },
|
|
67
|
+
{ id: "a-2044-2", task: "a-2044", lane: "press", from: at(9, 30), to: at(10, 45), setup: min(25) },
|
|
68
|
+
{ id: "a-2044-3", task: "a-2044", lane: "paint", from: at(14, 45), to: at(16), setup: min(15), teardown: min(15) },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const MOVES: readonly Transport[] = [
|
|
72
|
+
{ id: "t-2041-1", from: "a-2041-1", to: "a-2041-2", duration: min(10) },
|
|
73
|
+
{ id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", duration: min(45) },
|
|
74
|
+
{ id: "t-2044-2", from: "a-2044-2", to: "a-2044-3", duration: min(60), arrives: "main" },
|
|
75
|
+
/* Leaves the mill at 11:30 and has ten minutes to reach the paint shop's
|
|
76
|
+
setup at 11:40 - it takes twenty-five. A late transport, on purpose. */
|
|
77
|
+
{ id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", duration: min(25) },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
export default function FirstSchedule() {
|
|
81
|
+
return (
|
|
82
|
+
<Schedule ariaLabel="Plan of Tuesday, 17 March" initialDomain={DAY_OF_PLAN} height={240}>
|
|
83
|
+
{STATIONS.map((station) => (
|
|
84
|
+
<Lane key={station.id} id={station.id} label={station.label} />
|
|
85
|
+
))}
|
|
86
|
+
<Transports data={MOVES} />
|
|
87
|
+
<Subtasks data={STEPS} tasks={ORDERS} />
|
|
88
|
+
</Schedule>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
#### API
|
|
94
|
+
|
|
95
|
+
##### `ScheduleProps`
|
|
96
|
+
|
|
97
|
+
| Prop | Type | Default | Description |
|
|
98
|
+
|---|---|---|---|
|
|
99
|
+
| `ariaLabel` (required) | `string` | — | What the schedule shows, for a screen reader: "Plan of week 38". |
|
|
100
|
+
| `initialDomain` (required) | `readonly [number, number]` | — | The time in view when the schedule mounts, as two wall-clock instants. Pan and zoom take it from there; a new pair of values puts it back. |
|
|
101
|
+
| `height` | `number` | — | Height in CSS pixels, both bands included. The width is the host's. |
|
|
102
|
+
| `laneHeight` | `number` | — | Height of one lane in pixels. |
|
|
103
|
+
| `headerWidth` | `number` | — | Width of the lane headers in pixels - at most 40 % of the schedule, so that on a phone the plot keeps the larger part. |
|
|
104
|
+
| `calendar` | `CalendarInput` | — | An operating calendar: the intervals in which time counts. Nights and weekends outside them are cut out of the axis. Default: the wall clock. |
|
|
105
|
+
| `zoomLimits` | `ZoomLimits` | — | The narrowest and widest time span zoom may reach, in milliseconds. Default: one hour to 28 days. |
|
|
106
|
+
| `snap` | `"ticks" \| number \| SnapRaster \| false` | — | The raster a drag lands on: `"ticks"` - the fine band's current step -, a step in milliseconds from local midnight, a step with an offset (shifts at 06:00, 14:00 and 22:00 are eight hours offset by six), or `false` for none. It shapes the ghost and the intent, never the data. |
|
|
107
|
+
| `intents` | `readonly IntentKind[]` | — | The intents the caller handles. Each one enables its interaction; none leaves a read-only schedule (ADR-0023). |
|
|
108
|
+
| `canMoveTo` | `(subtask: Subtask, lane: string) => boolean` | — | Whether a subtask may go to a lane. Without it every lane is open; it narrows `"lane"`, it does not enable it. Asked once per lane when a drag takes hold, and asked again at the drop. The lanes it turns down are marked from the first frame of the drag - a planner sees the refusal before meeting it - and over one of them the ghost stays on the last lane that was allowed, the cursor says no and a line ties the ghost to the pointer. A refused lane costs the lane and nothing else: a drop after it still reports the move in time. It is asked for work dragged in from outside as well, with the key and task the application declared in `placing`. |
|
|
109
|
+
| `onIntent` | `(intent: Intent) => void` | — | Called once per intent when a drag ends. The data changes only if the caller changes it. |
|
|
110
|
+
| `onInteraction` | `(interaction: ScheduleInteraction) => void` | — | Click, context menu and hover, with what the pointer is on and where. While it is set, a right-click opens no browser menu. |
|
|
111
|
+
| `selectedTask` | `string \| null` | — | The selected task, controlled. Leave it out and the schedule keeps the selection itself. |
|
|
112
|
+
| `onSelectedTaskChange` | `(task: string \| null, subtask: string \| null) => void` | — | Called when a click selects a task or clears the selection, with the subtask that was clicked - null where the click was on a transport or on nothing. It is called again when another subtask of the same task is clicked. |
|
|
113
|
+
| `collapsedGroups` | `readonly string[]` | — | The **Lane group**s that are folded, controlled. Leave it out and the schedule keeps them itself, starting from `defaultCollapsedGroups`. Folding changes the view and not the plan, which is why it is no intent: a caller that applies every intent it receives must never find a fold among them (ADR-0025). A folded outer group hides the inner ones without their entries leaving the list, so unfolding it gives back the view that was there. |
|
|
114
|
+
| `defaultCollapsedGroups` | `readonly string[]` | — | Which groups are folded when the schedule mounts, where the schedule keeps the state itself. Ignored while `collapsedGroups` is given. |
|
|
115
|
+
| `onCollapsedGroupsChange` | `(groups: readonly string[]) => void` | — | Called with the whole list when the planner folds or unfolds a group. |
|
|
116
|
+
| `onDomainChange` | `(domain: readonly [number, number]) => void` | — | The visible time span after the planner panned or zoomed, as two wall-clock instants - for keeping a second schedule or a chart in step. A span handed in through `initialDomain` is not reported back. |
|
|
117
|
+
| `placing` | `PlacingItem \| null` | — | What the application is dragging in from outside while it drags it - its key, task, the length of its main time, its setup and teardown. The browser hands the dragged data over only on the drop, so the ghost before it can only come from here: set it on your own `dragstart`, clear it on `dragend`. Without `"place"` in `intents` no drop is accepted. |
|
|
118
|
+
| `route` | `TransportRoute` | — | How the transports are drawn: a `"curve"` that leaves and arrives forwards, a `"straight"` line, or `"orthogonal"` segments. A transport may say otherwise for itself. |
|
|
119
|
+
| `attach` | `TransportAttachment` | — | Where a transport's ends sit on their bars: the `"centre"` of both, or the `"nearest"` edge - which is the shortest line between two stops. Within one lane both mean the middle. A transport may say otherwise for itself. It changes the picture and never a finding: whether a transport is late follows from its `leaves` and `arrives` alone. |
|
|
120
|
+
| `ends` | `TransportEnds` | — | Whether a transport's two ends carry a dot (`"dot"`, the default) or the line stands alone (`"none"`). The dot says where the line is anchored - a help while a plan is being read, and noise in a plan full of short moves. A transport may say otherwise for itself. |
|
|
121
|
+
| `label` | `(subtask: Subtask) => string` | — | What stands written in a bar: a function from a subtask to a line of text, or nothing for bars without text. The text is cut off with an ellipsis where the bar is too narrow for it and left out where even that would say nothing; a bar that began before the view keeps its text at the view's edge. |
|
|
122
|
+
| `tooltip` | `false \| ((target: ScheduleTooltipTarget) => ReactNode)` | — | The tooltip on a hovered subtask or transport: its order, its times, its parts and its findings. `false` switches it off; a function receives what the pointer rests on and returns content of the application's own. |
|
|
123
|
+
| `now` | `boolean \| number` | — | A line marking the present across the lanes: `true` follows the clock by the minute, an instant fixes it there. Off by default. |
|
|
124
|
+
| `className` | `string` | — | Goes to the root element. |
|
|
125
|
+
| `style` | `CSSProperties` | — | Goes to the root element. |
|
|
126
|
+
| `children` | `ReactNode` | — | `Lane`, `Transports` and `Subtasks`, in the order they are to stand and be drawn. |
|
|
127
|
+
|
|
128
|
+
#### Why it is like this
|
|
129
|
+
|
|
130
|
+
##### The schedule changes nothing
|
|
131
|
+
|
|
132
|
+
Drag a bar and the plan does not move. The schedule draws a ghost, assesses it as if it were data, and when the drag ends it **reports what was asked for** - a move, a lane, a stretch, a setup, a teardown, a place. The subtasks it was given are the subtasks it still has. See `docs/adr/0023-intents-not-mutations.md`.
|
|
133
|
+
|
|
134
|
+
That is not caution, it is the only arrangement that can be true. A planning application has rules the schedule cannot know - a mould that fits one press, a shift that ends at two, an order that may not start before its material arrives - and a component that wrote to the plan first and asked afterwards would be wrong in exactly the cases that matter. Here the application is asked first, every time, and nothing is ever travelled to and then corrected.
|
|
135
|
+
|
|
136
|
+
##### Every intent carries values, not differences
|
|
137
|
+
|
|
138
|
+
A move says where the main time now starts and ends; it does not say "two hours later". Two intents from one drop - a move and a lane - can therefore be applied in either order, or one of them alone, or neither. `applyIntent` is the arithmetic, and it is exported so an application can run it without the component.
|
|
139
|
+
|
|
140
|
+
##### What that costs, said plainly
|
|
141
|
+
|
|
142
|
+
Nothing happens until you write the handler. The simplest useful schedule is a` useState` and one `map`. In exchange you get a component that cannot corrupt a plan, an undo that is yours, and a cascade (`ripple`) you may run or refuse.
|
|
143
|
+
|
|
144
|
+
### Lanes
|
|
145
|
+
|
|
146
|
+
One machine or station per lane, named by its header - in the order the lanes are declared, however many there are.
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { Lane } from "@umriss-ui/schedule";
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/lane
|
|
153
|
+
|
|
154
|
+
#### Examples
|
|
155
|
+
|
|
156
|
+
##### What a lane header says
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
import { Badge, Stack, Text } from "@umriss-ui/core";
|
|
160
|
+
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
161
|
+
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
162
|
+
|
|
163
|
+
/* A lane is declared by its id - the one subtasks name - and a label. The label
|
|
164
|
+
is content, not a string: a name with its resource number, a status beside
|
|
165
|
+
it. It is real text, read by a screen reader, and it stays at the left edge
|
|
166
|
+
while the plot pans.
|
|
167
|
+
|
|
168
|
+
The lanes stand in the order they are declared. A subtask on a lane that is
|
|
169
|
+
not declared is not drawn. */
|
|
170
|
+
|
|
171
|
+
const at = (hours: number) => new Date(2026, 2, 17, hours).getTime();
|
|
172
|
+
|
|
173
|
+
const TASKS: Task[] = [{ id: "order", color: "light-dark(#7c3aed, #a98bfa)" }];
|
|
174
|
+
|
|
175
|
+
const WORK: Subtask[] = [
|
|
176
|
+
{ id: "one", task: "order", lane: "m-104", from: at(7), to: at(9) },
|
|
177
|
+
{ id: "two", task: "order", lane: "m-231", from: at(9), to: at(11) },
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
export default function LaneHeaders() {
|
|
181
|
+
return (
|
|
182
|
+
<Schedule ariaLabel="Two presses" initialDomain={[at(6), at(12)]} height={150} headerWidth={200}>
|
|
183
|
+
<Lane
|
|
184
|
+
id="m-104"
|
|
185
|
+
label={
|
|
186
|
+
<Stack direction="row" gap={2} align="center">
|
|
187
|
+
<Text size="sm">Press 1</Text>
|
|
188
|
+
<Text size="xs" tone="muted" mono>
|
|
189
|
+
M-104
|
|
190
|
+
</Text>
|
|
191
|
+
</Stack>
|
|
192
|
+
}
|
|
193
|
+
/>
|
|
194
|
+
<Lane
|
|
195
|
+
id="m-231"
|
|
196
|
+
label={
|
|
197
|
+
<Stack direction="row" gap={2} align="center">
|
|
198
|
+
<Text size="sm">Press 2</Text>
|
|
199
|
+
<Badge tone="warning">Service</Badge>
|
|
200
|
+
</Stack>
|
|
201
|
+
}
|
|
202
|
+
/>
|
|
203
|
+
<Subtasks data={WORK} tasks={TASKS} />
|
|
204
|
+
</Schedule>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
##### More lanes than the height
|
|
210
|
+
|
|
211
|
+
```tsx
|
|
212
|
+
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
213
|
+
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
214
|
+
|
|
215
|
+
/* Twenty lanes in 300 pixels: the lanes scroll, and the lane headers go with
|
|
216
|
+
them - vertically only. The day band and the time band hold still, and so
|
|
217
|
+
does the column of headers when the plot pans through time.
|
|
218
|
+
|
|
219
|
+
`height` is the whole schedule, both bands included; `laneHeight` and
|
|
220
|
+
`headerWidth` set the rest. Drag the background up and down to reach the
|
|
221
|
+
lower lanes. */
|
|
222
|
+
|
|
223
|
+
const LINES: Task[] = [
|
|
224
|
+
{ id: "line-a", color: "light-dark(#2563eb, #6b9bff)" },
|
|
225
|
+
{ id: "line-b", color: "light-dark(#0d9488, #3cc7b8)" },
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
229
|
+
|
|
230
|
+
const CELLS = Array.from({ length: 20 }, (_, i) => ({ id: `cell-${i + 1}`, label: `Cell ${String(i + 1).padStart(2, "0")}` }));
|
|
231
|
+
|
|
232
|
+
/* One subtask per cell, staggered by a quarter hour down the lanes. */
|
|
233
|
+
const WORK: Subtask[] = CELLS.map((cell, i) => ({
|
|
234
|
+
id: `${cell.id}-job`,
|
|
235
|
+
task: i % 2 === 0 ? "line-a" : "line-b",
|
|
236
|
+
lane: cell.id,
|
|
237
|
+
from: at(7, 15 * i),
|
|
238
|
+
to: at(9, 15 * i),
|
|
239
|
+
setup: 15 * 60_000,
|
|
240
|
+
}));
|
|
241
|
+
|
|
242
|
+
export default function ManyLanes() {
|
|
243
|
+
return (
|
|
244
|
+
<Schedule
|
|
245
|
+
ariaLabel="Twenty cells, Tuesday morning"
|
|
246
|
+
initialDomain={[at(6), at(14)]}
|
|
247
|
+
height={300}
|
|
248
|
+
laneHeight={32}
|
|
249
|
+
headerWidth={110}
|
|
250
|
+
>
|
|
251
|
+
{CELLS.map((cell) => (
|
|
252
|
+
<Lane key={cell.id} id={cell.id} label={cell.label} />
|
|
253
|
+
))}
|
|
254
|
+
<Subtasks data={WORK} tasks={LINES} />
|
|
255
|
+
</Schedule>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
#### API
|
|
261
|
+
|
|
262
|
+
##### `LaneProps`
|
|
263
|
+
|
|
264
|
+
| Prop | Type | Default | Description |
|
|
265
|
+
|---|---|---|---|
|
|
266
|
+
| `id` (required) | `string` | — | The identity subtasks name in their `lane`. |
|
|
267
|
+
| `label` | `ReactNode` | — | What the lane header says - the machine or station. Real text, read by a screen reader; without one the header shows the id. |
|
|
268
|
+
|
|
269
|
+
#### Why it is like this
|
|
270
|
+
|
|
271
|
+
##### A lane is not a row
|
|
272
|
+
|
|
273
|
+
A row is a place in a list; a lane is a **machine**. Whatever stands on it stands on it because the plant put it there, and two things on one lane at one time are not a layout problem - they are a finding a planner has to see. That single sentence decides most of what this component does and does not do.
|
|
274
|
+
|
|
275
|
+
It is why a lane keeps its height whatever lies on it, why nothing is ever moved to another lane to make room, and why the schedule has no notion of "fitting" work into the space available. A table may re-flow. A plan may not: the picture would stop being the plant.
|
|
276
|
+
|
|
277
|
+
##### What that costs, said plainly
|
|
278
|
+
|
|
279
|
+
A busy lane gets crowded, and the schedule does not tidy it up for you. Three overlapping stops are drawn overlapping, offset by a few pixels each so that all three can be seen and hit, and marked. If you want them apart, you move them - and then the plan really has changed, which is the point.
|
|
280
|
+
|
|
281
|
+
### Lane groups
|
|
282
|
+
|
|
283
|
+
Halls, lines and machine groups over the lanes - to any depth, and folded into one row when a planner wants the part they work on.
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
import { LaneGroup, Lane } from "@umriss-ui/schedule";
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/lane-groups
|
|
290
|
+
|
|
291
|
+
#### Examples
|
|
292
|
+
|
|
293
|
+
##### A group of lanes
|
|
294
|
+
|
|
295
|
+
```tsx
|
|
296
|
+
import { Lane, LaneGroup, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
297
|
+
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
298
|
+
|
|
299
|
+
/* `<LaneGroup>` puts structure over the lanes. It is not a lane itself:
|
|
300
|
+
nothing sits on it, no finding is reported for it, no intent names it. A
|
|
301
|
+
subtask still names a machine, and `canMoveTo` is still asked about a
|
|
302
|
+
machine - a group only says which machines belong together.
|
|
303
|
+
|
|
304
|
+
Declared by composition, so a group reads in JSX as it reads in the plant:
|
|
305
|
+
the lanes inside the tags are the lanes in the hall. A lane never names its
|
|
306
|
+
group; the group gives its id downwards, which is why moving a machine
|
|
307
|
+
between groups is moving a line of JSX.
|
|
308
|
+
|
|
309
|
+
An open group shows a slim head above its lanes, with its name and how many
|
|
310
|
+
lanes it holds. The chevron folds it - that is the next example.
|
|
311
|
+
|
|
312
|
+
The lanes keep the order they were DECLARED in, whatever group they are in.
|
|
313
|
+
That is the order a reader wrote and the only one they can predict. */
|
|
314
|
+
|
|
315
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
316
|
+
const min = (n: number) => n * 60_000;
|
|
317
|
+
|
|
318
|
+
const TASKS: Task[] = [
|
|
319
|
+
{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
|
|
320
|
+
{ id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
|
|
321
|
+
];
|
|
322
|
+
|
|
323
|
+
const STEPS: Subtask[] = [
|
|
324
|
+
{ id: "a-2041-1", task: "a-2041", lane: "press-1", from: at(7), to: at(9), setup: min(30) },
|
|
325
|
+
{ id: "a-2043-1", task: "a-2043", lane: "press-2", from: at(8), to: at(10, 30), teardown: min(15) },
|
|
326
|
+
{ id: "a-2041-2", task: "a-2041", lane: "weld", from: at(10), to: at(12) },
|
|
327
|
+
{ id: "a-2043-2", task: "a-2043", lane: "paint", from: at(11, 30), to: at(14) },
|
|
328
|
+
];
|
|
329
|
+
|
|
330
|
+
export default function AGroup() {
|
|
331
|
+
return (
|
|
332
|
+
<Schedule ariaLabel="Two presses in a hall, and two stations outside it" initialDomain={[at(6), at(15)]} height={240}>
|
|
333
|
+
<LaneGroup id="presses" label="Press shop">
|
|
334
|
+
<Lane id="press-1" label="Press 1" />
|
|
335
|
+
<Lane id="press-2" label="Press 2" />
|
|
336
|
+
</LaneGroup>
|
|
337
|
+
<Lane id="weld" label="Welding bay" />
|
|
338
|
+
<Lane id="paint" label="Paint shop" />
|
|
339
|
+
<Subtasks data={STEPS} tasks={TASKS} />
|
|
340
|
+
</Schedule>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
##### Hall, line, machine
|
|
346
|
+
|
|
347
|
+
```tsx
|
|
348
|
+
import { Lane, LaneGroup, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
349
|
+
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
350
|
+
|
|
351
|
+
/* Groups nest, to any depth, because plants do: a hall holds lines, a line
|
|
352
|
+
holds machines. Each level indents its header by one step, and each says how
|
|
353
|
+
many lanes lie under it - however deep.
|
|
354
|
+
|
|
355
|
+
Fold the inner line and the hall stays open around it. Fold the hall and the
|
|
356
|
+
line goes with it: what the reader sees is one row for the whole hall. The
|
|
357
|
+
inner fold is not forgotten while that happens - open the hall again and the
|
|
358
|
+
line is still folded, because folding the hall never touched the line's own
|
|
359
|
+
state. */
|
|
360
|
+
|
|
361
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
362
|
+
|
|
363
|
+
const TASKS: Task[] = [
|
|
364
|
+
{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
|
|
365
|
+
{ id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
const STEPS: Subtask[] = [
|
|
369
|
+
{ id: "s1", task: "a-2041", lane: "lathe-1", from: at(7), to: at(9) },
|
|
370
|
+
{ id: "s2", task: "a-2043", lane: "lathe-2", from: at(8), to: at(10, 30) },
|
|
371
|
+
{ id: "s3", task: "a-2041", lane: "mill", from: at(9, 30), to: at(11, 30) },
|
|
372
|
+
{ id: "s4", task: "a-2043", lane: "press-1", from: at(11), to: at(13) },
|
|
373
|
+
{ id: "s5", task: "a-2041", lane: "paint", from: at(12), to: at(14) },
|
|
374
|
+
];
|
|
375
|
+
|
|
376
|
+
export default function Nesting() {
|
|
377
|
+
return (
|
|
378
|
+
<Schedule ariaLabel="A hall with a turning line and a press, and a paint shop outside it" initialDomain={[at(6), at(15)]} height={330} headerWidth={200}>
|
|
379
|
+
<LaneGroup id="hall-a" label="Hall A">
|
|
380
|
+
<LaneGroup id="turning" label="Turning line">
|
|
381
|
+
<Lane id="lathe-1" label="Lathe 1" />
|
|
382
|
+
<Lane id="lathe-2" label="Lathe 2" />
|
|
383
|
+
<Lane id="mill" label="Mill" />
|
|
384
|
+
</LaneGroup>
|
|
385
|
+
<Lane id="press-1" label="Press 1" />
|
|
386
|
+
</LaneGroup>
|
|
387
|
+
<Lane id="paint" label="Paint shop" />
|
|
388
|
+
<Subtasks data={STEPS} tasks={TASKS} />
|
|
389
|
+
</Schedule>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
##### Which groups are folded, controlled
|
|
395
|
+
|
|
396
|
+
```tsx
|
|
397
|
+
import { useState } from "react";
|
|
398
|
+
import { Button, Stack, Text } from "@umriss-ui/core";
|
|
399
|
+
import { Lane, LaneGroup, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
400
|
+
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
401
|
+
|
|
402
|
+
/* `collapsedGroups`, `defaultCollapsedGroups` and `onCollapsedGroupsChange`
|
|
403
|
+
are the shape `selectedTask` has: leave them out and the schedule keeps the
|
|
404
|
+
state itself; pass a list and the application owns it.
|
|
405
|
+
|
|
406
|
+
Owning it is worth something. The list can be stored with the planner's
|
|
407
|
+
other preferences, put in the address, or handed to a second schedule so two
|
|
408
|
+
views fold together. Here two buttons move it from outside, and the readout
|
|
409
|
+
is the application's own state.
|
|
410
|
+
|
|
411
|
+
It is NOT an intent. An intent asks to change the plan, and a caller that
|
|
412
|
+
applies every intent it receives must never find a fold among them: folding
|
|
413
|
+
says what is on screen and nothing about what the plant is doing. */
|
|
414
|
+
|
|
415
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
416
|
+
|
|
417
|
+
const TASKS: Task[] = [{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" }];
|
|
418
|
+
|
|
419
|
+
const STEPS: Subtask[] = [
|
|
420
|
+
{ id: "s1", task: "a-2041", lane: "press-1", from: at(7), to: at(9) },
|
|
421
|
+
{ id: "s2", task: "a-2041", lane: "press-2", from: at(9, 30), to: at(11) },
|
|
422
|
+
{ id: "s3", task: "a-2041", lane: "weld-1", from: at(11, 30), to: at(13) },
|
|
423
|
+
{ id: "s4", task: "a-2041", lane: "weld-2", from: at(13), to: at(14, 30) },
|
|
424
|
+
];
|
|
425
|
+
|
|
426
|
+
const ALL = ["presses", "welding"];
|
|
427
|
+
|
|
428
|
+
export default function Controlled() {
|
|
429
|
+
const [folded, setFolded] = useState<readonly string[]>(["welding"]);
|
|
430
|
+
|
|
431
|
+
return (
|
|
432
|
+
<Stack gap={3}>
|
|
433
|
+
<Stack direction="row" gap={2}>
|
|
434
|
+
<Button size="sm" variant="secondary" onClick={() => setFolded(ALL)} data-fold-all>
|
|
435
|
+
Fold everything
|
|
436
|
+
</Button>
|
|
437
|
+
<Button size="sm" variant="secondary" onClick={() => setFolded([])} data-unfold-all>
|
|
438
|
+
Unfold everything
|
|
439
|
+
</Button>
|
|
440
|
+
</Stack>
|
|
441
|
+
<Schedule
|
|
442
|
+
ariaLabel="Two presses and two welding bays, folded from outside"
|
|
443
|
+
initialDomain={[at(6), at(15)]}
|
|
444
|
+
height={280}
|
|
445
|
+
collapsedGroups={folded}
|
|
446
|
+
onCollapsedGroupsChange={setFolded}
|
|
447
|
+
>
|
|
448
|
+
<LaneGroup id="presses" label="Press shop">
|
|
449
|
+
<Lane id="press-1" label="Press 1" />
|
|
450
|
+
<Lane id="press-2" label="Press 2" />
|
|
451
|
+
</LaneGroup>
|
|
452
|
+
<LaneGroup id="welding" label="Welding">
|
|
453
|
+
<Lane id="weld-1" label="Bay 1" />
|
|
454
|
+
<Lane id="weld-2" label="Bay 2" />
|
|
455
|
+
</LaneGroup>
|
|
456
|
+
<Subtasks data={STEPS} tasks={TASKS} />
|
|
457
|
+
</Schedule>
|
|
458
|
+
<Text size="sm" mono tone="secondary" data-folded>
|
|
459
|
+
{folded.length === 0 ? "nothing folded" : folded.join(", ")}
|
|
460
|
+
</Text>
|
|
461
|
+
</Stack>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
##### What a folded group shows
|
|
467
|
+
|
|
468
|
+
```tsx
|
|
469
|
+
import { useState } from "react";
|
|
470
|
+
import { Lane, LaneGroup, Schedule, Subtasks, Transports, applyIntent, findings } from "@umriss-ui/schedule";
|
|
471
|
+
import { Stack, Text } from "@umriss-ui/core";
|
|
472
|
+
import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
|
|
473
|
+
|
|
474
|
+
/* A folded group is not a closed box. It shows a **miniature**: every lane in
|
|
475
|
+
it as a thin strip, at a smaller scale, with its work in the tasks' own
|
|
476
|
+
colours. The real work, smaller - not a summary, not a packing, not a
|
|
477
|
+
utilisation band.
|
|
478
|
+
|
|
479
|
+
Fold the hall and watch what survives. The transport from the saw still
|
|
480
|
+
arrives, at the strip of the machine it arrives at. The overlap on the mill
|
|
481
|
+
is still marked, on the strip AND on the row, because folding is a planner
|
|
482
|
+
tidying the view and must never be a planner hiding a finding. Hover a strip
|
|
483
|
+
and the tooltip names the stop; click it and its whole task is outlined
|
|
484
|
+
across the plan.
|
|
485
|
+
|
|
486
|
+
What a strip does NOT show is everything that needs room to be read: no
|
|
487
|
+
appearance, no label, no progress rail, and no grips - a bar three pixels
|
|
488
|
+
high is not something to stretch by three pixels. That is what unfolding is
|
|
489
|
+
for.
|
|
490
|
+
|
|
491
|
+
A drag held over the folded hall opens it for the gesture, so work can be
|
|
492
|
+
moved into a group without preparing the view first. It shuts again when the
|
|
493
|
+
drag ends - the application did not fold anything, so its own list is never
|
|
494
|
+
written to and it hears nothing.
|
|
495
|
+
|
|
496
|
+
`findings()` is the proof that nothing moved: the list below is computed
|
|
497
|
+
from the data and does not change by one entry when the hall folds. */
|
|
498
|
+
|
|
499
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
500
|
+
const min = (n: number) => n * 60_000;
|
|
501
|
+
|
|
502
|
+
const TASKS: Task[] = [
|
|
503
|
+
{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
|
|
504
|
+
{ id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
|
|
505
|
+
];
|
|
506
|
+
|
|
507
|
+
const STEPS: Subtask[] = [
|
|
508
|
+
{ id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6, 30), to: at(8), teardown: min(15) },
|
|
509
|
+
{ id: "a-2041-2", task: "a-2041", lane: "mill", from: at(9), to: at(11, 30), setup: min(30) },
|
|
510
|
+
{ id: "a-2043-1", task: "a-2043", lane: "lathe", from: at(7), to: at(9) },
|
|
511
|
+
/* Claims the mill while the housing still has it: an overlap, on purpose. */
|
|
512
|
+
{ id: "a-2043-2", task: "a-2043", lane: "mill", from: at(11), to: at(13), setup: min(15) },
|
|
513
|
+
{ id: "a-2043-3", task: "a-2043", lane: "paint", from: at(13, 30), to: at(15) },
|
|
514
|
+
];
|
|
515
|
+
|
|
516
|
+
const MOVES: Transport[] = [
|
|
517
|
+
/* Out of the saw, which stands outside the hall, into a machine inside it. */
|
|
518
|
+
{ id: "t-1", from: "a-2041-1", to: "a-2041-2", duration: min(20) },
|
|
519
|
+
{ id: "t-2", from: "a-2043-1", to: "a-2043-2", duration: min(30) },
|
|
520
|
+
{ id: "t-3", from: "a-2043-2", to: "a-2043-3", duration: min(20) },
|
|
521
|
+
];
|
|
522
|
+
|
|
523
|
+
const FOUND = findings(STEPS, MOVES);
|
|
524
|
+
|
|
525
|
+
export default function TheMiniature() {
|
|
526
|
+
const [folded, setFolded] = useState<readonly string[]>(["hall"]);
|
|
527
|
+
const [work, setWork] = useState<readonly Subtask[]>(STEPS);
|
|
528
|
+
const [last, setLast] = useState("Hold a bar over the folded hall");
|
|
529
|
+
|
|
530
|
+
return (
|
|
531
|
+
<Stack gap={3}>
|
|
532
|
+
<Schedule
|
|
533
|
+
ariaLabel="A hall that folds into one row"
|
|
534
|
+
initialDomain={[at(6), at(16)]}
|
|
535
|
+
height={240}
|
|
536
|
+
collapsedGroups={folded}
|
|
537
|
+
onCollapsedGroupsChange={setFolded}
|
|
538
|
+
intents={["move", "lane"]}
|
|
539
|
+
onIntent={(intent) => {
|
|
540
|
+
if (intent.kind === "place") return;
|
|
541
|
+
setWork((current) => current.map((step) => applyIntent(step, intent)));
|
|
542
|
+
setLast(`${intent.subtask}: ${intent.kind}`);
|
|
543
|
+
}}
|
|
544
|
+
>
|
|
545
|
+
<Lane id="saw" label="Saw 1" />
|
|
546
|
+
<LaneGroup id="hall" label="Hall A">
|
|
547
|
+
<Lane id="lathe" label="Lathe 1" />
|
|
548
|
+
<Lane id="mill" label="Mill" />
|
|
549
|
+
</LaneGroup>
|
|
550
|
+
<Lane id="paint" label="Paint shop" />
|
|
551
|
+
<Transports data={MOVES} />
|
|
552
|
+
<Subtasks data={work} tasks={TASKS} />
|
|
553
|
+
</Schedule>
|
|
554
|
+
<Text size="sm" mono tone="secondary" data-last-move>
|
|
555
|
+
{last}
|
|
556
|
+
</Text>
|
|
557
|
+
<Text size="sm" mono tone="secondary" data-findings-count>
|
|
558
|
+
{FOUND.overlaps.length} overlap and {FOUND.lateTransports.length} late transport - folded or not
|
|
559
|
+
</Text>
|
|
560
|
+
</Stack>
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
#### API
|
|
566
|
+
|
|
567
|
+
##### `LaneGroupProps`
|
|
568
|
+
|
|
569
|
+
| Prop | Type | Default | Description |
|
|
570
|
+
|---|---|---|---|
|
|
571
|
+
| `id` (required) | `string` | — | The group's identity - what `collapsedGroups` names. It is never a lane: no subtask sits on it, no finding is reported for it, and no intent names it (ADR-0025). |
|
|
572
|
+
| `label` | `ReactNode` | — | What the group's header says - the hall, the line, the machine group. Real text, read by a screen reader; without one the header shows the id. |
|
|
573
|
+
| `children` | `ReactNode` | — | `Lane`s and further `LaneGroup`s, to any depth. |
|
|
574
|
+
|
|
575
|
+
#### Why it is like this
|
|
576
|
+
|
|
577
|
+
##### A folded group keeps every bar its place
|
|
578
|
+
|
|
579
|
+
Folding is how a planner who works on the presses stops scrolling past the rest of the plant. It must not also be how they stop seeing it. So a folded group is not a closed box: it is a **miniature** - every lane in it as a thin strip, at a smaller scale, with its work in the tasks' own colours. The real work, smaller.
|
|
580
|
+
|
|
581
|
+
Everything that was true of a bar stays true of its strip. A transport arrives at the strip of the machine it arrives at, not at the top of the group. An overlap is marked on the strip *and* on the row, because a three-pixel strip is not where an alarm can live alone. Hover names the stop; a click takes its whole task across the plan. And `findings()` returns the same list folded and unfolded, because it is computed from the data and folding is not data.
|
|
582
|
+
|
|
583
|
+
##### A change of scale, not an arrangement
|
|
584
|
+
|
|
585
|
+
This is where the component's two standing sentences would be easiest to break, and they are not broken. **A lane is not a row**: a strip is still that machine, and every real lane still resolves to exactly one place. **An overlap is never packed into sub-lanes**: nothing is moved to make anything fit - every bar keeps the time it has and the strip its lane has, smaller.
|
|
586
|
+
|
|
587
|
+
##### What it costs, said plainly
|
|
588
|
+
|
|
589
|
+
A strip shows what needs no room: where the work is and whose it is. It shows no appearance, no bar label and no progress rail, and it offers no grips - a bar three pixels high is not something to stretch by three pixels. That is what unfolding is for, and it is one click or one keystroke away.
|
|
590
|
+
|
|
591
|
+
Nor is there a summary of any kind on a folded group - no band of how busy it is, no bar spanning its work. A summary is a claim the schedule computes, and once both are bars a planner cannot tell a computed claim from a drawn fact. See `docs/adr/0025-a-lane-group-is-structure-over-lanes-never-a-lane.md`.
|
|
592
|
+
|
|
593
|
+
### Time axis and calendar
|
|
594
|
+
|
|
595
|
+
The two bands, and the operating calendar that cuts the hours the plant does not run out of the axis.
|
|
596
|
+
|
|
597
|
+
```ts
|
|
598
|
+
import { Schedule } from "@umriss-ui/schedule";
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/time-axis
|
|
602
|
+
|
|
603
|
+
#### Examples
|
|
604
|
+
|
|
605
|
+
##### Nights cut out of the axis
|
|
606
|
+
|
|
607
|
+
```tsx
|
|
608
|
+
import { useState } from "react";
|
|
609
|
+
import { Lane, Schedule, Subtasks, Transports, applyIntent } from "@umriss-ui/schedule";
|
|
610
|
+
import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
|
|
611
|
+
|
|
612
|
+
/* The plant runs two shifts, 06:00 to 22:00. An operating calendar lists the
|
|
613
|
+
intervals in which time counts, and the axis leaves the rest out: three days
|
|
614
|
+
in the width of two, and no empty night between the evening's work and the
|
|
615
|
+
morning's. A dotted line marks each seam, because an axis that takes time
|
|
616
|
+
out and does not say so claims a continuity it does not have.
|
|
617
|
+
|
|
618
|
+
The calendar is a list of intervals and nothing more. Deriving it from a
|
|
619
|
+
shift plan with its holidays is the application's business - the schedule
|
|
620
|
+
draws what the list says.
|
|
621
|
+
|
|
622
|
+
Editing follows the calendar: drag the casting into the night and the ghost
|
|
623
|
+
stops at the seam where time counts again, so an intent never asks for a
|
|
624
|
+
time the plant does not run. */
|
|
625
|
+
|
|
626
|
+
const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, 16 + day, hours, minutes).getTime();
|
|
627
|
+
|
|
628
|
+
const SHIFTS = [0, 1, 2].map((day) => ({ from: at(day, 6), to: at(day, 22) }));
|
|
629
|
+
|
|
630
|
+
const TASKS: Task[] = [{ id: "casting", color: "light-dark(#c2410c, #f08a52)" }];
|
|
631
|
+
|
|
632
|
+
const STEPS: Subtask[] = [
|
|
633
|
+
{ id: "pour", task: "casting", lane: "foundry", from: at(0, 18), to: at(0, 21, 30), setup: 30 * 60_000 },
|
|
634
|
+
{ id: "fettle", task: "casting", lane: "fettling", from: at(1, 6, 30), to: at(1, 11), setup: 15 * 60_000 },
|
|
635
|
+
{ id: "machine", task: "casting", lane: "machining", from: at(1, 20), to: at(2, 9), teardown: 30 * 60_000 },
|
|
636
|
+
];
|
|
637
|
+
|
|
638
|
+
const MOVES: Transport[] = [
|
|
639
|
+
{ id: "cool", from: "pour", to: "fettle", duration: 20 * 60_000 },
|
|
640
|
+
{ id: "carry", from: "fettle", to: "machine", duration: 45 * 60_000 },
|
|
641
|
+
];
|
|
642
|
+
|
|
643
|
+
export default function OperatingCalendar() {
|
|
644
|
+
const [steps, setSteps] = useState<readonly Subtask[]>(STEPS);
|
|
645
|
+
return (
|
|
646
|
+
<Schedule
|
|
647
|
+
ariaLabel="Three days of casting, nights removed"
|
|
648
|
+
initialDomain={[at(0, 6), at(2, 22)]}
|
|
649
|
+
calendar={SHIFTS}
|
|
650
|
+
height={220}
|
|
651
|
+
intents={["move"]}
|
|
652
|
+
onIntent={(intent) => setSteps((current) => current.map((step) => applyIntent(step, intent)))}
|
|
653
|
+
>
|
|
654
|
+
<Lane id="foundry" label="Foundry" />
|
|
655
|
+
<Lane id="fettling" label="Fettling" />
|
|
656
|
+
<Lane id="machining" label="Machining" />
|
|
657
|
+
<Transports data={MOVES} />
|
|
658
|
+
<Subtasks data={steps} tasks={TASKS} />
|
|
659
|
+
</Schedule>
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
### Pan and zoom
|
|
665
|
+
|
|
666
|
+
Dragging the background, the wheel, Ctrl and a pinch: what moves the view, and what the view never changes.
|
|
667
|
+
|
|
668
|
+
```ts
|
|
669
|
+
import { Schedule } from "@umriss-ui/schedule";
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/pan-and-zoom
|
|
673
|
+
|
|
674
|
+
#### Examples
|
|
675
|
+
|
|
676
|
+
##### Moving through the plan
|
|
677
|
+
|
|
678
|
+
```tsx
|
|
679
|
+
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
680
|
+
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
681
|
+
|
|
682
|
+
/* Nothing here is configured. A schedule pans and zooms because that is what a
|
|
683
|
+
plan is for, and the gestures are the ones every scrolling surface has:
|
|
684
|
+
|
|
685
|
+
drag the background pans through time AND through the lanes
|
|
686
|
+
the wheel scrolls the lanes, and releases the page at the end
|
|
687
|
+
Shift with the wheel pans through time
|
|
688
|
+
Ctrl or Cmd with it zooms; a trackpad pinch arrives the same way
|
|
689
|
+
two fingers zoom
|
|
690
|
+
|
|
691
|
+
The wheel releasing the page is the part worth saying out loud: a plan
|
|
692
|
+
halfway down a page must not swallow the scroll that was meant for the page.
|
|
693
|
+
Once the lanes are at their end, the wheel goes back to the document.
|
|
694
|
+
|
|
695
|
+
What moves is the VIEW and only the view. No data changes, nothing is
|
|
696
|
+
reported, no intent is raised: pan and zoom exchange the visible span and
|
|
697
|
+
the scroll, and nothing else (ADR-0001). `zoomLimits` says how far the span
|
|
698
|
+
may be taken - an hour to twenty-eight days, unless a caller says otherwise;
|
|
699
|
+
this one is held between two hours and two days, so the ends are reachable
|
|
700
|
+
in a few turns of the wheel. */
|
|
701
|
+
|
|
702
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
703
|
+
const min = (n: number) => n * 60_000;
|
|
704
|
+
const HOUR = 60 * 60_000;
|
|
705
|
+
|
|
706
|
+
const TASKS: Task[] = [
|
|
707
|
+
{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
|
|
708
|
+
{ id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
|
|
709
|
+
];
|
|
710
|
+
|
|
711
|
+
const STATIONS = [
|
|
712
|
+
{ id: "saw", label: "Saw 1" },
|
|
713
|
+
{ id: "mill", label: "Mill" },
|
|
714
|
+
{ id: "press", label: "Press 2" },
|
|
715
|
+
];
|
|
716
|
+
|
|
717
|
+
const STEPS: Subtask[] = [
|
|
718
|
+
{ id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6), to: at(7), setup: min(15), teardown: min(10) },
|
|
719
|
+
{ id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
|
|
720
|
+
{ id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
|
|
721
|
+
{ id: "a-2043-2", task: "a-2043", lane: "mill", from: at(12), to: at(13, 30), setup: min(15) },
|
|
722
|
+
];
|
|
723
|
+
|
|
724
|
+
export default function PanAndZoom() {
|
|
725
|
+
return (
|
|
726
|
+
<Schedule
|
|
727
|
+
ariaLabel="A plan to pan and zoom"
|
|
728
|
+
initialDomain={[at(5, 30), at(18)]}
|
|
729
|
+
height={196}
|
|
730
|
+
zoomLimits={{ min: 2 * HOUR, max: 48 * HOUR }}
|
|
731
|
+
>
|
|
732
|
+
{STATIONS.map((station) => (
|
|
733
|
+
<Lane key={station.id} id={station.id} label={station.label} />
|
|
734
|
+
))}
|
|
735
|
+
<Subtasks data={STEPS} tasks={TASKS} />
|
|
736
|
+
</Schedule>
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
### Now line
|
|
742
|
+
|
|
743
|
+
A line across the lanes at the present moment, following the clock or fixed where a replay needs it.
|
|
744
|
+
|
|
745
|
+
```ts
|
|
746
|
+
import { Schedule } from "@umriss-ui/schedule";
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/now-line
|
|
750
|
+
|
|
751
|
+
#### Examples
|
|
752
|
+
|
|
753
|
+
##### Where the present stands
|
|
754
|
+
|
|
755
|
+
```tsx
|
|
756
|
+
import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
|
|
757
|
+
import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
|
|
758
|
+
|
|
759
|
+
/* `now` draws a line across the lanes at the present moment, and a mark where
|
|
760
|
+
it meets the time band. Everything to its left should have happened; what
|
|
761
|
+
still stands there in full colour is late, and a planner sees it at once.
|
|
762
|
+
|
|
763
|
+
In an application you write `now` and nothing else: it reads the clock and
|
|
764
|
+
moves on by the minute, so a schedule left open on a wall screen stays true.
|
|
765
|
+
This plan is a fixed Tuesday in March, and the real clock would put the line
|
|
766
|
+
outside it - where it would be as useless as the plan is old. An instant
|
|
767
|
+
instead of `true` fixes the line where it belongs, which is also what a
|
|
768
|
+
replay of a past shift needs, and what makes a picture of it hold still. */
|
|
769
|
+
|
|
770
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
771
|
+
const min = (n: number) => n * 60_000;
|
|
772
|
+
|
|
773
|
+
const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
const STATIONS = [
|
|
777
|
+
{ id: "saw", label: "Saw 1" },
|
|
778
|
+
{ id: "mill", label: "Mill" },
|
|
779
|
+
{ id: "press", label: "Press 2" },
|
|
780
|
+
];
|
|
781
|
+
|
|
782
|
+
const ORDERS: readonly Task[] = [
|
|
783
|
+
{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
|
|
784
|
+
{ id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
|
|
785
|
+
];
|
|
786
|
+
|
|
787
|
+
const STEPS: readonly Subtask[] = [
|
|
788
|
+
{ id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6), to: at(7), setup: min(15), teardown: min(10) },
|
|
789
|
+
{ id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
|
|
790
|
+
{ id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
|
|
791
|
+
{ id: "a-2043-2", task: "a-2043", lane: "mill", from: at(10), to: at(11, 30), setup: min(15) },
|
|
792
|
+
];
|
|
793
|
+
|
|
794
|
+
const MOVES: readonly Transport[] = [
|
|
795
|
+
{ id: "t-2041-1", from: "a-2041-1", to: "a-2041-2", duration: min(10) },
|
|
796
|
+
{ id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", duration: min(45) },
|
|
797
|
+
];
|
|
798
|
+
|
|
799
|
+
const HALF_PAST_TEN = new Date(2026, 2, 17, 10, 30).getTime();
|
|
800
|
+
|
|
801
|
+
export default function NowLine() {
|
|
802
|
+
return (
|
|
803
|
+
<Schedule
|
|
804
|
+
ariaLabel="Plan of Tuesday, 17 March, with the present"
|
|
805
|
+
initialDomain={DAY_OF_PLAN}
|
|
806
|
+
height={196}
|
|
807
|
+
now={HALF_PAST_TEN}
|
|
808
|
+
>
|
|
809
|
+
{STATIONS.map((station) => (
|
|
810
|
+
<Lane key={station.id} id={station.id} label={station.label} />
|
|
811
|
+
))}
|
|
812
|
+
<Transports data={MOVES} />
|
|
813
|
+
<Subtasks data={STEPS} tasks={ORDERS} />
|
|
814
|
+
</Schedule>
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
### Subtasks
|
|
820
|
+
|
|
821
|
+
The drawn intervals: a main time with its setup and teardown, in the colour of the task they belong to.
|
|
822
|
+
|
|
823
|
+
```ts
|
|
824
|
+
import { Subtasks } from "@umriss-ui/schedule";
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/subtasks
|
|
828
|
+
|
|
829
|
+
#### Examples
|
|
830
|
+
|
|
831
|
+
##### Main time, setup and teardown
|
|
832
|
+
|
|
833
|
+
```tsx
|
|
834
|
+
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
835
|
+
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
836
|
+
|
|
837
|
+
/* A subtask is a main time with an optional setup before it and a teardown
|
|
838
|
+
after it. The main time is drawn full, setup and teardown faint in the same
|
|
839
|
+
colour with an edge: preparation reads as belonging to the work, and as not
|
|
840
|
+
being it.
|
|
841
|
+
|
|
842
|
+
Both are durations beside the main time, not times of their own - moving
|
|
843
|
+
the subtask takes them along, and each is changed on its own (see Intent). */
|
|
844
|
+
|
|
845
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
846
|
+
const min = (n: number) => n * 60_000;
|
|
847
|
+
|
|
848
|
+
const TASKS: Task[] = [{ id: "housing", color: "light-dark(#2563eb, #6b9bff)" }];
|
|
849
|
+
|
|
850
|
+
const WORK: Subtask[] = [
|
|
851
|
+
{ id: "plain", task: "housing", lane: "mill", from: at(6, 30), to: at(8) },
|
|
852
|
+
{ id: "with-setup", task: "housing", lane: "mill", from: at(9), to: at(10, 30), setup: min(30) },
|
|
853
|
+
{ id: "with-both", task: "housing", lane: "mill", from: at(11, 30), to: at(13), setup: min(20), teardown: min(40) },
|
|
854
|
+
];
|
|
855
|
+
|
|
856
|
+
export default function SetupAndTeardown() {
|
|
857
|
+
return (
|
|
858
|
+
<Schedule ariaLabel="Three subtasks on the mill" initialDomain={[at(6), at(14, 30)]} height={110}>
|
|
859
|
+
<Lane id="mill" label="Mill" />
|
|
860
|
+
<Subtasks data={WORK} tasks={TASKS} />
|
|
861
|
+
</Schedule>
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
##### Colour is the task's
|
|
867
|
+
|
|
868
|
+
```tsx
|
|
869
|
+
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
870
|
+
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
871
|
+
|
|
872
|
+
/* Every subtask of a task shares one colour, and the colour is the caller's:
|
|
873
|
+
any CSS colour, a token of the application, a `light-dark()` pair. Here it
|
|
874
|
+
means the order's priority rather than the order itself - two rush orders in
|
|
875
|
+
the danger colour, the rest in the accent.
|
|
876
|
+
|
|
877
|
+
A subtask whose task is not in `tasks` is drawn muted: it is on the plan, and
|
|
878
|
+
nobody said what it is. */
|
|
879
|
+
|
|
880
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
881
|
+
|
|
882
|
+
const TASKS: Task[] = [
|
|
883
|
+
{ id: "rush-1", color: "var(--u-color-danger)" },
|
|
884
|
+
{ id: "rush-2", color: "var(--u-color-danger)" },
|
|
885
|
+
{ id: "normal", color: "var(--u-color-accent)" },
|
|
886
|
+
];
|
|
887
|
+
|
|
888
|
+
const WORK: Subtask[] = [
|
|
889
|
+
{ id: "r1-a", task: "rush-1", lane: "saw", from: at(6), to: at(7) },
|
|
890
|
+
{ id: "r1-b", task: "rush-1", lane: "lathe", from: at(7, 30), to: at(9) },
|
|
891
|
+
{ id: "r2-a", task: "rush-2", lane: "saw", from: at(7, 15), to: at(8, 15) },
|
|
892
|
+
{ id: "n-a", task: "normal", lane: "lathe", from: at(9, 30), to: at(11) },
|
|
893
|
+
{ id: "unknown", task: "not-listed", lane: "saw", from: at(9), to: at(10, 30) },
|
|
894
|
+
];
|
|
895
|
+
|
|
896
|
+
export default function TaskColours() {
|
|
897
|
+
return (
|
|
898
|
+
<Schedule ariaLabel="Rush orders among the rest" initialDomain={[at(5, 30), at(11, 30)]} height={150}>
|
|
899
|
+
<Lane id="saw" label="Saw" />
|
|
900
|
+
<Lane id="lathe" label="Lathe" />
|
|
901
|
+
<Subtasks data={WORK} tasks={TASKS} />
|
|
902
|
+
</Schedule>
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
#### API
|
|
908
|
+
|
|
909
|
+
##### `SubtasksProps`
|
|
910
|
+
|
|
911
|
+
| Prop | Type | Default | Description |
|
|
912
|
+
|---|---|---|---|
|
|
913
|
+
| `data` (required) | `readonly Subtask[]` | — | The subtasks, as the caller holds them. The schedule never changes one. |
|
|
914
|
+
| `tasks` (required) | `readonly Task[]` | — | The tasks the subtasks belong to - their identity and their colour. A subtask whose task is not among them is drawn in a muted colour. |
|
|
915
|
+
|
|
916
|
+
##### `Subtask`
|
|
917
|
+
|
|
918
|
+
| Prop | Type | Default | Description |
|
|
919
|
+
|---|---|---|---|
|
|
920
|
+
| `id` (required) | `string` | — | The caller's identity of the subtask. |
|
|
921
|
+
| `task` (required) | `string` | — | The id of the task it belongs to. |
|
|
922
|
+
| `lane` (required) | `string` | — | The id of the lane it sits on. |
|
|
923
|
+
| `from` (required) | `number` | — | Start of the main time. |
|
|
924
|
+
| `to` (required) | `number` | — | End of the main time. |
|
|
925
|
+
| `setup` | `number` | — | Duration of the setup before the main time; none is zero. |
|
|
926
|
+
| `teardown` | `number` | — | Duration of the teardown after the main time; none is zero. |
|
|
927
|
+
| `name` | `string` | — | What the subtask is called, where the schedule names it. |
|
|
928
|
+
| `appearance` | `readonly SubtaskAppearance[]` | — | What the bar says besides its colour: `"provisional"`, `"fixed"`, `"muted"`, `"open"`. Several hold at once; `"provisional"` and `"fixed"` contradict, and the later one in the list wins. |
|
|
929
|
+
| `progress` | `number` | — | The share of the work that is done, from 0 to 1, drawn as a filled part of the bar. Without it nothing is claimed - an empty bar and a bar at zero per cent are two different statements. |
|
|
930
|
+
|
|
931
|
+
##### `Task`
|
|
932
|
+
|
|
933
|
+
| Prop | Type | Default | Description |
|
|
934
|
+
|---|---|---|---|
|
|
935
|
+
| `id` (required) | `string` | — | The caller's identity of the task. |
|
|
936
|
+
| `color` (required) | `string` | — | Any CSS colour - a literal, a token, `light-dark(…)`. The colour means what the caller's domain needs it to mean. |
|
|
937
|
+
| `name` | `string` | — | What the task is called, where the schedule names it. |
|
|
938
|
+
|
|
939
|
+
### Bar labels
|
|
940
|
+
|
|
941
|
+
What stands written in a bar - cut off where it must be, left out where it would say nothing, held at the view's edge.
|
|
942
|
+
|
|
943
|
+
```ts
|
|
944
|
+
import { Schedule } from "@umriss-ui/schedule";
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/bar-labels
|
|
948
|
+
|
|
949
|
+
#### Examples
|
|
950
|
+
|
|
951
|
+
##### What a bar says
|
|
952
|
+
|
|
953
|
+
```tsx
|
|
954
|
+
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
955
|
+
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
956
|
+
|
|
957
|
+
/* `label` writes a line into every bar: the order, the article, the quantity -
|
|
958
|
+
whatever a planner reads first. The text lies on the main time, because the
|
|
959
|
+
setup is not the work.
|
|
960
|
+
|
|
961
|
+
It is cut off with an ellipsis where the bar is too narrow, and left out
|
|
962
|
+
where even a word and an ellipsis would say nothing - the inspections at the
|
|
963
|
+
bottom are minutes long, and a row of dots in them would be worse than
|
|
964
|
+
nothing. A bar that begins before the view keeps its text at the edge, the
|
|
965
|
+
way the day band keeps its date: pan into the afternoon and watch the labels
|
|
966
|
+
stay.
|
|
967
|
+
|
|
968
|
+
The text's colour follows the bar's: the schedule reads the brightness of
|
|
969
|
+
the colour the caller gave the task and puts light text on a dark bar and
|
|
970
|
+
dark text on a pale one. */
|
|
971
|
+
|
|
972
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
973
|
+
const min = (n: number) => n * 60_000;
|
|
974
|
+
|
|
975
|
+
const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
const STATIONS = [
|
|
979
|
+
{ id: "mill", label: "Mill" },
|
|
980
|
+
{ id: "press", label: "Press 2" },
|
|
981
|
+
{ id: "qa", label: "Inspection" },
|
|
982
|
+
];
|
|
983
|
+
|
|
984
|
+
const ORDERS: readonly Task[] = [
|
|
985
|
+
{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
|
|
986
|
+
{ id: "a-2044", name: "A-2044 Flange", color: "light-dark(#7c3aed, #a98bfa)" },
|
|
987
|
+
{ id: "a-2046", name: "A-2046 Axle", color: "light-dark(#4d7c0f, #8fc43e)" },
|
|
988
|
+
];
|
|
989
|
+
|
|
990
|
+
const STEPS: readonly Subtask[] = [
|
|
991
|
+
{ id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
|
|
992
|
+
{ id: "a-2044-2", task: "a-2044", lane: "press", from: at(9, 30), to: at(10, 45), setup: min(25) },
|
|
993
|
+
{ id: "a-2041-3", task: "a-2041", lane: "qa", from: at(11, 30), to: at(12, 15) },
|
|
994
|
+
{ id: "a-2046-3", task: "a-2046", lane: "qa", from: at(15), to: at(15, 30) },
|
|
995
|
+
];
|
|
996
|
+
|
|
997
|
+
const ORDER_NAMES = new Map(ORDERS.map((order) => [order.id, order.name ?? order.id]));
|
|
998
|
+
|
|
999
|
+
export default function BarLabels() {
|
|
1000
|
+
return (
|
|
1001
|
+
<Schedule
|
|
1002
|
+
ariaLabel="Plan of Tuesday, 17 March, with the orders written in"
|
|
1003
|
+
initialDomain={DAY_OF_PLAN}
|
|
1004
|
+
height={196}
|
|
1005
|
+
label={(subtask) => ORDER_NAMES.get(subtask.task) ?? subtask.task}
|
|
1006
|
+
>
|
|
1007
|
+
{STATIONS.map((station) => (
|
|
1008
|
+
<Lane key={station.id} id={station.id} label={station.label} />
|
|
1009
|
+
))}
|
|
1010
|
+
<Subtasks data={STEPS} tasks={ORDERS} />
|
|
1011
|
+
</Schedule>
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
```
|
|
1015
|
+
|
|
1016
|
+
### Appearances
|
|
1017
|
+
|
|
1018
|
+
What a bar says besides its colour: provisional, fixed, another shift's, running on - one channel of the drawing each.
|
|
1019
|
+
|
|
1020
|
+
```ts
|
|
1021
|
+
import { Subtasks, resolveAppearance } from "@umriss-ui/schedule";
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/appearances
|
|
1025
|
+
|
|
1026
|
+
#### Examples
|
|
1027
|
+
|
|
1028
|
+
##### Provisional: planned, not released
|
|
1029
|
+
|
|
1030
|
+
```tsx
|
|
1031
|
+
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
1032
|
+
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
1033
|
+
|
|
1034
|
+
/* `"provisional"` owns the FILL, and has none: the surface shows straight
|
|
1035
|
+
through, with a dashed outline in the task's colour at full weight.
|
|
1036
|
+
|
|
1037
|
+
Empty, and not pale. A pale fill of the task colour already means something
|
|
1038
|
+
in this picture - a setup or a teardown - and the released bar above carries
|
|
1039
|
+
both, so the two statements stand side by side and cannot be mistaken for
|
|
1040
|
+
one another. That was worth a channel of its own: a planner who has to ask
|
|
1041
|
+
whether a faint end is preparation or a draft has lost the glance the plan
|
|
1042
|
+
was drawn for. */
|
|
1043
|
+
|
|
1044
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
1045
|
+
const min = (n: number) => n * 60_000;
|
|
1046
|
+
|
|
1047
|
+
const TASKS: Task[] = [{ id: "order", color: "light-dark(#2563eb, #6b9bff)" }];
|
|
1048
|
+
|
|
1049
|
+
const WORK: Subtask[] = [
|
|
1050
|
+
{ id: "released", task: "order", lane: "released", from: at(7), to: at(10), setup: min(30), teardown: min(30) },
|
|
1051
|
+
{ id: "draft", task: "order", lane: "draft", from: at(7), to: at(10), appearance: ["provisional"] },
|
|
1052
|
+
];
|
|
1053
|
+
|
|
1054
|
+
export default function Provisional() {
|
|
1055
|
+
return (
|
|
1056
|
+
<Schedule ariaLabel="Released work above, provisional work below" initialDomain={[at(6, 30), at(11)]} height={144}>
|
|
1057
|
+
<Lane id="released" label="Released, with setup" />
|
|
1058
|
+
<Lane id="draft" label="Provisional" />
|
|
1059
|
+
<Subtasks data={WORK} tasks={TASKS} />
|
|
1060
|
+
</Schedule>
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
```
|
|
1064
|
+
|
|
1065
|
+
##### Fixed: work that may not move
|
|
1066
|
+
|
|
1067
|
+
```tsx
|
|
1068
|
+
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
1069
|
+
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
1070
|
+
|
|
1071
|
+
/* `"fixed"` owns the ENDS: a cap at each end of the main time, three pixels
|
|
1072
|
+
wide, in the colour the bar's own label takes, set just inside the end so
|
|
1073
|
+
that the bar's colour frames it.
|
|
1074
|
+
|
|
1075
|
+
At its ends and not across its face, for two reasons. The statement is about
|
|
1076
|
+
where the work begins and ends - that is where it is nailed down. And the
|
|
1077
|
+
face belongs to the label: a pattern over the whole bar is the loudest mark
|
|
1078
|
+
in the picture for the calmest thing it has to say, and it lies under the
|
|
1079
|
+
word a planner came to read. */
|
|
1080
|
+
|
|
1081
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
1082
|
+
|
|
1083
|
+
const TASKS: Task[] = [{ id: "order", color: "light-dark(#2563eb, #6b9bff)" }];
|
|
1084
|
+
|
|
1085
|
+
const WORK: Subtask[] = [
|
|
1086
|
+
{ id: "movable", task: "order", lane: "movable", from: at(7), to: at(10) },
|
|
1087
|
+
{ id: "nailed", task: "order", lane: "nailed", from: at(7), to: at(10), appearance: ["fixed"] },
|
|
1088
|
+
];
|
|
1089
|
+
|
|
1090
|
+
export default function Fixed() {
|
|
1091
|
+
return (
|
|
1092
|
+
<Schedule
|
|
1093
|
+
ariaLabel="Work that may move above, fixed work below"
|
|
1094
|
+
initialDomain={[at(6, 30), at(11)]}
|
|
1095
|
+
height={144}
|
|
1096
|
+
label={(subtask) => (subtask.id === "nailed" ? "A-2041 · fixed" : "A-2041")}
|
|
1097
|
+
>
|
|
1098
|
+
<Lane id="movable" label="May move" />
|
|
1099
|
+
<Lane id="nailed" label="Fixed" />
|
|
1100
|
+
<Subtasks data={WORK} tasks={TASKS} />
|
|
1101
|
+
</Schedule>
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
```
|
|
1105
|
+
|
|
1106
|
+
##### Muted: another shift's work
|
|
1107
|
+
|
|
1108
|
+
```tsx
|
|
1109
|
+
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
1110
|
+
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
1111
|
+
|
|
1112
|
+
/* `"muted"` owns the SATURATION: the task's colour mixed half into the
|
|
1113
|
+
surface, at full height, opaque, and with no outline.
|
|
1114
|
+
|
|
1115
|
+
Full height, because a bar drawn half as high reads as a different KIND of
|
|
1116
|
+
thing, and loses its label with its room. Opaque and unoutlined, because
|
|
1117
|
+
transparency and an edge are what a setup is made of - and this example puts
|
|
1118
|
+
a setup right beside it so the difference has to hold. Another crew's plan
|
|
1119
|
+
should step back; it should not turn into preparation on the way. */
|
|
1120
|
+
|
|
1121
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
1122
|
+
const min = (n: number) => n * 60_000;
|
|
1123
|
+
|
|
1124
|
+
const TASKS: Task[] = [{ id: "order", color: "light-dark(#2563eb, #6b9bff)" }];
|
|
1125
|
+
|
|
1126
|
+
const WORK: Subtask[] = [
|
|
1127
|
+
{ id: "ours", task: "order", lane: "ours", from: at(7), to: at(10), setup: min(30) },
|
|
1128
|
+
{ id: "theirs", task: "order", lane: "theirs", from: at(7), to: at(10), setup: min(30), appearance: ["muted"] },
|
|
1129
|
+
];
|
|
1130
|
+
|
|
1131
|
+
export default function Muted() {
|
|
1132
|
+
return (
|
|
1133
|
+
<Schedule
|
|
1134
|
+
ariaLabel="This shift's work above, another shift's below"
|
|
1135
|
+
initialDomain={[at(6, 30), at(11)]}
|
|
1136
|
+
height={144}
|
|
1137
|
+
label={() => "A-2041 Housing"}
|
|
1138
|
+
>
|
|
1139
|
+
<Lane id="ours" label="This shift" />
|
|
1140
|
+
<Lane id="theirs" label="Another shift" />
|
|
1141
|
+
<Subtasks data={WORK} tasks={TASKS} />
|
|
1142
|
+
</Schedule>
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
```
|
|
1146
|
+
|
|
1147
|
+
##### Progress: how far the work has got
|
|
1148
|
+
|
|
1149
|
+
```tsx
|
|
1150
|
+
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
1151
|
+
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
1152
|
+
|
|
1153
|
+
/* `progress` is not an appearance but a share from 0 to 1, and it owns a RAIL:
|
|
1154
|
+
a band inside the main time, set in from the bar's lower edge so that it is
|
|
1155
|
+
a mark ON the bar and not the bar's own edge.
|
|
1156
|
+
|
|
1157
|
+
Inside the main time, and stopping where it stops: progress measures the
|
|
1158
|
+
WORK, and a rail running on under the teardown would be measuring the
|
|
1159
|
+
clearing away as well. The bar below carries a teardown, so that can be
|
|
1160
|
+
seen.
|
|
1161
|
+
|
|
1162
|
+
A rail rather than a paler remainder, for the reason every appearance here
|
|
1163
|
+
has: a paler part of a bar is preparation in this picture.
|
|
1164
|
+
|
|
1165
|
+
Leaving `progress` out claims nothing at all. A bar at zero per cent and a
|
|
1166
|
+
bar that says nothing about progress are two different statements, and the
|
|
1167
|
+
top lane is the second of them. */
|
|
1168
|
+
|
|
1169
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
1170
|
+
const min = (n: number) => n * 60_000;
|
|
1171
|
+
|
|
1172
|
+
const TASKS: Task[] = [{ id: "order", color: "light-dark(#2563eb, #6b9bff)" }];
|
|
1173
|
+
|
|
1174
|
+
const WORK: Subtask[] = [
|
|
1175
|
+
{ id: "unclaimed", task: "order", lane: "unclaimed", from: at(7), to: at(10) },
|
|
1176
|
+
{ id: "started", task: "order", lane: "started", from: at(7), to: at(10), progress: 0.65, teardown: min(30) },
|
|
1177
|
+
];
|
|
1178
|
+
|
|
1179
|
+
export default function Progress() {
|
|
1180
|
+
return (
|
|
1181
|
+
<Schedule ariaLabel="Work claiming nothing above, work at 65 per cent below" initialDomain={[at(6, 30), at(11)]} height={144}>
|
|
1182
|
+
<Lane id="unclaimed" label="Says nothing" />
|
|
1183
|
+
<Lane id="started" label="65 per cent done" />
|
|
1184
|
+
<Subtasks data={WORK} tasks={TASKS} />
|
|
1185
|
+
</Schedule>
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
```
|
|
1189
|
+
|
|
1190
|
+
##### Open: work that goes on past the view
|
|
1191
|
+
|
|
1192
|
+
```tsx
|
|
1193
|
+
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
1194
|
+
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
1195
|
+
|
|
1196
|
+
/* `"open"` owns the FADE: where the bar passes the edge of the view it fades
|
|
1197
|
+
into the surface instead of ending in an edge a reader would take for its
|
|
1198
|
+
end.
|
|
1199
|
+
|
|
1200
|
+
At WHICHEVER edge it passes, left as well as right. An edge of the screen is
|
|
1201
|
+
an edge of the screen on both sides, and a bar that began before the view
|
|
1202
|
+
used to say nothing at all about it - the bottom lane is that case.
|
|
1203
|
+
|
|
1204
|
+
The middle lane passes neither edge, and carries no fade at all. That is not
|
|
1205
|
+
an omission: the fade says "the edge of the screen is not the end of this
|
|
1206
|
+
work", and a bar whose ends are both in view has no such edge to speak
|
|
1207
|
+
about. Zoom out until it does and the fade appears. */
|
|
1208
|
+
|
|
1209
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
1210
|
+
|
|
1211
|
+
const TASKS: Task[] = [{ id: "order", color: "light-dark(#2563eb, #6b9bff)" }];
|
|
1212
|
+
|
|
1213
|
+
const WORK: Subtask[] = [
|
|
1214
|
+
{ id: "runs-on", task: "order", lane: "runs-on", from: at(7), to: at(13), appearance: ["open"] },
|
|
1215
|
+
/* Wholly in view: both its ends are there to be seen, so there is no edge
|
|
1216
|
+
of the screen to say anything about. */
|
|
1217
|
+
{ id: "whole", task: "order", lane: "whole", from: at(7), to: at(10), appearance: ["open"] },
|
|
1218
|
+
{ id: "began", task: "order", lane: "began", from: at(4), to: at(10), appearance: ["open"] },
|
|
1219
|
+
];
|
|
1220
|
+
|
|
1221
|
+
export default function Open() {
|
|
1222
|
+
return (
|
|
1223
|
+
<Schedule ariaLabel="Work running past the view at either edge" initialDomain={[at(6, 30), at(11)]} height={188}>
|
|
1224
|
+
<Lane id="runs-on" label="Runs on" />
|
|
1225
|
+
<Lane id="whole" label="Wholly in view" />
|
|
1226
|
+
<Lane id="began" label="Began before" />
|
|
1227
|
+
<Subtasks data={WORK} tasks={TASKS} />
|
|
1228
|
+
</Schedule>
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
```
|
|
1232
|
+
|
|
1233
|
+
##### Several statements on one bar
|
|
1234
|
+
|
|
1235
|
+
```tsx
|
|
1236
|
+
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
1237
|
+
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
1238
|
+
|
|
1239
|
+
/* A bar rarely has only one thing to say. Another shift's work may be frozen
|
|
1240
|
+
and may run past the view, all at once - and it must then still read as
|
|
1241
|
+
three statements and not as a smear.
|
|
1242
|
+
|
|
1243
|
+
It can, because each of them owns a different property of the drawing. The
|
|
1244
|
+
saturation is muted, the ends are fixed, the edge of the view is open:
|
|
1245
|
+
nothing is painted twice over, so nothing cancels anything out. That is the
|
|
1246
|
+
whole reason the list is built that way, and this is the picture that proves
|
|
1247
|
+
it.
|
|
1248
|
+
|
|
1249
|
+
Two of them are settled before the drawing begins. `"provisional"` and
|
|
1250
|
+
`"fixed"` contradict - work cannot be planned and frozen at once - so the
|
|
1251
|
+
later one in the list wins, and the bar shows exactly one of the two. The
|
|
1252
|
+
rule is `resolveAppearance`; it is the caller's to read as well.
|
|
1253
|
+
|
|
1254
|
+
One combination has a visible order of its own: a FIXED bar that runs past
|
|
1255
|
+
the view loses its cap at that edge. The fade is painted after the caps
|
|
1256
|
+
because that is the truth of it - there is no end there to mark. The cap it
|
|
1257
|
+
still carries is the end that really is in view.
|
|
1258
|
+
|
|
1259
|
+
The labels are here for one more reason. A bar's label takes whatever colour
|
|
1260
|
+
reads on the bar, and a hollow bar is the surface - so provisional work is
|
|
1261
|
+
labelled in the ordinary text colour, and a muted bar in whatever reads on
|
|
1262
|
+
its mix. No bar loses its label to an appearance any more. */
|
|
1263
|
+
|
|
1264
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
1265
|
+
|
|
1266
|
+
const TASKS: Task[] = [
|
|
1267
|
+
{ id: "own", color: "light-dark(#2563eb, #6b9bff)" },
|
|
1268
|
+
{ id: "other", color: "light-dark(#c2410c, #f08a52)" },
|
|
1269
|
+
];
|
|
1270
|
+
|
|
1271
|
+
const WORK: Subtask[] = [
|
|
1272
|
+
/* Three statements, three channels: paler colour, capped ends, faded edge. */
|
|
1273
|
+
{
|
|
1274
|
+
id: "all-three",
|
|
1275
|
+
task: "other",
|
|
1276
|
+
lane: "all-three",
|
|
1277
|
+
from: at(7),
|
|
1278
|
+
to: at(13),
|
|
1279
|
+
appearance: ["fixed", "muted", "open"],
|
|
1280
|
+
setup: 30 * 60_000,
|
|
1281
|
+
},
|
|
1282
|
+
/* Provisional work that has got somewhere: the rail lies on the surface the
|
|
1283
|
+
hollow bar leaves, in the colour text takes there. */
|
|
1284
|
+
{ id: "planned", task: "own", lane: "planned", from: at(7), to: at(10), appearance: ["provisional"], progress: 0.4 },
|
|
1285
|
+
/* Both, in this order: `"fixed"` is the later word, so this bar is fixed. */
|
|
1286
|
+
{ id: "settled", task: "own", lane: "settled", from: at(7), to: at(10), appearance: ["provisional", "fixed"] },
|
|
1287
|
+
/* And the other way round, which makes it provisional. */
|
|
1288
|
+
{ id: "reopened", task: "own", lane: "reopened", from: at(7), to: at(10), appearance: ["fixed", "provisional"] },
|
|
1289
|
+
];
|
|
1290
|
+
|
|
1291
|
+
/** What stands in each bar. */
|
|
1292
|
+
const NAMES: Record<string, string> = {
|
|
1293
|
+
"all-three": "A-2043 · night shift",
|
|
1294
|
+
planned: "A-2041 · draft",
|
|
1295
|
+
settled: "A-2041 · released",
|
|
1296
|
+
reopened: "A-2041 · reopened",
|
|
1297
|
+
};
|
|
1298
|
+
|
|
1299
|
+
const LANES = [
|
|
1300
|
+
{ id: "all-three", label: "Another shift, fixed, runs on" },
|
|
1301
|
+
{ id: "planned", label: "Provisional, 40 per cent" },
|
|
1302
|
+
{ id: "settled", label: "provisional then fixed" },
|
|
1303
|
+
{ id: "reopened", label: "fixed then provisional" },
|
|
1304
|
+
];
|
|
1305
|
+
|
|
1306
|
+
export default function Combinations() {
|
|
1307
|
+
return (
|
|
1308
|
+
<Schedule
|
|
1309
|
+
ariaLabel="Bars carrying several appearances at once"
|
|
1310
|
+
initialDomain={[at(6, 30), at(11)]}
|
|
1311
|
+
height={230}
|
|
1312
|
+
label={(subtask) => NAMES[subtask.id] ?? subtask.id}
|
|
1313
|
+
>
|
|
1314
|
+
{LANES.map((lane) => (
|
|
1315
|
+
<Lane key={lane.id} id={lane.id} label={lane.label} />
|
|
1316
|
+
))}
|
|
1317
|
+
<Subtasks data={WORK} tasks={TASKS} />
|
|
1318
|
+
</Schedule>
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
```
|
|
1322
|
+
|
|
1323
|
+
### Overlap
|
|
1324
|
+
|
|
1325
|
+
Two subtasks claiming one lane at one time: offset, marked, and never packed into sub-lanes.
|
|
1326
|
+
|
|
1327
|
+
```ts
|
|
1328
|
+
import { Subtasks } from "@umriss-ui/schedule";
|
|
1329
|
+
```
|
|
1330
|
+
|
|
1331
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/overlap
|
|
1332
|
+
|
|
1333
|
+
#### Examples
|
|
1334
|
+
|
|
1335
|
+
##### An overlap is a finding
|
|
1336
|
+
|
|
1337
|
+
```tsx
|
|
1338
|
+
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
1339
|
+
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
1340
|
+
|
|
1341
|
+
/* Two subtasks that claim the same lane at the same time are both drawn: the
|
|
1342
|
+
later one offset a few pixels and edged, the time they share marked across
|
|
1343
|
+
the lane. They are never packed into sub-lanes - packing would turn the
|
|
1344
|
+
double booking into layout and hide it.
|
|
1345
|
+
|
|
1346
|
+
Setup and teardown occupy the lane: the second pair below overlaps only
|
|
1347
|
+
where one's teardown meets the other's setup, and that is an overlap too. */
|
|
1348
|
+
|
|
1349
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
1350
|
+
const min = (n: number) => n * 60_000;
|
|
1351
|
+
|
|
1352
|
+
const TASKS: Task[] = [
|
|
1353
|
+
{ id: "first", color: "light-dark(#0d9488, #3cc7b8)" },
|
|
1354
|
+
{ id: "second", color: "light-dark(#be185d, #f06aa6)" },
|
|
1355
|
+
];
|
|
1356
|
+
|
|
1357
|
+
const WORK: Subtask[] = [
|
|
1358
|
+
{ id: "a", task: "first", lane: "press-1", from: at(7), to: at(9, 30) },
|
|
1359
|
+
{ id: "b", task: "second", lane: "press-1", from: at(8, 30), to: at(10, 30) },
|
|
1360
|
+
{ id: "c", task: "first", lane: "press-2", from: at(7), to: at(8, 30), teardown: min(30) },
|
|
1361
|
+
{ id: "d", task: "second", lane: "press-2", from: at(9, 15), to: at(10, 45), setup: min(30) },
|
|
1362
|
+
];
|
|
1363
|
+
|
|
1364
|
+
export default function Overlap() {
|
|
1365
|
+
return (
|
|
1366
|
+
<Schedule ariaLabel="Two overlaps on two presses" initialDomain={[at(6, 30), at(11, 30)]} height={150}>
|
|
1367
|
+
<Lane id="press-1" label="Press 1" />
|
|
1368
|
+
<Lane id="press-2" label="Press 2" />
|
|
1369
|
+
<Subtasks data={WORK} tasks={TASKS} />
|
|
1370
|
+
</Schedule>
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
```
|
|
1374
|
+
|
|
1375
|
+
#### Why it is like this
|
|
1376
|
+
|
|
1377
|
+
##### An overlap is never packed into sub-lanes
|
|
1378
|
+
|
|
1379
|
+
Every calendar you have used solves an overlap by splitting the column: two appointments at ten o'clock become two half-width boxes side by side. That is right for a calendar, where a column is a *day* and both things can happen. It is wrong for a plan, where a lane is a **machine** and both things cannot.
|
|
1380
|
+
|
|
1381
|
+
Packing would make the picture tidy and the plan a lie: a planner would see two boxes that fit and read them as work that fits. So the bars stay where their times put them, each offset a few pixels below the last so that all of them can be seen and hit, and the shared time is marked underneath. The tidiness is given up on purpose.
|
|
1382
|
+
|
|
1383
|
+
##### The offset has a floor and a ceiling
|
|
1384
|
+
|
|
1385
|
+
Three levels of offset, and no more. A bar is made short enough that the deepest one still lies inside its lane, so an overlap never leaks into the machine below - which would be the same lie in a different direction.
|
|
1386
|
+
|
|
1387
|
+
### Transports
|
|
1388
|
+
|
|
1389
|
+
A task's moves between its subtasks, from an end to a start - with what they connect and where their lines touch.
|
|
1390
|
+
|
|
1391
|
+
```ts
|
|
1392
|
+
import { Transports } from "@umriss-ui/schedule";
|
|
1393
|
+
```
|
|
1394
|
+
|
|
1395
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/transports
|
|
1396
|
+
|
|
1397
|
+
#### Examples
|
|
1398
|
+
|
|
1399
|
+
##### Where a transport anchors
|
|
1400
|
+
|
|
1401
|
+
```tsx
|
|
1402
|
+
import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
|
|
1403
|
+
import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
|
|
1404
|
+
|
|
1405
|
+
/* A transport runs from one subtask's end to the next one's start - there is
|
|
1406
|
+
no other kind. Where exactly is declared per transport: `leaves` at the end of
|
|
1407
|
+
the main time or after the teardown, `arrives` at the start of the main time
|
|
1408
|
+
or before the setup. The default is the outer pair: the part leaves once the
|
|
1409
|
+
machine is cleared and must be there before the next one is set up.
|
|
1410
|
+
|
|
1411
|
+
The anchor is what the finding is judged by, so it should say what the
|
|
1412
|
+
transport really connects. Above the default, below a transport that leaves
|
|
1413
|
+
straight from the main time and arrives at it. */
|
|
1414
|
+
|
|
1415
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
1416
|
+
const min = (n: number) => n * 60_000;
|
|
1417
|
+
|
|
1418
|
+
const TASKS: Task[] = [
|
|
1419
|
+
{ id: "outer", color: "light-dark(#2563eb, #6b9bff)" },
|
|
1420
|
+
{ id: "inner", color: "light-dark(#4d7c0f, #8fc43e)" },
|
|
1421
|
+
];
|
|
1422
|
+
|
|
1423
|
+
const WORK: Subtask[] = [
|
|
1424
|
+
{ id: "o-1", task: "outer", lane: "lathe", from: at(7), to: at(8, 30), teardown: min(30) },
|
|
1425
|
+
{ id: "o-2", task: "outer", lane: "mill", from: at(10, 30), to: at(12), setup: min(30) },
|
|
1426
|
+
{ id: "i-1", task: "inner", lane: "lathe", from: at(12), to: at(13, 30), teardown: min(30) },
|
|
1427
|
+
{ id: "i-2", task: "inner", lane: "mill", from: at(15), to: at(16), setup: min(30) },
|
|
1428
|
+
];
|
|
1429
|
+
|
|
1430
|
+
const MOVES: Transport[] = [
|
|
1431
|
+
{ id: "outer-move", from: "o-1", to: "o-2", duration: min(60) },
|
|
1432
|
+
{ id: "inner-move", from: "i-1", to: "i-2", duration: min(60), leaves: "main", arrives: "main" },
|
|
1433
|
+
];
|
|
1434
|
+
|
|
1435
|
+
export default function Anchors() {
|
|
1436
|
+
return (
|
|
1437
|
+
<Schedule ariaLabel="Two transports with different anchors" initialDomain={[at(6, 30), at(16, 30)]} height={150}>
|
|
1438
|
+
<Lane id="lathe" label="Lathe" />
|
|
1439
|
+
<Lane id="mill" label="Mill" />
|
|
1440
|
+
<Transports data={MOVES} />
|
|
1441
|
+
<Subtasks data={WORK} tasks={TASKS} />
|
|
1442
|
+
</Schedule>
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
```
|
|
1446
|
+
|
|
1447
|
+
##### Where a line touches its bars
|
|
1448
|
+
|
|
1449
|
+
```tsx
|
|
1450
|
+
import { Stack, Text } from "@umriss-ui/core";
|
|
1451
|
+
import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
|
|
1452
|
+
import type { Subtask, Task, Transport, TransportAttachment } from "@umriss-ui/schedule";
|
|
1453
|
+
|
|
1454
|
+
/* `attach` says where a transport's line begins and ends ON the bars: the
|
|
1455
|
+
`"centre"` of both, or the `"nearest"` edge - the one facing the other stop.
|
|
1456
|
+
|
|
1457
|
+
`"nearest"` is the interesting one. A line leaves the lower edge of the
|
|
1458
|
+
upper bar and meets the upper edge of the lower one, so a short move between
|
|
1459
|
+
two neighbouring lanes is drawn as the short line it is instead of swinging
|
|
1460
|
+
out of its lane and back. Within one lane there is no nearer edge, and both
|
|
1461
|
+
mean the middle.
|
|
1462
|
+
|
|
1463
|
+
Not to be confused with the two ANCHORS, which say what a transport
|
|
1464
|
+
connects and therefore whether it is late. This says only where the line
|
|
1465
|
+
touches, and changes no finding. */
|
|
1466
|
+
|
|
1467
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
1468
|
+
const min = (n: number) => n * 60_000;
|
|
1469
|
+
|
|
1470
|
+
const TASKS: Task[] = [{ id: "frame", color: "light-dark(#2563eb, #6b9bff)" }];
|
|
1471
|
+
|
|
1472
|
+
const STEPS: Subtask[] = [
|
|
1473
|
+
{ id: "saw", task: "frame", lane: "saw", from: at(7), to: at(8, 30) },
|
|
1474
|
+
{ id: "mill", task: "frame", lane: "mill", from: at(9), to: at(10, 30) },
|
|
1475
|
+
{ id: "press", task: "frame", lane: "press", from: at(11), to: at(12) },
|
|
1476
|
+
/* Back up to the saw's lane: this is the move whose edges the attach
|
|
1477
|
+
decides. */
|
|
1478
|
+
{ id: "check", task: "frame", lane: "saw", from: at(12, 30), to: at(13) },
|
|
1479
|
+
];
|
|
1480
|
+
|
|
1481
|
+
const MOVES: Transport[] = [
|
|
1482
|
+
{ id: "to-mill", from: "saw", to: "mill", duration: min(20) },
|
|
1483
|
+
{ id: "to-press", from: "mill", to: "press", duration: min(20) },
|
|
1484
|
+
{ id: "to-check", from: "press", to: "check", duration: min(20) },
|
|
1485
|
+
];
|
|
1486
|
+
|
|
1487
|
+
function Variant({ attach }: { attach: TransportAttachment }) {
|
|
1488
|
+
return (
|
|
1489
|
+
<Stack gap={1}>
|
|
1490
|
+
<Text size="xs" tone="muted" mono>
|
|
1491
|
+
attach="{attach}"
|
|
1492
|
+
</Text>
|
|
1493
|
+
<Schedule ariaLabel={`A frame through three stations, lines from the ${attach}`} initialDomain={[at(6, 30), at(13, 30)]} height={188} attach={attach}>
|
|
1494
|
+
<Lane id="saw" label="Saw" />
|
|
1495
|
+
<Lane id="mill" label="Mill" />
|
|
1496
|
+
<Lane id="press" label="Press" />
|
|
1497
|
+
<Transports data={MOVES} />
|
|
1498
|
+
<Subtasks data={STEPS} tasks={TASKS} />
|
|
1499
|
+
</Schedule>
|
|
1500
|
+
</Stack>
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
export default function Attach() {
|
|
1505
|
+
return (
|
|
1506
|
+
<Stack gap={4}>
|
|
1507
|
+
<Variant attach="centre" />
|
|
1508
|
+
<Variant attach="nearest" />
|
|
1509
|
+
</Stack>
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
```
|
|
1513
|
+
|
|
1514
|
+
##### Whether the ends carry a dot
|
|
1515
|
+
|
|
1516
|
+
```tsx
|
|
1517
|
+
import { Stack, Text } from "@umriss-ui/core";
|
|
1518
|
+
import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
|
|
1519
|
+
import type { Subtask, Task, Transport, TransportEnds } from "@umriss-ui/schedule";
|
|
1520
|
+
|
|
1521
|
+
/* `ends` decides whether a transport's two ends are marked with a dot. The dot
|
|
1522
|
+
says where the line is anchored - a help while a plan is being read, and
|
|
1523
|
+
noise in a plan full of short moves. So it is the caller's choice, and a
|
|
1524
|
+
single transport may say otherwise for itself.
|
|
1525
|
+
|
|
1526
|
+
Picture only, like `route` and `attach`: no finding follows from it. */
|
|
1527
|
+
|
|
1528
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
1529
|
+
const min = (n: number) => n * 60_000;
|
|
1530
|
+
|
|
1531
|
+
const TASKS: Task[] = [{ id: "frame", color: "light-dark(#2563eb, #6b9bff)" }];
|
|
1532
|
+
|
|
1533
|
+
const STEPS: Subtask[] = [
|
|
1534
|
+
{ id: "saw", task: "frame", lane: "saw", from: at(7), to: at(8) },
|
|
1535
|
+
{ id: "mill", task: "frame", lane: "mill", from: at(8, 30), to: at(9, 30) },
|
|
1536
|
+
{ id: "press", task: "frame", lane: "press", from: at(10), to: at(11) },
|
|
1537
|
+
];
|
|
1538
|
+
|
|
1539
|
+
const MOVES: Transport[] = [
|
|
1540
|
+
{ id: "to-mill", from: "saw", to: "mill", duration: min(15) },
|
|
1541
|
+
{ id: "to-press", from: "mill", to: "press", duration: min(15) },
|
|
1542
|
+
];
|
|
1543
|
+
|
|
1544
|
+
function Variant({ ends }: { ends: TransportEnds }) {
|
|
1545
|
+
return (
|
|
1546
|
+
<Stack gap={1}>
|
|
1547
|
+
<Text size="xs" tone="muted" mono>
|
|
1548
|
+
ends="{ends}"
|
|
1549
|
+
</Text>
|
|
1550
|
+
<Schedule ariaLabel={`Three short moves, ends ${ends}`} initialDomain={[at(6, 30), at(11, 30)]} height={188} attach="nearest" ends={ends}>
|
|
1551
|
+
<Lane id="saw" label="Saw" />
|
|
1552
|
+
<Lane id="mill" label="Mill" />
|
|
1553
|
+
<Lane id="press" label="Press" />
|
|
1554
|
+
<Transports data={MOVES} />
|
|
1555
|
+
<Subtasks data={STEPS} tasks={TASKS} />
|
|
1556
|
+
</Schedule>
|
|
1557
|
+
</Stack>
|
|
1558
|
+
);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
export default function Ends() {
|
|
1562
|
+
return (
|
|
1563
|
+
<Stack gap={4}>
|
|
1564
|
+
<Variant ends="dot" />
|
|
1565
|
+
<Variant ends="none" />
|
|
1566
|
+
</Stack>
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
```
|
|
1570
|
+
|
|
1571
|
+
##### A transport that cannot arrive in time
|
|
1572
|
+
|
|
1573
|
+
```tsx
|
|
1574
|
+
import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
|
|
1575
|
+
import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
|
|
1576
|
+
|
|
1577
|
+
/* The forklift needs forty minutes from the saw to the press; the plan gives it
|
|
1578
|
+
twenty-five. A late transport is drawn dashed in the colour of a finding, as
|
|
1579
|
+
loud as a double booking, and the schedule moves nothing to make it fit.
|
|
1580
|
+
Whether the press waits or the saw starts earlier is the planner's decision -
|
|
1581
|
+
`ripple` offers the arithmetic for the first. */
|
|
1582
|
+
|
|
1583
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
1584
|
+
const min = (n: number) => n * 60_000;
|
|
1585
|
+
|
|
1586
|
+
const TASKS: Task[] = [{ id: "frame", color: "light-dark(#c2410c, #f08a52)" }];
|
|
1587
|
+
|
|
1588
|
+
const WORK: Subtask[] = [
|
|
1589
|
+
{ id: "cut", task: "frame", lane: "saw", from: at(7), to: at(8, 30), teardown: min(5) },
|
|
1590
|
+
{ id: "press", task: "frame", lane: "press", from: at(9, 15), to: at(10, 30), setup: min(15) },
|
|
1591
|
+
];
|
|
1592
|
+
|
|
1593
|
+
const MOVES: Transport[] = [{ id: "forklift", from: "cut", to: "press", duration: min(40) }];
|
|
1594
|
+
|
|
1595
|
+
export default function LateTransport() {
|
|
1596
|
+
return (
|
|
1597
|
+
<Schedule ariaLabel="A late transport from the saw to the press" initialDomain={[at(6, 30), at(11)]} height={150}>
|
|
1598
|
+
<Lane id="saw" label="Saw" />
|
|
1599
|
+
<Lane id="press" label="Press" />
|
|
1600
|
+
<Transports data={MOVES} />
|
|
1601
|
+
<Subtasks data={WORK} tasks={TASKS} />
|
|
1602
|
+
</Schedule>
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
```
|
|
1606
|
+
|
|
1607
|
+
#### API
|
|
1608
|
+
|
|
1609
|
+
##### `TransportsProps`
|
|
1610
|
+
|
|
1611
|
+
| Prop | Type | Default | Description |
|
|
1612
|
+
|---|---|---|---|
|
|
1613
|
+
| `data` (required) | `readonly Transport[]` | — | The transports, as the caller holds them. |
|
|
1614
|
+
|
|
1615
|
+
##### `Transport`
|
|
1616
|
+
|
|
1617
|
+
| Prop | Type | Default | Description |
|
|
1618
|
+
|---|---|---|---|
|
|
1619
|
+
| `id` (required) | `string` | — | The caller's identity of the transport. |
|
|
1620
|
+
| `from` (required) | `string` | — | The id of the subtask it leaves. |
|
|
1621
|
+
| `to` (required) | `string` | — | The id of the subtask it arrives at. |
|
|
1622
|
+
| `duration` (required) | `number` | — | How long the move takes. |
|
|
1623
|
+
| `leaves` | `"main" \| "teardown"` | — | Where it leaves: at the end of the main time, or after the teardown. Default `"teardown"` - the part leaves once the machine is cleared. |
|
|
1624
|
+
| `arrives` | `"main" \| "setup"` | — | Where it arrives: at the start of the main time, or before the setup. Default `"setup"` - the part must be there before the machine is set up. |
|
|
1625
|
+
| `route` | `TransportRoute` | — | How this one is drawn, where it is not drawn like the rest. It changes the picture and never the finding: whether a transport is late follows from `leaves` and `arrives` alone. |
|
|
1626
|
+
| `attach` | `TransportAttachment` | — | Where on the bars this one's line attaches, where it does not attach like the rest. Also picture only, and not to be confused with the two anchors above: those say what the transport connects, this says where the line touches. |
|
|
1627
|
+
| `ends` | `TransportEnds` | — | Whether this one's ends are marked with a dot, where it is not marked like the rest. Also picture only. |
|
|
1628
|
+
|
|
1629
|
+
### Routes
|
|
1630
|
+
|
|
1631
|
+
How a transport is drawn between its two ends: a curve that leaves forwards, a straight line, or orthogonal segments.
|
|
1632
|
+
|
|
1633
|
+
```ts
|
|
1634
|
+
import { Transports } from "@umriss-ui/schedule";
|
|
1635
|
+
```
|
|
1636
|
+
|
|
1637
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/routes
|
|
1638
|
+
|
|
1639
|
+
#### Examples
|
|
1640
|
+
|
|
1641
|
+
##### The shape of a transport's line
|
|
1642
|
+
|
|
1643
|
+
```tsx
|
|
1644
|
+
import { Stack, Text } from "@umriss-ui/core";
|
|
1645
|
+
import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
|
|
1646
|
+
import type { Subtask, Task, Transport, TransportRoute } from "@umriss-ui/schedule";
|
|
1647
|
+
|
|
1648
|
+
/* `route` gives a transport's line its shape, and nothing else:
|
|
1649
|
+
|
|
1650
|
+
"curve" leaves forwards and arrives forwards - the default
|
|
1651
|
+
"straight" the shortest line between its two ends
|
|
1652
|
+
"orthogonal" out of the bar, across, and in again
|
|
1653
|
+
|
|
1654
|
+
A curve that leaves and arrives forwards says which way the work is going,
|
|
1655
|
+
even where the arrival lies before the departure - such a move loops back,
|
|
1656
|
+
which is the picture of what it is. Straight lines suit a plan read from a
|
|
1657
|
+
distance; orthogonal ones suit a plant whose moves really do run along
|
|
1658
|
+
aisles.
|
|
1659
|
+
|
|
1660
|
+
It is the schedule's option, and a single transport may say otherwise for
|
|
1661
|
+
itself. Where its line touches the bars is `attach`, and whether its ends
|
|
1662
|
+
carry a dot is `ends` - both on the Transports page, each its own thing.
|
|
1663
|
+
|
|
1664
|
+
None of the three touches a finding. Whether a transport can arrive in time
|
|
1665
|
+
follows from its `leaves` and `arrives`, which say what it connects; these
|
|
1666
|
+
say only how it looks. */
|
|
1667
|
+
|
|
1668
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
1669
|
+
const min = (n: number) => n * 60_000;
|
|
1670
|
+
|
|
1671
|
+
const TASKS: Task[] = [{ id: "frame", color: "light-dark(#2563eb, #6b9bff)" }];
|
|
1672
|
+
|
|
1673
|
+
const STEPS: Subtask[] = [
|
|
1674
|
+
{ id: "saw", task: "frame", lane: "saw", from: at(7), to: at(8, 30) },
|
|
1675
|
+
{ id: "mill", task: "frame", lane: "mill", from: at(9), to: at(10, 30) },
|
|
1676
|
+
{ id: "press", task: "frame", lane: "press", from: at(11), to: at(12) },
|
|
1677
|
+
{ id: "check", task: "frame", lane: "saw", from: at(12, 30), to: at(13) },
|
|
1678
|
+
];
|
|
1679
|
+
|
|
1680
|
+
const MOVES: Transport[] = [
|
|
1681
|
+
{ id: "to-mill", from: "saw", to: "mill", duration: min(20) },
|
|
1682
|
+
{ id: "to-press", from: "mill", to: "press", duration: min(20) },
|
|
1683
|
+
/* Back up to the saw's lane: the attach decides which edges this one meets. */
|
|
1684
|
+
{ id: "to-check", from: "press", to: "check", duration: min(20) },
|
|
1685
|
+
];
|
|
1686
|
+
|
|
1687
|
+
function Variant({ route }: { route: TransportRoute }) {
|
|
1688
|
+
return (
|
|
1689
|
+
<Stack gap={1}>
|
|
1690
|
+
<Text size="xs" tone="muted" mono>
|
|
1691
|
+
route="{route}"
|
|
1692
|
+
</Text>
|
|
1693
|
+
<Schedule
|
|
1694
|
+
ariaLabel={`A frame through three stations, drawn ${route}`}
|
|
1695
|
+
initialDomain={[at(6, 30), at(13, 30)]}
|
|
1696
|
+
height={188}
|
|
1697
|
+
route={route}
|
|
1698
|
+
attach="nearest"
|
|
1699
|
+
>
|
|
1700
|
+
<Lane id="saw" label="Saw" />
|
|
1701
|
+
<Lane id="mill" label="Mill" />
|
|
1702
|
+
<Lane id="press" label="Press" />
|
|
1703
|
+
<Transports data={MOVES} />
|
|
1704
|
+
<Subtasks data={STEPS} tasks={TASKS} />
|
|
1705
|
+
</Schedule>
|
|
1706
|
+
</Stack>
|
|
1707
|
+
);
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
export default function Routes() {
|
|
1711
|
+
return (
|
|
1712
|
+
<Stack gap={4}>
|
|
1713
|
+
<Variant route="curve" />
|
|
1714
|
+
<Variant route="straight" />
|
|
1715
|
+
<Variant route="orthogonal" />
|
|
1716
|
+
</Stack>
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1719
|
+
```
|
|
1720
|
+
|
|
1721
|
+
## Reading
|
|
1722
|
+
|
|
1723
|
+
What a planner takes out of the picture: what is selected, what the pointer is on, and what a second view makes of it.
|
|
1724
|
+
|
|
1725
|
+
### Selection
|
|
1726
|
+
|
|
1727
|
+
A click takes a whole task, across every lane - controlled or kept by the schedule, with the stop that was hit.
|
|
1728
|
+
|
|
1729
|
+
```ts
|
|
1730
|
+
import { Schedule } from "@umriss-ui/schedule";
|
|
1731
|
+
```
|
|
1732
|
+
|
|
1733
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/selection
|
|
1734
|
+
|
|
1735
|
+
#### Examples
|
|
1736
|
+
|
|
1737
|
+
##### Selecting a task
|
|
1738
|
+
|
|
1739
|
+
```tsx
|
|
1740
|
+
import { useState } from "react";
|
|
1741
|
+
import { Stack, Text } from "@umriss-ui/core";
|
|
1742
|
+
import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
|
|
1743
|
+
import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
|
|
1744
|
+
|
|
1745
|
+
/* A click on a subtask selects its whole task: every subtask and every
|
|
1746
|
+
transport of it is outlined, across all lanes. A click on a transport selects
|
|
1747
|
+
the task it belongs to, a click on the empty plot clears the selection.
|
|
1748
|
+
|
|
1749
|
+
Here the selection is controlled - `selectedTask` and
|
|
1750
|
+
`onSelectedTaskChange` -, so the application can show what is selected
|
|
1751
|
+
elsewhere. Leave both out and the schedule keeps the selection itself.
|
|
1752
|
+
|
|
1753
|
+
The callback also carries the subtask that was clicked, so an application can
|
|
1754
|
+
show the stop and not only the order; clicking another stop of the same order
|
|
1755
|
+
reports again. Only the task is controlled - which stop is meant follows the
|
|
1756
|
+
pointer. */
|
|
1757
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
1758
|
+
const min = (n: number) => n * 60_000;
|
|
1759
|
+
|
|
1760
|
+
const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
|
|
1761
|
+
|
|
1762
|
+
|
|
1763
|
+
const STATIONS = [
|
|
1764
|
+
{ id: "saw", label: "Saw 1" },
|
|
1765
|
+
{ id: "lathe-1", label: "Lathe 1" },
|
|
1766
|
+
{ id: "mill", label: "Mill" },
|
|
1767
|
+
{ id: "press", label: "Press 2" },
|
|
1768
|
+
{ id: "paint", label: "Paint shop" },
|
|
1769
|
+
];
|
|
1770
|
+
|
|
1771
|
+
const ORDERS: readonly Task[] = [
|
|
1772
|
+
{ id: "a-2042", name: "A-2042 Shaft", color: "light-dark(#0d9488, #3cc7b8)" },
|
|
1773
|
+
{ id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
|
|
1774
|
+
];
|
|
1775
|
+
|
|
1776
|
+
const STEPS: readonly Subtask[] = [
|
|
1777
|
+
{ id: "a-2042-1", task: "a-2042", lane: "saw", from: at(7, 30), to: at(8, 15), setup: min(10) },
|
|
1778
|
+
{ id: "a-2042-2", task: "a-2042", lane: "lathe-1", from: at(9), to: at(11), setup: min(20), teardown: min(15) },
|
|
1779
|
+
{ id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
|
|
1780
|
+
{ id: "a-2043-2", task: "a-2043", lane: "mill", from: at(10), to: at(11, 30), setup: min(15) },
|
|
1781
|
+
{ id: "a-2043-3", task: "a-2043", lane: "paint", from: at(12), to: at(14), setup: min(20), teardown: min(20) },
|
|
1782
|
+
];
|
|
1783
|
+
|
|
1784
|
+
const MOVES: readonly Transport[] = [
|
|
1785
|
+
{ id: "t-2042-1", from: "a-2042-1", to: "a-2042-2", duration: min(15) },
|
|
1786
|
+
{ id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", duration: min(45) },
|
|
1787
|
+
/* Leaves the mill at 11:30 and has ten minutes to reach the paint shop's
|
|
1788
|
+
setup at 11:40 - it takes twenty-five. A late transport, on purpose. */
|
|
1789
|
+
{ id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", duration: min(25) },
|
|
1790
|
+
];
|
|
1791
|
+
|
|
1792
|
+
export default function Selection() {
|
|
1793
|
+
const [selected, setSelected] = useState<string | null>("a-2043");
|
|
1794
|
+
const [stop, setStop] = useState<string | null>(null);
|
|
1795
|
+
const order = ORDERS.find((o) => o.id === selected);
|
|
1796
|
+
|
|
1797
|
+
return (
|
|
1798
|
+
<Stack gap={3}>
|
|
1799
|
+
<Schedule
|
|
1800
|
+
ariaLabel="Plan of Tuesday, 17 March"
|
|
1801
|
+
initialDomain={DAY_OF_PLAN}
|
|
1802
|
+
height={284}
|
|
1803
|
+
selectedTask={selected}
|
|
1804
|
+
onSelectedTaskChange={(task, subtask) => {
|
|
1805
|
+
setSelected(task);
|
|
1806
|
+
setStop(subtask);
|
|
1807
|
+
}}
|
|
1808
|
+
>
|
|
1809
|
+
{STATIONS.map((station) => (
|
|
1810
|
+
<Lane key={station.id} id={station.id} label={station.label} />
|
|
1811
|
+
))}
|
|
1812
|
+
<Transports data={MOVES} />
|
|
1813
|
+
<Subtasks data={STEPS} tasks={ORDERS} />
|
|
1814
|
+
</Schedule>
|
|
1815
|
+
<Text size="sm" tone="secondary" data-selected-order>
|
|
1816
|
+
Selected: {order?.name ?? "nothing"}
|
|
1817
|
+
{stop !== null ? `, at ${stop}` : ""}
|
|
1818
|
+
</Text>
|
|
1819
|
+
</Stack>
|
|
1820
|
+
);
|
|
1821
|
+
}
|
|
1822
|
+
```
|
|
1823
|
+
|
|
1824
|
+
### Interactions
|
|
1825
|
+
|
|
1826
|
+
Click, context menu and hover, each with its target and its position - for a menu, a detail panel or a status line.
|
|
1827
|
+
|
|
1828
|
+
```ts
|
|
1829
|
+
import { Schedule } from "@umriss-ui/schedule";
|
|
1830
|
+
```
|
|
1831
|
+
|
|
1832
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/interactions
|
|
1833
|
+
|
|
1834
|
+
#### Examples
|
|
1835
|
+
|
|
1836
|
+
##### What the pointer reports
|
|
1837
|
+
|
|
1838
|
+
```tsx
|
|
1839
|
+
import { useState } from "react";
|
|
1840
|
+
import { Stack, Text } from "@umriss-ui/core";
|
|
1841
|
+
import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
|
|
1842
|
+
import type { ScheduleInteraction, Subtask, Task, Transport } from "@umriss-ui/schedule";
|
|
1843
|
+
|
|
1844
|
+
/* `onInteraction` hears a click, a context menu and a change of what is under
|
|
1845
|
+
the pointer - each with its target and its position: the subtask and the part
|
|
1846
|
+
of it, the transport, the lane, or nothing; the client point, the time and
|
|
1847
|
+
the lane under it. What the application does with it is its own: open a
|
|
1848
|
+
menu, show a detail, fill a status line like this one.
|
|
1849
|
+
|
|
1850
|
+
While `onInteraction` is set, a right-click opens no browser menu - the
|
|
1851
|
+
application is expected to answer it.
|
|
1852
|
+
|
|
1853
|
+
`onSelectedTaskChange` is the second line below: a click reports the task it
|
|
1854
|
+
selected together with the stop it hit, and clicking another stop of the same
|
|
1855
|
+
task reports again. */
|
|
1856
|
+
|
|
1857
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
1858
|
+
const min = (n: number) => n * 60_000;
|
|
1859
|
+
|
|
1860
|
+
const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
|
|
1861
|
+
|
|
1862
|
+
|
|
1863
|
+
const STATIONS = [
|
|
1864
|
+
{ id: "saw", label: "Saw 1" },
|
|
1865
|
+
{ id: "mill", label: "Mill" },
|
|
1866
|
+
];
|
|
1867
|
+
|
|
1868
|
+
const ORDERS: readonly Task[] = [
|
|
1869
|
+
{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
|
|
1870
|
+
];
|
|
1871
|
+
|
|
1872
|
+
const STEPS: readonly Subtask[] = [
|
|
1873
|
+
{ id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6), to: at(7), setup: min(15), teardown: min(10) },
|
|
1874
|
+
{ id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
|
|
1875
|
+
];
|
|
1876
|
+
|
|
1877
|
+
const MOVES: readonly Transport[] = [
|
|
1878
|
+
{ id: "t-2041-1", from: "a-2041-1", to: "a-2041-2", duration: min(10) },
|
|
1879
|
+
];
|
|
1880
|
+
|
|
1881
|
+
const describe = (interaction: ScheduleInteraction): string => {
|
|
1882
|
+
const { hit } = interaction;
|
|
1883
|
+
/* To the nearest minute: a pixel is about a minute wide here. */
|
|
1884
|
+
const time = new Date(Math.round(interaction.time / 60_000) * 60_000).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
|
|
1885
|
+
const target =
|
|
1886
|
+
hit.kind === "subtask"
|
|
1887
|
+
? `subtask ${hit.subtask.id} (${hit.part})`
|
|
1888
|
+
: hit.kind === "transport"
|
|
1889
|
+
? `transport ${hit.transport.id}`
|
|
1890
|
+
: hit.kind === "lane"
|
|
1891
|
+
? `lane ${hit.lane}`
|
|
1892
|
+
: "nothing";
|
|
1893
|
+
return `${interaction.type}: ${target} at ${time}`;
|
|
1894
|
+
};
|
|
1895
|
+
|
|
1896
|
+
export default function Interactions() {
|
|
1897
|
+
const [last, setLast] = useState("Move the pointer over the plan");
|
|
1898
|
+
const [selected, setSelected] = useState("nothing selected");
|
|
1899
|
+
|
|
1900
|
+
return (
|
|
1901
|
+
<Stack gap={3}>
|
|
1902
|
+
<Schedule
|
|
1903
|
+
ariaLabel="Plan of Tuesday, 17 March"
|
|
1904
|
+
initialDomain={DAY_OF_PLAN}
|
|
1905
|
+
height={152}
|
|
1906
|
+
onInteraction={(interaction) => setLast(describe(interaction))}
|
|
1907
|
+
onSelectedTaskChange={(task, subtask) => setSelected(task === null ? "nothing selected" : `${task} at ${subtask ?? "-"}`)}
|
|
1908
|
+
>
|
|
1909
|
+
{STATIONS.map((station) => (
|
|
1910
|
+
<Lane key={station.id} id={station.id} label={station.label} />
|
|
1911
|
+
))}
|
|
1912
|
+
<Transports data={MOVES} />
|
|
1913
|
+
<Subtasks data={STEPS} tasks={ORDERS} />
|
|
1914
|
+
</Schedule>
|
|
1915
|
+
<Text size="sm" mono tone="secondary" data-last-interaction>
|
|
1916
|
+
{last}
|
|
1917
|
+
</Text>
|
|
1918
|
+
<Text size="sm" mono tone="secondary" data-selection>
|
|
1919
|
+
{selected}
|
|
1920
|
+
</Text>
|
|
1921
|
+
</Stack>
|
|
1922
|
+
);
|
|
1923
|
+
}
|
|
1924
|
+
```
|
|
1925
|
+
|
|
1926
|
+
#### API
|
|
1927
|
+
|
|
1928
|
+
##### `ScheduleInteraction`
|
|
1929
|
+
|
|
1930
|
+
| Prop | Type | Default | Description |
|
|
1931
|
+
|---|---|---|---|
|
|
1932
|
+
| `type` (required) | `"click" \| "contextmenu" \| "hover"` | — | `"hover"` is reported when the target under the pointer changes, not on every movement. |
|
|
1933
|
+
| `hit` (required) | `ScheduleHit` | — | What the pointer is on. |
|
|
1934
|
+
| `clientX` (required) | `number` | — | The pointer's horizontal client coordinate - where a `ContextMenu` opens. |
|
|
1935
|
+
| `clientY` (required) | `number` | — | The pointer's vertical client coordinate. |
|
|
1936
|
+
| `time` (required) | `number` | — | The wall-clock instant under the pointer. |
|
|
1937
|
+
| `lane` (required) | `string \| null` | — | The lane under the pointer, or null below the last one. |
|
|
1938
|
+
|
|
1939
|
+
### Tooltip
|
|
1940
|
+
|
|
1941
|
+
What the pointer rests on, named with its times and its findings - or replaced by content of the application's own.
|
|
1942
|
+
|
|
1943
|
+
```ts
|
|
1944
|
+
import { Schedule } from "@umriss-ui/schedule";
|
|
1945
|
+
```
|
|
1946
|
+
|
|
1947
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/tooltip
|
|
1948
|
+
|
|
1949
|
+
#### Examples
|
|
1950
|
+
|
|
1951
|
+
##### What the tooltip says
|
|
1952
|
+
|
|
1953
|
+
```tsx
|
|
1954
|
+
import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
|
|
1955
|
+
import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
|
|
1956
|
+
|
|
1957
|
+
/* Resting the pointer on a subtask names the order, the stop, its main time
|
|
1958
|
+
and - where it has them - its setup and teardown, and then every finding on
|
|
1959
|
+
it: with whom it overlaps, and by how much a transport of it is late. Try
|
|
1960
|
+
the bracket in the paint shop at noon, or the housing on the mill at nine.
|
|
1961
|
+
|
|
1962
|
+
On a transport it names where the move goes from and to, how long it takes,
|
|
1963
|
+
and whether it can arrive in time.
|
|
1964
|
+
|
|
1965
|
+
Every word comes from the wording of @umriss-ui/core and every number from
|
|
1966
|
+
its formats, so a provider switches the tooltip with everything else. It
|
|
1967
|
+
steps aside while a drag is in flight, and it is placed from its measured
|
|
1968
|
+
size: beside the pointer where there is room, on the other side where there
|
|
1969
|
+
is not. Nothing here configures it - this is what a schedule does by
|
|
1970
|
+
itself. */
|
|
1971
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
1972
|
+
const min = (n: number) => n * 60_000;
|
|
1973
|
+
|
|
1974
|
+
const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
|
|
1975
|
+
|
|
1976
|
+
|
|
1977
|
+
const STATIONS = [
|
|
1978
|
+
{ id: "mill", label: "Mill" },
|
|
1979
|
+
{ id: "press", label: "Press 2" },
|
|
1980
|
+
{ id: "paint", label: "Paint shop" },
|
|
1981
|
+
];
|
|
1982
|
+
|
|
1983
|
+
const ORDERS: readonly Task[] = [
|
|
1984
|
+
{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
|
|
1985
|
+
{ id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
|
|
1986
|
+
];
|
|
1987
|
+
|
|
1988
|
+
const STEPS: readonly Subtask[] = [
|
|
1989
|
+
{ id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
|
|
1990
|
+
{ id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
|
|
1991
|
+
{ id: "a-2043-2", task: "a-2043", lane: "mill", from: at(10), to: at(11, 30), setup: min(15) },
|
|
1992
|
+
{ id: "a-2043-3", task: "a-2043", lane: "paint", from: at(12), to: at(14), setup: min(20), teardown: min(20) },
|
|
1993
|
+
];
|
|
1994
|
+
|
|
1995
|
+
const MOVES: readonly Transport[] = [
|
|
1996
|
+
{ id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", duration: min(45) },
|
|
1997
|
+
/* Leaves the mill at 11:30 and has ten minutes to reach the paint shop's
|
|
1998
|
+
setup at 11:40 - it takes twenty-five. A late transport, on purpose. */
|
|
1999
|
+
{ id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", duration: min(25) },
|
|
2000
|
+
];
|
|
2001
|
+
|
|
2002
|
+
export default function Tooltip() {
|
|
2003
|
+
return (
|
|
2004
|
+
<Schedule ariaLabel="Plan of Tuesday, 17 March, with its tooltip" initialDomain={DAY_OF_PLAN} height={196}>
|
|
2005
|
+
{STATIONS.map((station) => (
|
|
2006
|
+
<Lane key={station.id} id={station.id} label={station.label} />
|
|
2007
|
+
))}
|
|
2008
|
+
<Transports data={MOVES} />
|
|
2009
|
+
<Subtasks data={STEPS} tasks={ORDERS} />
|
|
2010
|
+
</Schedule>
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
```
|
|
2014
|
+
|
|
2015
|
+
##### A tooltip of the application's own
|
|
2016
|
+
|
|
2017
|
+
```tsx
|
|
2018
|
+
import { Badge, Stack, Text } from "@umriss-ui/core";
|
|
2019
|
+
import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
|
|
2020
|
+
import type { ScheduleTooltipTarget, Subtask, Task, Transport } from "@umriss-ui/schedule";
|
|
2021
|
+
|
|
2022
|
+
/* Resting the pointer on a subtask or a transport shows a tooltip: by default
|
|
2023
|
+
the order, the stop, its times, setup and teardown, and every finding on it -
|
|
2024
|
+
try the bracket in the paint shop at noon. Its words come from the provider's
|
|
2025
|
+
wording, so a German application reads it in German.
|
|
2026
|
+
|
|
2027
|
+
`tooltip` takes a function instead, which receives what the pointer rests on
|
|
2028
|
+
together with its findings and returns the application's own content - here
|
|
2029
|
+
a customer that only the application knows. `tooltip={false}` switches it
|
|
2030
|
+
off, for an application with a detail panel of its own. */
|
|
2031
|
+
|
|
2032
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
2033
|
+
const min = (n: number) => n * 60_000;
|
|
2034
|
+
|
|
2035
|
+
const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
|
|
2036
|
+
|
|
2037
|
+
|
|
2038
|
+
const STATIONS = [
|
|
2039
|
+
{ id: "mill", label: "Mill" },
|
|
2040
|
+
{ id: "press", label: "Press 2" },
|
|
2041
|
+
{ id: "paint", label: "Paint shop" },
|
|
2042
|
+
];
|
|
2043
|
+
|
|
2044
|
+
const ORDERS: readonly Task[] = [
|
|
2045
|
+
{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
|
|
2046
|
+
{ id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
|
|
2047
|
+
];
|
|
2048
|
+
|
|
2049
|
+
const STEPS: readonly Subtask[] = [
|
|
2050
|
+
{ id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
|
|
2051
|
+
{ id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
|
|
2052
|
+
{ id: "a-2043-2", task: "a-2043", lane: "mill", from: at(10), to: at(11, 30), setup: min(15) },
|
|
2053
|
+
{ id: "a-2043-3", task: "a-2043", lane: "paint", from: at(12), to: at(14), setup: min(20), teardown: min(20) },
|
|
2054
|
+
];
|
|
2055
|
+
|
|
2056
|
+
const MOVES: readonly Transport[] = [
|
|
2057
|
+
{ id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", duration: min(45) },
|
|
2058
|
+
/* Leaves the mill at 11:30 and has ten minutes to reach the paint shop's
|
|
2059
|
+
setup at 11:40 - it takes twenty-five. A late transport, on purpose. */
|
|
2060
|
+
{ id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", duration: min(25) },
|
|
2061
|
+
];
|
|
2062
|
+
|
|
2063
|
+
const CUSTOMERS: Record<string, string> = {
|
|
2064
|
+
"a-2041": "Brandt Metalworks",
|
|
2065
|
+
"a-2042": "Keller & Sons",
|
|
2066
|
+
"a-2043": "Northworks",
|
|
2067
|
+
"a-2044": "Brandt Metalworks",
|
|
2068
|
+
"a-2045": "Weiss Hydraulics",
|
|
2069
|
+
"a-2046": "Northworks",
|
|
2070
|
+
};
|
|
2071
|
+
|
|
2072
|
+
function OrderTooltip({ target }: { target: ScheduleTooltipTarget }) {
|
|
2073
|
+
const task = target.task;
|
|
2074
|
+
const findings = target.kind === "subtask" ? target.overlapping.length + target.lateTransports.length : target.late ? 1 : 0;
|
|
2075
|
+
return (
|
|
2076
|
+
<Stack gap={1}>
|
|
2077
|
+
<Text size="sm" weight="semibold">
|
|
2078
|
+
{task?.name ?? "Unknown order"}
|
|
2079
|
+
</Text>
|
|
2080
|
+
<Text size="xs" tone="secondary">
|
|
2081
|
+
{task ? CUSTOMERS[task.id] : ""}
|
|
2082
|
+
</Text>
|
|
2083
|
+
{findings > 0 && <Badge tone="danger">{findings === 1 ? "1 finding" : `${findings} findings`}</Badge>}
|
|
2084
|
+
</Stack>
|
|
2085
|
+
);
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
export default function OwnTooltip() {
|
|
2089
|
+
return (
|
|
2090
|
+
<Schedule
|
|
2091
|
+
ariaLabel="Plan of Tuesday, 17 March, with customers"
|
|
2092
|
+
initialDomain={DAY_OF_PLAN}
|
|
2093
|
+
height={196}
|
|
2094
|
+
tooltip={(target) => <OrderTooltip target={target} />}
|
|
2095
|
+
>
|
|
2096
|
+
{STATIONS.map((station) => (
|
|
2097
|
+
<Lane key={station.id} id={station.id} label={station.label} />
|
|
2098
|
+
))}
|
|
2099
|
+
<Transports data={MOVES} />
|
|
2100
|
+
<Subtasks data={STEPS} tasks={ORDERS} />
|
|
2101
|
+
</Schedule>
|
|
2102
|
+
);
|
|
2103
|
+
}
|
|
2104
|
+
```
|
|
2105
|
+
|
|
2106
|
+
#### API
|
|
2107
|
+
|
|
2108
|
+
##### `ScheduleTooltipTarget`
|
|
2109
|
+
|
|
2110
|
+
| Prop | Type | Default | Description |
|
|
2111
|
+
|---|---|---|---|
|
|
2112
|
+
| `kind` (required) | `"subtask" \| "transport"` | — | A subtask is hovered. A transport is hovered. |
|
|
2113
|
+
| `subtask` | `Subtask` | — | The hovered subtask. |
|
|
2114
|
+
| `task` (required) | `Task \| undefined` | — | Its task, where the tasks name it. |
|
|
2115
|
+
| `overlapping` | `readonly Subtask[]` | — | The subtasks it overlaps with on its lane. |
|
|
2116
|
+
| `lateTransports` | `readonly LateTransport[]` | — | The late transports leaving or reaching it. |
|
|
2117
|
+
| `transport` | `Transport` | — | The hovered transport. |
|
|
2118
|
+
| `from` | `Subtask \| undefined` | — | The subtask it leaves. |
|
|
2119
|
+
| `to` | `Subtask \| undefined` | — | The subtask it reaches. |
|
|
2120
|
+
| `late` | `LateTransport \| undefined` | — | Its finding, where it is late. |
|
|
2121
|
+
|
|
2122
|
+
### Linked schedules
|
|
2123
|
+
|
|
2124
|
+
Two plans of the same hours, kept in step through the span each reports when it is panned or zoomed.
|
|
2125
|
+
|
|
2126
|
+
```ts
|
|
2127
|
+
import { Schedule } from "@umriss-ui/schedule";
|
|
2128
|
+
```
|
|
2129
|
+
|
|
2130
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/linked-schedules
|
|
2131
|
+
|
|
2132
|
+
#### Examples
|
|
2133
|
+
|
|
2134
|
+
##### Two schedules in step
|
|
2135
|
+
|
|
2136
|
+
```tsx
|
|
2137
|
+
import { useState } from "react";
|
|
2138
|
+
import { Stack, Text } from "@umriss-ui/core";
|
|
2139
|
+
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
2140
|
+
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
2141
|
+
|
|
2142
|
+
/* `onDomainChange` reports the visible span after the planner pans or zooms -
|
|
2143
|
+
two wall-clock instants, at most once per frame. Handed to a second schedule
|
|
2144
|
+
as its `initialDomain`, the two move together: pan or zoom either one and the
|
|
2145
|
+
other follows. A span handed in is not reported back, so the two do not feed
|
|
2146
|
+
each other.
|
|
2147
|
+
|
|
2148
|
+
The same span goes to a chart's time axis where an application wants the
|
|
2149
|
+
plan and the measurements of the same hours under one another.
|
|
2150
|
+
|
|
2151
|
+
The two plans can stand a frame apart while a drag is in flight: a span is
|
|
2152
|
+
reported once per frame, and the round trip through the application's state
|
|
2153
|
+
arrives after the plan has moved on. It settles as soon as the drag ends. */
|
|
2154
|
+
|
|
2155
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
2156
|
+
const min = (n: number) => n * 60_000;
|
|
2157
|
+
|
|
2158
|
+
const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
|
|
2159
|
+
|
|
2160
|
+
|
|
2161
|
+
const STATIONS = [
|
|
2162
|
+
{ id: "saw", label: "Saw 1" },
|
|
2163
|
+
{ id: "lathe-1", label: "Lathe 1" },
|
|
2164
|
+
{ id: "mill", label: "Mill" },
|
|
2165
|
+
{ id: "press", label: "Press 2" },
|
|
2166
|
+
{ id: "paint", label: "Paint shop" },
|
|
2167
|
+
{ id: "qa", label: "Inspection" },
|
|
2168
|
+
];
|
|
2169
|
+
|
|
2170
|
+
const ORDERS: readonly Task[] = [
|
|
2171
|
+
{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
|
|
2172
|
+
{ id: "a-2042", name: "A-2042 Shaft", color: "light-dark(#0d9488, #3cc7b8)" },
|
|
2173
|
+
{ id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
|
|
2174
|
+
];
|
|
2175
|
+
|
|
2176
|
+
const STEPS: readonly Subtask[] = [
|
|
2177
|
+
{ id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6), to: at(7), setup: min(15), teardown: min(10) },
|
|
2178
|
+
{ id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
|
|
2179
|
+
{ id: "a-2041-3", task: "a-2041", lane: "qa", from: at(11, 30), to: at(12, 15) },
|
|
2180
|
+
{ id: "a-2042-1", task: "a-2042", lane: "saw", from: at(7, 30), to: at(8, 15), setup: min(10) },
|
|
2181
|
+
{ id: "a-2042-2", task: "a-2042", lane: "lathe-1", from: at(9), to: at(11), setup: min(20), teardown: min(15) },
|
|
2182
|
+
{ id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
|
|
2183
|
+
{ id: "a-2043-2", task: "a-2043", lane: "mill", from: at(10), to: at(11, 30), setup: min(15) },
|
|
2184
|
+
{ id: "a-2043-3", task: "a-2043", lane: "paint", from: at(12), to: at(14), setup: min(20), teardown: min(20) },
|
|
2185
|
+
];
|
|
2186
|
+
|
|
2187
|
+
const MACHINES = STATIONS.slice(0, 4);
|
|
2188
|
+
const ON_MACHINES = STEPS.filter((step) => MACHINES.some((machine) => machine.id === step.lane));
|
|
2189
|
+
const REST = STATIONS.slice(4);
|
|
2190
|
+
const ON_REST = STEPS.filter((step) => REST.some((machine) => machine.id === step.lane));
|
|
2191
|
+
export default function InStep() {
|
|
2192
|
+
const [domain, setDomain] = useState(DAY_OF_PLAN);
|
|
2193
|
+
return (
|
|
2194
|
+
<Stack gap={2}>
|
|
2195
|
+
<Schedule
|
|
2196
|
+
ariaLabel="The machines of Tuesday, 17 March"
|
|
2197
|
+
initialDomain={domain}
|
|
2198
|
+
height={240}
|
|
2199
|
+
onDomainChange={setDomain}
|
|
2200
|
+
>
|
|
2201
|
+
{MACHINES.map((machine) => (
|
|
2202
|
+
<Lane key={machine.id} id={machine.id} label={machine.label} />
|
|
2203
|
+
))}
|
|
2204
|
+
<Subtasks data={ON_MACHINES} tasks={ORDERS} />
|
|
2205
|
+
</Schedule>
|
|
2206
|
+
<Schedule ariaLabel="The rest of the plant, the same hours" initialDomain={domain} height={152} onDomainChange={setDomain}>
|
|
2207
|
+
{REST.map((machine) => (
|
|
2208
|
+
<Lane key={machine.id} id={machine.id} label={machine.label} />
|
|
2209
|
+
))}
|
|
2210
|
+
<Subtasks data={ON_REST} tasks={ORDERS} />
|
|
2211
|
+
</Schedule>
|
|
2212
|
+
<Text size="xs" mono tone="muted" data-span>
|
|
2213
|
+
{new Date(domain[0]).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })} –{" "}
|
|
2214
|
+
{new Date(domain[1]).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}
|
|
2215
|
+
</Text>
|
|
2216
|
+
</Stack>
|
|
2217
|
+
);
|
|
2218
|
+
}
|
|
2219
|
+
```
|
|
2220
|
+
|
|
2221
|
+
### The handle
|
|
2222
|
+
|
|
2223
|
+
The arithmetic between a point on the screen and a time on a lane, for an application's own marks beside the plot.
|
|
2224
|
+
|
|
2225
|
+
```ts
|
|
2226
|
+
import { Schedule } from "@umriss-ui/schedule";
|
|
2227
|
+
```
|
|
2228
|
+
|
|
2229
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/handle
|
|
2230
|
+
|
|
2231
|
+
#### Examples
|
|
2232
|
+
|
|
2233
|
+
##### A mark of the application's own
|
|
2234
|
+
|
|
2235
|
+
```tsx
|
|
2236
|
+
import { useEffect, useRef, useState } from "react";
|
|
2237
|
+
import { Stack, Text } from "@umriss-ui/core";
|
|
2238
|
+
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
2239
|
+
import type { ScheduleHandle, Subtask, Task } from "@umriss-ui/schedule";
|
|
2240
|
+
|
|
2241
|
+
/* `ScheduleHandle` is everything a schedule offers imperatively, and it is
|
|
2242
|
+
arithmetic and nothing else: the two directions between a point on the
|
|
2243
|
+
screen and a time on a lane.
|
|
2244
|
+
|
|
2245
|
+
clientPointOf(time, lane?) the client point of a time, on a lane's middle
|
|
2246
|
+
positionAt(clientX, clientY) the time and the lane at a client point
|
|
2247
|
+
visibleDomain() the span in view, as two wall-clock instants
|
|
2248
|
+
|
|
2249
|
+
Everything else a schedule does is props. This is here because an
|
|
2250
|
+
application's own marks - a shift change, a delivery window, a pin on the
|
|
2251
|
+
order somebody just rang about - are the application's DOM, not the
|
|
2252
|
+
schedule's, and they have to be placed.
|
|
2253
|
+
|
|
2254
|
+
The pin above the plan is placed with `clientPointOf`, turned into a point
|
|
2255
|
+
inside this example, and placed anew whenever the span moves. Pan or zoom
|
|
2256
|
+
the plan and watch it follow. */
|
|
2257
|
+
|
|
2258
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
2259
|
+
const min = (n: number) => n * 60_000;
|
|
2260
|
+
|
|
2261
|
+
const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
|
|
2262
|
+
|
|
2263
|
+
const STATIONS = [
|
|
2264
|
+
{ id: "saw", label: "Saw 1" },
|
|
2265
|
+
{ id: "mill", label: "Mill" },
|
|
2266
|
+
{ id: "press", label: "Press 2" },
|
|
2267
|
+
];
|
|
2268
|
+
|
|
2269
|
+
const ORDERS: readonly Task[] = [
|
|
2270
|
+
{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
|
|
2271
|
+
{ id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
|
|
2272
|
+
];
|
|
2273
|
+
|
|
2274
|
+
const STEPS: readonly Subtask[] = [
|
|
2275
|
+
{ id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6), to: at(7), setup: min(15), teardown: min(10) },
|
|
2276
|
+
{ id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
|
|
2277
|
+
{ id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
|
|
2278
|
+
{ id: "a-2043-2", task: "a-2043", lane: "mill", from: at(12), to: at(13, 30), setup: min(15) },
|
|
2279
|
+
];
|
|
2280
|
+
|
|
2281
|
+
const SHIFT_CHANGE = at(14);
|
|
2282
|
+
|
|
2283
|
+
export default function Handle() {
|
|
2284
|
+
const plan = useRef<ScheduleHandle>(null);
|
|
2285
|
+
const host = useRef<HTMLDivElement>(null);
|
|
2286
|
+
const [domain, setDomain] = useState(DAY_OF_PLAN);
|
|
2287
|
+
const [pin, setPin] = useState<number | null>(null);
|
|
2288
|
+
const [under, setUnder] = useState("Move the pointer over the plan");
|
|
2289
|
+
|
|
2290
|
+
/* Placed relative to this example, so the client point is turned into one
|
|
2291
|
+
inside it - and placed anew whenever the span moves. */
|
|
2292
|
+
useEffect(() => {
|
|
2293
|
+
const point = plan.current?.clientPointOf(SHIFT_CHANGE);
|
|
2294
|
+
const left = host.current?.getBoundingClientRect().left;
|
|
2295
|
+
setPin(point === undefined || point === null || left === undefined ? null : point.x - left);
|
|
2296
|
+
}, [domain]);
|
|
2297
|
+
|
|
2298
|
+
return (
|
|
2299
|
+
<Stack gap={2}>
|
|
2300
|
+
<div ref={host} style={{ position: "relative", height: 12 }}>
|
|
2301
|
+
{pin !== null && (
|
|
2302
|
+
<Text size="xs" tone="muted" style={{ position: "absolute", top: -4, left: pin, transform: "translateX(-50%)" }} data-pin>
|
|
2303
|
+
Shift change
|
|
2304
|
+
</Text>
|
|
2305
|
+
)}
|
|
2306
|
+
</div>
|
|
2307
|
+
<Schedule
|
|
2308
|
+
ref={plan}
|
|
2309
|
+
ariaLabel="A plan with a mark of the application's own"
|
|
2310
|
+
initialDomain={domain}
|
|
2311
|
+
height={196}
|
|
2312
|
+
onDomainChange={setDomain}
|
|
2313
|
+
onInteraction={(interaction) => {
|
|
2314
|
+
/* `positionAt` is the other direction: a client point back to a time
|
|
2315
|
+
and a lane. The interaction already carries both - this is for the
|
|
2316
|
+
application's own listeners, which have only a point. */
|
|
2317
|
+
const there = plan.current?.positionAt(interaction.clientX, interaction.clientY);
|
|
2318
|
+
setUnder(
|
|
2319
|
+
there == null
|
|
2320
|
+
? "off the plan"
|
|
2321
|
+
: `${new Date(there.time).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })} on ${there.lane ?? "no lane"}`,
|
|
2322
|
+
);
|
|
2323
|
+
}}
|
|
2324
|
+
>
|
|
2325
|
+
{STATIONS.map((station) => (
|
|
2326
|
+
<Lane key={station.id} id={station.id} label={station.label} />
|
|
2327
|
+
))}
|
|
2328
|
+
<Subtasks data={STEPS} tasks={ORDERS} />
|
|
2329
|
+
</Schedule>
|
|
2330
|
+
<Text size="sm" mono tone="secondary" data-under-pointer>
|
|
2331
|
+
{under}
|
|
2332
|
+
</Text>
|
|
2333
|
+
</Stack>
|
|
2334
|
+
);
|
|
2335
|
+
}
|
|
2336
|
+
```
|
|
2337
|
+
|
|
2338
|
+
## Editing
|
|
2339
|
+
|
|
2340
|
+
Direct manipulation that changes nothing by itself: the schedule reports, the application decides.
|
|
2341
|
+
|
|
2342
|
+
### Move and lane
|
|
2343
|
+
|
|
2344
|
+
Dragging a subtask through time and onto another lane - the ghost while it is in flight, the intents when it ends.
|
|
2345
|
+
|
|
2346
|
+
```ts
|
|
2347
|
+
import { Schedule, applyIntent } from "@umriss-ui/schedule";
|
|
2348
|
+
```
|
|
2349
|
+
|
|
2350
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/move-and-lane
|
|
2351
|
+
|
|
2352
|
+
#### Examples
|
|
2353
|
+
|
|
2354
|
+
##### Moving, and putting on another lane
|
|
2355
|
+
|
|
2356
|
+
```tsx
|
|
2357
|
+
import { useState } from "react";
|
|
2358
|
+
import { Stack, Text } from "@umriss-ui/core";
|
|
2359
|
+
import { Lane, Schedule, Subtasks, Transports, applyIntent } from "@umriss-ui/schedule";
|
|
2360
|
+
import type { Intent, Subtask, Task, Transport } from "@umriss-ui/schedule";
|
|
2361
|
+
|
|
2362
|
+
/* `intents` lists what the application handles, and each name enables its
|
|
2363
|
+
interaction: with `move` a subtask is dragged through time, with `lane`
|
|
2364
|
+
onto another lane. Without `intents` the schedule is read-only - that is a
|
|
2365
|
+
feature, not a lesser mode.
|
|
2366
|
+
|
|
2367
|
+
While the drag is in flight a ghost stands beside the unchanged plan, with
|
|
2368
|
+
its new times and whatever finding the drop would create. When it ends, one
|
|
2369
|
+
intent per change is reported. The plan changes only here, where the
|
|
2370
|
+
application applies the intent - `applyIntent` is the arithmetic for it. */
|
|
2371
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
2372
|
+
const min = (n: number) => n * 60_000;
|
|
2373
|
+
|
|
2374
|
+
const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
|
|
2375
|
+
|
|
2376
|
+
|
|
2377
|
+
const STATIONS = [
|
|
2378
|
+
{ id: "saw", label: "Saw 1" },
|
|
2379
|
+
{ id: "mill", label: "Mill" },
|
|
2380
|
+
{ id: "press", label: "Press 2" },
|
|
2381
|
+
{ id: "paint", label: "Paint shop" },
|
|
2382
|
+
{ id: "qa", label: "Inspection" },
|
|
2383
|
+
];
|
|
2384
|
+
|
|
2385
|
+
const ORDERS: readonly Task[] = [
|
|
2386
|
+
{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
|
|
2387
|
+
{ id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
|
|
2388
|
+
{ id: "a-2045", name: "A-2045 Cover", color: "light-dark(#be185d, #f06aa6)" },
|
|
2389
|
+
{ id: "a-2046", name: "A-2046 Axle", color: "light-dark(#4d7c0f, #8fc43e)" },
|
|
2390
|
+
];
|
|
2391
|
+
|
|
2392
|
+
const STEPS: readonly Subtask[] = [
|
|
2393
|
+
{ id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6), to: at(7), setup: min(15), teardown: min(10) },
|
|
2394
|
+
{ id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
|
|
2395
|
+
{ id: "a-2043-2", task: "a-2043", lane: "mill", from: at(10), to: at(11, 30), setup: min(15) },
|
|
2396
|
+
{ id: "a-2046-2", task: "a-2046", lane: "mill", from: at(13), to: at(14, 30), setup: min(20), teardown: min(10) },
|
|
2397
|
+
{ id: "a-2043-3", task: "a-2043", lane: "paint", from: at(12), to: at(14), setup: min(20), teardown: min(20) },
|
|
2398
|
+
{ id: "a-2045-3", task: "a-2045", lane: "qa", from: at(15, 45), to: at(16, 30) },
|
|
2399
|
+
];
|
|
2400
|
+
|
|
2401
|
+
const MOVES: readonly Transport[] = [
|
|
2402
|
+
{ id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", duration: min(45) },
|
|
2403
|
+
/* Leaves the mill at 11:30 and has ten minutes to reach the paint shop's
|
|
2404
|
+
setup at 11:40 - it takes twenty-five. A late transport, on purpose. */
|
|
2405
|
+
{ id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", duration: min(25) },
|
|
2406
|
+
];
|
|
2407
|
+
|
|
2408
|
+
export default function MoveAndLane() {
|
|
2409
|
+
const [steps, setSteps] = useState(STEPS);
|
|
2410
|
+
const [last, setLast] = useState<Intent | null>(null);
|
|
2411
|
+
|
|
2412
|
+
return (
|
|
2413
|
+
<Stack gap={3}>
|
|
2414
|
+
<Schedule
|
|
2415
|
+
ariaLabel="Plan of Tuesday, 17 March, editable"
|
|
2416
|
+
initialDomain={DAY_OF_PLAN}
|
|
2417
|
+
height={284}
|
|
2418
|
+
intents={["move", "lane"]}
|
|
2419
|
+
onIntent={(intent) => {
|
|
2420
|
+
setLast(intent);
|
|
2421
|
+
setSteps((current) => current.map((step) => applyIntent(step, intent)));
|
|
2422
|
+
}}
|
|
2423
|
+
>
|
|
2424
|
+
{STATIONS.map((station) => (
|
|
2425
|
+
<Lane key={station.id} id={station.id} label={station.label} />
|
|
2426
|
+
))}
|
|
2427
|
+
<Transports data={MOVES} />
|
|
2428
|
+
<Subtasks data={steps} tasks={ORDERS} />
|
|
2429
|
+
</Schedule>
|
|
2430
|
+
<Text size="sm" mono tone="secondary" data-last-intent>
|
|
2431
|
+
{last === null ? "No intent yet" : JSON.stringify(last)}
|
|
2432
|
+
</Text>
|
|
2433
|
+
</Stack>
|
|
2434
|
+
);
|
|
2435
|
+
}
|
|
2436
|
+
```
|
|
2437
|
+
|
|
2438
|
+
### Stretch, setup, teardown
|
|
2439
|
+
|
|
2440
|
+
The other three edits: the edges of the main time, and the two grips a selected subtask offers for its preparation.
|
|
2441
|
+
|
|
2442
|
+
```ts
|
|
2443
|
+
import { Schedule, applyIntent } from "@umriss-ui/schedule";
|
|
2444
|
+
```
|
|
2445
|
+
|
|
2446
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/stretch
|
|
2447
|
+
|
|
2448
|
+
#### Examples
|
|
2449
|
+
|
|
2450
|
+
##### Stretching, setup and teardown
|
|
2451
|
+
|
|
2452
|
+
```tsx
|
|
2453
|
+
import { useState } from "react";
|
|
2454
|
+
import { Lane, Schedule, Subtasks, applyIntent } from "@umriss-ui/schedule";
|
|
2455
|
+
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
2456
|
+
|
|
2457
|
+
/* The edges of the main time stretch it (`stretch`). Setup and teardown have
|
|
2458
|
+
grips of their own, and they appear only on the selected subtask: a plan
|
|
2459
|
+
where every bar bristles with handles is a plan nobody can read. Click a
|
|
2460
|
+
subtask, then drag the grip at its outer edge (`setup`, `teardown`).
|
|
2461
|
+
|
|
2462
|
+
Each is changed on its own - stretching the main time leaves the setup as
|
|
2463
|
+
long as it was. */
|
|
2464
|
+
|
|
2465
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
2466
|
+
const min = (n: number) => n * 60_000;
|
|
2467
|
+
|
|
2468
|
+
const TASKS: Task[] = [{ id: "shaft", color: "light-dark(#0d9488, #3cc7b8)" }];
|
|
2469
|
+
|
|
2470
|
+
const START: Subtask[] = [
|
|
2471
|
+
{ id: "turn", task: "shaft", lane: "lathe", from: at(8), to: at(10), setup: min(30), teardown: min(15) },
|
|
2472
|
+
{ id: "grind", task: "shaft", lane: "grinder", from: at(11), to: at(12, 30), setup: min(15) },
|
|
2473
|
+
];
|
|
2474
|
+
|
|
2475
|
+
export default function StretchSetupTeardown() {
|
|
2476
|
+
const [work, setWork] = useState(START);
|
|
2477
|
+
|
|
2478
|
+
return (
|
|
2479
|
+
<Schedule
|
|
2480
|
+
ariaLabel="A shaft on the lathe and the grinder"
|
|
2481
|
+
initialDomain={[at(6), at(13, 30)]}
|
|
2482
|
+
height={150}
|
|
2483
|
+
intents={["stretch", "setup", "teardown"]}
|
|
2484
|
+
onIntent={(intent) => setWork((current) => current.map((s) => applyIntent(s, intent)))}
|
|
2485
|
+
>
|
|
2486
|
+
<Lane id="lathe" label="Lathe" />
|
|
2487
|
+
<Lane id="grinder" label="Grinder" />
|
|
2488
|
+
<Subtasks data={work} tasks={TASKS} />
|
|
2489
|
+
</Schedule>
|
|
2490
|
+
);
|
|
2491
|
+
}
|
|
2492
|
+
```
|
|
2493
|
+
|
|
2494
|
+
### Snapping
|
|
2495
|
+
|
|
2496
|
+
The raster a drag lands on - the axis' own step, a duration, or a shift pattern with an offset.
|
|
2497
|
+
|
|
2498
|
+
```ts
|
|
2499
|
+
import { Schedule, snapTime } from "@umriss-ui/schedule";
|
|
2500
|
+
```
|
|
2501
|
+
|
|
2502
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/snapping
|
|
2503
|
+
|
|
2504
|
+
#### Examples
|
|
2505
|
+
|
|
2506
|
+
##### Snapping to a raster
|
|
2507
|
+
|
|
2508
|
+
```tsx
|
|
2509
|
+
import { useState } from "react";
|
|
2510
|
+
import { Lane, Schedule, Subtasks, applyIntent } from "@umriss-ui/schedule";
|
|
2511
|
+
import type { SnapRaster, Subtask, Task } from "@umriss-ui/schedule";
|
|
2512
|
+
|
|
2513
|
+
/* A drag lands on a raster. By default that is the fine band's current step -
|
|
2514
|
+
hours when zoomed out, quarter hours when zoomed in -, so what a planner drops
|
|
2515
|
+
lands on a line they can read. The upper schedule snaps to the plant's shift
|
|
2516
|
+
changes - eight hours, offset by six from local midnight: 06:00, 14:00,
|
|
2517
|
+
22:00 on the plant's clock, whatever the time zone -; the lower one does not
|
|
2518
|
+
snap at all.
|
|
2519
|
+
|
|
2520
|
+
Snapping shapes the ghost and therefore the intent. It never touches data
|
|
2521
|
+
that is not being dragged. */
|
|
2522
|
+
|
|
2523
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
2524
|
+
const HOUR = 3_600_000;
|
|
2525
|
+
|
|
2526
|
+
const SHIFTS: SnapRaster = { step: 8 * HOUR, offset: 6 * HOUR };
|
|
2527
|
+
|
|
2528
|
+
const TASKS: Task[] = [{ id: "batch", color: "light-dark(#7c3aed, #a98bfa)" }];
|
|
2529
|
+
|
|
2530
|
+
const BATCH: Subtask[] = [{ id: "cure", task: "batch", lane: "oven", from: at(6), to: at(14) }];
|
|
2531
|
+
|
|
2532
|
+
function Oven({ snap, label }: { snap: SnapRaster | false; label: string }) {
|
|
2533
|
+
const [work, setWork] = useState(BATCH);
|
|
2534
|
+
return (
|
|
2535
|
+
<Schedule
|
|
2536
|
+
ariaLabel={label}
|
|
2537
|
+
initialDomain={[at(4), at(24)]}
|
|
2538
|
+
height={110}
|
|
2539
|
+
snap={snap}
|
|
2540
|
+
intents={["move"]}
|
|
2541
|
+
onIntent={(intent) => setWork((current) => current.map((s) => applyIntent(s, intent)))}
|
|
2542
|
+
>
|
|
2543
|
+
<Lane id="oven" label={label} />
|
|
2544
|
+
<Subtasks data={work} tasks={TASKS} />
|
|
2545
|
+
</Schedule>
|
|
2546
|
+
);
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
export default function Snapping() {
|
|
2550
|
+
return (
|
|
2551
|
+
<>
|
|
2552
|
+
<Oven snap={SHIFTS} label="Shifts" />
|
|
2553
|
+
<div style={{ height: 12 }} />
|
|
2554
|
+
<Oven snap={false} label="Free" />
|
|
2555
|
+
</>
|
|
2556
|
+
);
|
|
2557
|
+
}
|
|
2558
|
+
```
|
|
2559
|
+
|
|
2560
|
+
### Placing from outside
|
|
2561
|
+
|
|
2562
|
+
Work that is not on the plan yet, dragged in from a list beside it with the browser's own drag and drop.
|
|
2563
|
+
|
|
2564
|
+
```ts
|
|
2565
|
+
import { Schedule, subtaskFromPlace } from "@umriss-ui/schedule";
|
|
2566
|
+
```
|
|
2567
|
+
|
|
2568
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/placing
|
|
2569
|
+
|
|
2570
|
+
#### Examples
|
|
2571
|
+
|
|
2572
|
+
##### Dragging unplanned work onto the plan
|
|
2573
|
+
|
|
2574
|
+
```tsx
|
|
2575
|
+
import { useState } from "react";
|
|
2576
|
+
import { Card, Stack, Text } from "@umriss-ui/core";
|
|
2577
|
+
import { Lane, Schedule, Subtasks, Transports, applyIntent, subtaskFromPlace } from "@umriss-ui/schedule";
|
|
2578
|
+
import type { Intent, PlacingItem, Subtask, Task, Transport } from "@umriss-ui/schedule";
|
|
2579
|
+
|
|
2580
|
+
/* Work that is not on the plan yet is dragged in from a list beside it - with
|
|
2581
|
+
the browser's own drag and drop, so the source can be any list the
|
|
2582
|
+
application has.
|
|
2583
|
+
|
|
2584
|
+
The browser hands the dragged data over only on the drop, so the ghost before
|
|
2585
|
+
it cannot come from there: the application declares what it is dragging in
|
|
2586
|
+
`placing`, on its own `dragstart`, and clears it on `dragend`. While the drag
|
|
2587
|
+
is over a lane, the schedule shows the ghost at the snapped time with the
|
|
2588
|
+
findings the drop would create - the last of the three below collides with
|
|
2589
|
+
the press wherever it is put in the morning.
|
|
2590
|
+
|
|
2591
|
+
The drop reports a `place` intent: the key the application gave the item, the
|
|
2592
|
+
task, the lane and the times. It creates no subtask itself - the id is the
|
|
2593
|
+
application's, and `subtaskFromPlace` builds the subtask from the intent. */
|
|
2594
|
+
|
|
2595
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
2596
|
+
const min = (n: number) => n * 60_000;
|
|
2597
|
+
|
|
2598
|
+
const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
|
|
2599
|
+
|
|
2600
|
+
|
|
2601
|
+
const STATIONS = [
|
|
2602
|
+
{ id: "saw", label: "Saw 1" },
|
|
2603
|
+
{ id: "press", label: "Press 2" },
|
|
2604
|
+
{ id: "qa", label: "Inspection" },
|
|
2605
|
+
];
|
|
2606
|
+
|
|
2607
|
+
const ORDERS: readonly Task[] = [
|
|
2608
|
+
{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
|
|
2609
|
+
{ id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
|
|
2610
|
+
{ id: "a-2044", name: "A-2044 Flange", color: "light-dark(#7c3aed, #a98bfa)" },
|
|
2611
|
+
];
|
|
2612
|
+
|
|
2613
|
+
const STEPS: readonly Subtask[] = [
|
|
2614
|
+
{ id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6), to: at(7), setup: min(15), teardown: min(10) },
|
|
2615
|
+
{ id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
|
|
2616
|
+
{ id: "a-2044-2", task: "a-2044", lane: "press", from: at(9, 30), to: at(10, 45), setup: min(25) },
|
|
2617
|
+
{ id: "a-2041-3", task: "a-2041", lane: "qa", from: at(11, 30), to: at(12, 15) },
|
|
2618
|
+
];
|
|
2619
|
+
|
|
2620
|
+
const MOVES: readonly Transport[] = [
|
|
2621
|
+
{ id: "t-2041-3", from: "a-2041-1", to: "a-2041-3", duration: min(20) },
|
|
2622
|
+
];
|
|
2623
|
+
|
|
2624
|
+
const HOUR = 3_600_000;
|
|
2625
|
+
|
|
2626
|
+
const WAITING: readonly (PlacingItem & { label: string })[] = [
|
|
2627
|
+
{ item: "a-2047", label: "A-2047 Cap · 2 h", task: "a-2041", duration: 2 * HOUR, setup: 15 * 60_000 },
|
|
2628
|
+
{ item: "a-2048", label: "A-2048 Ring · 1 h", task: "a-2042", duration: HOUR },
|
|
2629
|
+
{ item: "a-2049", label: "A-2049 Plate · 4 h", task: "a-2043", duration: 4 * HOUR, teardown: 20 * 60_000 },
|
|
2630
|
+
];
|
|
2631
|
+
|
|
2632
|
+
export default function DragIn() {
|
|
2633
|
+
const [plan, setPlan] = useState<{ steps: readonly Subtask[]; placed: number; last: string }>({
|
|
2634
|
+
steps: STEPS,
|
|
2635
|
+
placed: 0,
|
|
2636
|
+
last: "Drag an order onto a lane",
|
|
2637
|
+
});
|
|
2638
|
+
const [placing, setPlacing] = useState<PlacingItem | null>(null);
|
|
2639
|
+
|
|
2640
|
+
/* One functional update, so that a second intent in the same tick - and the
|
|
2641
|
+
counter the new id is built from - starts from the plan the first left. */
|
|
2642
|
+
const onIntent = (intent: Intent) =>
|
|
2643
|
+
setPlan((current) => {
|
|
2644
|
+
if (intent.kind !== "place") {
|
|
2645
|
+
return { ...current, steps: current.steps.map((step) => applyIntent(step, intent)), last: `${intent.kind} ${intent.subtask}` };
|
|
2646
|
+
}
|
|
2647
|
+
const placed = current.placed + 1;
|
|
2648
|
+
const id = `${intent.item}-${placed}`;
|
|
2649
|
+
return {
|
|
2650
|
+
steps: [...current.steps, subtaskFromPlace(intent, id)],
|
|
2651
|
+
placed,
|
|
2652
|
+
last: `place ${intent.item} on ${intent.lane} as ${id}`,
|
|
2653
|
+
};
|
|
2654
|
+
});
|
|
2655
|
+
|
|
2656
|
+
return (
|
|
2657
|
+
<Stack gap={3}>
|
|
2658
|
+
<Stack direction="row" gap={2}>
|
|
2659
|
+
{WAITING.map((order) => (
|
|
2660
|
+
<Card key={order.item}>
|
|
2661
|
+
<div
|
|
2662
|
+
draggable
|
|
2663
|
+
data-waiting={order.item}
|
|
2664
|
+
onDragStart={(event) => {
|
|
2665
|
+
event.dataTransfer.setData("text/plain", order.item);
|
|
2666
|
+
event.dataTransfer.effectAllowed = "copy";
|
|
2667
|
+
setPlacing(order);
|
|
2668
|
+
}}
|
|
2669
|
+
onDragEnd={() => setPlacing(null)}
|
|
2670
|
+
style={{ cursor: "grab" }}
|
|
2671
|
+
>
|
|
2672
|
+
<Text size="sm">{order.label}</Text>
|
|
2673
|
+
</div>
|
|
2674
|
+
</Card>
|
|
2675
|
+
))}
|
|
2676
|
+
</Stack>
|
|
2677
|
+
<Schedule
|
|
2678
|
+
ariaLabel="Plan of Tuesday, 17 March, with unplanned work beside it"
|
|
2679
|
+
initialDomain={DAY_OF_PLAN}
|
|
2680
|
+
height={196}
|
|
2681
|
+
intents={["place", "move", "lane"]}
|
|
2682
|
+
placing={placing}
|
|
2683
|
+
onIntent={onIntent}
|
|
2684
|
+
>
|
|
2685
|
+
{STATIONS.map((station) => (
|
|
2686
|
+
<Lane key={station.id} id={station.id} label={station.label} />
|
|
2687
|
+
))}
|
|
2688
|
+
<Transports data={MOVES} />
|
|
2689
|
+
<Subtasks data={plan.steps} tasks={ORDERS} />
|
|
2690
|
+
</Schedule>
|
|
2691
|
+
<Text size="sm" mono tone="secondary" data-last-place>
|
|
2692
|
+
{plan.last}
|
|
2693
|
+
</Text>
|
|
2694
|
+
</Stack>
|
|
2695
|
+
);
|
|
2696
|
+
}
|
|
2697
|
+
```
|
|
2698
|
+
|
|
2699
|
+
### Where a subtask may go
|
|
2700
|
+
|
|
2701
|
+
The lanes a piece of work may not go to, marked before the pointer arrives and refused without a warning colour.
|
|
2702
|
+
|
|
2703
|
+
```ts
|
|
2704
|
+
import { Schedule } from "@umriss-ui/schedule";
|
|
2705
|
+
```
|
|
2706
|
+
|
|
2707
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/where-it-may-go
|
|
2708
|
+
|
|
2709
|
+
#### Examples
|
|
2710
|
+
|
|
2711
|
+
##### A rule of the plant, seen before it is met
|
|
2712
|
+
|
|
2713
|
+
```tsx
|
|
2714
|
+
import { useState } from "react";
|
|
2715
|
+
import { Stack, Text } from "@umriss-ui/core";
|
|
2716
|
+
import { Lane, Schedule, Subtasks, applyIntent } from "@umriss-ui/schedule";
|
|
2717
|
+
import type { Intent, Subtask, Task } from "@umriss-ui/schedule";
|
|
2718
|
+
|
|
2719
|
+
/* Not every stop may go on every machine. A mould fits one press, a part that
|
|
2720
|
+
has been set up may move in time but not to another station, and a plant
|
|
2721
|
+
knows both - the schedule does not.
|
|
2722
|
+
|
|
2723
|
+
`canMoveTo` asks the application, once per lane when the drag takes hold and
|
|
2724
|
+
again at the drop. The lanes it turns down are marked from the first frame:
|
|
2725
|
+
drawn back and hatched, their headers with them, so that nobody has to try a
|
|
2726
|
+
lane to learn it is closed. Over one of them the ghost stays on the last
|
|
2727
|
+
lane that was allowed, the cursor says no, and a line ties the ghost to the
|
|
2728
|
+
pointer it is not following - it is being held, not stuck.
|
|
2729
|
+
|
|
2730
|
+
A refusal costs the lane and nothing else. Drop after it and the move in
|
|
2731
|
+
time is still reported: one rule of the plant must not take away the other
|
|
2732
|
+
half of a gesture. And none of it wears a warning colour, because a mould
|
|
2733
|
+
that fits one press is nobody's mistake.
|
|
2734
|
+
|
|
2735
|
+
It narrows `"lane"`; it does not enable it. Dragging in time keeps working
|
|
2736
|
+
for everything below - a restriction on the lane is not a restriction on the
|
|
2737
|
+
clock.
|
|
2738
|
+
|
|
2739
|
+
This example drags a part that is already on the plan. The same rule holds
|
|
2740
|
+
for work dragged in from a list - that is the next example; how a drag from
|
|
2741
|
+
outside is set up at all is its own chapter, *Placing from outside*. */
|
|
2742
|
+
|
|
2743
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
2744
|
+
|
|
2745
|
+
const TASKS: Task[] = [
|
|
2746
|
+
{ id: "wide", color: "light-dark(#2563eb, #6b9bff)" },
|
|
2747
|
+
{ id: "bound", color: "light-dark(#c2410c, #f08a52)" },
|
|
2748
|
+
];
|
|
2749
|
+
|
|
2750
|
+
const PRESSES = ["press-1", "press-2"];
|
|
2751
|
+
|
|
2752
|
+
const START: Subtask[] = [
|
|
2753
|
+
{ id: "free", task: "wide", lane: "press-1", from: at(7), to: at(9) },
|
|
2754
|
+
{ id: "moulded", task: "bound", lane: "press-2", from: at(9, 30), to: at(11, 30) },
|
|
2755
|
+
{ id: "welded", task: "wide", lane: "weld", from: at(12), to: at(14) },
|
|
2756
|
+
];
|
|
2757
|
+
|
|
2758
|
+
/* The mould fits the two presses and nothing else; everything else may go
|
|
2759
|
+
anywhere. A real plant reads this off its own master data. */
|
|
2760
|
+
const mayGo = (subtask: Subtask, lane: string) => subtask.task !== "bound" || PRESSES.includes(lane);
|
|
2761
|
+
|
|
2762
|
+
export default function WhereItMayGo() {
|
|
2763
|
+
const [work, setWork] = useState<readonly Subtask[]>(START);
|
|
2764
|
+
const [last, setLast] = useState("Drag the moulded part onto the welding bay");
|
|
2765
|
+
|
|
2766
|
+
/* Only `move` and `lane` are enabled, so only those two arrive - but the
|
|
2767
|
+
type is the whole union, and a `place` has no subtask to name. */
|
|
2768
|
+
const onIntent = (intent: Intent) => {
|
|
2769
|
+
if (intent.kind === "place") return;
|
|
2770
|
+
setWork((current) => current.map((s) => applyIntent(s, intent)));
|
|
2771
|
+
setLast(`${intent.subtask}: ${intent.kind}`);
|
|
2772
|
+
};
|
|
2773
|
+
|
|
2774
|
+
return (
|
|
2775
|
+
<Stack gap={3}>
|
|
2776
|
+
<Schedule
|
|
2777
|
+
ariaLabel="Two presses and a welding bay"
|
|
2778
|
+
initialDomain={[at(6, 30), at(15)]}
|
|
2779
|
+
height={190}
|
|
2780
|
+
intents={["move", "lane"]}
|
|
2781
|
+
canMoveTo={mayGo}
|
|
2782
|
+
onIntent={onIntent}
|
|
2783
|
+
>
|
|
2784
|
+
<Lane id="press-1" label="Press 1" />
|
|
2785
|
+
<Lane id="press-2" label="Press 2" />
|
|
2786
|
+
<Lane id="weld" label="Welding bay" />
|
|
2787
|
+
<Subtasks data={work} tasks={TASKS} />
|
|
2788
|
+
</Schedule>
|
|
2789
|
+
<Text size="sm" mono tone="secondary" data-last-move>
|
|
2790
|
+
{last}
|
|
2791
|
+
</Text>
|
|
2792
|
+
</Stack>
|
|
2793
|
+
);
|
|
2794
|
+
}
|
|
2795
|
+
```
|
|
2796
|
+
|
|
2797
|
+
##### The same rule for work dragged in
|
|
2798
|
+
|
|
2799
|
+
```tsx
|
|
2800
|
+
import { useState } from "react";
|
|
2801
|
+
import { Card, Stack, Text } from "@umriss-ui/core";
|
|
2802
|
+
import { Lane, Schedule, Subtasks, subtaskFromPlace } from "@umriss-ui/schedule";
|
|
2803
|
+
import type { Intent, PlacingItem, Subtask, Task } from "@umriss-ui/schedule";
|
|
2804
|
+
|
|
2805
|
+
/* `canMoveTo` is asked about work that is not on the plan yet too, with the
|
|
2806
|
+
key and task the application declared in `placing`. The welding bay is
|
|
2807
|
+
marked for the mould beside the plan exactly as it is for the one already on
|
|
2808
|
+
it, and the ghost holds on the last lane that allowed it.
|
|
2809
|
+
|
|
2810
|
+
One thing is the platform's and not the schedule's: a native drag has no
|
|
2811
|
+
cursor of its own to give, only the browser's answer to "may this be
|
|
2812
|
+
dropped". That answer follows the GHOST here and not the pointer - while a
|
|
2813
|
+
ghost stands somewhere allowed, a release lands there, because a ghost is
|
|
2814
|
+
the promise of where a drop lands. It says "no drop" exactly when a release
|
|
2815
|
+
would place nothing.
|
|
2816
|
+
|
|
2817
|
+
How a drag from a list is set up at all - `placing`, `dragstart`, `dragend`,
|
|
2818
|
+
and what a `place` intent carries - is *Placing from outside*. Nothing of it
|
|
2819
|
+
is repeated here. */
|
|
2820
|
+
|
|
2821
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
2822
|
+
|
|
2823
|
+
const TASKS: Task[] = [
|
|
2824
|
+
{ id: "wide", color: "light-dark(#2563eb, #6b9bff)" },
|
|
2825
|
+
{ id: "bound", color: "light-dark(#c2410c, #f08a52)" },
|
|
2826
|
+
];
|
|
2827
|
+
|
|
2828
|
+
const PRESSES = ["press-1", "press-2"];
|
|
2829
|
+
|
|
2830
|
+
const START: Subtask[] = [
|
|
2831
|
+
{ id: "free", task: "wide", lane: "press-1", from: at(7), to: at(9) },
|
|
2832
|
+
{ id: "welded", task: "wide", lane: "weld", from: at(12), to: at(14) },
|
|
2833
|
+
];
|
|
2834
|
+
|
|
2835
|
+
/* The mould fits the two presses and nothing else. */
|
|
2836
|
+
const mayGo = (subtask: Subtask, lane: string) => subtask.task !== "bound" || PRESSES.includes(lane);
|
|
2837
|
+
|
|
2838
|
+
const WAITING: PlacingItem & { label: string } = {
|
|
2839
|
+
item: "mould",
|
|
2840
|
+
label: "Mould A-77 · 2 h",
|
|
2841
|
+
task: "bound",
|
|
2842
|
+
duration: 2 * 60 * 60_000,
|
|
2843
|
+
};
|
|
2844
|
+
|
|
2845
|
+
export default function FromOutside() {
|
|
2846
|
+
const [work, setWork] = useState<readonly Subtask[]>(START);
|
|
2847
|
+
const [placing, setPlacing] = useState<PlacingItem | null>(null);
|
|
2848
|
+
const [last, setLast] = useState("Drag the mould onto the welding bay");
|
|
2849
|
+
|
|
2850
|
+
const onIntent = (intent: Intent) => {
|
|
2851
|
+
if (intent.kind !== "place") return;
|
|
2852
|
+
setWork((current) => [...current, subtaskFromPlace(intent, `${intent.item}-${current.length}`)]);
|
|
2853
|
+
setLast(`${intent.item}: place on ${intent.lane}`);
|
|
2854
|
+
};
|
|
2855
|
+
|
|
2856
|
+
return (
|
|
2857
|
+
<Stack gap={3}>
|
|
2858
|
+
<Card>
|
|
2859
|
+
<div
|
|
2860
|
+
draggable
|
|
2861
|
+
data-waiting={WAITING.item}
|
|
2862
|
+
onDragStart={(event) => {
|
|
2863
|
+
event.dataTransfer.setData("text/plain", WAITING.item);
|
|
2864
|
+
event.dataTransfer.effectAllowed = "copy";
|
|
2865
|
+
setPlacing(WAITING);
|
|
2866
|
+
}}
|
|
2867
|
+
onDragEnd={() => setPlacing(null)}
|
|
2868
|
+
style={{ cursor: "grab" }}
|
|
2869
|
+
>
|
|
2870
|
+
<Text size="sm">{WAITING.label}</Text>
|
|
2871
|
+
</div>
|
|
2872
|
+
</Card>
|
|
2873
|
+
<Schedule
|
|
2874
|
+
ariaLabel="A mould waiting beside two presses and a welding bay"
|
|
2875
|
+
initialDomain={[at(6, 30), at(15)]}
|
|
2876
|
+
height={190}
|
|
2877
|
+
intents={["place"]}
|
|
2878
|
+
placing={placing}
|
|
2879
|
+
canMoveTo={mayGo}
|
|
2880
|
+
onIntent={onIntent}
|
|
2881
|
+
>
|
|
2882
|
+
<Lane id="press-1" label="Press 1" />
|
|
2883
|
+
<Lane id="press-2" label="Press 2" />
|
|
2884
|
+
<Lane id="weld" label="Welding bay" />
|
|
2885
|
+
<Subtasks data={work} tasks={TASKS} />
|
|
2886
|
+
</Schedule>
|
|
2887
|
+
<Text size="sm" mono tone="secondary" data-last-place>
|
|
2888
|
+
{last}
|
|
2889
|
+
</Text>
|
|
2890
|
+
</Stack>
|
|
2891
|
+
);
|
|
2892
|
+
}
|
|
2893
|
+
```
|
|
2894
|
+
|
|
2895
|
+
#### Why it is like this
|
|
2896
|
+
|
|
2897
|
+
##### A refusal is not an error
|
|
2898
|
+
|
|
2899
|
+
A mould that fits one press is nobody's mistake. Neither is a part that has been set up and may no longer change station. These are **rules of the plant**, and a planner who meets one has not done anything wrong - they have learned something about the plant. So a refusal here wears no warning colour, no red, no alarm, and no exclamation mark: the lane is drawn back and hatched, the cursor says no, and the word says it once, in the label's own type.
|
|
2900
|
+
|
|
2901
|
+
##### Seen before it is met, not after
|
|
2902
|
+
|
|
2903
|
+
The lanes a piece of work may not go to are marked the moment the drag takes hold - before the pointer has reached any of them. A refusal that only appears once you have tried is a small punishment for guessing; one that is already drawn is a map. The rule is asked once per lane and held for the gesture, which is also what keeps a plant of hundreds of orders fluid while a drag runs.
|
|
2904
|
+
|
|
2905
|
+
##### The place is never travelled to and then corrected
|
|
2906
|
+
|
|
2907
|
+
The ghost never stands on a lane the work may not go to. It holds on the last lane that allowed it, with a hairline back to the pointer, so that it reads as held rather than stuck - and so that the ghost stays what it is everywhere else in this component: the promise of where a drop lands.
|
|
2908
|
+
|
|
2909
|
+
And a refusal costs only what it refuses. Drag across a closed lane and an hour earlier, drop, and the hour is still reported. One rule of the plant must not take away the other half of a gesture.
|
|
2910
|
+
|
|
2911
|
+
### Ripple
|
|
2912
|
+
|
|
2913
|
+
The cascade as arithmetic: the moves that push every successor whose transport no longer fits - for the application to run, or not.
|
|
2914
|
+
|
|
2915
|
+
```ts
|
|
2916
|
+
import { ripple, shiftTask, applyIntent } from "@umriss-ui/schedule";
|
|
2917
|
+
```
|
|
2918
|
+
|
|
2919
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/ripple
|
|
2920
|
+
|
|
2921
|
+
#### Examples
|
|
2922
|
+
|
|
2923
|
+
##### Pushing the successors
|
|
2924
|
+
|
|
2925
|
+
```tsx
|
|
2926
|
+
import { useState } from "react";
|
|
2927
|
+
import { Button, Stack, Text } from "@umriss-ui/core";
|
|
2928
|
+
import { Lane, Schedule, Subtasks, Transports, applyIntent, ripple } from "@umriss-ui/schedule";
|
|
2929
|
+
import type { MoveIntent, Subtask, Task, Transport } from "@umriss-ui/schedule";
|
|
2930
|
+
|
|
2931
|
+
/* The saw runs an hour late. `ripple` takes the data, the transports and that
|
|
2932
|
+
one intent, and returns the moves that push every successor whose transport
|
|
2933
|
+
no longer fits - by exactly what is missing, down the chain, never earlier.
|
|
2934
|
+
The schedule never runs it; whether a successor may move is the plant's
|
|
2935
|
+
decision, and this application decides with a button. */
|
|
2936
|
+
|
|
2937
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
2938
|
+
const min = (n: number) => n * 60_000;
|
|
2939
|
+
|
|
2940
|
+
const TASKS: Task[] = [{ id: "bracket", color: "light-dark(#c2410c, #f08a52)" }];
|
|
2941
|
+
|
|
2942
|
+
const START: Subtask[] = [
|
|
2943
|
+
{ id: "saw", task: "bracket", lane: "saw", from: at(6), to: at(7, 30) },
|
|
2944
|
+
{ id: "bend", task: "bracket", lane: "press", from: at(8), to: at(9) },
|
|
2945
|
+
{ id: "weld", task: "bracket", lane: "weld", from: at(9, 30), to: at(11) },
|
|
2946
|
+
];
|
|
2947
|
+
|
|
2948
|
+
const MOVES: Transport[] = [
|
|
2949
|
+
{ id: "to-press", from: "saw", to: "bend", duration: min(20) },
|
|
2950
|
+
{ id: "to-weld", from: "bend", to: "weld", duration: min(15) },
|
|
2951
|
+
];
|
|
2952
|
+
|
|
2953
|
+
const LATE: MoveIntent = { kind: "move", subtask: "saw", from: at(7), to: at(8, 30) };
|
|
2954
|
+
|
|
2955
|
+
export default function Cascade() {
|
|
2956
|
+
const [work, setWork] = useState<readonly Subtask[]>(START);
|
|
2957
|
+
/* The cascade of the late saw - once the saw is late; before that there is
|
|
2958
|
+
nothing to push. */
|
|
2959
|
+
const late = work.some((s) => s.id === LATE.subtask && s.from === LATE.from);
|
|
2960
|
+
const pushed = late ? ripple(work, MOVES, LATE) : [];
|
|
2961
|
+
|
|
2962
|
+
return (
|
|
2963
|
+
<Stack gap={3}>
|
|
2964
|
+
<Schedule ariaLabel="A bracket through saw, press and welding" initialDomain={[at(5, 30), at(13)]} height={190}>
|
|
2965
|
+
<Lane id="saw" label="Saw" />
|
|
2966
|
+
<Lane id="press" label="Press" />
|
|
2967
|
+
<Lane id="weld" label="Welding" />
|
|
2968
|
+
<Transports data={MOVES} />
|
|
2969
|
+
<Subtasks data={work} tasks={TASKS} />
|
|
2970
|
+
</Schedule>
|
|
2971
|
+
<Stack direction="row" gap={2} align="center">
|
|
2972
|
+
<Button size="sm" onClick={() => setWork((current) => current.map((s) => applyIntent(s, LATE)))} disabled={late}>
|
|
2973
|
+
Saw an hour late
|
|
2974
|
+
</Button>
|
|
2975
|
+
<Button size="sm" onClick={() => setWork((current) => pushed.reduce((data, move) => data.map((s) => applyIntent(s, move)), current))} disabled={pushed.length === 0}>
|
|
2976
|
+
Push {pushed.length} {pushed.length === 1 ? "successor" : "successors"}
|
|
2977
|
+
</Button>
|
|
2978
|
+
<Button size="sm" variant="ghost" onClick={() => setWork(START)}>
|
|
2979
|
+
Reset
|
|
2980
|
+
</Button>
|
|
2981
|
+
</Stack>
|
|
2982
|
+
<Text size="xs" mono tone="muted">
|
|
2983
|
+
{pushed.length === 0 ? "Nothing to push" : pushed.map((m) => m.subtask).join(", ")}
|
|
2984
|
+
</Text>
|
|
2985
|
+
</Stack>
|
|
2986
|
+
);
|
|
2987
|
+
}
|
|
2988
|
+
```
|
|
2989
|
+
|
|
2990
|
+
### Demonstration
|
|
2991
|
+
|
|
2992
|
+
A whole plant in a planner's hands: a context menu, the cascade, the findings, and every intent the schedule reports.
|
|
2993
|
+
|
|
2994
|
+
```ts
|
|
2995
|
+
import { Schedule, findings, ripple, shiftTask } from "@umriss-ui/schedule";
|
|
2996
|
+
```
|
|
2997
|
+
|
|
2998
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/demonstration
|
|
2999
|
+
|
|
3000
|
+
#### Examples
|
|
3001
|
+
|
|
3002
|
+
##### Demonstration: the plan in the planner's hands
|
|
3003
|
+
|
|
3004
|
+
```tsx
|
|
3005
|
+
import { useState } from "react";
|
|
3006
|
+
import { Checkbox, ContextMenu, MenuItem, MenuSeparator, Stack, Text } from "@umriss-ui/core";
|
|
3007
|
+
import { Lane, Schedule, Subtasks, Transports, applyIntent, findings, ripple, shiftTask } from "@umriss-ui/schedule";
|
|
3008
|
+
import type { Intent, ScheduleInteraction, Subtask } from "@umriss-ui/schedule";
|
|
3009
|
+
import { DAY_OF_PLAN, MOVES, ORDERS, STATIONS, STEPS } from "../../data";
|
|
3010
|
+
|
|
3011
|
+
/* The one example that does not carry its own data. A demonstration is a whole
|
|
3012
|
+
plant - seven stations, six orders and their moves through a Tuesday - and
|
|
3013
|
+
ninety lines of it in this file would bury the thing it demonstrates. It is
|
|
3014
|
+
shown instead: `data.ts` stands in the second tab of the code view, so a
|
|
3015
|
+
reader sees it and can copy both. That is the named exception the check
|
|
3016
|
+
allows, and the only one in this demo. */
|
|
3017
|
+
|
|
3018
|
+
/* The whole recipe, as an application writes it.
|
|
3019
|
+
|
|
3020
|
+
The plan lives in the application's state. The schedule draws it, a drag
|
|
3021
|
+
reports intents, and the application applies them - here, if asked, with the
|
|
3022
|
+
cascade `ripple` computes over the successors. The findings below come from
|
|
3023
|
+
the same data and change with every drop.
|
|
3024
|
+
|
|
3025
|
+
A right-click opens `ContextMenu` from @umriss-ui/core at the pointer: the
|
|
3026
|
+
schedule reports the interaction with its target and position and knows
|
|
3027
|
+
nothing of the menu. The entries act on the data the same way a drag does -
|
|
3028
|
+
through intents; moving the whole order is `shiftTask`, one move per stop. */
|
|
3029
|
+
|
|
3030
|
+
const QUARTER = 15 * 60_000;
|
|
3031
|
+
|
|
3032
|
+
type Menu = { interaction: ScheduleInteraction; subtask: Subtask | null };
|
|
3033
|
+
|
|
3034
|
+
type Plan = { steps: readonly Subtask[]; log: readonly string[] };
|
|
3035
|
+
|
|
3036
|
+
const START: Plan = { steps: STEPS, log: [] };
|
|
3037
|
+
|
|
3038
|
+
/* One drop can report two intents - a move and a lane - one after the other.
|
|
3039
|
+
Each is applied to the plan as the previous one left it: a functional update,
|
|
3040
|
+
not the plan this render happened to see. */
|
|
3041
|
+
function applied(plan: Plan, intent: Intent, cascade: boolean): Plan {
|
|
3042
|
+
const pushed = cascade ? ripple(plan.steps, MOVES, intent) : [];
|
|
3043
|
+
const steps = [intent, ...pushed].reduce((data, change) => data.map((step) => applyIntent(step, change)), plan.steps);
|
|
3044
|
+
const subject = intent.kind === "place" ? intent.item : intent.subtask;
|
|
3045
|
+
const line = `${intent.kind} ${subject}${pushed.length > 0 ? `, pushed ${pushed.length}` : ""}`;
|
|
3046
|
+
return { steps, log: [line, ...plan.log].slice(0, 4) };
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
export default function Demonstration() {
|
|
3050
|
+
const [plan, setPlan] = useState<Plan>(START);
|
|
3051
|
+
const [cascade, setCascade] = useState(true);
|
|
3052
|
+
const [menu, setMenu] = useState<Menu | null>(null);
|
|
3053
|
+
|
|
3054
|
+
const apply = (intent: Intent) => setPlan((current) => applied(current, intent, cascade));
|
|
3055
|
+
|
|
3056
|
+
const onInteraction = (interaction: ScheduleInteraction) => {
|
|
3057
|
+
if (interaction.type !== "contextmenu") return;
|
|
3058
|
+
const subtask = interaction.hit.kind === "subtask" ? interaction.hit.subtask : null;
|
|
3059
|
+
setMenu({ interaction, subtask });
|
|
3060
|
+
};
|
|
3061
|
+
|
|
3062
|
+
const found = findings(plan.steps, MOVES);
|
|
3063
|
+
const target = menu?.subtask ?? null;
|
|
3064
|
+
|
|
3065
|
+
return (
|
|
3066
|
+
<Stack gap={3}>
|
|
3067
|
+
<Checkbox label="Push successors when a transport no longer fits" checked={cascade} onChange={(e) => setCascade(e.target.checked)} />
|
|
3068
|
+
<Schedule
|
|
3069
|
+
ariaLabel="Plan of Tuesday, 17 March, in the planner's hands"
|
|
3070
|
+
initialDomain={DAY_OF_PLAN}
|
|
3071
|
+
height={380}
|
|
3072
|
+
intents={["move", "lane", "stretch", "setup", "teardown"]}
|
|
3073
|
+
onIntent={apply}
|
|
3074
|
+
onInteraction={onInteraction}
|
|
3075
|
+
>
|
|
3076
|
+
{STATIONS.map((station) => (
|
|
3077
|
+
<Lane key={station.id} id={station.id} label={station.label} />
|
|
3078
|
+
))}
|
|
3079
|
+
<Transports data={MOVES} />
|
|
3080
|
+
<Subtasks data={plan.steps} tasks={ORDERS} />
|
|
3081
|
+
</Schedule>
|
|
3082
|
+
<ContextMenu
|
|
3083
|
+
open={menu !== null}
|
|
3084
|
+
position={{ x: menu?.interaction.clientX ?? 0, y: menu?.interaction.clientY ?? 0 }}
|
|
3085
|
+
onOpenChange={(open) => {
|
|
3086
|
+
if (!open) setMenu(null);
|
|
3087
|
+
}}
|
|
3088
|
+
ariaLabel={target !== null ? `Actions for ${target.id}` : "Actions for the plan"}
|
|
3089
|
+
>
|
|
3090
|
+
{target !== null ? (
|
|
3091
|
+
<>
|
|
3092
|
+
<MenuItem onSelect={() => apply({ kind: "move", subtask: target.id, from: target.from - QUARTER, to: target.to - QUARTER })}>
|
|
3093
|
+
Earlier by a quarter hour
|
|
3094
|
+
</MenuItem>
|
|
3095
|
+
<MenuItem onSelect={() => apply({ kind: "move", subtask: target.id, from: target.from + QUARTER, to: target.to + QUARTER })}>
|
|
3096
|
+
Later by a quarter hour
|
|
3097
|
+
</MenuItem>
|
|
3098
|
+
<MenuItem onSelect={() => shiftTask(plan.steps, target.task, QUARTER).forEach(apply)}>
|
|
3099
|
+
The whole order later by a quarter hour
|
|
3100
|
+
</MenuItem>
|
|
3101
|
+
<MenuSeparator />
|
|
3102
|
+
<MenuItem onSelect={() => apply({ kind: "setup", subtask: target.id, setup: 0 })} disabled={(target.setup ?? 0) === 0}>
|
|
3103
|
+
Remove the setup
|
|
3104
|
+
</MenuItem>
|
|
3105
|
+
</>
|
|
3106
|
+
) : (
|
|
3107
|
+
<MenuItem onSelect={() => setPlan(START)}>Reset the plan</MenuItem>
|
|
3108
|
+
)}
|
|
3109
|
+
</ContextMenu>
|
|
3110
|
+
<Text size="sm" tone="secondary" data-findings-summary>
|
|
3111
|
+
{found.overlaps.length} {found.overlaps.length === 1 ? "overlap" : "overlaps"}, {found.lateTransports.length}{" "}
|
|
3112
|
+
{found.lateTransports.length === 1 ? "late transport" : "late transports"}
|
|
3113
|
+
</Text>
|
|
3114
|
+
<Text size="xs" mono tone="muted" data-intent-log>
|
|
3115
|
+
{plan.log.length === 0 ? "Drag a subtask, or right-click one" : plan.log.join(" · ")}
|
|
3116
|
+
</Text>
|
|
3117
|
+
</Stack>
|
|
3118
|
+
);
|
|
3119
|
+
}
|
|
3120
|
+
```
|
|
3121
|
+
|
|
3122
|
+
It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
|
|
3123
|
+
|
|
3124
|
+
## Findings
|
|
3125
|
+
|
|
3126
|
+
What a planner opens a schedule for, as data beside the picture.
|
|
3127
|
+
|
|
3128
|
+
### Findings as data
|
|
3129
|
+
|
|
3130
|
+
Overlaps on a lane and late transports, computed from the data - to list, count and act on.
|
|
3131
|
+
|
|
3132
|
+
```ts
|
|
3133
|
+
import { findings, overlaps, lateTransports } from "@umriss-ui/schedule";
|
|
3134
|
+
```
|
|
3135
|
+
|
|
3136
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/findings
|
|
3137
|
+
|
|
3138
|
+
#### Examples
|
|
3139
|
+
|
|
3140
|
+
##### The findings beside the picture
|
|
3141
|
+
|
|
3142
|
+
```tsx
|
|
3143
|
+
import { Stack, Text } from "@umriss-ui/core";
|
|
3144
|
+
import { Lane, Schedule, Subtasks, Transports, findings } from "@umriss-ui/schedule";
|
|
3145
|
+
import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
|
|
3146
|
+
|
|
3147
|
+
/* What the schedule draws, `findings` returns: every overlap with its lane, the
|
|
3148
|
+
two subtasks and the time they share, and every late transport with its
|
|
3149
|
+
departure, its arrival and how much time is missing. It is a pure function of
|
|
3150
|
+
the same data - a list, a count on a tile, a filter for "everything that does
|
|
3151
|
+
not work" is one call away. `overlaps` and `lateTransports` are the two
|
|
3152
|
+
halves on their own. */
|
|
3153
|
+
|
|
3154
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
3155
|
+
const min = (n: number) => n * 60_000;
|
|
3156
|
+
|
|
3157
|
+
const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
|
|
3158
|
+
|
|
3159
|
+
|
|
3160
|
+
const STATIONS = [
|
|
3161
|
+
{ id: "mill", label: "Mill" },
|
|
3162
|
+
{ id: "press", label: "Press 2" },
|
|
3163
|
+
{ id: "paint", label: "Paint shop" },
|
|
3164
|
+
];
|
|
3165
|
+
|
|
3166
|
+
const ORDERS: readonly Task[] = [
|
|
3167
|
+
{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
|
|
3168
|
+
{ id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
|
|
3169
|
+
];
|
|
3170
|
+
|
|
3171
|
+
const STEPS: readonly Subtask[] = [
|
|
3172
|
+
{ id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
|
|
3173
|
+
{ id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
|
|
3174
|
+
{ id: "a-2043-2", task: "a-2043", lane: "mill", from: at(10), to: at(11, 30), setup: min(15) },
|
|
3175
|
+
{ id: "a-2043-3", task: "a-2043", lane: "paint", from: at(12), to: at(14), setup: min(20), teardown: min(20) },
|
|
3176
|
+
];
|
|
3177
|
+
|
|
3178
|
+
const MOVES: readonly Transport[] = [
|
|
3179
|
+
{ id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", duration: min(45) },
|
|
3180
|
+
/* Leaves the mill at 11:30 and has ten minutes to reach the paint shop's
|
|
3181
|
+
setup at 11:40 - it takes twenty-five. A late transport, on purpose. */
|
|
3182
|
+
{ id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", duration: min(25) },
|
|
3183
|
+
];
|
|
3184
|
+
|
|
3185
|
+
const time = (instant: number) => new Date(instant).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
|
|
3186
|
+
|
|
3187
|
+
export default function AsData() {
|
|
3188
|
+
const found = findings(STEPS, MOVES);
|
|
3189
|
+
|
|
3190
|
+
return (
|
|
3191
|
+
<Stack gap={3}>
|
|
3192
|
+
<Schedule ariaLabel="Plan of Tuesday, 17 March" initialDomain={DAY_OF_PLAN} height={196}>
|
|
3193
|
+
{STATIONS.map((station) => (
|
|
3194
|
+
<Lane key={station.id} id={station.id} label={station.label} />
|
|
3195
|
+
))}
|
|
3196
|
+
<Transports data={MOVES} />
|
|
3197
|
+
<Subtasks data={STEPS} tasks={ORDERS} />
|
|
3198
|
+
</Schedule>
|
|
3199
|
+
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
|
3200
|
+
{found.overlaps.map((o) => (
|
|
3201
|
+
<Text as="li" size="sm" key={`${o.first}/${o.second}`}>
|
|
3202
|
+
Overlap on {o.lane}: {o.first} and {o.second}, {time(o.from)}–{time(o.to)}
|
|
3203
|
+
</Text>
|
|
3204
|
+
))}
|
|
3205
|
+
{found.lateTransports.map((l) => (
|
|
3206
|
+
<Text as="li" size="sm" key={l.transport}>
|
|
3207
|
+
Late transport {l.transport}: {Math.round(l.shortBy / 60_000)} minutes short
|
|
3208
|
+
</Text>
|
|
3209
|
+
))}
|
|
3210
|
+
</ul>
|
|
3211
|
+
</Stack>
|
|
3212
|
+
);
|
|
3213
|
+
}
|
|
3214
|
+
```
|
|
3215
|
+
|
|
3216
|
+
#### API
|
|
3217
|
+
|
|
3218
|
+
##### `Overlap`
|
|
3219
|
+
|
|
3220
|
+
| Prop | Type | Default | Description |
|
|
3221
|
+
|---|---|---|---|
|
|
3222
|
+
| `lane` (required) | `string` | — | The lane both sit on. |
|
|
3223
|
+
| `first` (required) | `string` | — | The subtask that starts earlier (setup included); on a tie, the one that stands first in the data. |
|
|
3224
|
+
| `second` (required) | `string` | — | The other one. |
|
|
3225
|
+
| `from` (required) | `number` | — | Where the covering begins. |
|
|
3226
|
+
| `to` (required) | `number` | — | Where it ends. |
|
|
3227
|
+
|
|
3228
|
+
##### `LateTransport`
|
|
3229
|
+
|
|
3230
|
+
| Prop | Type | Default | Description |
|
|
3231
|
+
|---|---|---|---|
|
|
3232
|
+
| `transport` (required) | `string` | — | The id of the transport. |
|
|
3233
|
+
| `departure` (required) | `number` | — | When it leaves, by its anchor. |
|
|
3234
|
+
| `arrival` (required) | `number` | — | When it has to have arrived, by its anchor. |
|
|
3235
|
+
| `shortBy` (required) | `number` | — | How much time is missing: departure plus duration, minus arrival. |
|
|
3236
|
+
|
|
3237
|
+
## The rest of the API
|
|
3238
|
+
|
|
3239
|
+
Exported as well, and named on no page above: the pure modules behind the components, and the types they are made of. Each with its declaration as the package's `.d.ts` carries it.
|
|
3240
|
+
|
|
3241
|
+
### `occupied`
|
|
3242
|
+
|
|
3243
|
+
```ts
|
|
3244
|
+
/** The interval a subtask occupies on its lane: setup and teardown included. */
|
|
3245
|
+
function occupied(subtask: Subtask): {
|
|
3246
|
+
from: number;
|
|
3247
|
+
to: number;
|
|
3248
|
+
};
|
|
3249
|
+
```
|
|
3250
|
+
|
|
3251
|
+
### `LaneIntent`
|
|
3252
|
+
|
|
3253
|
+
```ts
|
|
3254
|
+
/** Put the subtask on another lane. */
|
|
3255
|
+
interface LaneIntent {
|
|
3256
|
+
readonly kind: "lane";
|
|
3257
|
+
/** The id of the subtask. */
|
|
3258
|
+
readonly subtask: string;
|
|
3259
|
+
/** The id of the new lane. */
|
|
3260
|
+
readonly lane: string;
|
|
3261
|
+
}
|
|
3262
|
+
```
|
|
3263
|
+
|
|
3264
|
+
### `SetupIntent`
|
|
3265
|
+
|
|
3266
|
+
```ts
|
|
3267
|
+
/** Change the setup on its own. */
|
|
3268
|
+
interface SetupIntent {
|
|
3269
|
+
readonly kind: "setup";
|
|
3270
|
+
/** The id of the subtask. */
|
|
3271
|
+
readonly subtask: string;
|
|
3272
|
+
/** The new duration of the setup. */
|
|
3273
|
+
readonly setup: number;
|
|
3274
|
+
}
|
|
3275
|
+
```
|
|
3276
|
+
|
|
3277
|
+
### `StretchIntent`
|
|
3278
|
+
|
|
3279
|
+
```ts
|
|
3280
|
+
/** Stretch the main time at one or both of its edges. */
|
|
3281
|
+
interface StretchIntent {
|
|
3282
|
+
readonly kind: "stretch";
|
|
3283
|
+
/** The id of the subtask. */
|
|
3284
|
+
readonly subtask: string;
|
|
3285
|
+
/** The new start of the main time. */
|
|
3286
|
+
readonly from: number;
|
|
3287
|
+
/** The new end of the main time. */
|
|
3288
|
+
readonly to: number;
|
|
3289
|
+
}
|
|
3290
|
+
```
|
|
3291
|
+
|
|
3292
|
+
### `TeardownIntent`
|
|
3293
|
+
|
|
3294
|
+
```ts
|
|
3295
|
+
/** Change the teardown on its own. */
|
|
3296
|
+
interface TeardownIntent {
|
|
3297
|
+
readonly kind: "teardown";
|
|
3298
|
+
/** The id of the subtask. */
|
|
3299
|
+
readonly subtask: string;
|
|
3300
|
+
/** The new duration of the teardown. */
|
|
3301
|
+
readonly teardown: number;
|
|
3302
|
+
}
|
|
3303
|
+
```
|
|
3304
|
+
|
|
3305
|
+
### `Findings`
|
|
3306
|
+
|
|
3307
|
+
```ts
|
|
3308
|
+
/** Both kinds of finding over the same data. */
|
|
3309
|
+
interface Findings {
|
|
3310
|
+
readonly overlaps: readonly Overlap[];
|
|
3311
|
+
readonly lateTransports: readonly LateTransport[];
|
|
3312
|
+
}
|
|
3313
|
+
```
|
|
3314
|
+
|
|
3315
|
+
### `PlaceIntent`
|
|
3316
|
+
|
|
3317
|
+
```ts
|
|
3318
|
+
/** Put work on the plan that was not on it: the answer to a drag from outside
|
|
3319
|
+
the schedule (schedule-refinement 07). It carries no subtask id - there is
|
|
3320
|
+
no subtask yet; the caller creates one, with its own identity, from
|
|
3321
|
+
`subtaskFromPlace`. */
|
|
3322
|
+
interface PlaceIntent {
|
|
3323
|
+
readonly kind: "place";
|
|
3324
|
+
/** The caller's key for the dragged item, as it declared it while dragging. */
|
|
3325
|
+
readonly item: string;
|
|
3326
|
+
/** The task the work belongs to. */
|
|
3327
|
+
readonly task: string;
|
|
3328
|
+
/** The lane it was dropped on. */
|
|
3329
|
+
readonly lane: string;
|
|
3330
|
+
/** Where its main time would start. */
|
|
3331
|
+
readonly from: number;
|
|
3332
|
+
/** Where its main time would end. */
|
|
3333
|
+
readonly to: number;
|
|
3334
|
+
/** The setup it was declared with. */
|
|
3335
|
+
readonly setup?: number;
|
|
3336
|
+
/** The teardown it was declared with. */
|
|
3337
|
+
readonly teardown?: number;
|
|
3338
|
+
}
|
|
3339
|
+
```
|
|
3340
|
+
|
|
3341
|
+
### `ResolvedAppearance`
|
|
3342
|
+
|
|
3343
|
+
```ts
|
|
3344
|
+
/** What the drawing needs to know, with the contradiction already settled.
|
|
3345
|
+
|
|
3346
|
+
The four names are the ones this module was born with, and they are kept so
|
|
3347
|
+
that a caller's code does not change because a picture did. Two of them are
|
|
3348
|
+
now named after a drawing they no longer use, and the channel each one
|
|
3349
|
+
really owns stands beside it (`sceneDraw.ts`, "ONE CHANNEL PER
|
|
3350
|
+
STATEMENT"). */
|
|
3351
|
+
interface ResolvedAppearance {
|
|
3352
|
+
/** `provisional`: the FILL - none at all, so the surface shows through, with
|
|
3353
|
+
a dashed outline in the task colour. Planned, not released. */
|
|
3354
|
+
readonly dashed: boolean;
|
|
3355
|
+
/** `fixed`: the ENDS - a cap at each end of the main time. Not a hatch any
|
|
3356
|
+
more; a hatch now says "not available" of a refused lane, and the name is
|
|
3357
|
+
kept only so that this type did not change with the picture. */
|
|
3358
|
+
readonly hatched: boolean;
|
|
3359
|
+
/** `muted`: the SATURATION - the task colour mixed half into the surface,
|
|
3360
|
+
opaque and at full height. Another shift's, another crew's. */
|
|
3361
|
+
readonly muted: boolean;
|
|
3362
|
+
/** `open`: the FADE, where the bar passes the edge of the view. It goes on
|
|
3363
|
+
past what is drawn. */
|
|
3364
|
+
readonly open: boolean;
|
|
3365
|
+
}
|
|
3366
|
+
```
|
|
3367
|
+
|
|
3368
|
+
## Files the examples show
|
|
3369
|
+
|
|
3370
|
+
The demo's own data, which some examples import from beside themselves. Copied with the example, it runs.
|
|
3371
|
+
|
|
3372
|
+
### `data.ts`
|
|
3373
|
+
|
|
3374
|
+
```ts
|
|
3375
|
+
/* The plant of the demo: seven stations, six orders on their way through them
|
|
3376
|
+
on Tuesday, 17 March 2026 - the day the browser suites freeze the clock on.
|
|
3377
|
+
|
|
3378
|
+
Deterministic and written out, not generated from a seed: a planner reads
|
|
3379
|
+
these as a day's plan, and a finding is placed where it is on purpose - the
|
|
3380
|
+
overlap on the mill, the late transport into the paint shop. */
|
|
3381
|
+
|
|
3382
|
+
import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
|
|
3383
|
+
|
|
3384
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
3385
|
+
const min = (n: number) => n * 60_000;
|
|
3386
|
+
|
|
3387
|
+
export const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
|
|
3388
|
+
|
|
3389
|
+
export const STATIONS = [
|
|
3390
|
+
{ id: "saw", label: "Saw 1" },
|
|
3391
|
+
{ id: "lathe-1", label: "Lathe 1" },
|
|
3392
|
+
{ id: "lathe-2", label: "Lathe 2" },
|
|
3393
|
+
{ id: "mill", label: "Mill" },
|
|
3394
|
+
{ id: "press", label: "Press 2" },
|
|
3395
|
+
{ id: "paint", label: "Paint shop" },
|
|
3396
|
+
{ id: "qa", label: "Inspection" },
|
|
3397
|
+
] as const;
|
|
3398
|
+
|
|
3399
|
+
/* Colours in both schemes, as the application's own tokens would give them. */
|
|
3400
|
+
export const ORDERS: readonly Task[] = [
|
|
3401
|
+
{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
|
|
3402
|
+
{ id: "a-2042", name: "A-2042 Shaft", color: "light-dark(#0d9488, #3cc7b8)" },
|
|
3403
|
+
{ id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
|
|
3404
|
+
{ id: "a-2044", name: "A-2044 Flange", color: "light-dark(#7c3aed, #a98bfa)" },
|
|
3405
|
+
{ id: "a-2045", name: "A-2045 Cover", color: "light-dark(#be185d, #f06aa6)" },
|
|
3406
|
+
{ id: "a-2046", name: "A-2046 Axle", color: "light-dark(#4d7c0f, #8fc43e)" },
|
|
3407
|
+
];
|
|
3408
|
+
|
|
3409
|
+
export const STEPS: readonly Subtask[] = [
|
|
3410
|
+
{ id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6), to: at(7), setup: min(15), teardown: min(10) },
|
|
3411
|
+
{ id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
|
|
3412
|
+
{ id: "a-2041-3", task: "a-2041", lane: "qa", from: at(11, 30), to: at(12, 15) },
|
|
3413
|
+
|
|
3414
|
+
{ id: "a-2042-1", task: "a-2042", lane: "saw", from: at(7, 30), to: at(8, 15), setup: min(10) },
|
|
3415
|
+
{ id: "a-2042-2", task: "a-2042", lane: "lathe-1", from: at(9), to: at(11), setup: min(20), teardown: min(15) },
|
|
3416
|
+
{ id: "a-2042-3", task: "a-2042", lane: "qa", from: at(13), to: at(13, 30) },
|
|
3417
|
+
|
|
3418
|
+
{ id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
|
|
3419
|
+
{ id: "a-2043-2", task: "a-2043", lane: "mill", from: at(10), to: at(11, 30), setup: min(15) },
|
|
3420
|
+
{ id: "a-2043-3", task: "a-2043", lane: "paint", from: at(12), to: at(14), setup: min(20), teardown: min(20) },
|
|
3421
|
+
|
|
3422
|
+
{ id: "a-2044-1", task: "a-2044", lane: "lathe-2", from: at(6), to: at(8, 30), setup: min(20), teardown: min(10) },
|
|
3423
|
+
{ id: "a-2044-2", task: "a-2044", lane: "press", from: at(9, 30), to: at(10, 45), setup: min(25) },
|
|
3424
|
+
{ id: "a-2044-3", task: "a-2044", lane: "paint", from: at(14, 45), to: at(16), setup: min(15), teardown: min(15) },
|
|
3425
|
+
|
|
3426
|
+
{ id: "a-2045-1", task: "a-2045", lane: "lathe-1", from: at(12), to: at(13, 30), setup: min(15), teardown: min(10) },
|
|
3427
|
+
{ id: "a-2045-2", task: "a-2045", lane: "press", from: at(14, 15), to: at(15), setup: min(20) },
|
|
3428
|
+
{ id: "a-2045-3", task: "a-2045", lane: "qa", from: at(15, 45), to: at(16, 30) },
|
|
3429
|
+
|
|
3430
|
+
{ id: "a-2046-1", task: "a-2046", lane: "lathe-2", from: at(10), to: at(12), setup: min(20), teardown: min(15) },
|
|
3431
|
+
{ id: "a-2046-2", task: "a-2046", lane: "mill", from: at(13), to: at(14, 30), setup: min(20), teardown: min(10) },
|
|
3432
|
+
{ id: "a-2046-3", task: "a-2046", lane: "qa", from: at(15), to: at(15, 30) },
|
|
3433
|
+
];
|
|
3434
|
+
|
|
3435
|
+
export const MOVES: readonly Transport[] = [
|
|
3436
|
+
{ id: "t-2041-1", from: "a-2041-1", to: "a-2041-2", duration: min(10) },
|
|
3437
|
+
{ id: "t-2041-2", from: "a-2041-2", to: "a-2041-3", duration: min(20) },
|
|
3438
|
+
{ id: "t-2042-1", from: "a-2042-1", to: "a-2042-2", duration: min(15) },
|
|
3439
|
+
{ id: "t-2042-2", from: "a-2042-2", to: "a-2042-3", duration: min(30), leaves: "main" },
|
|
3440
|
+
{ id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", duration: min(45) },
|
|
3441
|
+
/* Leaves the mill at 11:30 and has 10 minutes to reach the paint shop's
|
|
3442
|
+
setup at 11:40 - it takes 25: a late transport, on purpose. */
|
|
3443
|
+
{ id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", duration: min(25) },
|
|
3444
|
+
{ id: "t-2044-1", from: "a-2044-1", to: "a-2044-2", duration: min(20) },
|
|
3445
|
+
{ id: "t-2044-2", from: "a-2044-2", to: "a-2044-3", duration: min(60), arrives: "main" },
|
|
3446
|
+
{ id: "t-2045-1", from: "a-2045-1", to: "a-2045-2", duration: min(15) },
|
|
3447
|
+
{ id: "t-2045-2", from: "a-2045-2", to: "a-2045-3", duration: min(15) },
|
|
3448
|
+
{ id: "t-2046-1", from: "a-2046-1", to: "a-2046-2", duration: min(20) },
|
|
3449
|
+
{ id: "t-2046-2", from: "a-2046-2", to: "a-2046-3", duration: min(10), leaves: "main" },
|
|
3450
|
+
];
|
|
3451
|
+
```
|