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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Then ask:
</picture>
</p>

The agent writes `.stackmap/<name>/diagram.json`, validates and repairs it, and delivers `.stackmap/<name>/diagram.html`. Open that file in any browser. To change the diagram, ask again: the agent edits the JSON and delivers it again. The viewer itself is read-only.
The agent writes `.stackmap/<name>/diagram.json`, validates and repairs it, and delivers `.stackmap/<name>/diagram.html`, opening it in your browser. It opens in any browser later too. To change the diagram, ask again: the agent edits the JSON and delivers it again. The viewer itself is read-only.

## The viewer

Expand Down Expand Up @@ -90,9 +90,9 @@ Full reference: [skill/references/schema.md](skill/references/schema.md), or the
The skill runs the CLI for you, and it works on its own too (Node 22.12 or later):

```bash
npx @hyamero/stackmap validate diagram.json [--json] # diagnostics with fixes; exit 1 on errors
npx @hyamero/stackmap deliver diagram.json [-o out.html] # validate → layout → one offline HTML file
npx @hyamero/stackmap serve diagram.json [--port 4400] # live viewer that reloads on every save
npx @hyamero/stackmap validate diagram.json [--json] # diagnostics with fixes; exit 1 on errors
npx @hyamero/stackmap deliver diagram.json [-o out.html] [--open] # validate → layout → one offline HTML file
npx @hyamero/stackmap serve diagram.json [--port 4400] # live viewer that reloads on every save
```

- **validate** lists every problem with a code, its evidence and the allowed fixes, including card text that won't fit. It exits `1` when the diagram has errors and `2` on usage, IO or internal errors.
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs';
import { parseArgs } from 'node:util';
import { deliverCommand, validateCommand, type CommandResult } from './commands';
import { openInBrowser } from './open';
import { serve } from './serve';
import { styleFor } from './style';

Expand All @@ -12,12 +13,14 @@ const HELP = `stackmap ${VERSION}: validate agent-authored diagrams and deliver

Usage:
stackmap validate <diagram.json> [--json] check the diagram; exit 1 on errors
stackmap deliver <diagram.json> [-o out.html] validate, lay out and write the viewer
stackmap deliver <diagram.json> [-o out.html] [--open]
validate, lay out and write the viewer
stackmap serve <diagram.json> [--port 4400] live viewer that reloads when the file changes

Options:
--json machine-readable diagnostics (validate)
-o, --out output path (deliver; default: next to the input, .html)
--open open the written viewer in the default browser (deliver)
--port port for serve (default 4400; the next free one is used if taken)
-h, --help show this help
-v, --version show the version
Expand All @@ -41,6 +44,7 @@ async function run(argv: string[]): Promise<CommandResult> {
options: {
json: { type: 'boolean' },
out: { type: 'string', short: 'o' },
open: { type: 'boolean' },
port: { type: 'string' },
help: { type: 'boolean', short: 'h' },
version: { type: 'boolean', short: 'v' },
Expand All @@ -58,6 +62,7 @@ async function run(argv: string[]): Promise<CommandResult> {
if (command !== 'validate' && command !== 'deliver' && command !== 'serve') return usage(`unknown command "${command}"`);
if (!file || extra.length) return usage(`${command} takes exactly one diagram file`);
if (values.port !== undefined && command !== 'serve') return usage('--port applies to serve');
if (values.open && command !== 'deliver') return usage('--open applies to deliver');
if (command === 'validate') {
if (values.out !== undefined) return usage('-o/--out applies to deliver, not validate');
return values.json ? validateCommand(file, { json: true }) : signed(command, await validateCommand(file, { json: false }));
Expand All @@ -70,7 +75,7 @@ async function run(argv: string[]): Promise<CommandResult> {
} catch (e) {
return { code: 2, stdout: '', stderr: `stackmap: internal error: viewer template missing (${(e as Error).message})\n` };
}
if (command === 'deliver') return signed(command, await deliverCommand(file, { template, out: values.out, style }));
if (command === 'deliver') return signed(command, await deliverCommand(file, { template, out: values.out, style, open: values.open ? openInBrowser : undefined }));

const port = values.port === undefined ? 4400 : Number(values.port);
if (!Number.isInteger(port) || port < 0 || port > 65535) return usage(`--port must be 0-65535, got "${values.port}"`);
Expand Down
18 changes: 16 additions & 2 deletions packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,18 @@ export async function buildHtml(path: string, template: string): Promise<Validat

export async function deliverCommand(
path: string,
{ template, out, style = plain }: { template: string; out?: string; style?: Style },
{
template,
out,
style = plain,
open,
}: {
template: string;
out?: string;
style?: Style;
/** opens the written file (deliver --open): null once it did, else why it couldn't */
open?: (file: string) => Promise<string | null>;
},
): Promise<CommandResult> {
try {
const target = out ?? path.slice(0, path.length - extname(path).length) + '.html';
Expand All @@ -82,7 +93,10 @@ export async function deliverCommand(
writeAtomic(target, html);
const bytes = Buffer.byteLength(html);
const sha = createHash('sha256').update(html).digest('hex');
return { code: 0, stdout: `delivered ${style.path(target)} · sha256 ${sha} · ${bytes} bytes\n`, stderr: report(diagnostics) };
// The file is written either way, so a browser that won't open is a warning, not a failure.
const unopened = open ? await open(target) : null;
const warning = unopened ? `warning: couldn't open ${target} in a browser (${unopened})\n` : '';
return { code: 0, stdout: `delivered ${style.path(target)} · sha256 ${sha} · ${bytes} bytes\n`, stderr: report(diagnostics) + warning };
} catch (e) {
return failure(e);
}
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/src/open.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { spawn } from 'node:child_process';

