diff --git a/apps/graphs/test/__snapshots__/plate.test.ts.snap b/apps/graphs/test/__snapshots__/plate.test.ts.snap
index 1f588b9..4c0b1a1 100644
--- a/apps/graphs/test/__snapshots__/plate.test.ts.snap
+++ b/apps/graphs/test/__snapshots__/plate.test.ts.snap
@@ -1,20 +1,20 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`plate invariants > draws a round-robin group's third subtitle shape 1`] = `""`;
+exports[`plate invariants > draws a round-robin group's third subtitle shape 1`] = `""`;
-exports[`plate snapshots > draws canopy-tui-polish as a plate 1`] = `""`;
+exports[`plate snapshots > draws canopy-tui-polish as a plate 1`] = `""`;
-exports[`plate snapshots > draws hello-graph as a plate 1`] = `""`;
+exports[`plate snapshots > draws hello-graph as a plate 1`] = `""`;
-exports[`plate snapshots > draws quality-bubble as a plate 1`] = `""`;
+exports[`plate snapshots > draws quality-bubble as a plate 1`] = `""`;
-exports[`plate snapshots > draws resilient-members as a plate 1`] = `""`;
+exports[`plate snapshots > draws resilient-members as a plate 1`] = `""`;
-exports[`plate snapshots > draws review-panel as a plate 1`] = `""`;
+exports[`plate snapshots > draws review-panel as a plate 1`] = `""`;
-exports[`plate snapshots > draws single-committer as a plate 1`] = `""`;
+exports[`plate snapshots > draws single-committer as a plate 1`] = `""`;
-exports[`plate snapshots > draws spec-loop as a plate 1`] = `""`;
+exports[`plate snapshots > draws spec-loop as a plate 1`] = `""`;
exports[`the node plate > draws an ensemble node as a group of numbered member chips 1`] = `""`;
diff --git a/apps/graphs/test/plate-lane.test.ts b/apps/graphs/test/plate-lane.test.ts
index 58910ef..06b451d 100644
--- a/apps/graphs/test/plate-lane.test.ts
+++ b/apps/graphs/test/plate-lane.test.ts
@@ -275,10 +275,11 @@ describe("no-handler plates", () => {
it("keeps a handler-free svg byte-identical", async () => {
// Re-pinned when the vertical layout went away and the marker ids lost
// their orientation prefix, then again when the walk's unit twins joined
- // the catalogue plate.
+ // the catalogue plate, then when channel edges left from the top middle
+ // and the twins started handing off mid-leg with the token.
const svg = renderPlate(qualityBubble as unknown as GraphDocument);
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(svg));
const hex = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
- expect(hex).toBe("c954baa22c0d109fefd430e1d67ccccf54b30f1170437c88635db556e24a1130");
+ expect(hex).toBe("f4c722422178fad13866e9d9c588de258499c5171a40e92dcd3095d32afc2966");
});
});
diff --git a/apps/graphs/test/plate-state.test.ts b/apps/graphs/test/plate-state.test.ts
index 71f85b3..2146f8d 100644
--- a/apps/graphs/test/plate-state.test.ts
+++ b/apps/graphs/test/plate-state.test.ts
@@ -122,6 +122,11 @@ describe("renderPlate with a state", () => {
expect(svg).toContain(`class="state-badge"`);
// The token stops on the running member instead of walking the chain.
expect(svg).not.toContain(`]*>/);
+ expect(twin).toContain(`data-name="${label}"`);
});
it("draws one static twin for the running unit and no SMIL", () => {
diff --git a/apps/graphs/test/plate-walk.test.ts b/apps/graphs/test/plate-walk.test.ts
index 08bd00b..9db6609 100644
--- a/apps/graphs/test/plate-walk.test.ts
+++ b/apps/graphs/test/plate-walk.test.ts
@@ -251,15 +251,26 @@ describe("the lit units", () => {
return pattern.exec(svg)?.[1]?.split(";") ?? [];
}
- it("lights a twin only on the legs that touch its unit", () => {
+ it("lights a twin only on the half-legs that touch its unit", () => {
// Every draw dips: the eight-leg walk loops gate↔fix three times, so
- // fix touches legs 1–6, start only leg 0 and done only the final leg.
+ // fix leaves legs 2, 4, 6 and reaches legs 1, 3, 5; start only leaves
+ // leg 0 and done only reaches the final leg. A step leaving a unit
+ // lights the leg's first half, reaching it the second half, so the
+ // glow hands off mid-leg as the token passes.
const svg = renderPlate(LOOP, undefined, () => 0);
expect(svg.match(/class="unit-lit"/g) ?? []).toHaveLength(4);
- expect(twinValues(svg, "fix")).toEqual(["0", "1", "1", "1", "1", "1", "1", "0", "0"]);
- expect(twinValues(svg, "gate")).toEqual(["1", "1", "1", "1", "1", "1", "1", "1", "0"]);
- expect(twinValues(svg, "start")).toEqual(["1", "0", "0", "0", "0", "0", "0", "0", "0"]);
- expect(twinValues(svg, "done")).toEqual(["0", "0", "0", "0", "0", "0", "0", "1", "0"]);
+ expect(twinValues(svg, "fix")).toEqual(
+ ["0", "0", "0", "1", "1", "0", "0", "1", "1", "0", "0", "1", "1", "0", "0", "0", "0"],
+ );
+ expect(twinValues(svg, "gate")).toEqual(
+ ["0", "1", "1", "0", "0", "1", "1", "0", "0", "1", "1", "0", "0", "1", "1", "0", "0"],
+ );
+ expect(twinValues(svg, "start")).toEqual(
+ ["1", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0"],
+ );
+ expect(twinValues(svg, "done")).toEqual(
+ ["0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "1", "0"],
+ );
expect(svg).toContain(
`@media (prefers-reduced-motion:reduce){.plate-svg .unit-lit{display:none}}`,
);
diff --git a/apps/graphs/test/plate.test.ts b/apps/graphs/test/plate.test.ts
index ef717ad..9bdf4e9 100644
--- a/apps/graphs/test/plate.test.ts
+++ b/apps/graphs/test/plate.test.ts
@@ -724,8 +724,9 @@ describe("the pattern figures", () => {
expect(layout).not.toBeNull();
const fail = layout?.edges.find((edge) => edge.from === "review-panel" && edge.to === "implement");
expect(fail?.cls).toBe("fail");
- // A channel: only straight runs and rounded corners, from the source's
- // top corner to the target's top corner, topping out above the row.
+ // A channel: only straight runs and rounded corners, from the middle
+ // of the source's top edge to the middle of the target's, topping
+ // out above the row.
for (const letter of commands(fail?.d ?? "")) {
expect("MLQ").toContain(letter);
}
@@ -738,14 +739,8 @@ describe("the pattern figures", () => {
}
const box = nodeBox(implement);
const round = (value: number): number => Math.round(value * 100) / 100;
- expect([
- [round(panel.x - panel.w / 2), round(panel.y - panel.h / 2)],
- [round(panel.x + panel.w / 2), round(panel.y - panel.h / 2)],
- ]).toContainEqual(pathStart(fail.d));
- expect([
- [round(implement.x - box.hw), round(implement.y - box.hh)],
- [round(implement.x + box.hw), round(implement.y - box.hh)],
- ]).toContainEqual(pathEnd(fail.d));
+ expect(pathStart(fail.d)).toEqual([round(panel.x), round(panel.y - panel.h / 2)]);
+ expect(pathEnd(fail.d)).toEqual([round(implement.x), round(implement.y - box.hh)]);
const rowTop = Math.min(
...(layout?.nodes ?? [])
.filter((node) => node.lane === "main")
diff --git a/packages/plate/layout.ts b/packages/plate/layout.ts
index bafb49d..11908ff 100644
--- a/packages/plate/layout.ts
+++ b/packages/plate/layout.ts
@@ -402,9 +402,9 @@ export function layoutPlate(document: unknown, random: () => number = happyRando
// Edge endpoints on the unit borders, by drawn name. A forward edge leaves
// the source border facing its target at the source height and enters the
// target border facing its source at the target height, so the bend sits
- // at the midpoint of the rank gap. A channel edge leaves the source's top
- // corner facing its target and enters the target's top corner facing its
- // source, clearing the units stacked above.
+ // at the midpoint of the rank gap. A channel edge leaves the middle of
+ // the source's top edge and enters the middle of the target's top edge,
+ // clearing the units stacked above.
const groupByName = new Map(groups.map((group) => [group.name, group]));
const displayName = drawnName;
/** Whether an edge draws along a channel above the flow. */
@@ -430,10 +430,9 @@ export function layoutPlate(document: unknown, random: () => number = happyRando
b: { x: num(to.x + to.hw), y: to.y },
};
const channelEnds = (from: Extent, to: Extent): { a: Pt; b: Pt } => {
- const dir = to.x < from.x ? -1 : 1;
return {
- a: { x: num(from.x + dir * from.hw), y: num(from.y - from.hh) },
- b: { x: num(to.x - dir * to.hw), y: num(to.y - to.hh) },
+ a: { x: num(from.x), y: num(from.y - from.hh) },
+ b: { x: num(to.x), y: num(to.y - to.hh) },
};
};
diff --git a/packages/plate/render.ts b/packages/plate/render.ts
index 174121c..f514d84 100644
--- a/packages/plate/render.ts
+++ b/packages/plate/render.ts
@@ -357,8 +357,8 @@ function twinOpen(twin: UnitTwin): string {
/**
* The catalogue's lit unit: the unit's own rect behind it, riding a
- * discrete SMIL opacity that turns on exactly on the walk's legs that
- * touch it. `prefers-reduced-motion` hides it whole.
+ * discrete SMIL opacity that turns on exactly on the walk's half-legs
+ * that touch it. `prefers-reduced-motion` hides it whole.
*/
function renderLitUnit(twin: UnitTwin, legs: number[], total: number, seconds: number): string {
const { values, keyTimes } = litTiming(legs, total);
@@ -375,32 +375,53 @@ function renderStaticLitUnit(twin: UnitTwin): string {
return `${twinOpen(twin)}/>`;
}
-/** The legs of the walk that reach or leave one unit. */
-function touchingLegs(steps: WalkStep[], name: string): number[] {
- const legs: number[] = [];
+/**
+ * The half-legs of the walk that touch one unit: a step leaving it lights
+ * the first half of its leg, a step reaching it the second half — the glow
+ * hands off mid-leg, as the token passes the midpoint, instead of lighting
+ * both ends for the whole leg.
+ */
+function touchingSlots(steps: WalkStep[], name: string): number[] {
+ const slots: number[] = [];
for (const [index, step] of steps.entries()) {
- if (step.from === name || step.to === name) {
- legs.push(index);
+ if (step.from === name) {
+ slots.push(index * 2);
+ }
+ if (step.to === name) {
+ slots.push(index * 2 + 1);
}
}
- return legs;
+ return slots;
}
/** The catalogue's glow: one lit twin per unit the walk visits, behind them. */
function renderLits(layout: PlateLayout): string {
- const total = layout.walkSteps.length;
+ const total = layout.walkSteps.length * 2;
if (total === 0) {
return "";
}
const units = [
- ...layout.nodes.map((node) => ({ twin: nodeTwin(node), legs: touchingLegs(layout.walkSteps, node.name) })),
- ...layout.groups.map((group) => ({ twin: groupTwin(group), legs: touchingLegs(layout.walkSteps, group.name) })),
+ ...layout.nodes.map((node) => ({ twin: nodeTwin(node), legs: touchingSlots(layout.walkSteps, node.name) })),
+ ...layout.groups.map((group) => ({ twin: groupTwin(group), legs: touchingSlots(layout.walkSteps, group.name) })),
].filter((unit) => unit.legs.length > 0);
return `${units
.map((unit) => renderLitUnit(unit.twin, unit.legs, total, layout.walkSeconds))
.join("")}`;
}
+/** The glow twin of a running ensemble member: its own chip, where the token sits. */
+function memberTwin(groupName: string, member: PlacedMember, index: number): UnitTwin {
+ return {
+ name: member.label === "" ? `${groupName}#${index + 1}` : member.label,
+ x: member.x,
+ y: member.y,
+ w: MEMBER_GEOM.w,
+ h: MEMBER_GEOM.h,
+ rx: 1,
+ rotate: 0,
+ };
+}
+
function renderMember(
groupName: string,
member: PlacedMember,
@@ -415,6 +436,7 @@ function renderMember(
const title = mark === null
? ""
: `${escape(member.label)} — ${STATE_WORD[mark.status]}`;
+ const twin = mark?.lit === true ? renderStaticLitUnit(memberTwin(groupName, member, index)) : "";
const ring = mark?.lit === true
? renderRing(member.x, member.y, Math.max(MEMBER_GEOM.w, MEMBER_GEOM.h) / 2 + 1.7)
: "";
@@ -427,6 +449,7 @@ function renderMember(
title +
`` +
+ twin +
ring +
`${escape(member.label)}` +
harness +
@@ -435,16 +458,16 @@ function renderMember(
);
}
-/** Whether a running key lights the whole ensemble. */
+/**
+ * Whether the ensemble's own key runs: then the whole box glows. A running
+ * member lights its own chip instead (renderMember), where the token sits,
+ * so the glow never floats apart from the dot.
+ */
function groupRunning(state: PlateState | undefined, group: PlacedGroup): boolean {
if (state === undefined) {
return false;
}
- const keys = [
- group.name,
- ...group.members.flatMap((member, index) => [member.label, `${group.name}#${index + 1}`]),
- ];
- return keys.some((key) => statusOf(state, key) === "running");
+ return statusOf(state, group.name) === "running";
}
function renderGroups(layout: PlateLayout, state: PlateState | undefined): string {