General
Flow
Usage#
import { WdsFlowComponent } from '@wds/angular/flow';Examples#
Default#
nodes and edges are properties (in HTML set them from a script); each step is your own card in the slot named after its node id, and positions are placed by hand. Drag the pane to move it, scroll to zoom, drag a step to move it and drag from a handle to another step to connect them; click selects and Delete removes the selection. type: 'step' draws right-angled edges, label names a branch (Yes / No) and animated moves dashes along the path that ran. The controls call zoomIn(), zoomOut() and fitView(), and the lock sets readonly. The flow needs the @xyflow/system package: pnpm add @xyflow/system.
import { Component, computed, signal, viewChild } from '@angular/core';
import type { WdsFlowEdge, WdsFlowNode } from '@wds/core/flow';
import { WdsAvatarComponent } from '@wds/angular/avatar';
import { WdsBadgeComponent } from '@wds/angular/badge';
import { WDS_BREADCRUMB } from '@wds/angular/breadcrumb';
import { WdsButtonComponent } from '@wds/angular/button';
import { WDS_BUTTON_GROUP } from '@wds/angular/button-group';
import { WdsFlowComponent } from '@wds/angular/flow';
import { EDGES, ICONS, NODES, STATUS, STEPS, newStep, type Step } from './flow-data';
@Component({
selector: 'app-flow-default-demo',
host: { class: 'block w-full' },
imports: [...WDS_BREADCRUMB, ...WDS_BUTTON_GROUP, WdsAvatarComponent, WdsBadgeComponent, WdsButtonComponent, WdsFlowComponent],
template: `
<div class="flex h-[480px] w-full flex-col overflow-hidden rounded-xl border border-border bg-background text-sm">
<header class="flex items-center gap-3 border-b border-border px-4 py-2">
<wds-breadcrumb class="shrink-0" label="Workflow">
<wds-breadcrumb-item><wds-breadcrumb-link href="#">Workflows</wds-breadcrumb-link></wds-breadcrumb-item>
<wds-breadcrumb-item><wds-breadcrumb-page>Lead Routing</wds-breadcrumb-page></wds-breadcrumb-item>
</wds-breadcrumb>
<span class="min-w-0 truncate text-xs text-muted-foreground">{{ summary() }} · {{ savedAt() }}</span>
<div class="ms-auto flex shrink-0 gap-2">
<wds-button variant="outline" size="sm" (click)="addStep()">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
@for (d of icons.plus; track $index) {
<path [attr.d]="d" />
}
</svg>
Add step
</wds-button>
<wds-button variant="ghost" size="sm" [disabled]="!dirty()" (click)="update()">Update</wds-button>
</div>
</header>
<div class="relative min-h-0 flex-1">
<wds-flow
class="h-full bg-muted/30"
label="Lead Routing workflow"
fit-view-on-init
[fitViewPadding]="0.2"
[readonly]="locked()"
[nodes]="nodes()"
[edges]="edges()"
(wdsNodesChange)="onNodesChange($event.nodes)"
(wdsEdgesChange)="onEdgesChange($event.edges)"
(wdsViewportChange)="zoom.set($event.zoom)"
>
<!-- Each node is your own card in the slot named after its id. -->
@for (step of steps(); track step.id) {
<div [attr.slot]="step.id" class="w-48 rounded-lg border border-border bg-card shadow-xs">
<div class="flex items-center gap-2 p-2">
<span
class="flex size-7 shrink-0 items-center justify-center rounded-md border"
[class]="step.kind === 'trigger' ? 'border-foreground bg-foreground text-background' : 'border-border bg-background'"
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@for (d of step.icon; track $index) {
<path [attr.d]="d" />
}
</svg>
</span>
<div class="min-w-0">
<div class="truncate text-[13px] leading-tight font-medium">{{ step.title }}</div>
<div class="truncate text-[11px] leading-tight text-muted-foreground">{{ step.subtitle }}</div>
</div>
</div>
<div class="flex items-center gap-1.5 border-t border-border px-2 py-1.5 text-[11px] text-muted-foreground">
<wds-avatar class="[--wds-avatar-font-size:9px] [--wds-avatar-size:1.25rem]" [alt]="'Owner: ' + step.owner.name">{{ step.owner.initials }}</wds-avatar>
<span class="tabular-nums">{{ step.duration ?? 'Not run' }}</span>
<wds-badge variant="outline" class="ms-auto gap-1 text-[11px]" [class]="status[step.status].className">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
@for (d of status[step.status].icon; track $index) {
<path [attr.d]="d" />
}
</svg>
{{ status[step.status].label }}
</wds-badge>
</div>
</div>
}
</wds-flow>
<wds-button-group class="absolute bottom-3 left-3 rounded-lg bg-background shadow-sm" aria-label="Canvas controls">
<wds-button variant="outline" size="icon-sm" label="Zoom out" (click)="flow().zoomOut()">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14" /></svg>
</wds-button>
<wds-button-group-text class="w-14 justify-center tabular-nums" aria-live="polite">{{ zoomLabel() }}</wds-button-group-text>
<wds-button variant="outline" size="icon-sm" label="Zoom in" (click)="flow().zoomIn()">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14" /><path d="M12 5v14" /></svg>
</wds-button>
<wds-button variant="outline" size="icon-sm" label="Fit view" (click)="flow().fitView({ padding: 0.2, duration: 200 })">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H5a2 2 0 0 0-2 2v3" /><path d="M21 8V5a2 2 0 0 0-2-2h-3" /><path d="M3 16v3a2 2 0 0 0 2 2h3" /><path d="M16 21h3a2 2 0 0 0 2-2v-3" /></svg>
</wds-button>
<!-- readonly: no dragging, connecting or deleting; the pane still pans and zooms. -->
<wds-button variant="outline" size="icon-sm" label="Lock the canvas" [attr.aria-pressed]="locked()" [class.bg-accent]="locked()" (click)="locked.set(!locked())">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2z" />
<path [attr.d]="locked() ? 'M7 11V7a5 5 0 0 1 10 0v4' : 'M7 11V7a5 5 0 0 1 9.9-1'" />
</svg>
</wds-button>
</wds-button-group>
</div>
</div>
`,
})
export class FlowDefaultDemo {
protected readonly flow = viewChild.required(WdsFlowComponent);
protected readonly icons = ICONS;
protected readonly status = STATUS;
protected readonly steps = signal<Step[]>(STEPS);
protected readonly nodes = signal(NODES);
protected readonly edges = signal(EDGES);
protected readonly zoom = signal(1);
protected readonly locked = signal(false);
protected readonly dirty = signal(false);
protected readonly savedAt = signal('2h ago');
protected readonly zoomLabel = computed(() => `${Math.round(this.zoom() * 100)}%`);
protected readonly summary = computed(() => {
const steps = this.nodes().length;
const links = this.edges().length;
return `${steps} ${steps === 1 ? 'step' : 'steps'}, ${links} ${links === 1 ? 'connection' : 'connections'}`;
});
private added = 0;
protected onNodesChange(nodes: WdsFlowNode[]) {
this.nodes.set(nodes);
// Deleted nodes take their cards with them.
this.steps.update((steps) => steps.filter((s) => nodes.some((n) => n.id === s.id)));
this.dirty.set(true);
}
protected onEdgesChange(edges: WdsFlowEdge[]) {
// New connections get the same right-angled line.
this.edges.set(edges.map((e) => ({ type: 'step', ...e })));
this.dirty.set(true);
}
protected addStep() {
const { step, node } = newStep(++this.added, this.nodes());
this.steps.update((steps) => [...steps, step]);
this.nodes.update((nodes) => [...nodes.map((n) => ({ ...n, selected: false })), node]);
this.dirty.set(true);
requestAnimationFrame(() => this.flow().fitView({ padding: 0.2, duration: 200 }));
}
protected update() {
this.dirty.set(false);
this.savedAt.set('just now');
}
}import type { WdsFlowEdge, WdsFlowNode } from '@wds/core/flow';
export type StepKind = 'trigger' | 'condition' | 'action';
export type StepStatus = 'success' | 'failed' | 'idle' | 'skipped';
export interface Step {
id: string;
kind: StepKind;
title: string;
subtitle: string;
/** Lucide icon as SVG path data (24 × 24). */
icon: string[];
owner: { name: string; initials: string };
/** Duration of the last run, or `null` when the step did not run. */
duration: string | null;
status: StepStatus;
}
/** `M cx-r cy` + two arcs: a circle as path data. */
const circle = (cx: number, cy: number, r: number) => `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0`;
export const ICONS = {
webhook: [
'M18 16.98h-5.99c-1.1 0-1.95.94-2.48 1.9A4 4 0 0 1 2 17c.01-.7.2-1.4.57-2',
'm6 17 3.13-5.78c.53-.97.1-2.18-.5-3.1a4 4 0 1 1 6.89-4.06',
'm12 6 3.13 5.73C15.66 12.7 16.9 13 18 13a4 4 0 0 1 0 8',
],
branch: ['M6 3v12', circle(18, 6, 3), circle(6, 18, 3), 'M18 9a9 9 0 0 1-9 9'],
database: ['M3 5a9 3 0 1 0 18 0a9 3 0 1 0-18 0', 'M3 5v14a9 3 0 0 0 18 0V5', 'M3 12a9 3 0 0 0 18 0'],
zap: [
'M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z',
],
mail: ['M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z', 'm22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7'],
plus: ['M5 12h14', 'M12 5v14'],
};
/** Badge of the last run: text, icon and colors. */
export const STATUS: Record<StepStatus, { label: string; icon: string[]; className: string }> = {
success: {
label: 'Success',
icon: [circle(12, 12, 10), 'm9 12 2 2 4-4'],
className: 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300',
},
failed: {
label: 'Failed',
icon: [circle(12, 12, 10), 'm15 9-6 6', 'm9 9 6 6'],
className: 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-300',
},
idle: { label: 'Idle', icon: [circle(12, 12, 10), 'M12 6v6l4 2'], className: '' },
skipped: { label: 'Skipped', icon: ['M5 12h14'], className: '' },
};
export const KIND_LABEL: Record<StepKind, string> = { trigger: 'Trigger', condition: 'Condition', action: 'Action' };
export const STEPS: Step[] = [
{
id: 'form-submitted',
kind: 'trigger',
title: 'Form submitted',
subtitle: 'Webhook: demo.request',
icon: ICONS.webhook,
owner: { name: 'Olivia Martin', initials: 'OM' },
duration: '0.2s',
status: 'success',
},
{
id: 'plan-is-enterprise',
kind: 'condition',
title: 'Plan is Enterprise',
subtitle: 'company.size > 200',
icon: ICONS.branch,
owner: { name: 'Olivia Martin', initials: 'OM' },
duration: '0.1s',
status: 'success',
},
{
id: 'enrich-company',
kind: 'action',
title: 'Enrich company',
subtitle: 'Clearbit lookup',
icon: ICONS.database,
owner: { name: 'Jackson Lee', initials: 'JL' },
duration: '8.0s',
status: 'failed',
},
{
id: 'create-deal',
kind: 'action',
title: 'Create deal',
subtitle: 'HubSpot pipeline: Inbound',
icon: ICONS.zap,
owner: { name: 'Jackson Lee', initials: 'JL' },
duration: null,
status: 'idle',
},
{
id: 'notify-sales',
kind: 'action',
title: 'Notify sales',
subtitle: 'Slack #inbound-leads',
icon: ICONS.mail,
owner: { name: 'Isabella Nguyen', initials: 'IN' },
duration: null,
status: 'skipped',
},
];
/** Hand-placed positions: a routing diagram someone drew. */
const PLACED: WdsFlowNode[] = [
{ id: 'form-submitted', type: 'input', position: { x: 0, y: 90 } },
{ id: 'plan-is-enterprise', position: { x: 240, y: 90 } },
{ id: 'enrich-company', position: { x: 480, y: 0 } },
{ id: 'create-deal', type: 'output', position: { x: 720, y: 0 } },
{ id: 'notify-sales', type: 'output', position: { x: 480, y: 180 } },
];
export const NODES: WdsFlowNode[] = PLACED.map((node) => {
const step = STEPS.find((s) => s.id === node.id)!;
return { ...node, data: { label: step.title }, ariaLabel: `${KIND_LABEL[step.kind]}: ${step.title}` };
});
/** Dashes move along the branch that ran; `step` draws right angles. */
export const EDGES: WdsFlowEdge[] = [
{ id: 'form-plan', source: 'form-submitted', target: 'plan-is-enterprise', type: 'step', animated: true },
{ id: 'plan-enrich', source: 'plan-is-enterprise', target: 'enrich-company', type: 'step', label: 'Yes', animated: true },
{ id: 'plan-notify', source: 'plan-is-enterprise', target: 'notify-sales', type: 'step', label: 'No' },
{ id: 'enrich-deal', source: 'enrich-company', target: 'create-deal', type: 'step', animated: true },
];
/** A new action to the right of the rightmost step. */
export function newStep(index: number, nodes: WdsFlowNode[]): { step: Step; node: WdsFlowNode } {
const id = `step-${index}`;
const right = Math.max(...nodes.map((n) => n.position.x), 0);
const step: Step = {
id,
kind: 'action',
title: `New step ${index}`,
subtitle: 'Configure this step',
icon: ICONS.zap,
owner: { name: 'Olivia Martin', initials: 'OM' },
duration: null,
status: 'idle',
};
const node: WdsFlowNode = {
id,
type: 'output',
position: { x: right + 240, y: 90 },
data: { label: step.title },
ariaLabel: `${KIND_LABEL.action}: ${step.title}`,
selected: true,
};
return { step, node };
}Growth map#
@dagrejs/dagre) lays the columns out from left to right, and edge labels show the correlation. The toolbar switches between mode="pan" and mode="select" (selection box) and calls zoomIn(), zoomOut() and fitView(); wds-viewport-change updates the zoom level. A click on a card opens the inspector; isValidConnection refuses loops and duplicate links. A right click opens a wds-context-menu around the flow: wds-node-context-menu, wds-edge-context-menu and wds-pane-context-menu say what was clicked (with the pointer in flow coordinates) before the menu opens, so it shows node, link or canvas actions.
import { Component, computed, signal, viewChild } from '@angular/core';
import type { WdsFlowConnection, WdsFlowEdge, WdsFlowNode, WdsFlowXY } from '@wds/core/flow';
import { WdsBadgeComponent } from '@wds/angular/badge';
import { WDS_BREADCRUMB } from '@wds/angular/breadcrumb';
import { WdsButtonComponent } from '@wds/angular/button';
import { WdsContextMenuComponent } from '@wds/angular/context-menu';
import { WDS_BUTTON_GROUP } from '@wds/angular/button-group';
import { WDS_DROPDOWN } from '@wds/angular/dropdown';
import { WDS_FIELD } from '@wds/angular/field';
import { WdsFlowComponent } from '@wds/angular/flow';
import { WdsInputComponent } from '@wds/angular/input';
import { WdsProgressComponent } from '@wds/angular/progress';
import { WDS_SELECT } from '@wds/angular/select';
import { WdsSheetComponent } from '@wds/angular/sheet';
import { WDS_TABS } from '@wds/angular/tabs';
import {
KIND_LABEL,
LINKS,
METRICS,
PERIODS,
autoLayout,
createsCycle,
formatChange,
formatValue,
isImprovement,
type Metric,
type MetricLink,
type Period,
} from './growth-map-data';
const NORTH_STAR = 'weekly-repeat-orders';
/** What the context menu was opened on. */
type MenuTarget = { kind: 'node'; id: string } | { kind: 'edge'; id: string } | { kind: 'pane'; position: WdsFlowXY };
@Component({
selector: 'app-flow-growth-map-demo',
host: { class: 'block w-full' },
imports: [
...WDS_BREADCRUMB,
...WDS_BUTTON_GROUP,
...WDS_DROPDOWN,
...WDS_FIELD,
...WDS_SELECT,
...WDS_TABS,
WdsBadgeComponent,
WdsButtonComponent,
WdsContextMenuComponent,
WdsFlowComponent,
WdsInputComponent,
WdsProgressComponent,
WdsSheetComponent,
],
template: `
<div class="flex h-[640px] w-full flex-col overflow-hidden rounded-xl border border-border bg-background text-sm">
<header class="flex flex-wrap items-center gap-3 border-b border-border px-4 py-2">
<wds-breadcrumb label="Map">
<wds-breadcrumb-item><wds-breadcrumb-link href="#">Maps</wds-breadcrumb-link></wds-breadcrumb-item>
<wds-breadcrumb-item><wds-breadcrumb-page>Repeat Order Growth</wds-breadcrumb-page></wds-breadcrumb-item>
</wds-breadcrumb>
<wds-tabs class="mx-auto" [value]="period()" (wdsChange)="period.set($any($event.value))">
@for (p of periods; track p.value) {
<wds-tab [value]="p.value">{{ p.label }}</wds-tab>
}
</wds-tabs>
<wds-button variant="outline" size="sm" (click)="addMetric()">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14" /><path d="M12 5v14" /></svg>
Add metric
</wds-button>
<wds-dropdown (wdsSelect)="onMenu($event.value)">
<wds-button slot="trigger" variant="outline" size="icon-sm" label="More actions">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.5" /><circle cx="12" cy="12" r="1.5" /><circle cx="19" cy="12" r="1.5" /></svg>
</wds-button>
<wds-dropdown-item value="layout">Auto layout</wds-dropdown-item>
<wds-dropdown-item value="fit">Fit view</wds-dropdown-item>
<wds-dropdown-separator />
<wds-dropdown-item value="reset">Reset map</wds-dropdown-item>
</wds-dropdown>
</header>
<div class="relative min-h-0 flex-1">
<!-- The flow reports what was right-clicked (node, edge or pane) before the menu opens. -->
<wds-context-menu (wdsSelect)="onContextAction($event.value)">
<wds-flow
slot="trigger"
class="h-full bg-muted/30 [--wds-flow-node-radius:var(--radius-xl)]"
label="Repeat Order Growth metric map"
fit-view-on-init
[fitViewPadding]="0.15"
[mode]="tool()"
[nodes]="nodes()"
[edges]="edges()"
[isValidConnection]="isValidConnection"
(wdsNodesChange)="onNodesChange($event.nodes)"
(wdsEdgesChange)="onEdgesChange($event.edges)"
(wdsNodeClick)="select($event.node.id)"
(wdsPaneClick)="close()"
(wdsViewportChange)="zoom.set($event.zoom)"
(wdsNodeContextMenu)="onNodeMenu($event.node.id)"
(wdsEdgeContextMenu)="onEdgeMenu($event.edge.id)"
(wdsPaneContextMenu)="menuTarget.set({ kind: 'pane', position: $event.position })"
>
@for (m of metrics(); track m.id) {
<div [attr.slot]="m.id" class="w-60 rounded-xl border border-border bg-muted p-1 shadow-xs">
<div class="flex items-center gap-2 px-1.5 pt-0.5 pb-1.5">
<span class="flex size-7 shrink-0 items-center justify-center rounded-md border border-border bg-background">
@switch (m.kind) {
@case ('initiative') {
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
}
@case ('north-star') {
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="2" /></svg>
}
@case ('output') {
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M22 7 13.5 15.5 8.5 10.5 2 17" /><path d="M16 7h6v6" /></svg>
}
@default {
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M22 12h-4l-3 9L9 3l-3 9H2" /></svg>
}
}
</span>
<div class="min-w-0">
<div class="truncate text-[13px] leading-tight font-medium">{{ m.title }}</div>
<div class="text-[11px] leading-tight text-muted-foreground">{{ kindLabel[m.kind] }}</div>
</div>
</div>
<div class="rounded-lg border border-border bg-card px-3 py-2">
@if (m.kind === 'initiative') {
<div class="flex items-center gap-2">
<span class="font-semibold tabular-nums">{{ m.progress }}%</span>
<wds-progress class="ms-auto w-12" [value]="m.progress ?? 0" label="Progress" />
<wds-badge variant="secondary" class="text-[11px]" [class]="statusClass(m)">{{ m.status }}</wds-badge>
</div>
<div class="mt-1 text-[11px] text-muted-foreground">{{ m.team }} · {{ m.openIssues }} open</div>
} @else {
<div class="flex items-center gap-2">
<span class="font-semibold tabular-nums">{{ value(m) }}</span>
<span class="ms-auto text-[11px] tabular-nums" [class]="changeClass(m)">{{ change(m) }}</span>
</div>
<div class="mt-1 flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{{ periodLabel() }} · {{ m.aggregation }}</span>
@if (m.goal !== undefined) {
<wds-progress class="ms-auto w-12" [value]="m.goal" label="Goal" />
<span class="tabular-nums">{{ m.goal }}%</span>
}
</div>
}
</div>
</div>
}
</wds-flow>
@switch (menuTarget().kind) {
@case ('node') {
<wds-dropdown-label>{{ menuTitle() }}</wds-dropdown-label>
<wds-dropdown-item value="edit">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9" /><path d="M16.38 3.62a1 1 0 0 1 3 3L7.37 18.64a2 2 0 0 1-.86.5l-2.87.84a.5.5 0 0 1-.62-.62l.84-2.87a2 2 0 0 1 .5-.86z" /></svg>
Edit metric
</wds-dropdown-item>
<wds-dropdown-item value="links">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></svg>
Edit links
</wds-dropdown-item>
<wds-dropdown-item value="duplicate" [disabled]="isNorthStar()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></svg>
Duplicate
</wds-dropdown-item>
<wds-dropdown-item value="focus">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="2" /></svg>
Focus
</wds-dropdown-item>
<wds-dropdown-separator />
<wds-dropdown-item value="delete" variant="destructive" [disabled]="isNorthStar()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /></svg>
Delete metric
<span slot="shortcut">⌫</span>
</wds-dropdown-item>
}
@case ('edge') {
<wds-dropdown-label>{{ menuTitle() }}</wds-dropdown-label>
<wds-dropdown-item value="edit-link">Edit correlation</wds-dropdown-item>
<wds-dropdown-separator />
<wds-dropdown-item value="delete-link" variant="destructive">Delete link<span slot="shortcut">⌫</span></wds-dropdown-item>
}
@default {
<wds-dropdown-item value="add-here">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14" /><path d="M12 5v14" /></svg>
Add metric here
</wds-dropdown-item>
<wds-dropdown-separator />
<wds-dropdown-item value="layout">Auto layout</wds-dropdown-item>
<wds-dropdown-item value="fit">Fit view</wds-dropdown-item>
}
}
</wds-context-menu>
<wds-button-group class="absolute bottom-3 left-3 rounded-lg bg-background shadow-sm" aria-label="Canvas tools">
<wds-button variant="outline" size="icon-sm" label="Select" [attr.aria-pressed]="tool() === 'select'" [class.bg-accent]="tool() === 'select'" (click)="tool.set('select')">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><path d="M4.04 4.04 10 21l2.5-7.5L20 11z" /></svg>
</wds-button>
<wds-button variant="outline" size="icon-sm" label="Pan" [attr.aria-pressed]="tool() === 'pan'" [class.bg-accent]="tool() === 'pan'" (click)="tool.set('pan')">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 11V6a2 2 0 0 0-4 0v5" /><path d="M14 10V4a2 2 0 0 0-4 0v6" /><path d="M10 10.5V6a2 2 0 0 0-4 0v8" /><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15" /></svg>
</wds-button>
<wds-button variant="outline" size="icon-sm" label="Zoom out" (click)="flow().zoomOut()">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14" /></svg>
</wds-button>
<wds-button-group-text class="w-14 justify-center tabular-nums" aria-live="polite">{{ zoomLabel() }}</wds-button-group-text>
<wds-button variant="outline" size="icon-sm" label="Zoom in" (click)="flow().zoomIn()">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14" /><path d="M12 5v14" /></svg>
</wds-button>
<wds-button variant="outline" size="icon-sm" label="Focus the north star" (click)="focusNorthStar()">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="2" /></svg>
</wds-button>
<wds-button variant="outline" size="icon-sm" label="Auto layout" (click)="relayout()">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /></svg>
</wds-button>
</wds-button-group>
<!-- Non-modal and contained: the map stays usable, and a click on another card switches the metric. -->
<wds-sheet
contained
inset
divided
class="[--wds-sheet-size:22rem] [&::part(body)]:flex [&::part(body)]:flex-col [&::part(body)]:p-0"
[open]="selectedId() !== null"
(wdsClose)="close()"
>
@if (draft(); as d) {
<span slot="heading" class="block truncate">{{ d.title || 'Untitled metric' }}</span>
<wds-badge slot="description" variant="secondary" class="text-[11px]" [class]="d.kind === 'north-star' ? 'text-amber-700 [--wds-badge-bg:color-mix(in_oklch,var(--color-amber-400)_20%,transparent)]' : ''">
{{ kindLabel[d.kind] }}
</wds-badge>
<wds-tabs variant="line" class="min-h-0 flex-1 [&::part(list)]:px-4" [value]="inspectorTab()" (wdsChange)="inspectorTab.set($event.value)">
<wds-tab value="settings">Settings</wds-tab>
<wds-tab value="links">Links</wds-tab>
<wds-tab-panel value="settings" class="min-h-0 overflow-y-auto">
<div class="flex flex-col gap-4 p-4">
<wds-field>
<wds-field-label>Title</wds-field-label>
<wds-input [value]="d.title" (wdsInput)="patch({ title: $event.value })" />
</wds-field>
<wds-field>
<wds-field-label>Role</wds-field-label>
<wds-select [value]="d.kind" [disabled]="d.kind === 'north-star'" (wdsChange)="patch({ kind: $any($event.value) })">
<wds-select-item value="initiative">Initiative</wds-select-item>
<wds-select-item value="input">Input</wds-select-item>
<wds-select-item value="north-star" disabled>North Star</wds-select-item>
<wds-select-item value="output">Output</wds-select-item>
</wds-select>
@if (d.kind === 'north-star') {
<wds-field-description>Promote another metric to hand this role on.</wds-field-description>
}
</wds-field>
@if (d.kind === 'initiative') {
<div class="grid grid-cols-2 gap-3">
<wds-field>
<wds-field-label>Status</wds-field-label>
<wds-select [value]="d.status ?? 'To Do'" (wdsChange)="patch({ status: $any($event.value) })">
<wds-select-item value="To Do">To Do</wds-select-item>
<wds-select-item value="In Progress">In Progress</wds-select-item>
<wds-select-item value="Blocked">Blocked</wds-select-item>
<wds-select-item value="Done">Done</wds-select-item>
</wds-select>
</wds-field>
<wds-field>
<wds-field-label>Progress %</wds-field-label>
<wds-input type="number" min="0" max="100" [value]="'' + (d.progress ?? 0)" (wdsInput)="patch({ progress: +$event.value })" />
</wds-field>
</div>
<wds-field>
<wds-field-label>Team</wds-field-label>
<wds-input [value]="d.team ?? ''" (wdsInput)="patch({ team: $event.value })" />
</wds-field>
} @else {
<div class="grid grid-cols-2 gap-3">
<wds-field>
<wds-field-label>Aggregation</wds-field-label>
<wds-select [value]="d.aggregation ?? 'Sum'" (wdsChange)="patch({ aggregation: $any($event.value) })">
<wds-select-item value="Sum">Sum</wds-select-item>
<wds-select-item value="Average">Average</wds-select-item>
</wds-select>
</wds-field>
<wds-field>
<wds-field-label>Unit</wds-field-label>
<wds-select [value]="d.unit ?? 'Count'" (wdsChange)="patch({ unit: $any($event.value) })">
<wds-select-item value="Count">Count</wds-select-item>
<wds-select-item value="Percent">Percent</wds-select-item>
<wds-select-item value="Currency">Currency</wds-select-item>
</wds-select>
</wds-field>
</div>
<wds-field>
<wds-field-label>Better when</wds-field-label>
<wds-select [value]="d.betterWhen ?? 'higher'" (wdsChange)="patch({ betterWhen: $any($event.value) })">
<wds-select-item value="higher">Higher is better</wds-select-item>
<wds-select-item value="lower">Lower is better</wds-select-item>
</wds-select>
</wds-field>
<table class="w-full border-separate border-spacing-y-1.5 text-sm">
<thead>
<tr class="text-left text-xs font-medium tracking-wide text-muted-foreground uppercase">
<th class="font-medium">Period</th>
<th class="font-medium">Value</th>
<th class="font-medium">Change %</th>
</tr>
</thead>
<tbody>
@for (p of periods; track p.value) {
<tr>
<th scope="row" class="pe-2 text-left font-normal whitespace-nowrap">{{ p.long }}</th>
<td class="pe-2">
<wds-input type="number" [attr.aria-label]="p.long + ' value'" [value]="'' + (d.values?.[p.value]?.value ?? 0)" (wdsInput)="patchValue(p.value, 'value', $event.value)" />
</td>
<td class="w-24">
<wds-input type="number" step="0.1" [attr.aria-label]="p.long + ' change'" [value]="'' + (d.values?.[p.value]?.change ?? 0)" (wdsInput)="patchValue(p.value, 'change', $event.value)" />
</td>
</tr>
}
</tbody>
</table>
}
</div>
</wds-tab-panel>
<wds-tab-panel value="links" class="min-h-0 overflow-y-auto">
<div class="flex flex-col gap-2 p-4">
@for (l of selectedLinks(); track l.source + l.target) {
<div class="flex items-center gap-2 rounded-lg border border-border px-3 py-2">
<div class="min-w-0 flex-1">
<div class="text-xs text-muted-foreground">{{ l.source === d.id ? 'Drives' : 'Driven by' }}</div>
<div class="truncate font-medium">{{ titleOf(l.source === d.id ? l.target : l.source) }}</div>
</div>
<wds-input class="w-20" type="number" step="0.01" min="-1" max="1" aria-label="Correlation" [value]="l.weight === undefined ? '' : '' + l.weight" (wdsChange)="setWeight(l, $event.value)" />
<wds-button variant="ghost" size="icon-sm" label="Remove link" (click)="removeLink(l)">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
</wds-button>
</div>
} @empty {
<p class="m-0 text-muted-foreground">No links yet. Drag from a handle on the card to another metric.</p>
}
</div>
</wds-tab-panel>
</wds-tabs>
<div slot="footer" class="grid grid-cols-2 gap-2">
<wds-button variant="outline" (click)="close()">Cancel</wds-button>
<wds-button [disabled]="!dirty()" (click)="save()">Save metric</wds-button>
</div>
}
</wds-sheet>
</div>
</div>
`,
})
export class FlowGrowthMapDemo {
protected readonly flow = viewChild.required(WdsFlowComponent);
protected readonly periods = PERIODS;
protected readonly kindLabel = KIND_LABEL;
protected readonly metrics = signal<Metric[]>(METRICS);
protected readonly links = signal<MetricLink[]>(LINKS);
protected readonly positions = signal(autoLayout(METRICS, LINKS));
protected readonly period = signal<Period>('7d');
protected readonly tool = signal<'pan' | 'select'>('pan');
protected readonly zoom = signal(1);
protected readonly selectedId = signal<string | null>(null);
protected readonly draft = signal<Metric | null>(null);
protected readonly dirty = signal(false);
protected readonly inspectorTab = signal('settings');
protected readonly menuTarget = signal<MenuTarget>({ kind: 'pane', position: { x: 0, y: 0 } });
protected readonly isNorthStar = computed(() => {
const t = this.menuTarget();
return t.kind === 'node' && t.id === NORTH_STAR;
});
protected readonly menuTitle = computed(() => {
const t = this.menuTarget();
if (t.kind === 'node') return this.titleOf(t.id);
if (t.kind === 'edge') {
const link = this.linkOf(t.id);
return link ? `${this.titleOf(link.source)} → ${this.titleOf(link.target)}` : '';
}
return '';
});
protected readonly zoomLabel = computed(() => `${Math.round(this.zoom() * 100)}%`);
protected readonly periodLabel = computed(() => PERIODS.find((p) => p.value === this.period())!.long);
protected readonly nodes = computed<WdsFlowNode[]>(() =>
this.metrics().map((m) => ({
id: m.id,
position: this.positions()[m.id] ?? { x: 0, y: 0 },
data: { label: `${KIND_LABEL[m.kind]}: ${m.title}` },
type: m.kind === 'initiative' ? 'input' : 'default',
deletable: m.kind !== 'north-star',
}))
);
protected readonly edges = computed<WdsFlowEdge[]>(() =>
this.links().map((l) => ({
id: `${l.source}->${l.target}`,
source: l.source,
target: l.target,
label: l.weight === undefined ? undefined : l.weight.toFixed(2),
tone: l.weight !== undefined && l.weight < 0 ? 'destructive' : 'default',
}))
);
protected readonly selectedLinks = computed(() => {
const id = this.selectedId();
return this.links().filter((l) => l.source === id || l.target === id);
});
/** No loops and no second link between the same metrics. */
protected readonly isValidConnection = (c: WdsFlowConnection) =>
c.source !== c.target &&
!this.links().some((l) => l.source === c.source && l.target === c.target) &&
!createsCycle(this.links(), c.source, c.target);
protected value(m: Metric) {
const v = m.values?.[this.period()];
return v ? formatValue(m, v.value) : '—';
}
protected change(m: Metric) {
const v = m.values?.[this.period()];
return v ? formatChange(v.change) : '';
}
protected changeClass(m: Metric) {
const v = m.values?.[this.period()];
if (!v) return '';
return isImprovement(m, v.change) ? 'text-emerald-600' : 'text-red-600';
}
protected statusClass(m: Metric) {
switch (m.status) {
case 'In Progress':
return 'text-violet-700 [--wds-badge-bg:color-mix(in_oklch,var(--color-violet-500)_12%,transparent)]';
case 'Blocked':
return 'text-red-600 [--wds-badge-bg:color-mix(in_oklch,var(--color-red-500)_12%,transparent)]';
case 'Done':
return 'text-emerald-700 [--wds-badge-bg:color-mix(in_oklch,var(--color-emerald-500)_12%,transparent)]';
default:
return '';
}
}
protected titleOf(id: string) {
return this.metrics().find((m) => m.id === id)?.title ?? id;
}
// ——— Flow ———
protected onNodesChange(nodes: WdsFlowNode[]) {
const ids = new Set(nodes.map((n) => n.id));
this.positions.set(Object.fromEntries(nodes.map((n) => [n.id, n.position])));
if (ids.size !== this.metrics().length) {
this.metrics.update((list) => list.filter((m) => ids.has(m.id)));
if (!ids.has(this.selectedId() ?? '')) this.close();
}
}
protected onEdgesChange(edges: WdsFlowEdge[]) {
const weights = new Map(this.links().map((l) => [`${l.source}->${l.target}`, l.weight]));
this.links.set(edges.map((e) => ({ source: e.source, target: e.target, weight: weights.get(`${e.source}->${e.target}`) })));
}
protected relayout() {
this.positions.set(autoLayout(this.metrics(), this.links()));
requestAnimationFrame(() => this.flow().fitView({ padding: 0.15, duration: 300 }));
}
protected focusNorthStar() {
this.flow().fitView({ nodes: [NORTH_STAR], maxZoom: 1.25, duration: 300 });
}
protected onMenu(value: string) {
if (value === 'layout') this.relayout();
if (value === 'fit') this.flow().fitView({ padding: 0.15, duration: 300 });
if (value === 'reset') {
this.close();
this.metrics.set(METRICS);
this.links.set(LINKS);
this.relayout();
}
}
// ——— Context menu ———
protected onNodeMenu(id: string) {
this.menuTarget.set({ kind: 'node', id });
this.flow().setSelection([id]);
}
protected onEdgeMenu(id: string) {
this.menuTarget.set({ kind: 'edge', id });
this.flow().setSelection([], [id]);
}
protected onContextAction(action: string) {
const target = this.menuTarget();
if (target.kind === 'pane') {
if (action === 'add-here') this.addMetric(target.position);
else this.onMenu(action);
return;
}
if (target.kind === 'edge') {
const link = this.linkOf(target.id);
if (!link) return;
if (action === 'edit-link') {
this.select(link.source);
this.inspectorTab.set('links');
}
if (action === 'delete-link') this.removeLink(link);
return;
}
const id = target.id;
switch (action) {
case 'edit':
this.select(id);
this.inspectorTab.set('settings');
break;
case 'links':
this.select(id);
this.inspectorTab.set('links');
break;
case 'duplicate':
this.duplicate(id);
break;
case 'focus':
this.flow().fitView({ nodes: [id], maxZoom: 1.25, duration: 300 });
break;
case 'delete':
this.deleteMetric(id);
break;
}
}
protected linkOf(edgeId: string) {
return this.links().find((l) => `${l.source}->${l.target}` === edgeId);
}
protected duplicate(id: string) {
const source = this.metrics().find((m) => m.id === id);
if (!source) return;
const copy: Metric = { ...structuredClone(source), id: `${id}-${Date.now()}`, title: `${source.title} (copy)` };
const { x, y } = this.positions()[id] ?? { x: 0, y: 0 };
this.metrics.update((list) => [...list, copy]);
this.positions.update((p) => ({ ...p, [copy.id]: { x: x + 32, y: y + 32 } }));
// The copy keeps the links of the original.
this.links.update((list) => [
...list,
...list.filter((l) => l.source === id).map((l) => ({ ...l, source: copy.id })),
...list.filter((l) => l.target === id).map((l) => ({ ...l, target: copy.id })),
]);
}
protected deleteMetric(id: string) {
if (id === NORTH_STAR) return;
this.metrics.update((list) => list.filter((m) => m.id !== id));
this.links.update((list) => list.filter((l) => l.source !== id && l.target !== id));
if (this.selectedId() === id) this.close();
}
protected addMetric(position?: WdsFlowXY) {
const id = `metric-${Date.now()}`;
const metric: Metric = {
id,
kind: 'input',
title: 'New metric',
aggregation: 'Sum',
unit: 'Count',
betterWhen: 'higher',
values: { '7d': { value: 0, change: 0 }, '6w': { value: 0, change: 0 }, '12m': { value: 0, change: 0 } },
};
const { x, y, zoom } = this.flow().getViewport();
this.metrics.update((list) => [...list, metric]);
this.positions.update((p) => ({ ...p, [id]: position ?? { x: (40 - x) / zoom, y: (40 - y) / zoom } }));
this.select(id);
}
// ——— Inspector ———
protected select(id: string) {
const metric = this.metrics().find((m) => m.id === id);
if (!metric) return;
this.selectedId.set(id);
this.draft.set(structuredClone(metric));
this.dirty.set(false);
}
/** The draft stays until the next selection, so the sheet keeps its content while it slides out. */
protected close() {
this.selectedId.set(null);
}
protected patch(change: Partial<Metric>) {
this.draft.update((d) => (d ? { ...d, ...change } : d));
this.dirty.set(true);
}
protected patchValue(period: Period, key: 'value' | 'change', raw: string) {
const d = this.draft();
if (!d) return;
const values = structuredClone(d.values ?? { '7d': { value: 0, change: 0 }, '6w': { value: 0, change: 0 }, '12m': { value: 0, change: 0 } });
values[period][key] = Number(raw) || 0;
this.patch({ values });
}
protected save() {
const d = this.draft();
if (!d) return;
this.metrics.update((list) => list.map((m) => (m.id === d.id ? d : m)));
this.dirty.set(false);
}
protected setWeight(link: MetricLink, raw: string) {
const weight = raw === '' ? undefined : Math.max(-1, Math.min(1, Number(raw)));
this.links.update((list) => list.map((l) => (l === link ? { ...l, weight } : l)));
}
protected removeLink(link: MetricLink) {
this.links.update((list) => list.filter((l) => l !== link));
}
}import { Graph, layout } from '@dagrejs/dagre';
import type { WdsFlowXY } from '@wds/core/flow';
export type Period = '7d' | '6w' | '12m';
export type MetricKind = 'initiative' | 'input' | 'north-star' | 'output';
export type InitiativeStatus = 'To Do' | 'In Progress' | 'Blocked' | 'Done';
export const PERIODS: { value: Period; label: string; long: string }[] = [
{ value: '7d', label: '7 days', long: 'Past 7 days' },
{ value: '6w', label: '6 weeks', long: 'Past 6 weeks' },
{ value: '12m', label: '12 months', long: 'Past 12 months' },
];
export const KIND_LABEL: Record<MetricKind, string> = {
initiative: 'Initiative',
input: 'Input',
'north-star': 'North Star',
output: 'Output',
};
export interface MetricValue {
value: number;
/** Change against the previous period, in %. */
change: number;
}
export interface Metric {
id: string;
kind: MetricKind;
title: string;
// Metric (input, north star, output)
aggregation?: 'Sum' | 'Average';
unit?: 'Count' | 'Percent' | 'Currency';
betterWhen?: 'higher' | 'lower';
values?: Record<Period, MetricValue>;
/** Share of the goal reached, in %. */
goal?: number;
// Initiative
progress?: number;
status?: InitiativeStatus;
team?: string;
openIssues?: number;
}
/** A link between two metrics; `weight` is the correlation (-1…1). */
export interface MetricLink {
source: string;
target: string;
weight?: number;
}
export const METRICS: Metric[] = [
{ id: 'free-returns', kind: 'initiative', title: 'Free first returns', progress: 62, status: 'In Progress', team: 'GitHub Project', openIssues: 3 },
{ id: 'sms-alerts', kind: 'initiative', title: 'SMS restock alerts', progress: 40, status: 'To Do', team: 'Growth Team', openIssues: 5 },
{ id: 'loyalty-email', kind: 'initiative', title: 'Loyalty upgrade email', progress: 0, status: 'Blocked', team: 'Lifecycle Team', openIssues: 4 },
{
id: 'checkout-completion',
kind: 'input',
title: 'Checkout completion',
aggregation: 'Average',
unit: 'Percent',
betterWhen: 'higher',
values: { '7d': { value: 68.4, change: 1.2 }, '6w': { value: 67.1, change: 2.4 }, '12m': { value: 64.9, change: 5.8 } },
},
{
id: 'restock-optins',
kind: 'input',
title: 'Restock alert opt-ins',
aggregation: 'Sum',
unit: 'Count',
betterWhen: 'higher',
values: { '7d': { value: 1240, change: 3.1 }, '6w': { value: 6980, change: 7.4 }, '12m': { value: 51200, change: 22.5 } },
},
{
id: 'discount-depth',
kind: 'input',
title: 'Average discount depth',
aggregation: 'Average',
unit: 'Percent',
betterWhen: 'lower',
goal: 58,
values: { '7d': { value: 14.2, change: -0.8 }, '6w': { value: 14.9, change: -1.6 }, '12m': { value: 16.3, change: 2.1 } },
},
{
id: 'weekly-repeat-orders',
kind: 'north-star',
title: 'Weekly repeat orders',
aggregation: 'Sum',
unit: 'Count',
betterWhen: 'higher',
values: { '7d': { value: 3912, change: 0.9 }, '6w': { value: 22408, change: 2.6 }, '12m': { value: 168730, change: 31.8 } },
},
{
id: 'repeat-revenue',
kind: 'output',
title: 'Repeat customer revenue',
aggregation: 'Sum',
unit: 'Currency',
betterWhen: 'higher',
values: { '7d': { value: 184200, change: 1.4 }, '6w': { value: 1062000, change: 3.9 }, '12m': { value: 8120000, change: 27.2 } },
},
{
id: 'retention-90d',
kind: 'output',
title: '90-day retention',
aggregation: 'Average',
unit: 'Percent',
betterWhen: 'higher',
values: { '7d': { value: 41.8, change: 0.4 }, '6w': { value: 41.2, change: 1.1 }, '12m': { value: 38.6, change: 4.3 } },
},
];
export const LINKS: MetricLink[] = [
{ source: 'free-returns', target: 'checkout-completion' },
{ source: 'sms-alerts', target: 'restock-optins' },
{ source: 'loyalty-email', target: 'discount-depth' },
{ source: 'checkout-completion', target: 'weekly-repeat-orders', weight: 0.91 },
{ source: 'restock-optins', target: 'weekly-repeat-orders', weight: 0.88 },
{ source: 'discount-depth', target: 'weekly-repeat-orders', weight: -0.58 },
{ source: 'weekly-repeat-orders', target: 'repeat-revenue', weight: 0.97 },
{ source: 'weekly-repeat-orders', target: 'retention-90d', weight: 0.84 },
];
export const NODE_WIDTH = 240;
export const NODE_HEIGHT = 100;
/** Positions from dagre: columns from left to right (initiatives → inputs → north star → outputs). */
export function autoLayout(metrics: Metric[], links: MetricLink[]): Record<string, WdsFlowXY> {
const g = new Graph();
g.setGraph({ rankdir: 'LR', nodesep: 40, ranksep: 110 });
g.setDefaultEdgeLabel(() => ({}));
// dagre stacks nodes added later above earlier ones; add them in reverse to keep the list order top to bottom.
for (const m of [...metrics].reverse()) g.setNode(m.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
for (const l of [...links].reverse()) g.setEdge(l.source, l.target);
layout(g);
return Object.fromEntries(
metrics.map((m) => {
const { x, y } = g.node(m.id);
return [m.id, { x: x - NODE_WIDTH / 2, y: y - NODE_HEIGHT / 2 }];
})
);
}
/** Would the link `source → target` close a loop? */
export function createsCycle(links: MetricLink[], source: string, target: string): boolean {
const stack = [target];
const seen = new Set<string>();
while (stack.length) {
const id = stack.pop()!;
if (id === source) return true;
if (seen.has(id)) continue;
seen.add(id);
for (const l of links) if (l.source === id) stack.push(l.target);
}
return false;
}
export function formatValue(metric: Metric, value: number): string {
if (metric.unit === 'Percent') return `${value.toFixed(1)}%`;
if (metric.unit === 'Currency') {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 }).format(value);
}
return new Intl.NumberFormat('en-US').format(value);
}
export function formatChange(change: number): string {
return `${change > 0 ? '+' : change < 0 ? '−' : ''}${Math.abs(change).toFixed(1)}%`;
}
/** Is the change good news, given which direction is better? */
export function isImprovement(metric: Metric, change: number): boolean {
return metric.betterWhen === 'lower' ? change <= 0 : change >= 0;
}API References#
wds-flow#
@xyflow/system,
the core of React Flow. Pan with the mouse or by scrolling, zoom with the wheel or a pinch, drag nodes,
connect handles and select with a click or a selection box (mode="select").
Node content is your own markup in the slot named after the node id (<div slot="metric-1">); a node
without slotted content shows data.label. The flow draws the handles on the node edges itself.
Moved, connected and deleted elements come out in wds-nodes-change and wds-edges-change; assign the
arrays back to nodes and edges to keep them in your state.
Requires the @xyflow/system package (an optional dependency of @wds/core, loaded only by @wds/core/flow).
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
| mode | 'pan' | 'select' | 'pan' | pan moves it, select draws a selection box. |
| pan-on-scroll.panOnScroll | boolean | false | |
| readonly | boolean | false | |
| fit-view-on-init.fitViewOnInit | boolean | false | |
| fit-view-padding.fitViewPadding | number | 0.1 | fit-view-on-init and fitView(), as a share of the pane. |
| min-zoom.minZoom | number | 0.25 | |
| max-zoom.maxZoom | number | 2 | |
| background | WdsFlowBackground | 'dots' | |
| gap | number | 16 | |
| snap-to-grid.snapToGrid | boolean | false | gap grid. |
| label | string | 'Flow' |
Properties
| Property | Description |
|---|---|
| nodesWdsFlowNode[] | position with the slotted element named after its id as content. |
| edgesWdsFlowEdge[] | |
| isValidConnection(connection: WdsFlowConnection) => boolean | undefined |
Events
| Event | Description |
|---|---|
| wds-viewport-changeCustomEvent<WdsFlowViewport> | |
| wds-selection-changeCustomEvent<WdsFlowSelectionChangeDetail> | |
| wds-node-clickCustomEvent<WdsFlowNodeClickDetail> | |
| wds-edge-clickCustomEvent<WdsFlowEdgeClickDetail> | |
| wds-pane-clickCustomEvent<void> | |
| nameCustomEvent | |
| wds-nodes-changeCustomEvent<WdsFlowNodesChangeDetail> | |
| wds-edges-changeCustomEvent<WdsFlowEdgesChangeDetail> | |
| wds-connectCustomEvent<WdsFlowConnectDetail> | |
| wds-node-context-menuCustomEvent<WdsFlowNodeContextMenuDetail> | wds-context-menu around the flow opens its menu. |
| wds-edge-context-menuCustomEvent<WdsFlowEdgeContextMenuDetail> | |
| wds-pane-context-menuCustomEvent<WdsFlowPaneContextMenuDetail> |
Methods
| Method | Description |
|---|---|
| getViewport() | |
| setViewport(viewport: WdsFlowViewport, options: { duration?: number }) | |
| setCenter(x: number, y: number, options: { zoom?: number; duration?: number }) | |
| zoomIn(options: { duration?: number }) | |
| zoomOut(options: { duration?: number }) | |
| zoomTo(zoom: number, options: { duration?: number }) | |
| fitView(options: WdsFlowFitViewOptions) | options.nodes) are in view. |
| screenToFlowPosition(point: WdsFlowXY) | clientX, clientY) to flow coordinates. |
| setSelection(nodes: string[], edges: string[]) |
Slots
| Slot | Description |
|---|---|
| (default) | slot set to a node id. |
CSS parts (::part)
| Part | Description |
|---|---|
| pane | |
| node | |
| edge-label |
CSS custom properties
| Property | Description |
|---|---|
| --wds-flow-height | |
| --wds-flow-background | |
| --wds-flow-pattern | |
| --wds-flow-edge | |
| --wds-flow-edge-selected | |
| --wds-flow-handle | |
| --wds-flow-node-radius | --wds-radius-lg). |
| --wds-flow-success | success edge label. |
Accessibility#
Screen reader#
role="application" and label as its name. Each node is a focusable group named by ariaLabel or data.label (your slotted content is read inside it), and each edge is a focusable group named "Edge from … to …" with its label.
aria-hidden); connecting needs a pointer. Offer another way to link nodes, such as the Links tab in the Growth map example.
Keyboard support#
| Key | Function |
|---|---|
snap-to-grid). | |