/** The command that opens a file in the default browser on `platform`. */
export function browserCommand(file: string, platform: NodeJS.Platform = process.platform): { cmd: string; args: string[] } {
if (platform === 'darwin') return { cmd: 'open', args: [file] };
// `start` is a cmd builtin, and its first quoted argument is the window title.
if (platform === 'win32') return { cmd: 'cmd', args: ['/c', 'start', '""', file] };
return { cmd: 'xdg-open', args: [file] };
}

/**
* Opens `file` in the default browser; resolves null once the opener has handed it off, or why it couldn't (no
* opener installed, a headless machine). An opener that is still running after a moment is taken as success.
*/
export function openInBrowser(file: string): Promise<string | null> {
const { cmd, args } = browserCommand(file);
return new Promise((resolve) => {
const child = spawn(cmd, args, { stdio: 'ignore', detached: true, windowsVerbatimArguments: cmd === 'cmd' });
const timer = setTimeout(() => resolve(null), 3000);
const done = (why: string | null) => {
clearTimeout(timer);
resolve(why);
};
child.once('error', (e) => done((e as NodeJS.ErrnoException).code === 'ENOENT' ? `${cmd} not found` : e.message));
child.once('exit', (code) => done(code ? `${cmd} exited with ${code}` : null));
child.unref();
});
}
20 changes: 20 additions & 0 deletions packages/cli/test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,26 @@ describe('validate', () => {
});

