ktx/docs-site/components/product-mechanics.tsx
Andrey Avtomonov 7d156d9a06
feat(docs): visualize KTX ingestion with ReactFlow diagram (#133)
* feat(docs): visualize KTX ingestion with ReactFlow diagram

Reframe the introduction around the two user-facing ingestion outputs (wiki
and executable semantic layer) and replace the static product-mechanics card
flow with a ReactFlow diagram: sources fan into a sequential ingest pipeline,
which forks into wiki and semantic-layer outputs connected by a bidirectional
"references" edge. Drop the .ktx/raw-sources internal-implementation rows from
the intro table and update the content test to guard the new copy.

* Improve KTX docs introduction

* feat(docs): animate ingestion flow with running dots

Replace static smoothstep edges in the introduction page's ingestion
diagram with a custom animated edge that runs glowing cyan dots along
each path, conveying the source → stage → output flow. Dot duration
scales with path length and is hidden under prefers-reduced-motion.

* feat(docs): route ingestion atoms through full source→output journey

Replace per-edge dots with full-journey particles: each atom is born at
a source, threads the entire stage chain, and lands at either the wiki
or semantic layer. Particles are tinted by their source's accent so
the origin is legible. Each source produces exactly 2 atoms (8 total)
to guarantee every input is visibly active, while the destination and
begin offsets are randomized per page load. Particles populate on
client mount to avoid hydration mismatch, and are hidden under
prefers-reduced-motion.
2026-05-18 17:41:37 +02:00

628 lines
17 KiB
TypeScript

"use client";
import {
Background,
BackgroundVariant,
type Edge,
type EdgeProps,
getSmoothStepPath,
Handle,
MarkerType,
type Node,
type NodeProps,
Position,
ReactFlow,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useEffect, useMemo, useState } from "react";
type SourceNodeData = {
accent: string;
body: string;
items: string[];
title: string;
};
type StageNodeData = {
body: string;
index: number;
title: string;
};
type OutputNodeData = {
accent: string;
body: string;
path: string;
tags: string[];
title: string;
};
type SourceNode = Node<SourceNodeData, "source">;
type StageNode = Node<StageNodeData, "stage">;
type OutputNode = Node<OutputNodeData, "output">;
type FlowNode = SourceNode | StageNode | OutputNode;
const SOURCE_W = 210;
const SOURCE_H = 200;
const STAGE_W = 280;
const STAGE_H = 120;
const OUTPUT_W = 340;
const OUTPUT_H = 232;
const ROW_SOURCES_Y = 80;
const ROW_STAGE_START_Y = 360;
const STAGE_GAP = 30;
const ROW_OUTPUTS_Y = 1000;
const STAGE_CENTER_X = 460;
const STAGE_X = STAGE_CENTER_X - STAGE_W / 2;
const SOURCE_GAP_X = 24;
const SOURCES_TOTAL = SOURCE_W * 4 + SOURCE_GAP_X * 3;
const SOURCES_START_X = STAGE_CENTER_X - SOURCES_TOTAL / 2;
const OUTPUT_GAP_X = 180;
const OUTPUTS_TOTAL = OUTPUT_W * 2 + OUTPUT_GAP_X;
const OUTPUTS_START_X = STAGE_CENTER_X - OUTPUTS_TOTAL / 2;
const EDGE_STROKE = "#94a3b8";
const sourceData: SourceNodeData[] = [
{
title: "Databases",
body: "Schemas, columns, keys, row counts, and query history.",
items: ["PostgreSQL", "Snowflake", "BigQuery", "SQLite"],
accent: "#3b82f6",
},
{
title: "BI tools",
body: "Dashboards, questions, explores, usage, and trusted examples.",
items: ["Metabase", "Looker"],
accent: "#f97316",
},
{
title: "Modeling code",
body: "Existing metrics, dimensions, models, joins, and entities.",
items: ["dbt", "LookML", "MetricFlow"],
accent: "#f59e0b",
},
{
title: "Docs and notes",
body: "Policies, caveats, team definitions, and analyst context.",
items: ["Notion", "Any text"],
accent: "#10b981",
},
];
const stageData: Omit<StageNodeData, "index">[] = [
{
title: "Source adapters",
body: "Read each configured system in its native shape.",
},
{
title: "Context builder",
body: "Turn source evidence into proposed context updates.",
},
{
title: "Reconciliation",
body: "Merge new evidence with the context that already exists.",
},
{
title: "Validation",
body: "Check references and semantics before agents rely on them.",
},
];
const outputData: OutputNodeData[] = [
{
title: "Wiki",
path: "wiki/*.md",
tags: ["free-form", "auto-maintained"],
body: "Definitions, caveats, policies, analyst notes, and business language that agents can search.",
accent: "#10b981",
},
{
title: "Semantic layer",
path: "semantic-layer/*.yaml",
tags: ["structured", "executable", "auto-maintained"],
body: "Metrics, joins, tables, dimensions, filters, and segments that KTX can validate and compile into SQL.",
accent: "#3b82f6",
},
];
const nodes: FlowNode[] = [
...sourceData.map<SourceNode>((source, index) => ({
id: `source-${index}`,
type: "source",
position: {
x: SOURCES_START_X + index * (SOURCE_W + SOURCE_GAP_X),
y: ROW_SOURCES_Y,
},
data: source,
draggable: false,
selectable: false,
})),
...stageData.map<StageNode>((stage, index) => ({
id: `stage-${index}`,
type: "stage",
position: {
x: STAGE_X,
y: ROW_STAGE_START_Y + index * (STAGE_H + STAGE_GAP),
},
data: { ...stage, index: index + 1 },
draggable: false,
selectable: false,
})),
...outputData.map<OutputNode>((output, index) => ({
id: `output-${index}`,
type: "output",
position: {
x: OUTPUTS_START_X + index * (OUTPUT_W + OUTPUT_GAP_X),
y: ROW_OUTPUTS_Y,
},
data: output,
draggable: false,
selectable: false,
})),
];
const REF_EDGE_STROKE = "#64748b";
const flowEdges = [
...sourceData.map((_, index) => ({
id: `e-source-${index}-stage-0`,
source: `source-${index}`,
target: "stage-0",
})),
...stageData.slice(0, -1).map((_, index) => ({
id: `e-stage-${index}-stage-${index + 1}`,
source: `stage-${index}`,
target: `stage-${index + 1}`,
})),
...outputData.map((_, index) => ({
id: `e-stage-3-output-${index}`,
source: "stage-3",
target: `output-${index}`,
})),
].map((edge) => ({
...edge,
type: "smoothstep" as const,
style: { stroke: EDGE_STROKE, strokeWidth: 1.5 },
markerEnd: {
type: MarkerType.ArrowClosed,
color: EDGE_STROKE,
width: 16,
height: 16,
},
}));
const refsEdge = {
id: "e-output-refs",
source: "output-0",
sourceHandle: "right",
target: "output-1",
targetHandle: "left",
type: "straight" as const,
label: "references",
labelBgPadding: [6, 3] as [number, number],
labelBgBorderRadius: 4,
labelStyle: {
fontSize: 13,
fontWeight: 500,
fill: "var(--color-fd-muted-foreground)",
},
labelBgStyle: {
fill: "var(--color-fd-background)",
stroke: "var(--color-fd-border)",
strokeWidth: 1,
},
style: {
stroke: REF_EDGE_STROKE,
strokeWidth: 1.25,
strokeDasharray: "4 4",
},
markerStart: {
type: MarkerType.ArrowClosed,
color: REF_EDGE_STROKE,
width: 14,
height: 14,
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: REF_EDGE_STROKE,
width: 14,
height: 14,
},
};
function SourceNodeView({ data }: NodeProps<SourceNode>) {
return (
<div
style={{
width: SOURCE_W,
height: SOURCE_H,
borderTop: `3px solid ${data.accent}`,
}}
className="overflow-hidden rounded-md border border-fd-border bg-fd-card px-3.5 py-3 shadow-sm"
>
<Handle type="target" position={Position.Top} className="!opacity-0" />
<p className="text-[16px] font-semibold leading-6 text-fd-foreground">
{data.title}
</p>
<p className="mt-1 line-clamp-3 text-[13px] leading-5 text-fd-muted-foreground">
{data.body}
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{data.items.map((item) => (
<span
key={item}
className="rounded border border-fd-border bg-fd-background px-1.5 py-0.5 text-[12px] leading-5 text-fd-muted-foreground"
>
{item}
</span>
))}
</div>
<Handle type="source" position={Position.Bottom} className="!opacity-0" />
</div>
);
}
function StageNodeView({ data }: NodeProps<StageNode>) {
return (
<div
style={{ width: STAGE_W, height: STAGE_H }}
className="flex items-center gap-3.5 rounded-md border border-cyan-200/20 bg-[#0f1f23] px-4 py-3.5 text-white shadow-sm dark:bg-[#0b181b]"
>
<Handle type="target" position={Position.Top} className="!opacity-0" />
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-cyan-300/95 font-mono text-sm font-semibold text-[#0b1c20]">
{data.index}
</span>
<div className="min-w-0">
<p className="text-[16px] font-semibold leading-6 text-white">
{data.title}
</p>
<p className="mt-1 line-clamp-3 text-[13px] leading-5 text-cyan-50/75">
{data.body}
</p>
</div>
<Handle type="source" position={Position.Bottom} className="!opacity-0" />
</div>
);
}
function OutputNodeView({ data }: NodeProps<OutputNode>) {
return (
<div
style={{
width: OUTPUT_W,
height: OUTPUT_H,
borderTop: `3px solid ${data.accent}`,
}}
className="overflow-hidden rounded-md border border-fd-border bg-fd-card px-4 py-3.5 shadow-sm"
>
<Handle type="target" position={Position.Top} className="!opacity-0" />
<Handle
id="left"
type="target"
position={Position.Left}
className="!opacity-0"
/>
<Handle
id="right"
type="source"
position={Position.Right}
className="!opacity-0"
/>
<p
className="font-mono text-[13px] font-semibold tracking-tight"
style={{ color: data.accent }}
>
{data.path}
</p>
<p className="mt-1.5 text-[16px] font-semibold leading-6 text-fd-foreground">
{data.title}
</p>
<div className="mt-1.5 flex flex-nowrap gap-1">
{data.tags.map((tag) => (
<span
key={tag}
className="whitespace-nowrap rounded border border-fd-border bg-fd-background px-1.5 py-0.5 text-[12px] leading-5 text-fd-muted-foreground"
>
{tag}
</span>
))}
</div>
<p className="mt-2 line-clamp-3 text-[13px] leading-5 text-fd-muted-foreground">
{data.body}
</p>
</div>
);
}
const PARTICLES_PER_SOURCE = 2;
const PARTICLE_SPEED_PX_PER_SEC = 130;
const PARTICLE_MIN_DURATION_SEC = 4;
const stageTopY = (i: number) => ROW_STAGE_START_Y + i * (STAGE_H + STAGE_GAP);
const stageBottomY = (i: number) => stageTopY(i) + STAGE_H;
function buildParticlePath(
sourceIndex: number,
outputIndex: number,
): { d: string; length: number } {
const sourceCenterX =
SOURCES_START_X + sourceIndex * (SOURCE_W + SOURCE_GAP_X) + SOURCE_W / 2;
const sourceBottomYVal = ROW_SOURCES_Y + SOURCE_H;
const outputCenterX =
OUTPUTS_START_X + outputIndex * (OUTPUT_W + OUTPUT_GAP_X) + OUTPUT_W / 2;
const legs: Array<[number, number, number, number]> = [
[sourceCenterX, sourceBottomYVal, STAGE_CENTER_X, stageTopY(0)],
[STAGE_CENTER_X, stageBottomY(0), STAGE_CENTER_X, stageTopY(1)],
[STAGE_CENTER_X, stageBottomY(1), STAGE_CENTER_X, stageTopY(2)],
[STAGE_CENTER_X, stageBottomY(2), STAGE_CENTER_X, stageTopY(3)],
[STAGE_CENTER_X, stageBottomY(3), outputCenterX, ROW_OUTPUTS_Y],
];
const segments = legs.map(([sx, sy, tx, ty]) => {
const [segment] = getSmoothStepPath({
sourceX: sx,
sourceY: sy,
sourcePosition: Position.Bottom,
targetX: tx,
targetY: ty,
targetPosition: Position.Top,
});
return segment;
});
let d = segments[0];
for (let i = 1; i < segments.length; i += 1) {
d += ` ${segments[i].replace(/^M/, "L")}`;
}
const length = legs.reduce(
(sum, [sx, sy, tx, ty]) => sum + Math.abs(tx - sx) + Math.abs(ty - sy),
0,
);
return { d, length };
}
type ParticleEdgeData = {
d: string;
duration: number;
beginOffset: number;
color: string;
};
type ParticleEdge = Edge<ParticleEdgeData, "particle">;
function ParticleEdgeView({ id, data }: EdgeProps<ParticleEdge>) {
if (!data) return null;
const pathId = `mechanics-particle-path-${id}`;
return (
<>
<path
id={pathId}
d={data.d}
fill="none"
stroke="none"
pointerEvents="none"
/>
<g className="mechanics-particle" style={{ color: data.color }}>
<circle r={7.5} fill="currentColor" opacity={0.16} />
<circle r={3.75} fill="currentColor" opacity={0.32} />
<circle r={2.1} fill="currentColor" />
<animateMotion
dur={`${data.duration.toFixed(2)}s`}
begin={`-${data.beginOffset.toFixed(2)}s`}
repeatCount="indefinite"
>
<mpath href={`#${pathId}`} />
</animateMotion>
</g>
</>
);
}
const nodeTypes = {
source: SourceNodeView,
stage: StageNodeView,
output: OutputNodeView,
};
const edgeTypes = {
particle: ParticleEdgeView,
};
const staticEdges = [...flowEdges, refsEdge];
type ParticleSpec = {
id: string;
sourceIndex: number;
outputIndex: number;
};
function makeRandomParticles(perSource: number): ParticleSpec[] {
const specs: ParticleSpec[] = [];
for (let sourceIndex = 0; sourceIndex < sourceData.length; sourceIndex += 1) {
for (let n = 0; n < perSource; n += 1) {
specs.push({
id: `particle-${sourceIndex}-${n}`,
sourceIndex,
outputIndex: Math.floor(Math.random() * outputData.length),
});
}
}
return specs;
}
function specToEdge(spec: ParticleSpec): {
id: string;
source: string;
target: string;
type: "particle";
data: ParticleEdgeData;
} {
const { d, length } = buildParticlePath(spec.sourceIndex, spec.outputIndex);
const duration = Math.max(
PARTICLE_MIN_DURATION_SEC,
length / PARTICLE_SPEED_PX_PER_SEC,
);
return {
id: spec.id,
source: `source-${spec.sourceIndex}`,
target: `output-${spec.outputIndex}`,
type: "particle",
data: {
d,
duration,
beginOffset: Math.random() * duration,
color: sourceData[spec.sourceIndex].accent,
},
};
}
export function ProductMechanics() {
const [particles, setParticles] = useState<ParticleSpec[]>([]);
useEffect(() => {
setParticles(makeRandomParticles(PARTICLES_PER_SOURCE));
}, []);
const edges = useMemo(
() => [...staticEdges, ...particles.map(specToEdge)],
[particles],
);
return (
<section
className="not-prose my-12 w-full max-w-full min-w-0 space-y-5"
aria-labelledby="mechanics-title"
>
<div className="max-w-3xl">
<h2
id="mechanics-title"
className="text-xl font-semibold tracking-normal text-fd-foreground sm:text-2xl"
style={{ fontFamily: "var(--font-display)" }}
>
How ingestion works
</h2>
<p className="mt-3 text-sm leading-6 text-fd-muted-foreground">
KTX ingests source evidence, reconciles it with your existing project,
and produces durable context that agents can search, review, and
execute.
</p>
</div>
<article
className="max-w-full min-w-0 overflow-hidden rounded-lg border border-fd-border bg-fd-card shadow-sm"
aria-label="KTX ingestion flow from source systems to durable context outputs"
>
<div className="border-b border-fd-border bg-fd-muted/35 px-5 py-4">
<p className="text-xs font-semibold uppercase tracking-wide text-fd-primary">
Ingestion flow
</p>
<h3
className="mt-1 text-base font-semibold tracking-normal text-fd-foreground sm:text-lg"
style={{ fontFamily: "var(--font-display)" }}
>
From scattered source systems to agent-ready context
</h3>
<p className="mt-2 max-w-3xl text-xs leading-5 text-fd-muted-foreground">
The inputs can be structured systems or loose team knowledge. The
outputs are the two files agents need: a readable wiki and an
executable semantic layer.
</p>
</div>
<div
className="mechanics-canvas bg-fd-background"
style={{
height: "min(1180px, 165vw)",
minHeight: 680,
}}
>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
fitViewOptions={{ padding: 0.04 }}
nodesDraggable={false}
nodesConnectable={false}
nodesFocusable={false}
edgesFocusable={false}
elementsSelectable={false}
panOnDrag={false}
panOnScroll={false}
zoomOnScroll={false}
zoomOnPinch={false}
zoomOnDoubleClick={false}
preventScrolling={false}
minZoom={0.2}
maxZoom={1.5}
proOptions={{ hideAttribution: true }}
>
<Background
variant={BackgroundVariant.Dots}
gap={18}
size={1}
color="var(--color-fd-border)"
/>
</ReactFlow>
</div>
</article>
<style>{`
.mechanics-canvas .react-flow__node {
background: transparent;
border: 0;
box-shadow: none;
padding: 0;
border-radius: 0;
width: auto;
text-align: left;
user-select: text;
-webkit-user-select: text;
cursor: auto;
pointer-events: all !important;
}
.mechanics-canvas .react-flow__node > * {
pointer-events: auto;
user-select: text;
-webkit-user-select: text;
}
.mechanics-canvas .react-flow__node.selected,
.mechanics-canvas .react-flow__node:focus,
.mechanics-canvas .react-flow__node:focus-visible {
outline: none;
box-shadow: none;
}
.mechanics-canvas .react-flow__pane {
cursor: default;
}
.mechanics-canvas .react-flow__handle {
width: 1px;
height: 1px;
min-width: 0;
min-height: 0;
background: transparent;
border: 0;
pointer-events: none;
}
.mechanics-canvas .mechanics-particle {
pointer-events: none;
filter: drop-shadow(0 0 6px currentColor);
}
@media (prefers-reduced-motion: reduce) {
.mechanics-canvas .mechanics-particle {
display: none;
}
}
`}</style>
</section>
);
}