Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/viewer/src/canvas/FlowLayer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { Flow } from '../motion/flow';
import { FlowLayer, type FlowClock } from './FlowLayer';

const flow: Flow = {
period: 2000,
pulses: [
{
id: 'ab',
from: 'a',
to: 'b',
kind: 'sync',
tint: 'service',
path: [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
],
length: 100,
delay: 0,
travel: 500,
glow: null,
},
],
};

describe('FlowLayer', () => {
it('draws the frame an outside clock asks for, and nothing when it asks for none', () => {
let draw: (ms: number | null) => void = () => {};
const clock: FlowClock = (d) => {
draw = d;
return () => {};
};
const { container } = render(<FlowLayer flow={flow} width={100} height={10} clock={clock} />);
const pulse = container.querySelector<SVGGElement>('[data-pulse="ab"]')!;
const head = container.querySelector<SVGGElement>('[data-part="head"]')!;
draw(250);
expect(pulse.style.visibility).toBe('');
expect(head.getAttribute('transform')).toMatch(/^translate\(75 0\)$/);
draw(null);
expect(pulse.style.visibility).toBe('hidden');
});
});
39 changes: 26 additions & 13 deletions packages/viewer/src/canvas/FlowLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,29 @@ const accent = (p: FlowPulse) => `var(--sm-${p.tint}-accent)`;
const polyline = (p: FlowPulse) => `M${p.path.map((q) => `${q.x} ${q.y}`).join('L')}`;
const PACKETS = [0, 1, 2];

/** Drives the layer: calls `draw` with ms into playback, or null to hide every pulse; returns a stop. */
export type FlowClock = (draw: (ms: number | null) => void) => () => void;

