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
3 changes: 2 additions & 1 deletion packages/core/src/card-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export function cardTextSlots(card: CardData): TextSlot[] {
if (item) add(`/footer/${side}/text`, item.text, 'sans400', 12, half - (item.icon ? 13 + 6 : 0));
}

add('/cta/label', card.cta?.label, 'sans500', 13, PANEL - 2 * 12 - 15); // px-3 link, 15px arrow
// px-3 tile; a linked CTA also shows a 15px arrow.
add('/cta/label', card.cta?.label, 'sans500', 13, PANEL - 2 * 12 - (card.cta?.href ? 15 : 0));
return slots;
}
4 changes: 2 additions & 2 deletions packages/core/src/samples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const commerceApi: DiagramDraft = {
],
statsNote: '2 replication links',
footer: { left: { text: 'EU West', icon: 'region' }, right: { text: '3 members', icon: 'members' } },
cta: { label: 'Open cluster' },
cta: { label: 'Open cluster', href: 'https://console.example.com/clusters/orders' },
},
},
{
Expand All @@ -65,7 +65,7 @@ export const commerceApi: DiagramDraft = {
],
statsNote: '3 replication links',
footer: { left: { text: 'EU West', icon: 'region' }, right: { text: '6 members', icon: 'members' } },
cta: { label: 'Open cluster' },
cta: { label: 'Open cluster', href: 'https://console.example.com/clusters/sessions' },
},
},
],
Expand Down
5 changes: 3 additions & 2 deletions packages/core/test/card-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ describe('cardTextSlots', () => {
expect(slot(card, '/footer/right/text')!.maxWidth).toBe(118);
});

it('the CTA label leaves room for its arrow', () => {
expect(slot({ title: 't', cta: { label: 'Open cluster' } }, '/cta/label')).toMatchObject({ face: 'sans500', size: 13, maxWidth: 217 });
it('a linked CTA label leaves room for its arrow; an unlinked one (no arrow) gets it back', () => {
expect(slot({ title: 't', cta: { label: 'Open', href: 'https://x.dev' } }, '/cta/label')).toMatchObject({ face: 'sans500', size: 13, maxWidth: 217 });
expect(slot({ title: 't', cta: { label: 'Open' } }, '/cta/label')).toMatchObject({ maxWidth: 232 });
});
});
9 changes: 9 additions & 0 deletions packages/schema/test/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,15 @@ describe('validateDiagram', () => {
expect(id!.allowedFixes).toEqual([expect.stringMatching(/^remove "id"/)]);
});

it('an edge label limit counts characters, not UTF-16 units (emoji)', () => {
const emoji = '🚀'.repeat(13); // 13 characters, 26 UTF-16 units
expect(diags(base({ edges: [{ id: 'ab', from: 'a', to: 'b', label: emoji }] }))).toEqual([]);
expect(diags(base({ edges: [{ id: 'ab', from: 'a', to: 'b', label: '🚀'.repeat(25) }] }))[0]).toMatchObject({
subject: '/edges/0/label',
allowedFixes: ['shorten "label" to at most 24 characters'],
});
});