describe('deliver', () => {
it('opens the written file when asked, and still delivers with a warning when it can\'t', async () => {
const input = file('platform.json', groupedPlatform);
const opened: string[] = [];
const ok = await deliverCommand(input, { template: TEMPLATE, open: async (f) => (opened.push(f), null) });
expect(ok.code).toBe(0);
expect(opened).toEqual([join(dir, 'platform.html')]);
expect(ok.stderr).toBe('');
const failed = await deliverCommand(input, { template: TEMPLATE, open: async () => 'xdg-open not found' });
expect(failed.code).toBe(0);
expect(failed.stdout).toMatch(/^delivered /);
expect(failed.stderr).toBe(`warning: couldn't open ${join(dir, 'platform.html')} in a browser (xdg-open not found)\n`);
});

it('opens nothing when validation fails', async () => {
let opened = false;
const r = await deliverCommand(file('broken.json', broken), { template: TEMPLATE, open: async () => ((opened = true), null) });
expect(r.code).toBe(1);
expect(opened).toBe(false);
});

it('writes the HTML next to the input and prints a sha256 receipt', async () => {
const input = file('platform.json', groupedPlatform);
const r = await deliverCommand(input, { template: TEMPLATE });
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/test/open.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';
import { browserCommand } from '../src/open';

describe('browserCommand', () => {
it("uses each platform's own opener", () => {
expect(browserCommand('/tmp/a b.html', 'darwin')).toEqual({ cmd: 'open', args: ['/tmp/a b.html'] });
expect(browserCommand('C:\\d\\a.html', 'win32')).toEqual({ cmd: 'cmd', args: ['/c', 'start', '""', 'C:\\d\\a.html'] });
expect(browserCommand('/tmp/a.html', 'linux')).toEqual({ cmd: 'xdg-open', args: ['/tmp/a.html'] });
});
});
26 changes: 5 additions & 21 deletions packages/layout/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import ElkApi from 'elkjs/lib/elk-api.js';
import ElkBundled from 'elkjs/lib/elk.bundled.js';
import type { ElkExtendedEdge, ElkNode, ElkPort } from 'elkjs/lib/elk-api';
import { cardSize, isLaneKind, usesCompactCards, type DiagramDraft, type Direction, type LaidOutDiagram, type Point, type Rect } from '@stackmap/core';
import { findLabelSpot, labelWidth, placeLabel } from './labels';
import { placeLabels } from './labels';
import { layoutLanes } from './lanes';
import { layoutSequence } from './sequence';

Expand Down Expand Up @@ -158,35 +158,19 @@ export async function layoutDiagram(draft: DiagramDraft): Promise<LaidOutDiagram
return collect(draft, result, direction, stages !== null);
};
let laid = await run();
if (!compact) return laid;
const place = (l: LaidOutDiagram) => placeLabels(draft.edges, l.edges, Object.values(l.nodes), !compact);
let spots = place(laid);
// Compact layouts start with tight layer gaps and widen them once if a label found no room to sit.
let spots = compactLabels(draft, laid);
if (spots.misfit > 0) {
if (compact && spots.misfit > 0) {
laid = await run(Math.min(COMPACT_GAP.max, Math.max(Number(root.layoutOptions!['elk.layered.spacing.nodeNodeBetweenLayers']), spots.misfit + 24)));
spots = compactLabels(draft, laid);
spots = place(laid);
}
return { ...laid, labels: spots.labels };
} finally {
dispose();
}
}

/** Widest label (px) that found no free spot on its route; 0 when all fit. Labels are placed either way. */
function compactLabels(draft: DiagramDraft, laid: LaidOutDiagram): { labels: Record<string, Point>; misfit: number } {
const cards = Object.values(laid.nodes);
const taken: Rect[] = [];
const labels: Record<string, Point> = {};
let misfit = 0;
const pending = draft.edges.filter((e) => e.label && laid.edges[e.id]);
for (const e of pending) {
const spot = findLabelSpot(laid.edges[e.id]!, e.label!, cards, taken);
if (spot) labels[e.id] = spot;
else misfit = Math.max(misfit, labelWidth(e.label!));
}
for (const e of pending) if (!labels[e.id]) labels[e.id] = placeLabel(laid.edges[e.id]!, e.label!, cards, taken);
return { labels, misfit };
}

function collect(draft: DiagramDraft, result: ElkNode, direction: Direction, staged: boolean): LaidOutDiagram {
const nodes: Record<string, Rect> = {};
const groups: Record<string, Rect> = {};
Expand Down
66 changes: 52 additions & 14 deletions packages/layout/src/labels.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,65 @@
import { measureText, type Point, type Rect } from '@stackmap/core';
import { measureText, polylineMidpoint, type Point, type Rect } from '@stackmap/core';

// Edge label placement shared by the lane and compact ELK layouts: the viewer's pill (11px Geist, 8px padding
// each side, 20px tall) on a run of the route, clear of cards and of other labels.
// Edge label placement shared by the lane and ELK layouts: the viewer's pill (11px Geist, 8px padding each side,
// 20px tall) on a run of the route, clear of cards, other labels and other edges' lines.

const LABEL_H = 20;
export const labelWidth = (text: string) => Math.ceil(measureText(text, 'sans400', 11)) + 16;
const round = (n: number) => Math.round(n * 100) / 100;

const overlaps = (a: Rect, b: Rect) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
/** Whether any axis-aligned segment of a route runs through the rectangle. */
const crossed = (r: Rect, lines: Point[][]) =>
lines.some((pts) =>
pts.slice(1).some((b, i) => {
const a = pts[i]!;
return Math.max(a.x, b.x) > r.x && Math.min(a.x, b.x) < r.x + r.width && Math.max(a.y, b.y) > r.y && Math.min(a.y, b.y) < r.y + r.height;
}),
);
const pillAt = (p: Point, w: number): Rect => ({ x: p.x - w / 2, y: p.y - LABEL_H / 2, width: w, height: LABEL_H });

/**
* The label goes on the longest run that holds its pill without touching a card or another label; along a run
* it may slide off the midpoint to clear a neighbour's pill. Horizontal runs are preferred.
* Every label of a laid-out diagram, in draft order. A label goes on the longest run of its route that holds its
* pill clear of cards, other labels and other edges' lines; along a run it may slide off the midpoint to clear a
* neighbour. Horizontal runs are preferred. Where no spot clears the lines, one that clears cards and labels does,
* and failing that the middle of the longest run. With `keepMidpoints`, a label whose route midpoint is already
* clear stays there (where the viewer drew it before full layouts placed labels), so only the crowded ones move.
* `misfit` is the widest label that found no fully clear spot, 0 when all did.
*/
export function placeLabel(points: Point[], text: string, cards: Rect[], taken: Rect[]): Point {
return findLabelSpot(points, text, cards, taken) ?? claimFallback(points, text, taken);
export function placeLabels(
edges: { id: string; label?: string }[],
routes: Record<string, Point[]>,
cards: Rect[],
keepMidpoints = false,
): { labels: Record<string, Point>; misfit: number } {
const labelled = edges.filter((e) => e.label && routes[e.id]);
const others = (id: string) => Object.entries(routes).flatMap(([k, pts]) => (k === id ? [] : [pts]));
const taken: Rect[] = [];
const labels: Record<string, Point> = {};
if (keepMidpoints) {
for (const e of labelled) {
const p = polylineMidpoint(routes[e.id]!);
const pill = pillAt(p, labelWidth(e.label!));
if (cards.some((r) => overlaps(pill, r)) || taken.some((r) => overlaps(pill, r)) || crossed(pill, others(e.id))) continue;
// Claimed now, so a later label whose midpoint lands here is the one that moves.
taken.push(pill);
labels[e.id] = { x: round(p.x), y: round(p.y) };
}
}
let misfit = 0;
for (const e of labelled) {
if (labels[e.id]) continue;
const spot = findLabelSpot(routes[e.id]!, e.label!, cards, taken, others(e.id));
if (spot) labels[e.id] = spot;
else misfit = Math.max(misfit, labelWidth(e.label!));
}
for (const e of labelled) if (!labels[e.id]) labels[e.id] = findLabelSpot(routes[e.id]!, e.label!, cards, taken) ?? claimFallback(routes[e.id]!, e.label!, taken);
return { labels, misfit };
}

/** A spot that fits, claimed in `taken`; null when none does. */
export function findLabelSpot(points: Point[], text: string, cards: Rect[], taken: Rect[]): Point | null {
export function findLabelSpot(points: Point[], text: string, cards: Rect[], taken: Rect[], lines: Point[][] = []): Point | null {
const w = labelWidth(text);
const pillAt = (p: Point): Rect => ({ x: p.x - w / 2, y: p.y - LABEL_H / 2, width: w, height: LABEL_H });
let best: { p: Point; score: number } | null = null;
for (let i = 1; i < points.length; i++) {
const a = points[i - 1]!;
Expand All @@ -36,8 +75,8 @@ export function findLabelSpot(points: Point[], text: string, cards: Rect[], take
search: for (const [dx, dy] of offsets) {
for (const t of [0.5, 0.35, 0.65, 0.2, 0.8]) {
const p = { x: round(a.x + (b.x - a.x) * t + dx), y: round(a.y + (b.y - a.y) * t + dy) };
const pill = pillAt(p);
if (cards.some((r) => overlaps(pill, r)) || taken.some((r) => overlaps(pill, r))) continue;
const pill = pillAt(p, w);
if (cards.some((r) => overlaps(pill, r)) || taken.some((r) => overlaps(pill, r)) || crossed(pill, lines)) continue;
// Longer and horizontal runs first; off-centre and off-line spots lose a little.
const score = (horizontal ? 2 : 1) * len - Math.abs(t - 0.5) * 40 - (dx || dy ? 30 : 0);
if (!best || score > best.score) best = { p, score };
Expand All @@ -46,7 +85,7 @@ export function findLabelSpot(points: Point[], text: string, cards: Rect[], take
}
}
if (!best) return null;
taken.push(pillAt(best.p));
taken.push(pillAt(best.p, w));
return best.p;
}

Expand All @@ -59,8 +98,7 @@ function claimFallback(points: Point[], text: string, taken: Rect[]): Point {
const len = Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
if (len > best.len) best = { p: { x: round((a.x + b.x) / 2), y: round((a.y + b.y) / 2) }, len };
}
const w = labelWidth(text);
taken.push({ x: best.p.x - w / 2, y: best.p.y - LABEL_H / 2, width: w, height: LABEL_H });
taken.push(pillAt(best.p, labelWidth(text)));
return best.p;
}

7 changes: 2 additions & 5 deletions packages/layout/src/lanes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { assignColumns, backEdges, cardSize, COMPACT, type DiagramDraft, type DiagramEdge, type LaidOutDiagram, type Point, type Rect } from '@stackmap/core';
import { labelWidth, placeLabel } from './labels';
import { labelWidth, placeLabels } from './labels';

export { assignColumns, backEdges } from '@stackmap/core';
import { nudge, routeEdges, STUB, type PortChoice, type RouteRequest } from './route';
Expand Down Expand Up @@ -182,10 +182,7 @@ export function layoutLanes(draft: DiagramDraft): LaidOutDiagram {
if (routed[e.id]) edges[e.id] = routed[e.id]!.map((p) => ({ x: round(p.x), y: round(p.y) }));
}

const cards = Object.values(rects);
const taken: Rect[] = [];
const labels: Record<string, Point> = {};
for (const e of draft.edges) if (e.label && edges[e.id]) labels[e.id] = placeLabel(edges[e.id]!, e.label, cards, taken);
const { labels } = placeLabels(draft.edges, edges, Object.values(rects));

const height = y - LANE_GAP + PAD;
return {
Expand Down
Loading
Loading