const playing: FlowClock = (draw) => {
let began: number | null = null;
let frame = 0;
const tick = (now: number) => {
began ??= now;
draw(now - began);
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
};

/**
* Flow playback on the canvas, drawing `pulseFrame` for every pulse: a landing glow behind the target, a
* tapering trail lighting the wire, the port flash, and the head (a dot; a train of packets on an async edge;
* a hollow ring on a reply). Sits above the edges and under the cards, so glows bloom out from behind a card
* and a pulse slides out of one card into the next. One rAF loop writes attributes; React renders only when
* the flow changes. Exports skip it (`data-flow`).
* the flow changes; a `clock` (a scroll scrub) can drive it instead. Exports skip it (`data-flow`).
*/
export function FlowLayer({ flow, width, height }: { flow: Flow; width: number; height: number }) {
export function FlowLayer({ flow, width, height, clock }: { flow: Flow; width: number; height: number; clock?: FlowClock }) {
const root = useRef<SVGGElement>(null);
useEffect(() => {
const groups = [...(root.current?.children ?? [])] as SVGGElement[];
Expand All @@ -31,13 +46,12 @@ export function FlowLayer({ flow, width, height }: { flow: Flow; width: number;
if (el.style.visibility !== v) el.style.visibility = v;
return on;
};
let began: number | null = null;
let frame = 0;
const tick = (now: number) => {
began ??= now;
// Only the viewer's own playback says which edges are in flight; a borrowed clock is someone else's.
const tracked = !clock;
const draw = (ms: number | null) => {
const flying: string[] = [];
flow.pulses.forEach((p, i) => {
const f = pulseFrame(flow, p, now - began!);
const f = ms === null ? null : pulseFrame(flow, p, ms);
const el = parts[i]!;
if (!show(groups[i]!, !!f) || !f) return;
if (f.head !== null) flying.push(p.id);
Expand Down Expand Up @@ -74,15 +88,14 @@ export function FlowLayer({ flow, width, height }: { flow: Flow; width: number;
h.setAttribute('transform', `translate(${at.x} ${at.y})`);
});
});
setInFlight(flying);
frame = requestAnimationFrame(tick);
if (tracked) setInFlight(flying);
};
frame = requestAnimationFrame(tick);
const stop = (clock ?? playing)(draw);
return () => {
cancelAnimationFrame(frame);
setInFlight([]);
stop();
if (tracked) setInFlight([]);
};
}, [flow]);
}, [flow, clock]);
return (
<svg aria-hidden="true" data-flow="" className="pointer-events-none absolute top-0 left-0 overflow-visible" width={width} height={height}>
<g ref={root}>
Expand Down
2 changes: 1 addition & 1 deletion packages/viewer/src/chrome/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export function Toolbar({ theme, onToggleTheme, onPresent }: { theme: ThemeChoic
return (
<div
ref={root}
className={`${PANEL_CLASS} relative flex items-center gap-0.5 p-1.5`}
className={`${PANEL_CLASS} relative flex items-center gap-0.5 p-1.5 @max-md:flex-wrap`}
style={PANEL_STYLE}
onKeyDown={(e) => {
if (e.key !== 'Escape' || !lensOpen) return;
Expand Down
15 changes: 9 additions & 6 deletions packages/viewer/src/chrome/ViewerShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,10 @@ export function ViewerShell({
}, [reloaded]);
return (
<ExploreProvider draft={draft}>
<div ref={shell} data-presenting={presenting || undefined} className="flex h-full flex-col bg-page font-sans text-fg">
<div ref={shell} data-presenting={presenting || undefined} className="@container flex h-full flex-col bg-page font-sans text-fg">
{!presenting && (
<header className="mx-8 mt-4 flex gap-8 border-b border-divider">
<div className="flex max-w-[45%] min-w-0 items-center gap-3 py-3">
<header className="mx-8 mt-4 flex gap-8 border-b border-divider @max-md:mx-4 @max-md:mt-2 @max-md:flex-wrap @max-md:gap-x-4 @max-md:gap-y-0">
<div className="flex max-w-[45%] min-w-0 items-center gap-3 py-3 @max-md:max-w-full @max-md:basis-full @max-md:pb-1">
<Title className="truncate text-[20px] leading-7 font-semibold tracking-tight">{draft.title}</Title>
<span
className="shrink-0 rounded-lg bg-panel px-2 py-0.5 text-[13px] text-fg-muted"
Expand All @@ -159,7 +159,7 @@ export function ViewerShell({
</header>
)}
{!presenting && <ViewCaption />}
<main className={`flex min-h-0 flex-1 gap-4 ${presenting ? '' : 'px-8 pt-4 pb-6'}`}>
<main className={`flex min-h-0 flex-1 gap-4 @max-md:gap-2 ${presenting ? '' : 'px-8 pt-4 pb-6 @max-md:px-3 @max-md:pt-3 @max-md:pb-3'}`}>
<section
id={DIAGRAM_ID}
role="tabpanel"
Expand All @@ -168,8 +168,11 @@ export function ViewerShell({
style={presenting ? undefined : { boxShadow: 'inset 0 0 0 1px var(--sm-panel-border)' }}
>
<DiagramCanvas diagram={diagram} chrome={!presenting}>
<CanvasPanel position="top-left" className="flex gap-2">
<IdentityCard draft={draft} />
<CanvasPanel position="top-left" className="flex gap-2 @max-md:right-[15px]">
{/* Narrow, the header already names the diagram: the toolbar gets the room. */}
<div className="contents @max-md:hidden">
<IdentityCard draft={draft} />
</div>
<Toolbar theme={theme} onToggleTheme={onToggleTheme} onPresent={() => present(true)} />
</CanvasPanel>
</DiagramCanvas>
Expand Down
53 changes: 53 additions & 0 deletions packages/viewer/src/motion/flowOf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { CARD, COMPACT } from '@stackmap/core';
import { routeBetween, type Graph } from '../explore/graph';
import type { Emphasis } from '../explore/emphasis';
import type { ExploreState } from '../explore/state';
import { EDGE_RADIUS, type Scene } from '../canvas/scene';
import { hopFlow, hopRanks, NO_FLOW, roundedPolyline, waveFlow, type Flow, type FlowEdge } from './flow';

/** What a pulse needs from the scene: its drawn route, its tint, and what lights up when it lands. */
function flowEdges(scene: Scene): Map<string, FlowEdge> {
const cards = new Map(scene.cards.map((c) => [c.node.id, c]));
const radius = scene.compact ? COMPACT.radius : CARD.radius;
return new Map(
scene.edges.map((e) => {
const end = e.points.at(-1);
// A sequence lands on the activation bar the message opens or feeds (the innermost), else on the lifeline.
const bar =
scene.kind === 'sequence' && end
? scene.activations.filter((a) => a.node === e.to && end.y >= a.rect.y - 2 && end.y <= a.rect.y + a.rect.height + 2).sort((a, b) => b.depth - a.depth)[0]
: undefined;
const target = cards.get(e.to);
const glow = scene.kind === 'sequence' ? (bar ? { rect: bar.rect, radius: 2, tint: bar.type } : null) : target ? { rect: target.rect, radius, tint: target.node.type } : null;
const tint = e.tone === 'error' ? 'failure' : (cards.get(e.from)?.node.type ?? 'external');
return [e.id, { id: e.id, from: e.from, to: e.to, kind: e.kind, tint, path: roundedPolyline(e.points, EDGE_RADIUS), glow }];
}),
);
}

/**
* The flow that plays is whatever the explorer is showing: a route runs hop by hop from its start, a selection
* (traced or not) runs through its lit connections, and otherwise everything not dimmed (by a view, the lens or
* a search) plays: a wave in reading order, or a sequence's messages replayed one after another.
*/
export function flowOf(scene: Scene, graph: Graph, state: Pick<ExploreState, 'route' | 'selected'>, em: Emphasis): Flow {
const edges = flowEdges(scene);
const pick = (ids: string[]) => ids.flatMap((id) => edges.get(id) ?? []);
const lit = scene.edges.filter((e) => em.edges.get(e.id)?.tint).map((e) => e.id);
const serial = graph.timed;
if (state.route) {
const route = routeBetween(graph, state.route.from, state.route.to);
if (!route) return NO_FLOW;
// A reversed route's `from` is already the end it really runs from.
return hopFlow(pick(lit), hopRanks(graph, new Set(lit), { start: route.from, fallback: route.from }), { serial });
}
if (state.selected) return hopFlow(pick(lit), hopRanks(graph, new Set(lit), { fallback: state.selected }), { serial });
const shown = scene.edges.filter((e) => !em.edges.get(e.id)?.dim).map((e) => e.id);
if (serial) return hopFlow(pick(shown), hopRanks(graph, new Set(shown), { fallback: '' }), { serial });
const rect = new Map(scene.cards.map((c) => [c.node.id, c.rect]));
const at = (id: string) => {
const r = rect.get(id);
return r ? (scene.direction === 'RIGHT' ? r.x : r.y) : 0;
};
return waveFlow(pick(shown).map((e) => ({ ...e, at: at(e.from) })));
}
56 changes: 4 additions & 52 deletions packages/viewer/src/motion/useFlow.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,10 @@
import { useMemo } from 'react';
import { CARD, COMPACT } from '@stackmap/core';
import { useExplore } from '../explore/ExploreContext';
import { routeBetween, type Graph } from '../explore/graph';
import type { Emphasis } from '../explore/emphasis';
import type { ExploreState } from '../explore/state';
import { EDGE_RADIUS, type Scene } from '../canvas/scene';
import { hopFlow, hopRanks, NO_FLOW, roundedPolyline, waveFlow, type Flow, type FlowEdge } from './flow';
import type { Scene } from '../canvas/scene';
import type { Flow } from './flow';
import { flowOf } from './flowOf';

/** What a pulse needs from the scene: its drawn route, its tint, and what lights up when it lands. */
function flowEdges(scene: Scene): Map<string, FlowEdge> {
const cards = new Map(scene.cards.map((c) => [c.node.id, c]));
const radius = scene.compact ? COMPACT.radius : CARD.radius;
return new Map(
scene.edges.map((e) => {
const end = e.points.at(-1);
// A sequence lands on the activation bar the message opens or feeds (the innermost), else on the lifeline.
const bar =
scene.kind === 'sequence' && end
? scene.activations.filter((a) => a.node === e.to && end.y >= a.rect.y - 2 && end.y <= a.rect.y + a.rect.height + 2).sort((a, b) => b.depth - a.depth)[0]
: undefined;
const target = cards.get(e.to);
const glow = scene.kind === 'sequence' ? (bar ? { rect: bar.rect, radius: 2, tint: bar.type } : null) : target ? { rect: target.rect, radius, tint: target.node.type } : null;
const tint = e.tone === 'error' ? 'failure' : (cards.get(e.from)?.node.type ?? 'external');
return [e.id, { id: e.id, from: e.from, to: e.to, kind: e.kind, tint, path: roundedPolyline(e.points, EDGE_RADIUS), glow }];
}),
);
}

/**
* The flow that plays is whatever the explorer is showing: a route runs hop by hop from its start, a selection
* (traced or not) runs through its lit connections, and otherwise everything not dimmed (by a view, the lens or
* a search) plays: a wave in reading order, or a sequence's messages replayed one after another.
*/
export function flowOf(scene: Scene, graph: Graph, state: Pick<ExploreState, 'route' | 'selected'>, em: Emphasis): Flow {
const edges = flowEdges(scene);
const pick = (ids: string[]) => ids.flatMap((id) => edges.get(id) ?? []);
const lit = scene.edges.filter((e) => em.edges.get(e.id)?.tint).map((e) => e.id);
const serial = graph.timed;
if (state.route) {
const route = routeBetween(graph, state.route.from, state.route.to);
if (!route) return NO_FLOW;
// A reversed route's `from` is already the end it really runs from.
return hopFlow(pick(lit), hopRanks(graph, new Set(lit), { start: route.from, fallback: route.from }), { serial });
}
if (state.selected) return hopFlow(pick(lit), hopRanks(graph, new Set(lit), { fallback: state.selected }), { serial });
const shown = scene.edges.filter((e) => !em.edges.get(e.id)?.dim).map((e) => e.id);
if (serial) return hopFlow(pick(shown), hopRanks(graph, new Set(shown), { fallback: '' }), { serial });
const rect = new Map(scene.cards.map((c) => [c.node.id, c.rect]));
const at = (id: string) => {
const r = rect.get(id);
return r ? (scene.direction === 'RIGHT' ? r.x : r.y) : 0;
};
return waveFlow(pick(shown).map((e) => ({ ...e, at: at(e.from) })));
}
export { flowOf };

export function useFlow(scene: Scene): Flow {
const { graph, state, emphasis } = useExplore();
Expand Down
27 changes: 27 additions & 0 deletions site/components/landing/KindFlow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use client';

import { useCallback, useRef } from 'react';
import { FlowLayer, type FlowClock } from '@stackmap/viewer/src/canvas/FlowLayer';
import { holdTime, type KindFlow as Data } from './kinds-geometry';
import { s } from './style';

/** Kind k's pulses over its layer, playing one loop through its hold as the scene scrubs; hidden at rest. */
export function KindFlow({ data, k }: { data: Data; k: number }) {
const box = useRef<HTMLDivElement>(null);
const { flow, origin, width, height } = data;
const clock = useCallback<FlowClock>(
(draw) => {
const scene = box.current?.closest<HTMLElement>('[data-scene]');
if (!scene) return () => {};
const onScene = (e: Event) => draw(holdTime((e as CustomEvent<number>).detail, k, flow.period));
scene.addEventListener('scene', onScene);
return () => scene.removeEventListener('scene', onScene);
},
[k, flow.period],
);
return (
<div ref={box} className="kflow" style={s({ transform: `translate(${-origin.x}px, ${-origin.y}px)` })}>
<FlowLayer flow={flow} width={width} height={height} clock={clock} />
</div>
);
}
7 changes: 5 additions & 2 deletions site/components/landing/KindsStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ import { NodeCard } from '@stackmap/viewer/src/card/NodeCard';
import { StepCard } from '@stackmap/viewer/src/card/StepCard';
import { Still } from '@/components/diagram/Still';
import { stillOf } from '@/lib/diagrams';
import { kindsGeometry, kindsTweens, type Area } from './kinds-geometry';
import { KindFlow } from './KindFlow';
import { kindsGeometry, kindsTweens, restingFlow, type Area } from './kinds-geometry';
import { s } from './style';

const px = (n: number) => `${Math.round(n * 10) / 10}px`;

/**
* One checkout, five ways: each kind's layer (its lanes, phases and connections) plus the card slots that
* glide between kinds. At rest the scene's data-kind picks what shows; scrolling plays the tweens it carries.
* glide between kinds. At rest the scene's data-kind picks what shows; scrolling plays the tweens it carries,
* and each kind's flow in its hold.
*/
export function KindsStage({ checkout, area, root }: { checkout: Record<string, LaidOutDiagram>; area: Area; root: string }) {
const g = kindsGeometry(checkout, area);
Expand All @@ -35,6 +37,7 @@ export function KindsStage({ checkout, area, root }: { checkout: Record<string,
{g.layers.map((l, k) => (
<div key={l.kind} className={`klayer kl${k}`} style={s({ left: px(l.left), top: px(l.top), transform: `scale(${g.scale})` })}>
<Still still={stillOf(`checkout/${l.kind}`)} />
<KindFlow data={restingFlow(l.diagram)} k={k} />
</div>
))}
{g.slots.map((faces, i) => (
Expand Down
2 changes: 2 additions & 0 deletions site/components/landing/desktop.css
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@
.vx {position:absolute;left:720px;top:450px;width:0;height:0}
.vring {position:absolute;left:0;top:0}
.vchip {position:absolute;left:0;top:0;white-space:nowrap;padding:8px 14px;border-radius:9px;background:var(--sm-panel);box-shadow:inset 0 0 0 1px var(--sm-panel-border);font-family:var(--font-mono);line-height:1.5;color:var(--sm-text)}
/* Decorative texture: the names are drawn, not text, so they carry no contrast duty. */
.vchip[data-label]::before {content:attr(data-label)}
.vghost {padding:0;background:none;box-shadow:none}
.kline-wrap {position:absolute;inset:0;transform-origin:720px 450px}
.kl {position:absolute;left:96px;margin:0;font-size:96px;line-height:1;font-weight:600;letter-spacing:-0.045em;color:var(--sm-text);white-space:nowrap;transition:color 300ms ease}
Expand Down
7 changes: 7 additions & 0 deletions site/components/landing/desktop/Hero.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { SITE } from '@/lib/site-data';
import { BoardCopy } from '../BoardCopy';
import { s } from '../style';
import { LINKS } from '@/components/site/links';

export function Hero() {
return (
Expand Down Expand Up @@ -156,6 +157,12 @@ export function Hero() {
<code className="mono cmd-t">{SITE.install.skill}</code>
<BoardCopy text={SITE.install.skill} label="Copy the install command" />
</div>
<a className="lnk" href={LINKS.film.href} target="_blank" rel="noreferrer">
<svg className="ic" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" />
</svg>
<span>{LINKS.film.label}</span>
</a>
<a className="lnk" href="https://github.com/hyamero/stackmap" target="_blank" rel="noreferrer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
Expand Down
Loading
Loading