it('limits say how far to cut', () => {
const stats = Array.from({ length: 4 }, (_, i) => ({ value: String(i), label: 'x' }));
expect(diags(base({ nodes: [node('a', { card: { title: 'a', stats } })] }))[0]).toMatchObject({
Expand Down
53 changes: 53 additions & 0 deletions packages/viewer/e2e/explore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,3 +303,56 @@ test('a view fits its members clear of the toolbar and zoom bar', async ({ page
}
}
});

test('holding an arrow key pans the full distance for every repeat', async ({ page }) => {
const start = await viewportOf(page);
await page.locator('.sm-stage').focus();
await page.keyboard.down('ArrowRight');
for (let i = 0; i < 4; i++) await page.keyboard.down('ArrowRight'); // auto-repeat
await page.keyboard.up('ArrowRight');
await settle(page);
expect((await viewportOf(page)).x).toBeCloseTo(start.x - 5 * 80, 0);
});

test('dragging on the minimap pans the canvas with the pointer', async ({ page }) => {
await page.getByRole('button', { name: 'Toggle minimap' }).click();
const map = (await page.locator('svg.sm-minimap').boundingBox())!;
const before = await viewportOf(page);
await page.mouse.move(map.x + map.width * 0.3, map.y + map.height / 2);
await page.mouse.down();
await page.mouse.move(map.x + map.width * 0.7, map.y + map.height / 2, { steps: 6 });
const mid = await viewportOf(page);
await page.mouse.up();
expect(mid.x).toBeLessThan(before.x - 50); // moved during the drag, not only on release
expect(mid.k).toBeCloseTo(before.k, 5);
});

test('the canvas neither selects text nor scrolls the page on touch', async ({ page }) => {
const style = await page.locator('.sm-stage').evaluate((el) => {
const s = getComputedStyle(el);
return { select: s.userSelect, touch: s.touchAction };
});
expect(style).toEqual({ select: 'none', touch: 'none' });
});

test('a canvas that mounts at zero size fits once it gets one, without errors', async ({ page }) => {
const errors: string[] = [];
page.on('console', (m) => m.type() === 'error' && errors.push(m.text()));
// A constructed sheet survives document parsing (a <style> added this early doesn't).
await page.addInitScript(() => {
const sheet = new CSSStyleSheet();
sheet.replaceSync('section[aria-label="Diagram"] { height: 0 !important; flex: none !important; }');
document.adoptedStyleSheets = [sheet];
});
await page.goto('/?page=sample');
await expect(page.locator('.sm-card')).toHaveCount(6);
expect((await page.locator('.sm-stage').boundingBox())!.height).toBe(0);
await page.evaluate(() => (document.adoptedStyleSheets = []));
await settle(page);
const stage = (await page.locator('.sm-stage').boundingBox())!;
for (const id of ['edge', 'orders', 'commerce-api-3']) {
const c = (await card(page, id).boundingBox())!;
expect(c.x >= stage.x && c.y >= stage.y && c.x + c.width <= stage.x + stage.width && c.y + c.height <= stage.y + stage.height, id).toBe(true);
}
expect(errors).toEqual([]); // a zero-size zoom animation used to render NaN transforms
});
14 changes: 7 additions & 7 deletions packages/viewer/src/canvas/DiagramCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,19 @@ function union(rects: Rect[]): Rect | null {
}

// Camera follows the explorer: a view fits its members (Overview fits everything, Q27); a revealing
// selection (search, deep link) centres the node. Skips the first run for Overview so the initial fit stands.
// selection (search, deep link) centres the node. The initial Overview needs nothing: useZoom already fit it.
function useCameraEffects(scene: Scene, camera: Camera, restored: boolean) {
const { draft, state } = useExplore();
const rectOf = useMemo(() => new Map(scene.cards.map((c) => [c.node.id, c.rect])), [scene]);
const first = useRef(true);
// Compare against the previous view, not a "first run" flag: StrictMode re-runs effects on mount.
const shownView = useRef<string | null | undefined>(restored ? state.view : null);
useEffect(() => {
// After a live reload the restored camera stands; later view changes move it as usual.
if (first.current && restored) return void (first.current = false);
if (shownView.current === state.view) return;
shownView.current = state.view;
const members = state.view ? (draft.views?.find((v) => v.id === state.view)?.nodes ?? []) : [];
const box = union(members.flatMap((id) => rectOf.get(id) ?? []));
if (box) camera.fitRect(box);
else if (!first.current) camera.fit();
first.current = false;
else camera.fit();
}, [state.view, draft, rectOf, camera]);
// The reveal a live reload restores from the hash is already on screen.
const restoredReveal = useRef(restored ? state.reveal : -1);
Expand Down Expand Up @@ -99,7 +99,7 @@ export function DiagramCanvas({ diagram, children }: { diagram: LaidOutDiagram;
ArrowLeft: [PAN_STEP, 0],
ArrowRight: [-PAN_STEP, 0],
};
if (pan[e.key]) camera.panBy(...pan[e.key]!);
if (pan[e.key]) camera.panBy(...pan[e.key]!, { instant: e.repeat });
else if (e.key === '+' || e.key === '=') camera.zoomIn();
else if (e.key === '-') camera.zoomOut();
else if (e.key === '0') camera.fit();
Expand Down
27 changes: 21 additions & 6 deletions packages/viewer/src/canvas/Minimap.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { MouseEvent } from 'react';
import { useRef, type PointerEvent } from 'react';
import type { Rect } from '@stackmap/core';
import { PANEL_CLASS, PANEL_STYLE } from '../chrome/ui';
import type { Scene } from './scene';
Expand Down Expand Up @@ -26,24 +26,39 @@ export function Minimap({ scene }: { scene: Scene }) {
height: Math.max(box.y + box.height, view.y + view.height) - y0,
};

const onClick = (e: MouseEvent<SVGSVGElement>) => {
// Press to jump there; keep the button down and drag to steer the canvas live.
const dragging = useRef(false);
const toDiagram = (e: PointerEvent<SVGSVGElement>) => {
const ctm = e.currentTarget.getScreenCTM();
if (!ctm) return;
const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
return ctm && new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
};
const onPointerDown = (e: PointerEvent<SVGSVGElement>) => {
const p = toDiagram(e);
if (!p) return;
dragging.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
centerOn({ x: p.x, y: p.y });
};
const onPointerMove = (e: PointerEvent<SVGSVGElement>) => {
const p = dragging.current && toDiagram(e);
if (p) centerOn({ x: p.x, y: p.y }, { instant: true });
};
const stop = () => void (dragging.current = false);

return (
<div className={`${PANEL_CLASS} p-1.5`} style={PANEL_STYLE}>
<svg
role="img"
aria-label="Minimap"
className="sm-minimap block cursor-pointer overflow-visible"
className="sm-minimap block cursor-pointer overflow-visible touch-none select-none"
width={WIDTH}
height={height}
viewBox={`${frame.x} ${frame.y} ${frame.width} ${frame.height}`}
preserveAspectRatio="xMidYMid meet"
onClick={onClick}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={stop}
onPointerCancel={stop}
>
{scene.frames.map((f) => (
<rect
Expand Down
55 changes: 46 additions & 9 deletions packages/viewer/src/canvas/useZoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@ import { clearStoredViewport, readStoredViewport } from '../live';
import { CHROME_INSET, fitTransform, MAX_ZOOM, MIN_ZOOM, viewportRect, ZOOM_STEP, type Size, type Transform } from './viewport';

/** Camera moves. Stable across renders, so consumers don't re-render on every pan/zoom frame. */
/** `instant` skips the animation: drags and key auto-repeat would otherwise interrupt it every frame. */
export interface MoveOptions {
instant?: boolean;
}

export interface Camera {
zoomIn(): void;
zoomOut(): void;
fit(): void;
centerOn(p: Point): void;
centerOn(p: Point, opts?: MoveOptions): void;
/** Fit an arbitrary diagram-space rect (e.g. a view's members), never zooming past 100%. */
fitRect(r: Rect): void;
panBy(dx: number, dy: number): void;
panBy(dx: number, dy: number, opts?: MoveOptions): void;
/** Pan the least distance that brings `r` fully on stage; no-op when it already is. */
ensureVisible(r: Rect): void;
}
Expand All @@ -27,13 +32,15 @@ export interface ViewportApi extends Camera {
restored: boolean;
}

const duration = () => (matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 150);
const duration = (opts?: MoveOptions) => (opts?.instant || matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 150);
const toZoom = (t: Transform) => zoomIdentity.translate(t.x, t.y).scale(t.k);

export function useZoom(stageRef: RefObject<HTMLDivElement | null>, content: Rect): ViewportApi {
const [transform, setTransform] = useState<Transform>({ x: 0, y: 0, k: 1 });
const current = useRef(transform);
current.current = transform;
// Where an animated pan is heading, so a press during the animation adds to it instead of losing the rest.
const pending = useRef<Transform | null>(null);
const [stage, setStage] = useState<Size>({ width: 0, height: 0 });
const behavior = useRef<ZoomBehavior<HTMLDivElement, unknown> | null>(null);
// Read once per mount; `stackmap serve` stores the camera right before it reloads the page.
Expand All @@ -46,6 +53,7 @@ export function useZoom(stageRef: RefObject<HTMLDivElement | null>, content: Rec
const z = zoom<HTMLDivElement, unknown>()
.scaleExtent([MIN_ZOOM, MAX_ZOOM])
.on('zoom', (event: D3ZoomEvent<HTMLDivElement, unknown>) => {
if (event.sourceEvent) pending.current = null; // the user took over
const { x, y, k } = event.transform;
setTransform({ x, y, k });
});
Expand All @@ -57,11 +65,20 @@ export function useZoom(stageRef: RefObject<HTMLDivElement | null>, content: Rec
setStage(size);
sel.call(z.transform, toZoom(stored ?? fitTransform(content, size, { inset: CHROME_INSET })));
if (stored) clearStoredViewport();
// A stage that mounts hidden or collapsed has no size to fit to; fit on its first real one instead.
let fitted = stored !== null || (size.width > 0 && size.height > 0);

const observer =
typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(() => setStage({ width: el.clientWidth, height: el.clientHeight }));
: new ResizeObserver(() => {
const next = { width: el.clientWidth, height: el.clientHeight };
setStage(next);
if (!fitted && next.width > 0 && next.height > 0) {
fitted = true;
sel.call(z.transform, toZoom(fitTransform(content, next, { inset: CHROME_INSET })));
}
});
observer?.observe(el);
return () => {
observer?.disconnect();
Expand All @@ -74,7 +91,8 @@ export function useZoom(stageRef: RefObject<HTMLDivElement | null>, content: Rec
(apply: (z: ZoomBehavior<HTMLDivElement, unknown>, el: HTMLDivElement) => void) => {
const el = stageRef.current;
const z = behavior.current;
if (el && z) apply(z, el);
// A 0×0 stage has nothing to show, and d3's zoom interpolation divides by its size (NaN transform).
if (el && z && el.clientWidth > 0 && el.clientHeight > 0) apply(z, el);
},
[stageRef],
);
Expand All @@ -99,10 +117,13 @@ export function useZoom(stageRef: RefObject<HTMLDivElement | null>, content: Rec
);
// Centre between the chrome bands, not on the raw stage, so the node doesn't sit under the toolbar.
const centerOn = useCallback(
(p: Point) =>
(p: Point, opts?: MoveOptions) =>
animate((z, el) => {
const anchor: [number, number] = [el.clientWidth / 2, (el.clientHeight + CHROME_INSET.top - CHROME_INSET.bottom) / 2];
select(el).transition().duration(duration()).call(z.translateTo, p.x, p.y, anchor);
pending.current = null;
// A 0ms transition still lands a frame later, so rapid instant moves would cancel each other.
if (opts?.instant) select(el).interrupt().call(z.translateTo, p.x, p.y, anchor);
else select(el).transition().duration(duration()).call(z.translateTo, p.x, p.y, anchor);
}),
[animate],
);
Expand All @@ -123,8 +144,24 @@ export function useZoom(stageRef: RefObject<HTMLDivElement | null>, content: Rec
[animate],
);
const panBy = useCallback(
(dx: number, dy: number) =>
animate((z, el) => select(el).transition().duration(duration()).call(z.translateBy, dx / current.current.k, dy / current.current.k)),
(dx: number, dy: number, opts?: MoveOptions) =>
animate((z, el) => {
const base = pending.current ?? current.current;
const next = { x: base.x + dx, y: base.y + dy, k: base.k };
if (duration(opts) === 0) {
pending.current = null;
select(el).interrupt().call(z.transform, toZoom(next));
return;
}
pending.current = next;
select(el)
.transition()
.duration(duration())
.call(z.transform, toZoom(next))
.on('end', () => {
if (pending.current === next) pending.current = null;
});
}),
[animate],
);
const ensureVisible = useCallback(
Expand Down
17 changes: 17 additions & 0 deletions packages/viewer/src/card/NodeCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,21 @@ describe('NodeCard', () => {
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noreferrer');
});

it('a CTA without href is plain text, not a link-looking button', () => {
const { container } = render(<NodeCard node={{ id: 'n', type: 'database', card: { title: 'Orders', cta: { label: 'Open cluster' } } }} />);
const cta = container.querySelector('[data-testid="card-cta"]')!;
expect(cta.querySelector('a')).toBeNull();
expect(cta.querySelector('svg')).toBeNull(); // no arrow promising navigation
expect(cta.textContent).toBe('Open cluster');
});

it('a CTA with href is a link that opens in a new tab', () => {
const { container } = render(
<NodeCard node={{ id: 'n', type: 'database', card: { title: 'Orders', cta: { label: 'Open cluster', href: 'https://example.com' } } }} />,
);
const a = container.querySelector('[data-testid="card-cta"] a')!;
expect(a).toHaveAttribute('href', 'https://example.com');
expect(a).toHaveAttribute('rel', 'noreferrer');
});
});
37 changes: 23 additions & 14 deletions packages/viewer/src/card/NodeCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,20 +121,29 @@ export function NodeCard({ node }: { node: DiagramNode }) {

{card.cta && (
<div data-testid="card-cta" className="shrink-0 px-3" style={{ height: CARD.cta }}>
<a
href={card.cta.href}
// Not a tab stop inside the card (nested interactive); the inspector repeats the link.
tabIndex={-1}
target="_blank"
rel="noreferrer"
className="flex h-9 items-center justify-between rounded-lg px-3 text-[13px] font-medium text-fg"
style={{ background: tint('tile') }}
>
<span className="truncate" title={card.cta.label}>
{card.cta.label}
</span>
<ArrowUpRight size={15} strokeWidth={1.75} aria-hidden="true" style={{ color: tint('accent') }} />
</a>
{card.cta.href ? (
<a
href={card.cta.href}
// Not a tab stop inside the card (nested interactive); the inspector repeats the link.
tabIndex={-1}
target="_blank"
rel="noreferrer"
className="flex h-9 items-center justify-between rounded-lg px-3 text-[13px] font-medium text-fg"
style={{ background: tint('tile') }}
>
<span className="truncate" title={card.cta.label}>
{card.cta.label}
</span>
<ArrowUpRight size={15} strokeWidth={1.75} aria-hidden="true" style={{ color: tint('accent') }} />
</a>
) : (
// No link: same tile, but no arrow promising navigation that isn't there.
<div className="flex h-9 items-center rounded-lg px-3 text-[13px] font-medium text-fg" style={{ background: tint('tile') }}>
<span className="truncate" title={card.cta.label}>
{card.cta.label}
</span>
</div>
)}
</div>
)}
</div>
Expand Down
Loading
Loading