mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-22 08:38:08 +02:00
feat: README architecture diagrams + React Flow diagram studio
Replace the tall portrait README ingestion SVG with two landscape diagrams — "1 · Ingestion" (build the context layer) and "2 · Serving" (agents query it through MCP) — wired in as transparent 2x PNGs that read on GitHub light and dark. Add docs-site/diagram-studio: a static React Flow page with custom themed nodes and the inlined ktx mascot that renders both diagrams and exports them to PNG via html-to-image (the diagrams' reproducible source). Remove the superseded ingestion-flow SVGs.
This commit is contained in:
parent
d320d54ab2
commit
8baa581ed9
11 changed files with 1147 additions and 211 deletions
328
docs-site/components/diagram-studio/flows.ts
Normal file
328
docs-site/components/diagram-studio/flows.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
import { type Edge, MarkerType, type Node } from "@xyflow/react";
|
||||
|
||||
import { C } from "./nodes";
|
||||
|
||||
const EDGE_COLOR = "#b3bcc4";
|
||||
const MARKER_COLOR = "#9aa6ad";
|
||||
|
||||
const labelStyle = {
|
||||
fontFamily: "var(--font-inter), system-ui, sans-serif",
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
fill: C.inkMuted,
|
||||
};
|
||||
const labelBgStyle = { fill: "#ffffff", stroke: C.chipBorder, strokeWidth: 1 };
|
||||
const labelBg = {
|
||||
labelBgPadding: [8, 4] as [number, number],
|
||||
labelBgBorderRadius: 6,
|
||||
labelStyle,
|
||||
labelBgStyle,
|
||||
};
|
||||
|
||||
const marker = { type: MarkerType.ArrowClosed, color: MARKER_COLOR, width: 16, height: 16 };
|
||||
const edgeStyle = { stroke: EDGE_COLOR, strokeWidth: 2 };
|
||||
|
||||
/* ============================== INGESTION =============================== */
|
||||
|
||||
const SRC_W = 300;
|
||||
const SRC_H = 138;
|
||||
const SRC_GAP = 24;
|
||||
const srcY = (i: number) => i * (SRC_H + SRC_GAP);
|
||||
|
||||
export const ingestionNodes: Node[] = [
|
||||
{
|
||||
id: "title",
|
||||
type: "title",
|
||||
position: { x: 0, y: -96 },
|
||||
data: {
|
||||
width: 560,
|
||||
eyebrow: "1 · Ingestion",
|
||||
title: "ktx builds your context layer",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "db",
|
||||
type: "card",
|
||||
position: { x: 0, y: srcY(0) },
|
||||
data: {
|
||||
width: SRC_W,
|
||||
height: SRC_H,
|
||||
accent: C.teal,
|
||||
rows: [
|
||||
{ kind: "title", text: "Databases" },
|
||||
{ kind: "desc", text: "Schemas, keys, query history" },
|
||||
{ kind: "muted", text: "Postgres · Snowflake · BigQuery · …" },
|
||||
],
|
||||
handles: [{ side: "right", type: "source", id: "out" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bi",
|
||||
type: "card",
|
||||
position: { x: 0, y: srcY(1) },
|
||||
data: {
|
||||
width: SRC_W,
|
||||
height: SRC_H,
|
||||
accent: C.orange,
|
||||
rows: [
|
||||
{ kind: "title", text: "BI tools" },
|
||||
{ kind: "desc", text: "Dashboards, explores, usage" },
|
||||
{ kind: "muted", text: "Metabase · Looker · …" },
|
||||
],
|
||||
handles: [{ side: "right", type: "source", id: "out" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "model",
|
||||
type: "card",
|
||||
position: { x: 0, y: srcY(2) },
|
||||
data: {
|
||||
width: SRC_W,
|
||||
height: SRC_H,
|
||||
accent: C.amber,
|
||||
rows: [
|
||||
{ kind: "title", text: "Modeling code" },
|
||||
{ kind: "desc", text: "Metrics, models, joins, entities" },
|
||||
{ kind: "muted", text: "dbt · LookML · MetricFlow · …" },
|
||||
],
|
||||
handles: [{ side: "right", type: "source", id: "out" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "docs",
|
||||
type: "card",
|
||||
position: { x: 0, y: srcY(3) },
|
||||
data: {
|
||||
width: SRC_W,
|
||||
height: SRC_H,
|
||||
accent: C.emerald,
|
||||
rows: [
|
||||
{ kind: "title", text: "Docs & notes" },
|
||||
{ kind: "desc", text: "Policies, definitions, notes" },
|
||||
{ kind: "muted", text: "Notion · any text · …" },
|
||||
],
|
||||
handles: [{ side: "right", type: "source", id: "out" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "engine",
|
||||
type: "engine",
|
||||
position: { x: 420, y: 52 },
|
||||
data: {
|
||||
width: 380,
|
||||
height: 520,
|
||||
steps: [
|
||||
{ n: 1, title: "Source connectors", desc: "Read each source in its shape" },
|
||||
{ n: 2, title: "Context builder", desc: "Evidence into proposed updates" },
|
||||
{ n: 3, title: "Reconciliation", desc: "Merge with existing context" },
|
||||
{ n: 4, title: "Validation", desc: "Check references & semantics" },
|
||||
],
|
||||
handles: [
|
||||
{ side: "left", type: "target", id: "in" },
|
||||
{ side: "right", type: "source", id: "out" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "wiki",
|
||||
type: "card",
|
||||
position: { x: 900, y: 66 },
|
||||
data: {
|
||||
width: 320,
|
||||
height: 220,
|
||||
accent: C.emerald,
|
||||
rows: [
|
||||
{ kind: "mono", text: "wiki/*.md", color: C.emerald },
|
||||
{ kind: "title", text: "Wiki" },
|
||||
{ kind: "chips", items: ["free-form", "auto-maintained"] },
|
||||
{ kind: "desc", text: "Definitions, caveats, policies," },
|
||||
{ kind: "desc", text: "and notes agents can search." },
|
||||
],
|
||||
handles: [{ side: "left", type: "target", id: "in" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sl",
|
||||
type: "card",
|
||||
position: { x: 900, y: 338 },
|
||||
data: {
|
||||
width: 320,
|
||||
height: 220,
|
||||
accent: C.teal,
|
||||
rows: [
|
||||
{ kind: "mono", text: "semantic-layer/*.yaml", color: C.teal },
|
||||
{ kind: "title", text: "Semantic layer" },
|
||||
{ kind: "chips", items: ["executable", "auto-maintained"] },
|
||||
{ kind: "desc", text: "Metrics, joins, dimensions, and" },
|
||||
{ kind: "desc", text: "filters ktx compiles into SQL." },
|
||||
],
|
||||
handles: [{ side: "left", type: "target", id: "in" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const ingestEdge = (source: string, target: string): Edge => ({
|
||||
id: `${source}-${target}`,
|
||||
source,
|
||||
target,
|
||||
sourceHandle: "out",
|
||||
targetHandle: "in",
|
||||
type: "default",
|
||||
style: edgeStyle,
|
||||
markerEnd: marker,
|
||||
});
|
||||
|
||||
export const ingestionEdges: Edge[] = [
|
||||
ingestEdge("db", "engine"),
|
||||
ingestEdge("bi", "engine"),
|
||||
ingestEdge("model", "engine"),
|
||||
ingestEdge("docs", "engine"),
|
||||
ingestEdge("engine", "wiki"),
|
||||
ingestEdge("engine", "sl"),
|
||||
];
|
||||
|
||||
/* =============================== RUNTIME ================================ */
|
||||
|
||||
export const runtimeNodes: Node[] = [
|
||||
{
|
||||
id: "title",
|
||||
type: "title",
|
||||
position: { x: 0, y: -84 },
|
||||
data: {
|
||||
width: 560,
|
||||
eyebrow: "2 · Serving",
|
||||
title: "agents query it through MCP",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agent",
|
||||
type: "card",
|
||||
position: { x: 0, y: 115 },
|
||||
data: {
|
||||
width: 280,
|
||||
height: 190,
|
||||
accent: C.neutral,
|
||||
align: "center",
|
||||
rows: [
|
||||
{ kind: "title", text: "Your agent" },
|
||||
{ kind: "muted", text: "Claude Code · Cursor" },
|
||||
{ kind: "muted", text: "Codex · OpenCode" },
|
||||
],
|
||||
handles: [
|
||||
{ side: "right", type: "source", id: "ask", top: "42%" },
|
||||
{ side: "right", type: "target", id: "answer", top: "62%" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "hub",
|
||||
type: "hub",
|
||||
position: { x: 420, y: 85 },
|
||||
data: {
|
||||
width: 360,
|
||||
height: 250,
|
||||
rows: [
|
||||
"Search wiki + semantic layer",
|
||||
"Return approved metrics",
|
||||
"Compile metrics → SQL",
|
||||
],
|
||||
handles: [
|
||||
{ side: "left", type: "target", id: "ask", top: "42%" },
|
||||
{ side: "left", type: "source", id: "answer", top: "62%" },
|
||||
{ side: "right", type: "source", id: "to-context", top: "30%" },
|
||||
{ side: "right", type: "source", id: "to-warehouse", top: "72%" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "context",
|
||||
type: "card",
|
||||
position: { x: 920, y: 15 },
|
||||
data: {
|
||||
width: 300,
|
||||
height: 150,
|
||||
accent: C.teal,
|
||||
rows: [
|
||||
{ kind: "title", text: "Context layer" },
|
||||
{ kind: "mono", text: "wiki/*.md", color: C.emerald },
|
||||
{ kind: "mono", text: "semantic-layer/*.yaml", color: C.teal },
|
||||
],
|
||||
handles: [{ side: "left", type: "target", id: "in" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "warehouse",
|
||||
type: "card",
|
||||
position: { x: 920, y: 255 },
|
||||
data: {
|
||||
width: 300,
|
||||
height: 150,
|
||||
accent: C.slate,
|
||||
rows: [
|
||||
{ kind: "title", text: "Warehouse" },
|
||||
{
|
||||
kind: "badge",
|
||||
text: "read-only",
|
||||
bg: "#ecf6f8",
|
||||
border: "#bfe3ea",
|
||||
color: C.teal,
|
||||
},
|
||||
{ kind: "desc", text: "Runs the compiled SQL" },
|
||||
],
|
||||
handles: [{ side: "left", type: "target", id: "in" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const runtimeEdges: Edge[] = [
|
||||
{
|
||||
id: "ask",
|
||||
source: "agent",
|
||||
sourceHandle: "ask",
|
||||
target: "hub",
|
||||
targetHandle: "ask",
|
||||
type: "default",
|
||||
label: "ask",
|
||||
...labelBg,
|
||||
style: edgeStyle,
|
||||
markerEnd: marker,
|
||||
},
|
||||
{
|
||||
id: "answer",
|
||||
source: "hub",
|
||||
sourceHandle: "answer",
|
||||
target: "agent",
|
||||
targetHandle: "answer",
|
||||
type: "default",
|
||||
label: "answer",
|
||||
...labelBg,
|
||||
style: edgeStyle,
|
||||
markerEnd: marker,
|
||||
},
|
||||
{
|
||||
id: "search",
|
||||
source: "hub",
|
||||
sourceHandle: "to-context",
|
||||
target: "context",
|
||||
targetHandle: "in",
|
||||
type: "default",
|
||||
label: "search",
|
||||
...labelBg,
|
||||
style: edgeStyle,
|
||||
markerStart: marker,
|
||||
markerEnd: marker,
|
||||
},
|
||||
{
|
||||
id: "readonly",
|
||||
source: "hub",
|
||||
sourceHandle: "to-warehouse",
|
||||
target: "warehouse",
|
||||
targetHandle: "in",
|
||||
type: "default",
|
||||
label: "read-only",
|
||||
...labelBg,
|
||||
style: edgeStyle,
|
||||
markerStart: marker,
|
||||
markerEnd: marker,
|
||||
},
|
||||
];
|
||||
57
docs-site/components/diagram-studio/mascot.tsx
Normal file
57
docs-site/components/diagram-studio/mascot.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Inlined ktx mascot, ported from assets/ktx-mascot.svg.
|
||||
*
|
||||
* - `light` renders the dark-bodied mascot for light surfaces.
|
||||
* - `dark` renders the cream-bodied mascot for dark surfaces (e.g. the ktx
|
||||
* hub panel), mirroring brand/ktx-mascot-dark.svg.
|
||||
*/
|
||||
export function KtxMascot({
|
||||
variant = "light",
|
||||
size = 56,
|
||||
}: {
|
||||
variant?: "light" | "dark";
|
||||
size?: number;
|
||||
}) {
|
||||
const body = variant === "dark" ? "#F5F1EA" : "#1B3139";
|
||||
const eye = variant === "dark" ? "#1B3139" : "#F5F1EA";
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 200 200"
|
||||
width={size}
|
||||
height={size}
|
||||
role="img"
|
||||
aria-label="ktx mascot"
|
||||
>
|
||||
<g fill="none" stroke={body} strokeWidth="16" strokeLinecap="round">
|
||||
<path d="M 62 110 Q 32 130 44 152" />
|
||||
<path d="M 88 116 Q 80 152 70 174" />
|
||||
<path d="M 112 116 Q 120 152 130 174" />
|
||||
</g>
|
||||
<path
|
||||
d="M 134 108 C 162 116, 172 96, 162 78 C 154 64, 168 56, 178 60"
|
||||
fill="none"
|
||||
stroke="#FF8A4C"
|
||||
strokeWidth="16"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M 48 102 C 48 56, 78 30, 100 30 C 122 30, 152 56, 152 102 C 152 116, 132 120, 100 120 C 68 120, 48 116, 48 102 Z"
|
||||
fill={body}
|
||||
/>
|
||||
<path
|
||||
d="M 80 84 Q 86 77 92 84"
|
||||
fill="none"
|
||||
stroke={eye}
|
||||
strokeWidth="3.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M 108 84 Q 114 77 120 84"
|
||||
fill="none"
|
||||
stroke={eye}
|
||||
strokeWidth="3.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
493
docs-site/components/diagram-studio/nodes.tsx
Normal file
493
docs-site/components/diagram-studio/nodes.tsx
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
"use client";
|
||||
|
||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||
|
||||
import { KtxMascot } from "./mascot";
|
||||
|
||||
/** Fixed palette mirrored from the approved SVG diagrams so the exported PNG
|
||||
* is theme-independent (one image that reads on light and dark GitHub). */
|
||||
export const C = {
|
||||
ink: "#1b1b18",
|
||||
inkSoft: "#57534e",
|
||||
inkMuted: "#8c857f",
|
||||
cardBorder: "#e2dfd9",
|
||||
engineBg: "#15323a",
|
||||
engineBorder: "#23474f",
|
||||
cyan: "#55dced",
|
||||
stepNum: "#06262c",
|
||||
stepTitle: "#f3f1ec",
|
||||
stepDesc: "#9fb6bc",
|
||||
hubRow: "#eef4f5",
|
||||
chipBg: "#faf9f6",
|
||||
chipBorder: "#e7e5e4",
|
||||
teal: "#0e7490",
|
||||
emerald: "#059669",
|
||||
orange: "#f97316",
|
||||
amber: "#d97706",
|
||||
slate: "#334155",
|
||||
neutral: "#94a3b8",
|
||||
} as const;
|
||||
|
||||
const DISPLAY = "var(--font-display), system-ui, sans-serif";
|
||||
const BODY = "var(--font-inter), system-ui, sans-serif";
|
||||
const MONO = "var(--font-mono), ui-monospace, monospace";
|
||||
|
||||
const CARD_SHADOW = "0 3px 12px rgba(27, 49, 57, 0.10)";
|
||||
const ENGINE_SHADOW = "0 6px 22px rgba(2, 12, 15, 0.30)";
|
||||
|
||||
/** ktx logo mascot size, shared by the engine and hub headers. */
|
||||
const LOGO_SIZE = 56;
|
||||
|
||||
type HandleSpec = {
|
||||
side: "left" | "right";
|
||||
type: "source" | "target";
|
||||
id: string;
|
||||
top?: string;
|
||||
};
|
||||
|
||||
function Handles({ specs }: { specs?: HandleSpec[] }) {
|
||||
if (!specs) return null;
|
||||
return (
|
||||
<>
|
||||
{specs.map((h) => (
|
||||
<Handle
|
||||
key={`${h.type}-${h.id}`}
|
||||
id={h.id}
|
||||
type={h.type}
|
||||
position={h.side === "left" ? Position.Left : Position.Right}
|
||||
isConnectable={false}
|
||||
style={{
|
||||
opacity: 0,
|
||||
border: 0,
|
||||
background: "transparent",
|
||||
...(h.top ? { top: h.top } : {}),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------- Card node ------------------------------- */
|
||||
|
||||
type CardRow =
|
||||
| { kind: "title"; text: string }
|
||||
| { kind: "mono"; text: string; color: string }
|
||||
| { kind: "desc"; text: string }
|
||||
| { kind: "muted"; text: string }
|
||||
| { kind: "chips"; items: string[] }
|
||||
| { kind: "badge"; text: string; bg: string; border: string; color: string };
|
||||
|
||||
type CardData = {
|
||||
width: number;
|
||||
height: number;
|
||||
accent: string;
|
||||
align?: "center";
|
||||
rows: CardRow[];
|
||||
handles?: HandleSpec[];
|
||||
};
|
||||
|
||||
function gapFor(kind: CardRow["kind"], prev?: CardRow["kind"]): number {
|
||||
if (!prev) return 0;
|
||||
if (kind === "desc" && prev === "desc") return 3;
|
||||
if (kind === "mono" && prev === "mono") return 2;
|
||||
if (kind === "title") return 6;
|
||||
return 10;
|
||||
}
|
||||
|
||||
function CardRowView({ row }: { row: CardRow }) {
|
||||
switch (row.kind) {
|
||||
case "title":
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: DISPLAY,
|
||||
fontWeight: 700,
|
||||
fontSize: 26,
|
||||
lineHeight: 1.15,
|
||||
color: C.ink,
|
||||
}}
|
||||
>
|
||||
{row.text}
|
||||
</span>
|
||||
);
|
||||
case "mono":
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: MONO,
|
||||
fontWeight: 700,
|
||||
fontSize: 18,
|
||||
lineHeight: 1.4,
|
||||
color: row.color,
|
||||
}}
|
||||
>
|
||||
{row.text}
|
||||
</span>
|
||||
);
|
||||
case "desc":
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: BODY,
|
||||
fontWeight: 500,
|
||||
fontSize: 17,
|
||||
lineHeight: 1.45,
|
||||
color: C.inkSoft,
|
||||
}}
|
||||
>
|
||||
{row.text}
|
||||
</span>
|
||||
);
|
||||
case "muted":
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: BODY,
|
||||
fontWeight: 500,
|
||||
fontSize: 14,
|
||||
lineHeight: 1.4,
|
||||
color: C.inkMuted,
|
||||
}}
|
||||
>
|
||||
{row.text}
|
||||
</span>
|
||||
);
|
||||
case "chips":
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{row.items.map((c) => (
|
||||
<span
|
||||
key={c}
|
||||
style={{
|
||||
fontFamily: BODY,
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
color: C.inkSoft,
|
||||
background: C.chipBg,
|
||||
border: `1px solid ${C.chipBorder}`,
|
||||
borderRadius: 6,
|
||||
padding: "4px 10px",
|
||||
}}
|
||||
>
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case "badge":
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
borderRadius: 14,
|
||||
padding: "3px 12px",
|
||||
fontFamily: BODY,
|
||||
fontWeight: 700,
|
||||
fontSize: 14,
|
||||
background: row.bg,
|
||||
border: `1px solid ${row.border}`,
|
||||
color: row.color,
|
||||
}}
|
||||
>
|
||||
{row.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function CardNode({ data }: NodeProps<Node<CardData>>) {
|
||||
const center = data.align === "center";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
position: "relative",
|
||||
background: "#ffffff",
|
||||
border: `1px solid ${C.cardBorder}`,
|
||||
borderRadius: 10,
|
||||
boxShadow: CARD_SHADOW,
|
||||
padding: "18px 20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: center ? "center" : "flex-start",
|
||||
justifyContent: center ? "center" : "flex-start",
|
||||
textAlign: center ? "center" : "left",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 2,
|
||||
right: 2,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
background: data.accent,
|
||||
}}
|
||||
/>
|
||||
<Handles specs={data.handles} />
|
||||
{data.rows.map((row, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ marginTop: gapFor(row.kind, data.rows[i - 1]?.kind) }}
|
||||
>
|
||||
<CardRowView row={row} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------ Engine node ------------------------------ */
|
||||
|
||||
type EngineStep = { n: number; title: string; desc: string };
|
||||
|
||||
type EngineData = {
|
||||
width: number;
|
||||
height: number;
|
||||
steps: EngineStep[];
|
||||
handles?: HandleSpec[];
|
||||
};
|
||||
|
||||
function EngineNode({ data }: NodeProps<Node<EngineData>>) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
position: "relative",
|
||||
background: C.engineBg,
|
||||
border: `1px solid ${C.engineBorder}`,
|
||||
borderRadius: 14,
|
||||
boxShadow: ENGINE_SHADOW,
|
||||
padding: "24px 24px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 2,
|
||||
right: 2,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
background: C.cyan,
|
||||
}}
|
||||
/>
|
||||
<Handles specs={data.handles} />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<KtxMascot variant="dark" size={LOGO_SIZE} />
|
||||
<span
|
||||
style={{
|
||||
fontFamily: DISPLAY,
|
||||
fontWeight: 700,
|
||||
fontSize: 30,
|
||||
color: C.stepTitle,
|
||||
}}
|
||||
>
|
||||
ktx
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-around",
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
{data.steps.map((s) => (
|
||||
<div
|
||||
key={s.n}
|
||||
style={{ display: "flex", alignItems: "center", gap: 18 }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
flex: "none",
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: "50%",
|
||||
background: C.cyan,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: DISPLAY,
|
||||
fontWeight: 800,
|
||||
fontSize: 22,
|
||||
color: C.stepNum,
|
||||
}}
|
||||
>
|
||||
{s.n}
|
||||
</span>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: DISPLAY,
|
||||
fontWeight: 700,
|
||||
fontSize: 24,
|
||||
lineHeight: 1.1,
|
||||
color: C.stepTitle,
|
||||
}}
|
||||
>
|
||||
{s.title}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: BODY,
|
||||
fontWeight: 500,
|
||||
fontSize: 16,
|
||||
lineHeight: 1.3,
|
||||
color: C.stepDesc,
|
||||
}}
|
||||
>
|
||||
{s.desc}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------- Hub node ------------------------------- */
|
||||
|
||||
type HubData = {
|
||||
width: number;
|
||||
height: number;
|
||||
rows: string[];
|
||||
handles?: HandleSpec[];
|
||||
};
|
||||
|
||||
function HubNode({ data }: NodeProps<Node<HubData>>) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
position: "relative",
|
||||
background: C.engineBg,
|
||||
border: `1px solid ${C.engineBorder}`,
|
||||
borderRadius: 14,
|
||||
boxShadow: ENGINE_SHADOW,
|
||||
padding: "24px 24px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 2,
|
||||
right: 2,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
background: C.cyan,
|
||||
}}
|
||||
/>
|
||||
<Handles specs={data.handles} />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<KtxMascot variant="dark" size={LOGO_SIZE} />
|
||||
<span
|
||||
style={{
|
||||
fontFamily: DISPLAY,
|
||||
fontWeight: 700,
|
||||
fontSize: 30,
|
||||
color: C.stepTitle,
|
||||
}}
|
||||
>
|
||||
ktx
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 22,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 18,
|
||||
}}
|
||||
>
|
||||
{data.rows.map((r) => (
|
||||
<div key={r} style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<span
|
||||
style={{
|
||||
flex: "none",
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
background: C.cyan,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: BODY,
|
||||
fontWeight: 600,
|
||||
fontSize: 19,
|
||||
color: C.hubRow,
|
||||
}}
|
||||
>
|
||||
{r}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------- Title node ------------------------------ */
|
||||
|
||||
type TitleData = { width: number; eyebrow: string; title: string };
|
||||
|
||||
function TitleNode({ data }: NodeProps<Node<TitleData>>) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: data.width,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: BODY,
|
||||
fontSize: 19,
|
||||
fontWeight: 800,
|
||||
letterSpacing: 2,
|
||||
textTransform: "uppercase",
|
||||
color: C.teal,
|
||||
}}
|
||||
>
|
||||
{data.eyebrow}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: DISPLAY,
|
||||
fontSize: 24,
|
||||
fontWeight: 600,
|
||||
color: C.inkMuted,
|
||||
}}
|
||||
>
|
||||
{data.title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const nodeTypes = {
|
||||
card: CardNode,
|
||||
engine: EngineNode,
|
||||
hub: HubNode,
|
||||
title: TitleNode,
|
||||
};
|
||||
242
docs-site/components/diagram-studio/studio.tsx
Normal file
242
docs-site/components/diagram-studio/studio.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
"use client";
|
||||
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
type Edge,
|
||||
getNodesBounds,
|
||||
type Node,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { toPng } from "html-to-image";
|
||||
|
||||
import {
|
||||
ingestionEdges,
|
||||
ingestionNodes,
|
||||
runtimeEdges,
|
||||
runtimeNodes,
|
||||
} from "./flows";
|
||||
import { nodeTypes } from "./nodes";
|
||||
|
||||
const EXPORT_PADDING = 48;
|
||||
const EXPORT_PIXEL_RATIO = 2;
|
||||
|
||||
function DiagramCanvasInner({
|
||||
initialNodes,
|
||||
initialEdges,
|
||||
fileName,
|
||||
height,
|
||||
dark,
|
||||
}: {
|
||||
initialNodes: Node[];
|
||||
initialEdges: Edge[];
|
||||
fileName: string;
|
||||
height: number;
|
||||
dark: boolean;
|
||||
}) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [nodes, , onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, , onEdgesChange] = useEdgesState(initialEdges);
|
||||
const { getNodes } = useReactFlow();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const download = useCallback(async () => {
|
||||
const viewport = wrapperRef.current?.querySelector<HTMLElement>(
|
||||
".react-flow__viewport",
|
||||
);
|
||||
if (!viewport) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await document.fonts.ready;
|
||||
const bounds = getNodesBounds(getNodes());
|
||||
const outW = Math.ceil(bounds.width + EXPORT_PADDING * 2);
|
||||
const outH = Math.ceil(bounds.height + EXPORT_PADDING * 2);
|
||||
const tx = EXPORT_PADDING - bounds.x;
|
||||
const ty = EXPORT_PADDING - bounds.y;
|
||||
const dataUrl = await toPng(viewport, {
|
||||
width: outW,
|
||||
height: outH,
|
||||
pixelRatio: EXPORT_PIXEL_RATIO,
|
||||
// transparent background so one PNG works on light and dark GitHub
|
||||
style: {
|
||||
width: `${outW}px`,
|
||||
height: `${outH}px`,
|
||||
transform: `translate(${tx}px, ${ty}px) scale(1)`,
|
||||
},
|
||||
});
|
||||
const link = document.createElement("a");
|
||||
link.download = fileName;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [fileName, getNodes]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={download}
|
||||
disabled={busy}
|
||||
style={btnStyle(busy)}
|
||||
>
|
||||
{busy ? "Exporting…" : "Download PNG"}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
style={{
|
||||
height,
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(127,127,127,0.2)",
|
||||
background: dark ? "#0d1117" : "#ffffff",
|
||||
}}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.08 }}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
nodesFocusable={false}
|
||||
edgesFocusable={false}
|
||||
elementsSelectable={false}
|
||||
panOnDrag={false}
|
||||
panOnScroll={false}
|
||||
zoomOnScroll={false}
|
||||
zoomOnPinch={false}
|
||||
zoomOnDoubleClick={false}
|
||||
preventScrolling={false}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={18}
|
||||
size={1}
|
||||
color={dark ? "#1f2a30" : "#e6e2db"}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function btnStyle(disabled: boolean): React.CSSProperties {
|
||||
return {
|
||||
fontFamily: "var(--font-inter), system-ui, sans-serif",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
padding: "7px 14px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid #0e7490",
|
||||
background: disabled ? "#9bbdc6" : "#0e7490",
|
||||
color: "#ffffff",
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
};
|
||||
}
|
||||
|
||||
function DiagramCanvas(props: {
|
||||
initialNodes: Node[];
|
||||
initialEdges: Edge[];
|
||||
fileName: string;
|
||||
height: number;
|
||||
dark: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<DiagramCanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiagramStudio() {
|
||||
const [dark, setDark] = useState(false);
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
maxWidth: 1320,
|
||||
margin: "0 auto",
|
||||
padding: "32px 24px 80px",
|
||||
fontFamily: "var(--font-inter), system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
<header style={{ marginBottom: 24 }}>
|
||||
<h1
|
||||
style={{
|
||||
fontFamily: "var(--font-display), system-ui, sans-serif",
|
||||
fontSize: 30,
|
||||
fontWeight: 700,
|
||||
color: "#1b1b18",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
ktx diagram studio
|
||||
</h1>
|
||||
<p style={{ color: "#6b6560", marginTop: 6, fontSize: 15 }}>
|
||||
Static diagrams. Export is a transparent 2× PNG framed to the node
|
||||
bounds — the dark-background toggle is only for previewing.
|
||||
</p>
|
||||
<label
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
color: "#57534e",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dark}
|
||||
onChange={(e) => setDark(e.target.checked)}
|
||||
/>
|
||||
Preview on dark background
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<section style={{ marginBottom: 40 }}>
|
||||
<h2 style={sectionTitle}>1 · Ingestion — building the context layer</h2>
|
||||
<DiagramCanvas
|
||||
initialNodes={ingestionNodes}
|
||||
initialEdges={ingestionEdges}
|
||||
fileName="ingestion-flow.png"
|
||||
height={560}
|
||||
dark={dark}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 style={sectionTitle}>2 · Serving — answering agents at runtime</h2>
|
||||
<DiagramCanvas
|
||||
initialNodes={runtimeNodes}
|
||||
initialEdges={runtimeEdges}
|
||||
fileName="mcp-runtime-flow.png"
|
||||
height={480}
|
||||
dark={dark}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const sectionTitle: React.CSSProperties = {
|
||||
fontFamily: "var(--font-display), system-ui, sans-serif",
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: "#1b1b18",
|
||||
marginBottom: 12,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue