@tsplc/sim 0.0.0-stage → 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/README.md +6 -2
- package/dist/hardware/cognex.js +169 -0
- package/dist/hardware/cognex.js.map +12 -0
- package/dist/hardware/generic.js +141 -0
- package/dist/hardware/generic.js.map +12 -0
- package/dist/hardware/intelligrated.js +158 -0
- package/dist/hardware/intelligrated.js.map +12 -0
- package/dist/index.js +605 -0
- package/dist/index.js.map +15 -0
- package/dist/serve.js +292 -0
- package/dist/serve.js.map +11 -0
- package/package.json +48 -4
- package/src/hardware/cognex.ts +165 -0
- package/src/hardware/generic.ts +74 -0
- package/src/hardware/intelligrated.ts +150 -0
- package/src/hardware.ts +167 -0
- package/src/index.ts +44 -0
- package/src/io.ts +27 -0
- package/src/layout.ts +117 -0
- package/src/line.ts +198 -0
- package/src/page.ts +208 -0
- package/src/paths.ts +51 -0
- package/src/serve.ts +113 -0
- package/src/simulation.ts +533 -0
package/src/layout.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Conveyor, Heading, Line, Target } from "./line";
|
|
2
|
+
|
|
3
|
+
interface Point {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
}
|
|
7
|
+
interface Route {
|
|
8
|
+
/** Corners in millimetres, from start to end. */
|
|
9
|
+
points: Point[];
|
|
10
|
+
headings: Heading[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The view's fixed drawing scale, px per mm, shared by every conveyor so loads keep their proportions. */
|
|
14
|
+
const scale = 0.05;
|
|
15
|
+
/** How far past a side exit a conveyor it feeds starts, leaving room to draw the transfer. */
|
|
16
|
+
const exitClearance = 1000;
|
|
17
|
+
|
|
18
|
+
const direction: Record<Heading, Point> = {
|
|
19
|
+
right: { x: 1, y: 0 },
|
|
20
|
+
left: { x: -1, y: 0 },
|
|
21
|
+
up: { x: 0, y: -1 },
|
|
22
|
+
down: { x: 0, y: 1 },
|
|
23
|
+
};
|
|
24
|
+
const leftOf: Record<Heading, Heading> = { right: "up", up: "left", left: "down", down: "right" };
|
|
25
|
+
const rightOf: Record<Heading, Heading> = { right: "down", down: "left", left: "up", up: "right" };
|
|
26
|
+
|
|
27
|
+
const lengthOf = (conveyor: Conveyor) =>
|
|
28
|
+
conveyor.parts.reduce((sum, part) => sum + (part.kind === "part" ? part.length : 0), 0);
|
|
29
|
+
|
|
30
|
+
const travel = (from: Point, heading: Heading, distance: number): Point => ({
|
|
31
|
+
x: from.x + direction[heading].x * distance,
|
|
32
|
+
y: from.y + direction[heading].y * distance,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const pointAt = (route: Route, mm: number) => {
|
|
36
|
+
let remaining = mm;
|
|
37
|
+
for (const [index, heading] of route.headings.entries()) {
|
|
38
|
+
const from = route.points[index]!,
|
|
39
|
+
to = route.points[index + 1]!,
|
|
40
|
+
length = Math.abs(to.x - from.x) + Math.abs(to.y - from.y);
|
|
41
|
+
if (remaining <= length || index === route.headings.length - 1)
|
|
42
|
+
return { point: travel(from, heading, remaining), heading };
|
|
43
|
+
remaining -= length;
|
|
44
|
+
}
|
|
45
|
+
throw new Error("A conveyor needs a route.");
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Lays a line out in millimetres and returns each conveyor's drawing. A conveyor's length comes
|
|
50
|
+
* from its parts. One with no `at` starts where another discharges into it or feeds it sideways.
|
|
51
|
+
*/
|
|
52
|
+
export const drawLine = (line: Line): ReadonlyMap<Conveyor, string> => {
|
|
53
|
+
const conveyors = Object.values(line.conveyors);
|
|
54
|
+
const isConveyor = new Set<Target>(conveyors);
|
|
55
|
+
const feeders = new Map<Target, { from: Conveyor; at: number; side?: "left" | "right" }>();
|
|
56
|
+
for (const conveyor of conveyors) {
|
|
57
|
+
let position = 0;
|
|
58
|
+
for (const part of conveyor.parts) {
|
|
59
|
+
if (part.kind !== "part") continue;
|
|
60
|
+
for (const side of ["left", "right"] as const) {
|
|
61
|
+
const exit = part.exits?.[side];
|
|
62
|
+
if (exit && isConveyor.has(exit))
|
|
63
|
+
feeders.set(exit, { from: conveyor, at: position + part.length / 2, side });
|
|
64
|
+
}
|
|
65
|
+
position += part.length;
|
|
66
|
+
}
|
|
67
|
+
const next = conveyor.next;
|
|
68
|
+
if (next && !("to" in next) && isConveyor.has(next))
|
|
69
|
+
feeders.set(next, { from: conveyor, at: position });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const routes = new Map<Conveyor, Route>();
|
|
73
|
+
const active = new Set<Conveyor>();
|
|
74
|
+
const route = (conveyor: Conveyor): Route => {
|
|
75
|
+
const known = routes.get(conveyor);
|
|
76
|
+
if (known) return known;
|
|
77
|
+
if (active.has(conveyor)) throw new Error("Conveyors with no `at` cannot feed each other.");
|
|
78
|
+
active.add(conveyor);
|
|
79
|
+
let start: Point = conveyor.at ?? { x: 0, y: 0 };
|
|
80
|
+
let heading: Heading = conveyor.heading ?? "right";
|
|
81
|
+
const feeder = conveyor.at ? undefined : feeders.get(conveyor);
|
|
82
|
+
if (feeder) {
|
|
83
|
+
const exit = pointAt(route(feeder.from), feeder.at);
|
|
84
|
+
heading = !feeder.side
|
|
85
|
+
? exit.heading
|
|
86
|
+
: feeder.side === "left"
|
|
87
|
+
? leftOf[exit.heading]
|
|
88
|
+
: rightOf[exit.heading];
|
|
89
|
+
start = feeder.side ? travel(exit.point, heading, exitClearance) : exit.point;
|
|
90
|
+
}
|
|
91
|
+
const result: Route = { points: [start], headings: [] };
|
|
92
|
+
let remaining = lengthOf(conveyor);
|
|
93
|
+
for (const turn of conveyor.turns ?? []) {
|
|
94
|
+
if (turn.after <= 0 || turn.after >= remaining)
|
|
95
|
+
throw new Error(`A turn ${turn.after} mm along must fall inside its conveyor.`);
|
|
96
|
+
result.headings.push(heading);
|
|
97
|
+
result.points.push(travel(result.points.at(-1)!, heading, turn.after));
|
|
98
|
+
remaining -= turn.after;
|
|
99
|
+
heading = turn.to;
|
|
100
|
+
}
|
|
101
|
+
result.headings.push(heading);
|
|
102
|
+
result.points.push(travel(result.points.at(-1)!, heading, remaining));
|
|
103
|
+
active.delete(conveyor);
|
|
104
|
+
routes.set(conveyor, result);
|
|
105
|
+
return result;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const pixels = (mm: number) => Math.round(mm * scale * 100) / 100;
|
|
109
|
+
return new Map(
|
|
110
|
+
conveyors.map((conveyor) => [
|
|
111
|
+
conveyor,
|
|
112
|
+
route(conveyor)
|
|
113
|
+
.points.map(({ x, y }, index) => `${index ? "L" : "M"} ${pixels(x)} ${pixels(y)}`)
|
|
114
|
+
.join(" "),
|
|
115
|
+
]),
|
|
116
|
+
);
|
|
117
|
+
};
|
package/src/line.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { Device, Wire } from "./hardware";
|
|
2
|
+
|
|
3
|
+
type AnyDevice = Device<{ in: Record<string, unknown>; out: Record<string, unknown> }>;
|
|
4
|
+
type Devices = Record<string, AnyDevice>;
|
|
5
|
+
/** A zone controller senses the zone's photoeye and drives its rollers. */
|
|
6
|
+
type ZoneController = Device<{ in: { photoeye: boolean }; out: { motor: number } }>;
|
|
7
|
+
type MotorDevice = Device<{ in: Record<string, unknown>; out: { motor: number } }>;
|
|
8
|
+
type ReaderDevice = Device<{ in: { read: string | null }; out: Record<string, unknown> }>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How a part moves the loads on it this tick, in mm/s: `along` the conveyor, or `across` it
|
|
12
|
+
* toward the right (positive) or left (negative) exit. With `across` set, loads whose middle is on
|
|
13
|
+
* the part do not travel along; they move sideways until they leave by the exit.
|
|
14
|
+
*/
|
|
15
|
+
export type Motion = { along: number } | { across: number } | { held: true };
|
|
16
|
+
|
|
17
|
+
/** What a part's sensors can see, in millimetres from the part's start. */
|
|
18
|
+
export interface Sensing {
|
|
19
|
+
/** A beam across the conveyor at `at` is blocked (by a load not yet moved off to the side). */
|
|
20
|
+
blocked: (at: number) => boolean;
|
|
21
|
+
/** Any load is on the part, including mid-transfer. */
|
|
22
|
+
occupied: boolean;
|
|
23
|
+
/** The loads on the part: edges in millimetres from its start, and how far each has moved across. */
|
|
24
|
+
loads: ReadonlyArray<{ head: number; tail: number; lateral: number }>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Where a load goes next: a station or the start of another conveyor. */
|
|
28
|
+
export type Target = Conveyor | Station;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A length of conveyor and the devices on it, such as a zone, a gravity section or a project's
|
|
32
|
+
* own diverter. `motion` turns the devices' outputs into load movement; `sense` feeds what the
|
|
33
|
+
* part's sensors see to its devices' inputs and to PLC inputs wired straight to sensors.
|
|
34
|
+
*/
|
|
35
|
+
export interface CustomPart<Parts extends Devices = Devices> {
|
|
36
|
+
readonly kind: "part";
|
|
37
|
+
readonly id?: string;
|
|
38
|
+
/** What it is, for the view, such as "zone" or "diverter". */
|
|
39
|
+
readonly label?: string;
|
|
40
|
+
/** Millimetres along the conveyor. */
|
|
41
|
+
readonly length: number;
|
|
42
|
+
/** Lateral travel before a load leaves by a side exit. */
|
|
43
|
+
readonly width?: number;
|
|
44
|
+
readonly exits?: { readonly left?: Target; readonly right?: Target };
|
|
45
|
+
readonly devices: Parts;
|
|
46
|
+
motion(devices: Parts): Motion;
|
|
47
|
+
sense?(sensing: Sensing, devices: Parts): ReadonlyArray<readonly [Wire<boolean>, boolean]> | void;
|
|
48
|
+
}
|
|
49
|
+
export const part = <Parts extends Devices>(
|
|
50
|
+
options: Omit<CustomPart<Parts>, "kind">,
|
|
51
|
+
): CustomPart<Parts> => ({ kind: "part", ...options });
|
|
52
|
+
|
|
53
|
+
/** A driven length whose controller watches a photoeye, by default 50 mm before its end. */
|
|
54
|
+
export const zone = (options: {
|
|
55
|
+
id?: string;
|
|
56
|
+
length: number;
|
|
57
|
+
controller: ZoneController;
|
|
58
|
+
eye?: number;
|
|
59
|
+
}) =>
|
|
60
|
+
part({
|
|
61
|
+
...(options.id !== undefined && { id: options.id }),
|
|
62
|
+
label: "zone",
|
|
63
|
+
length: options.length,
|
|
64
|
+
devices: { controller: options.controller },
|
|
65
|
+
motion: ({ controller }) => ({ along: controller.world.out.motor }),
|
|
66
|
+
sense: ({ blocked }, { controller }) => {
|
|
67
|
+
controller.world.in.photoeye = blocked(options.eye ?? options.length - 50);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** A length with a motor, or a fixed speed such as a gravity section (0 = passive). */
|
|
72
|
+
export const section = (options: {
|
|
73
|
+
id?: string;
|
|
74
|
+
length: number;
|
|
75
|
+
motor?: MotorDevice;
|
|
76
|
+
speed?: number;
|
|
77
|
+
}) =>
|
|
78
|
+
part({
|
|
79
|
+
...(options.id !== undefined && { id: options.id }),
|
|
80
|
+
label: "section",
|
|
81
|
+
length: options.length,
|
|
82
|
+
devices: options.motor ? { motor: options.motor } : {},
|
|
83
|
+
motion: () => ({ along: options.motor?.world.out.motor ?? options.speed ?? 0 }),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** A photoeye beam across the conveyor, wired straight to a PLC input. */
|
|
87
|
+
export interface Photoeye {
|
|
88
|
+
readonly kind: "photoeye";
|
|
89
|
+
readonly id?: string;
|
|
90
|
+
/** Millimetres from where it appears in the parts list; negative places it inside the part before. */
|
|
91
|
+
readonly offset?: number;
|
|
92
|
+
readonly output: Wire<boolean>;
|
|
93
|
+
}
|
|
94
|
+
/** A barcode reader that reads each load's code as its leading edge passes. */
|
|
95
|
+
export interface Reader {
|
|
96
|
+
readonly kind: "reader";
|
|
97
|
+
readonly id?: string;
|
|
98
|
+
readonly offset?: number;
|
|
99
|
+
readonly device: ReaderDevice;
|
|
100
|
+
}
|
|
101
|
+
/** Where an operator takes loads off the line into a station, as they reach it. */
|
|
102
|
+
export interface Pickup {
|
|
103
|
+
readonly kind: "pickup";
|
|
104
|
+
readonly id?: string;
|
|
105
|
+
readonly offset?: number;
|
|
106
|
+
readonly station: Station;
|
|
107
|
+
/** Which side of the conveyor the station is drawn on, by default the right. */
|
|
108
|
+
readonly side?: "left" | "right";
|
|
109
|
+
}
|
|
110
|
+
export const pickup = (options: Omit<Pickup, "kind">): Pickup => ({ kind: "pickup", ...options });
|
|
111
|
+
|
|
112
|
+
export type Part = CustomPart | Photoeye | Reader | Pickup;
|
|
113
|
+
|
|
114
|
+
export const photoeye = (options: Omit<Photoeye, "kind">): Photoeye => ({
|
|
115
|
+
kind: "photoeye",
|
|
116
|
+
...options,
|
|
117
|
+
});
|
|
118
|
+
export const reader = (options: Omit<Reader, "kind">): Reader => ({ kind: "reader", ...options });
|
|
119
|
+
|
|
120
|
+
export type Heading = "right" | "left" | "up" | "down";
|
|
121
|
+
|
|
122
|
+
export interface Conveyor {
|
|
123
|
+
/**
|
|
124
|
+
* Where it starts on the floor, in millimetres with y growing downward. Omit it to start where
|
|
125
|
+
* another conveyor discharges into this one or feeds it from a side exit.
|
|
126
|
+
*/
|
|
127
|
+
readonly at?: { readonly x: number; readonly y: number };
|
|
128
|
+
/** Its direction from `at`, by default right. One fed from another conveyor takes its direction from there. */
|
|
129
|
+
readonly heading?: Heading;
|
|
130
|
+
/** Corners, each `after` millimetres past the last; it runs on to the length of its parts. */
|
|
131
|
+
readonly turns?: ReadonlyArray<{ readonly after: number; readonly to: Heading }>;
|
|
132
|
+
readonly parts: readonly Part[];
|
|
133
|
+
/**
|
|
134
|
+
* Where the last part discharges: a conveyor's start or a station, or `{ to, at }` to merge
|
|
135
|
+
* onto another conveyor at `at` mm from its start.
|
|
136
|
+
*/
|
|
137
|
+
readonly next?: Target | { readonly to: Conveyor; readonly at: number };
|
|
138
|
+
}
|
|
139
|
+
export const conveyor = (options: Conveyor): Conveyor => options;
|
|
140
|
+
|
|
141
|
+
export interface StationSensor {
|
|
142
|
+
readonly output: Wire<boolean>;
|
|
143
|
+
readonly active: (station: { readonly loads: number }) => boolean;
|
|
144
|
+
}
|
|
145
|
+
/** Where loads leave the line: an operator station, a collection point or an exit. */
|
|
146
|
+
export interface Station {
|
|
147
|
+
/** Loads it holds before refusing more; unlimited when omitted. */
|
|
148
|
+
readonly capacity?: number;
|
|
149
|
+
readonly sensors?: readonly StationSensor[];
|
|
150
|
+
readonly label?: string;
|
|
151
|
+
}
|
|
152
|
+
export const station = (options: Station = {}): Station => options;
|
|
153
|
+
|
|
154
|
+
export interface LoadKind {
|
|
155
|
+
/** Millimetres along the direction of travel. */
|
|
156
|
+
readonly length: number;
|
|
157
|
+
readonly width?: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface Line {
|
|
161
|
+
readonly conveyors: Readonly<Record<string, Conveyor>>;
|
|
162
|
+
/** Devices not on a conveyor, such as pushbuttons and stack lights. */
|
|
163
|
+
readonly devices?: Readonly<Record<string, AnyDevice>>;
|
|
164
|
+
readonly stations?: Readonly<Record<string, Station>>;
|
|
165
|
+
readonly loads: Readonly<Record<string, LoadKind>>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The name each conveyor and station has in a line. */
|
|
169
|
+
export const targetNames = (line: Line): ReadonlyMap<Target, string> =>
|
|
170
|
+
new Map<Target, string>([
|
|
171
|
+
...Object.entries(line.conveyors).map(([name, target]) => [target, name] as const),
|
|
172
|
+
...Object.entries(line.stations ?? {}).map(([name, target]) => [target, name] as const),
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
/** Checks every conveyor and station a part leads to is in the line, and returns the line unchanged. */
|
|
176
|
+
export const defineLine = <const Definition extends Line>(line: Definition): Definition => {
|
|
177
|
+
const names = targetNames(line);
|
|
178
|
+
for (const [id, item] of Object.entries(line.conveyors)) {
|
|
179
|
+
const next = item.next && "to" in item.next ? item.next.to : item.next;
|
|
180
|
+
const targets: Array<Target | undefined> = [next];
|
|
181
|
+
for (const part of item.parts) {
|
|
182
|
+
if (part.kind === "part") targets.push(part.exits?.left, part.exits?.right);
|
|
183
|
+
if (part.kind === "pickup") targets.push(part.station);
|
|
184
|
+
}
|
|
185
|
+
for (const target of targets)
|
|
186
|
+
if (target !== undefined && !names.has(target))
|
|
187
|
+
throw new Error(`Conveyor ${id} leads to a conveyor or station missing from the line.`);
|
|
188
|
+
}
|
|
189
|
+
return line;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/** A record with one value per id, keeping the ids in its type. */
|
|
193
|
+
export const keyed = <Id extends string, Value>(
|
|
194
|
+
ids: readonly Id[],
|
|
195
|
+
make: (id: Id) => Value,
|
|
196
|
+
): Record<Id, Value> =>
|
|
197
|
+
// Object.fromEntries erases the keys; every id has just been given a value.
|
|
198
|
+
Object.fromEntries(ids.map((id) => [id, make(id)])) as Record<Id, Value>;
|
package/src/page.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
const escape = (value: string) =>
|
|
2
|
+
value.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
|
3
|
+
|
|
4
|
+
/** The live view: a self-contained page that polls `/state`. */
|
|
5
|
+
export const page = (options: { title: string; css: string }) => `<!doctype html>
|
|
6
|
+
<html><head><meta charset="utf-8"><title>${escape(options.title)}</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root{--bg:#f6f7f9;--panel:#fff;--fg:#1c2026;--muted:#6b7280;--border:#d8dce3;--track:#c9ced8;
|
|
9
|
+
--running:#16a34a;--busy:#2563eb;--stopped:#9aa3b2;--idle:#c3c9d4;--warning:#d97706;--fault:#dc2626;--load:#7c3aed}
|
|
10
|
+
@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#191c22;--fg:#e6e8ec;--muted:#9aa1ad;
|
|
11
|
+
--border:#2c313a;--track:#3a404b;--stopped:#6b7280;--idle:#4b5260;--load:#a78bfa}}
|
|
12
|
+
*{box-sizing:border-box}body{margin:0;font:13px system-ui,sans-serif;background:var(--bg);color:var(--fg);
|
|
13
|
+
display:grid;grid-template-rows:auto 1fr;height:100vh}
|
|
14
|
+
header{display:flex;gap:10px;align-items:center;padding:8px 14px;border-bottom:1px solid var(--border);background:var(--panel)}
|
|
15
|
+
header h1{font-size:14px;margin:0 8px 0 0}#clock{font-family:ui-monospace,monospace;color:var(--muted)}
|
|
16
|
+
#error{color:var(--fault)}button{font:inherit;border:1px solid var(--border);background:var(--bg);color:var(--fg);
|
|
17
|
+
border-radius:6px;padding:3px 9px;cursor:pointer}button:hover{border-color:var(--muted)}
|
|
18
|
+
main{display:grid;grid-template-columns:1fr 340px;min-height:0}svg{width:100%;height:100%}
|
|
19
|
+
aside{border-left:1px solid var(--border);background:var(--panel);overflow:auto;padding:12px}
|
|
20
|
+
h2{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin:14px 0 6px}
|
|
21
|
+
.device{border:1px solid var(--border);border-radius:8px;padding:8px;margin-bottom:8px}
|
|
22
|
+
.device b{display:block;margin-bottom:4px}table{width:100%;border-collapse:collapse;font-size:12px}
|
|
23
|
+
td{padding:2px 0;border-bottom:1px dashed var(--border)}td:last-child{text-align:right;font-family:ui-monospace,monospace}
|
|
24
|
+
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
|
|
25
|
+
.actions{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
|
|
26
|
+
.control{display:flex;align-items:center;gap:6px;padding:3px 0;border-bottom:1px dashed var(--border)}
|
|
27
|
+
.control b{flex:1;font-weight:500}.control span{color:var(--muted)}.control button{padding:1px 7px}.part{cursor:pointer}
|
|
28
|
+
#toast{position:fixed;bottom:14px;left:14px;background:var(--fault);color:#fff;padding:6px 10px;border-radius:6px;display:none}
|
|
29
|
+
${options.css}
|
|
30
|
+
</style></head><body>
|
|
31
|
+
<header><h1>${escape(options.title)}</h1><span id="clock"></span>
|
|
32
|
+
<button id="pause">Pause</button><button id="step" disabled>Step</button><span id="error"></span></header>
|
|
33
|
+
<main><svg id="line"><g id="tracks"></g><g id="parts"></g><g id="stations"></g><g id="loads"></g></svg>
|
|
34
|
+
<aside><div id="inspector"><p style="color:var(--muted)">Select a part of the line to inspect it.</p></div>
|
|
35
|
+
<h2>Actions</h2><div id="actions"></div><h2>Controls</h2><div id="controls"></div></aside></main><div id="toast"></div>
|
|
36
|
+
<script>
|
|
37
|
+
const NS = "http://www.w3.org/2000/svg", TRACK = 16;
|
|
38
|
+
const $ = (id) => document.getElementById(id);
|
|
39
|
+
const el = (name, attrs = {}, parent) => { const node = document.createElementNS(NS, name);
|
|
40
|
+
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v); parent?.appendChild(node); return node; };
|
|
41
|
+
const html = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
42
|
+
let state, selected = null, selectedLoad = null, built = "", geometry = new Map(), stationAt = new Map();
|
|
43
|
+
|
|
44
|
+
const point = (id, mm) => {
|
|
45
|
+
const g = geometry.get(id), d = Math.max(0, Math.min(mm, g.length)) * g.scale;
|
|
46
|
+
const p = g.path.getPointAtLength(d), a = g.path.getPointAtLength(Math.max(0, d - 1)),
|
|
47
|
+
b = g.path.getPointAtLength(Math.min(g.total, d + 1)), angle = Math.atan2(b.y - a.y, b.x - a.x);
|
|
48
|
+
return { x: p.x, y: p.y, angle, nx: -Math.sin(angle), ny: Math.cos(angle) };
|
|
49
|
+
};
|
|
50
|
+
const color = (status) => "var(--" + status + ")";
|
|
51
|
+
|
|
52
|
+
const build = () => {
|
|
53
|
+
for (const id of ["tracks", "parts", "stations"]) $(id).replaceChildren();
|
|
54
|
+
geometry = new Map(); stationAt = new Map();
|
|
55
|
+
for (const c of state.conveyors) {
|
|
56
|
+
const path = el("path", { d: c.draw, fill: "none", stroke: "var(--track)", "stroke-width": TRACK, "stroke-linecap": "round" }, $("tracks"));
|
|
57
|
+
const total = path.getTotalLength();
|
|
58
|
+
geometry.set(c.id, { path, total, length: c.length, scale: total / c.length });
|
|
59
|
+
}
|
|
60
|
+
for (const c of state.conveyors) {
|
|
61
|
+
const g = geometry.get(c.id);
|
|
62
|
+
for (const part of c.parts) {
|
|
63
|
+
if (part.start === part.end) {
|
|
64
|
+
const p = point(c.id, part.start), size = part.kind === "reader" ? 7 : 5, sign = part.side === "left" ? -1 : 1;
|
|
65
|
+
if (part.station && !stationAt.has(part.station)) stationAt.set(part.station, { x: p.x + sign * p.nx * 45, y: p.y + sign * p.ny * 45 });
|
|
66
|
+
const node = part.kind === "reader"
|
|
67
|
+
? el("rect", { x: p.x + p.nx * (TRACK / 2 + 6) - size / 2, y: p.y + p.ny * (TRACK / 2 + 6) - size / 2, width: size, height: size }, $("parts"))
|
|
68
|
+
: el("circle", { cx: p.x + sign * p.nx * (TRACK / 2 + 5), cy: p.y + sign * p.ny * (TRACK / 2 + 5), r: size / 2 + 1 }, $("parts"));
|
|
69
|
+
node.dataset.part = part.id; node.classList.add("part");
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const seg = el("path", { d: c.draw, fill: "none", "stroke-width": TRACK - 6,
|
|
73
|
+
"stroke-dasharray": "0 " + part.start * g.scale + " " + (part.end - part.start) * g.scale + " " + g.total }, $("parts"));
|
|
74
|
+
seg.dataset.part = part.id; seg.classList.add("part");
|
|
75
|
+
const b = point(c.id, part.start);
|
|
76
|
+
el("line", { x1: b.x + b.nx * TRACK / 2, y1: b.y + b.ny * TRACK / 2, x2: b.x - b.nx * TRACK / 2, y2: b.y - b.ny * TRACK / 2, stroke: "var(--bg)", "stroke-width": 1.5 }, $("tracks"));
|
|
77
|
+
for (const [side, sign] of [["left", -1], ["right", 1]]) {
|
|
78
|
+
const exit = part.exits?.[side]; if (!exit) continue;
|
|
79
|
+
const p = point(c.id, (part.start + part.end) / 2);
|
|
80
|
+
el("line", { x1: p.x, y1: p.y, x2: p.x + sign * p.nx * 46, y2: p.y + sign * p.ny * 46, stroke: "var(--track)", "stroke-width": 8 }, $("tracks"));
|
|
81
|
+
if (!stationAt.has(exit)) stationAt.set(exit, { x: p.x + sign * p.nx * 70, y: p.y + sign * p.ny * 70 });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const c of state.conveyors) {
|
|
86
|
+
const next = c.next ?? null;
|
|
87
|
+
if (next && !geometry.has(next) && !stationAt.has(next)) {
|
|
88
|
+
const p = point(c.id, c.length); stationAt.set(next, { x: p.x + Math.cos(p.angle) * 45, y: p.y + Math.sin(p.angle) * 45 });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const s of state.stations) {
|
|
92
|
+
const at = stationAt.get(s.id) ?? { x: 0, y: 0 };
|
|
93
|
+
const group = el("g", { transform: "translate(" + at.x + " " + at.y + ")" }, $("stations"));
|
|
94
|
+
group.dataset.part = s.id; group.classList.add("part");
|
|
95
|
+
el("rect", { x: -38, y: -14, width: 76, height: 28, rx: 5, fill: "var(--panel)", stroke: "var(--border)" }, group);
|
|
96
|
+
el("text", { "text-anchor": "middle", y: -1, "font-size": 9, fill: "var(--fg)" }, group).textContent = s.label;
|
|
97
|
+
el("text", { "text-anchor": "middle", y: 10, "font-size": 9, fill: "var(--muted)", "data-count": s.id }, group);
|
|
98
|
+
}
|
|
99
|
+
const box = $("line").getBBox(), pad = 60;
|
|
100
|
+
$("line").setAttribute("viewBox", [box.x - pad, box.y - pad, box.width + 2 * pad, box.height + 2 * pad].join(" "));
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const render = () => {
|
|
104
|
+
const key = JSON.stringify(state.conveyors.map((c) => [c.id, c.draw, c.parts.map((p) => p.id)]));
|
|
105
|
+
if (key !== built) { built = key; build(); }
|
|
106
|
+
$("clock").textContent = (state.timeMs / 1000).toFixed(1) + " s";
|
|
107
|
+
$("error").textContent = state.error ?? "";
|
|
108
|
+
$("pause").textContent = state.paused ? "Resume" : "Pause"; $("step").disabled = !state.paused;
|
|
109
|
+
const views = new Map(state.conveyors.flatMap((c) => c.parts.map((p) => [p.id, p])));
|
|
110
|
+
for (const node of document.querySelectorAll("[data-part]")) {
|
|
111
|
+
const part = views.get(node.dataset.part); if (!part) continue;
|
|
112
|
+
const status = color(part.view.status);
|
|
113
|
+
node.setAttribute(node.tagName === "path" ? "stroke" : "fill", status);
|
|
114
|
+
node.setAttribute("stroke-opacity", selected === part.id ? 1 : 0.85);
|
|
115
|
+
}
|
|
116
|
+
for (const s of state.stations) {
|
|
117
|
+
const count = document.querySelector('[data-count="' + s.id + '"]'); if (count) count.textContent = s.loads + " held";
|
|
118
|
+
}
|
|
119
|
+
const loads = $("loads"); loads.replaceChildren();
|
|
120
|
+
const waiting = new Map();
|
|
121
|
+
for (const load of state.loads) {
|
|
122
|
+
if (load.place.kind === "waiting") { waiting.set(load.place.conveyor, (waiting.get(load.place.conveyor) ?? 0) + 1); continue; }
|
|
123
|
+
if (load.place.kind !== "conveyor") continue;
|
|
124
|
+
const g = geometry.get(load.place.conveyor), p = point(load.place.conveyor, load.place.head - load.length / 2);
|
|
125
|
+
const w = Math.max(load.length * g.scale, 4), h = Math.max(Math.min(load.width * g.scale, TRACK + 6), 4);
|
|
126
|
+
const x = p.x + p.nx * load.place.lateral * g.scale, y = p.y + p.ny * load.place.lateral * g.scale;
|
|
127
|
+
const r = el("rect", { x: -w / 2, y: -h / 2, width: w, height: h, rx: 2, fill: "var(--load)", opacity: .9,
|
|
128
|
+
transform: "translate(" + x + " " + y + ") rotate(" + p.angle * 180 / Math.PI + ")" }, loads);
|
|
129
|
+
r.dataset.load = load.id; r.classList.add("part");
|
|
130
|
+
if (selectedLoad === load.id) r.setAttribute("stroke", "var(--fg)");
|
|
131
|
+
el("title", {}, r).textContent = load.code + " (" + load.kind + ")";
|
|
132
|
+
}
|
|
133
|
+
for (const [conveyor, count] of waiting) {
|
|
134
|
+
const p = point(conveyor, 0);
|
|
135
|
+
el("text", { x: p.x - Math.cos(p.angle) * 30, y: p.y - Math.sin(p.angle) * 30, "font-size": 10, fill: "var(--muted)", "text-anchor": "middle" }, loads).textContent = "+" + count + " waiting";
|
|
136
|
+
}
|
|
137
|
+
inspect(); renderControls(); renderActions();
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const removeButton = (id) => "<button data-remove='" + html(id) + "'>Remove</button>";
|
|
141
|
+
const inspectLoad = () => {
|
|
142
|
+
const load = state.loads.find((l) => l.id === selectedLoad);
|
|
143
|
+
if (!load) { $("inspector").innerHTML = "<p style=color:var(--muted)>The load has left the line.</p>"; return; }
|
|
144
|
+
const where = load.place.kind === "conveyor" ? load.place.conveyor + " at " + Math.round(load.place.head) + " mm"
|
|
145
|
+
: load.place.kind === "station" ? load.place.station : "waiting for " + load.place.conveyor;
|
|
146
|
+
$("inspector").innerHTML = "<h2>" + html(load.code) + " · " + html(load.kind) + "</h2><div>" + html(where) + "</div>" +
|
|
147
|
+
"<div class=actions>" + state.actions.filter((a) => a.at === load.id).map((a) => "<button data-run='" + html(a.id) + "'>" + html(a.label) + "</button>").join("") +
|
|
148
|
+
removeButton(load.id) + "</div>";
|
|
149
|
+
};
|
|
150
|
+
const inspect = () => {
|
|
151
|
+
if (selectedLoad) return inspectLoad();
|
|
152
|
+
if (!selected) return;
|
|
153
|
+
const part = state.conveyors.flatMap((c) => c.parts).find((p) => p.id === selected);
|
|
154
|
+
const devices = state.devices.filter((d) => d.part === selected);
|
|
155
|
+
const loads = state.loads.filter((l) => l.place.kind === "station" && l.place.station === selected);
|
|
156
|
+
const rows = (obj) => Object.entries(obj ?? {}).map(([k, v]) => "<tr><td>" + html(k) + "</td><td>" + html(Array.isArray(v) ? "[" + v.length + "]" : v) + "</td></tr>").join("");
|
|
157
|
+
$("inspector").innerHTML = "<h2>" + html(selected) + (part ? " · " + part.kind : "") + "</h2>" +
|
|
158
|
+
(part ? "<div><span class=dot style=background:" + color(part.view.status) + "></span>" + html(part.view.label ?? part.view.status) + "</div>" : "") +
|
|
159
|
+
(loads.length ? "<table>" + loads.map((l) => "<tr><td>" + html(l.code) + " · " + html(l.kind) + "</td><td>" + removeButton(l.id) + "</td></tr>").join("") + "</table>" : "") +
|
|
160
|
+
"<div class=actions>" + state.actions.filter((a) => a.at === selected).map((a) => "<button data-run='" + html(a.id) + "'>" + html(a.label) + "</button>").join("") + "</div>" +
|
|
161
|
+
devices.map((d) => "<div class=device><b><span class=dot style=background:" + color(d.view.status) + "></span>" +
|
|
162
|
+
html(d.id.slice(selected.length + 1) || d.model) + " · " + html(d.model) + "</b>" +
|
|
163
|
+
(d.view.label ? "<div style=color:var(--muted)>" + html(d.view.label) + "</div>" : "") +
|
|
164
|
+
"<table>" + rows(d.view.details) + rows(d.pins) + "</table><div class=actions>" +
|
|
165
|
+
d.actions.map((a) => "<button data-device='" + html(d.id) + "' data-action='" + html(a) + "'>" + html(a) + "</button>").join("") +
|
|
166
|
+
"</div></div>").join("");
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const renderControls = () => {
|
|
170
|
+
const controls = state.devices.filter((d) => d.part === null);
|
|
171
|
+
$("controls").innerHTML = controls.length
|
|
172
|
+
? controls.map((d) => "<div class=control><i class=dot style=background:" + color(d.view.status) + "></i><b>" + html(d.id) + "</b><span>" +
|
|
173
|
+
html(d.view.label ?? d.view.status) + "</span>" + d.actions.map((a) => "<button data-device='" + html(d.id) + "' data-action='" + html(a) + "'>" + html(a) + "</button>").join("") + "</div>").join("")
|
|
174
|
+
: "<p style=color:var(--muted)>None.</p>";
|
|
175
|
+
};
|
|
176
|
+
const renderActions = () => {
|
|
177
|
+
const groups = new Map();
|
|
178
|
+
for (const a of state.actions) groups.set(a.at ?? "", [...(groups.get(a.at ?? "") ?? []), a]);
|
|
179
|
+
$("actions").innerHTML = groups.size
|
|
180
|
+
? [...groups].map(([at, actions]) => (at ? "<div style='margin-top:8px;color:var(--muted)'>" + html(at) + "</div>" : "") +
|
|
181
|
+
"<div class=actions>" + actions.map((a) => "<button data-run='" + html(a.id) + "'>" + html(a.label) + "</button>").join("") + "</div>").join("")
|
|
182
|
+
: "<p style=color:var(--muted)>None available.</p>";
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const post = async (url, body) => {
|
|
186
|
+
const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body ?? {}) });
|
|
187
|
+
if (!response.ok) { const toast = $("toast"); toast.textContent = (await response.json()).message ?? "Failed"; toast.style.display = "block"; setTimeout(() => toast.style.display = "none", 3000); }
|
|
188
|
+
await refresh();
|
|
189
|
+
};
|
|
190
|
+
document.addEventListener("click", (event) => {
|
|
191
|
+
const target = event.target.closest("[data-part],[data-device],[data-run],[data-load],[data-remove]");
|
|
192
|
+
if (!target) return;
|
|
193
|
+
if (target.dataset.run) return post("/actions/" + encodeURIComponent(target.dataset.run));
|
|
194
|
+
if (target.dataset.remove) return post("/loads/remove", { id: target.dataset.remove });
|
|
195
|
+
if (target.dataset.load) { selectedLoad = target.dataset.load; selected = null; return render(); }
|
|
196
|
+
if (target.dataset.device) {
|
|
197
|
+
const argument = target.dataset.action === "read" ? prompt("Code to read (empty for a no-read)") : "";
|
|
198
|
+
if (argument !== null) post("/devices/act", { device: target.dataset.device, action: target.dataset.action, argument });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
selected = target.dataset.part; selectedLoad = null; render();
|
|
202
|
+
});
|
|
203
|
+
$("pause").onclick = () => post("/control", { pause: !state.paused });
|
|
204
|
+
$("step").onclick = () => post("/control", { step: true });
|
|
205
|
+
const refresh = async () => { state = await (await fetch("/state")).json(); render(); };
|
|
206
|
+
const loop = async () => { try { await refresh(); } catch {} setTimeout(loop, 200); };
|
|
207
|
+
loop();
|
|
208
|
+
</script></body></html>`;
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
declare const valueType: unique symbol;
|
|
2
|
+
const pathKey = Symbol("tsplc.path");
|
|
3
|
+
|
|
4
|
+
/** One PLC variable of type `Value`, such as `LineIO.Conveyor_Inbound.Cards[3].Output.MotionAsserted`. */
|
|
5
|
+
export interface Path<Value> {
|
|
6
|
+
readonly [valueType]?: Value;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Typed paths into a PLC's globals: records nest, `.at(index)` selects a record array's element, and
|
|
11
|
+
* scalars and scalar arrays (read and written whole) are `Path`s.
|
|
12
|
+
*/
|
|
13
|
+
export type Paths<T> =
|
|
14
|
+
T extends ReadonlyArray<infer Item>
|
|
15
|
+
? Item extends object
|
|
16
|
+
? { readonly at: (index: number) => Paths<Item> }
|
|
17
|
+
: Path<T>
|
|
18
|
+
: T extends object
|
|
19
|
+
? { readonly [Key in keyof T]: Paths<T[Key]> }
|
|
20
|
+
: Path<T>;
|
|
21
|
+
|
|
22
|
+
const join = (segments: readonly string[]) =>
|
|
23
|
+
segments.reduce(
|
|
24
|
+
(path, segment) =>
|
|
25
|
+
/^\d+$/.test(segment) ? `${path}[${segment}]` : path ? `${path}.${segment}` : segment,
|
|
26
|
+
"",
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// A function target so that `.at` is callable, and still a path for a record field named `at`.
|
|
30
|
+
const proxy = (segments: readonly string[]): unknown =>
|
|
31
|
+
new Proxy(() => undefined, {
|
|
32
|
+
get: (_, key) => {
|
|
33
|
+
if (key === pathKey) return join(segments);
|
|
34
|
+
if (typeof key === "symbol") return undefined;
|
|
35
|
+
return proxy([...segments, key]);
|
|
36
|
+
},
|
|
37
|
+
apply: (_, __, [index]: [number]) => proxy([...segments.slice(0, -1), String(index)]),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Builds typed paths for a generated `PlcGlobals`:
|
|
42
|
+
* `plcPaths<PlcGlobals>().LineIO.Conveyor_Inbound.Cards.at(3).Output.MotionAsserted`.
|
|
43
|
+
*/
|
|
44
|
+
export const plcPaths = <Globals>(): Paths<Globals> => proxy([]) as Paths<Globals>;
|
|
45
|
+
|
|
46
|
+
/** The dotted variable path, e.g. `LineIO.Conveyor_Inbound.Cards[3].Output.MotionAsserted`. */
|
|
47
|
+
export const pathName = (path: Path<unknown>): string => {
|
|
48
|
+
const name: unknown = Reflect.get(path, pathKey);
|
|
49
|
+
if (typeof name !== "string" || !name) throw new Error("Expected a path from plcPaths().");
|
|
50
|
+
return name;
|
|
51
|
+
};
|