Skip to content

Commit

Permalink
feat: Timeline view in react viewer
Browse files Browse the repository at this point in the history
This still needs a fix to properly present the tree "skeleton". We don't yet recognize last-sibling.

Signed-off-by: Nick Mitchell <[email protected]>
  • Loading branch information
starpit committed Jan 22, 2025
1 parent ce46c0d commit 4aee9d4
Show file tree
Hide file tree
Showing 9 changed files with 335 additions and 11 deletions.
11 changes: 6 additions & 5 deletions pdl-live-react/src/Viewer.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { useMemo } from "react"
import { lazy, useMemo } from "react"
import { useLocation } from "react-router"

import Code from "./view/Code"
const Code = lazy(() => import("./view/Code"))
const Timeline = lazy(() => import("./view/timeline/Timeline"))
import Transcript from "./view/transcript/Transcript"

import type { PdlBlock } from "./pdl_ast"

import "./Viewer.css"

/** This is the main view component */
Expand All @@ -14,7 +13,7 @@ export default function Viewer({ value }: { value: string }) {
const { hash: activeTab } = useLocation()

const data = useMemo(
() => (value ? (JSON.parse(value) as PdlBlock) : null),
() => (value ? (JSON.parse(value) as import("./pdl_ast").PdlBlock) : null),
[value],
)

Expand All @@ -28,6 +27,8 @@ export default function Viewer({ value }: { value: string }) {
return (
<Code block={data} limitHeight={false} raw={activeTab === "#raw"} />
)
case "#timeline":
return <Timeline block={data} />
default:
case "#transcript":
return <Transcript data={data} />
Expand Down
7 changes: 7 additions & 0 deletions pdl-live-react/src/ViewerTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ export default function Viewer() {
className="pdl-viewer-tab"
title={<TabTitleText>Transcript</TabTitleText>}
/>,
<Tab
key="#timeline"
href="#timeline"
eventKey="#timeline"
className="pdl-viewer-tab"
title={<TabTitleText>Timeline</TabTitleText>}
/>,
<Tab
key="#source"
href="#source"
Expand Down
10 changes: 8 additions & 2 deletions pdl-live-react/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ export function isPdlBlock(
)
}

export function isNonScalarPdlBlock(data: PdlBlock): data is NonScalarPdlBlock {
export function isNonScalarPdlBlock(
data: unknown | PdlBlock,
): data is NonScalarPdlBlock {
return data != null && typeof data === "object"
}

Expand Down Expand Up @@ -126,7 +128,7 @@ export function nonNullable<T>(value: T): value is NonNullable<T> {

/** Does the given block have timing information? */
export function hasTimingInformation(
block: PdlBlock,
block: unknown | PdlBlock,
): block is PdlBlockWithTiming {
return (
isNonScalarPdlBlock(block) &&
Expand All @@ -135,3 +137,7 @@ export function hasTimingInformation(
typeof block.timezone === "string"
)
}

export function capitalizeAndUnSnakeCase(s: string) {
return s[0].toUpperCase() + s.slice(1).replace(/[-_]/, " ")
}
67 changes: 67 additions & 0 deletions pdl-live-react/src/view/timeline/Timeline.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
.pdl-timeline {
margin: 1em 0;
display: table;
border-collapse: collapse;
}

.pdl-timeline-row {
display: table-row;
}

.pdl-timeline-cell {
display: table-cell;
vertical-align: middle;

&[data-cell="kind"] {
white-space: pre;
height: 0.875em;
line-height: 0.875em;
}
&[data-cell="bar"] {
width: 100%;
padding: 0 1em;
}
&[data-cell="duration"] {
white-space: nowrap;
}
}

.pdl-timeline-kind {
font-size: 0.875em;
}
.pdl-timeline-row:hover {
.pdl-timeline-kind {
text-decoration: underline;
}
}

.pdl-timeline-bar-outer {
width: 100%;
position: relative;
display: block;
height: 0.875em;
line-height: 0.875em;

border: 1px solid var(--pf-t--global--background--color--secondary--default);
transition: background-color var(--pf-t--global--motion--delay--short)
var(--pf-t--global--motion--timing-function--default);
&:hover {
background-color: var(--pf-t--global--background--color--secondary--hover);
}
}

.pdl-timeline-bar {
position: absolute;
min-width: 1px;

background-color: var(--pf-t--global--background--color--inverse--default);
transition: background-color var(--pf-t--global--motion--delay--short)
var(--pf-t--global--motion--timing-function--default);
&:hover {
background-color: var(--pf-t--global--color--brand--hover);
}

&[data-kind="model"] {
background-color: var(--pf-t--global--icon--color--severity--none--default);
}
}
88 changes: 88 additions & 0 deletions pdl-live-react/src/view/timeline/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { useMemo } from "react"

import TimelineRow, { type Position } from "./TimelineRow"
import { type TimelineRow as TimelineRowModel, computeModel } from "./model"

import "./Timeline.css"

type Props = {
block: import("../../pdl_ast").PdlBlock
}

export default function Timeline({ block }: Props) {
const [model, min, max] = useMemo(() => {
const model = computeModel(block).sort(
(a, b) => a.start_nanos - b.start_nanos,
)
const [min, max] = model.reduce(
([min, max], row) => [
Math.min(min, row.start_nanos),
Math.max(max, row.end_nanos),
],
[Number.MAX_VALUE, Number.MIN_VALUE],
)
return [model, min, max]
}, [block])

const pushPops = useMemo(() => pushPopsFor(model), [model])

return (
<div className="pdl-timeline">
{model.map((row, idx) => (
<TimelineRow
key={idx}
{...row}
min={min}
max={max}
{...pushPops[idx]}
/>
))}
</div>
)
}

function positionOf(
row: TimelineRowModel,
idx: number,
A: TimelineRowModel[],
): Position {
return idx === A.length - 1 || A[idx + 1].depth < row.depth
? "pop"
: idx === 0 || A[idx - 1].depth < row.depth
? "push"
: A[idx - 1].depth === row.depth
? "middle"
: "pop"
}

/* function nextSibling(row: TimelineRowModel, idx: number, A: TimelineRowModel[]) {
let sidx = idx + 1
while (sidx < A.length && A[sidx].depth > row.depth) {
sidx++
}
return sidx < A.length && A[sidx].depth === row.depth ? sidx : -1
} */

/*function pushPopsFor(model: TimelineRowModel[]): { prefix: string, position: Position }[] {
return model.reduce((Ps, row, idx) => {
const position = positionOf(row, idx, model)
if (Ps.parent === -1) {
return {prefix: "", position}
}
//const siblingIdx = nextSibling(model[Ps.parent], Ps.parent, model)
const prefix = Ps.parentPrefix
if (position === 'push' && Ps.parentHasSibling) {
prefix += "│ "
return {
prefix,
position,
}
})
}
*/

function pushPopsFor(model: TimelineRowModel[]): { position: Position }[] {
return model.map((row, idx, A) => ({ position: positionOf(row, idx, A) }))
}
27 changes: 27 additions & 0 deletions pdl-live-react/src/view/timeline/TimelineBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useMemo } from "react"

type Props = import("./model").TimelineRowWithExtrema

export default function TimelineBar({
kind,
start_nanos,
end_nanos,
min,
max,
}: Props) {
const style = useMemo(
() => ({
left: (100 * (start_nanos - min)) / (max - min) + "%",
width: (100 * (end_nanos - start_nanos)) / (max - min) + "%",
}),
[start_nanos, end_nanos],
)

return (
<span className="pdl-timeline-bar-outer">
<span className="pdl-timeline-bar" style={style} data-kind={kind}>
&nbsp;
</span>
</span>
)
}
59 changes: 59 additions & 0 deletions pdl-live-react/src/view/timeline/TimelineRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import prettyMs from "pretty-ms"

import TimelineBar from "./TimelineBar"
import { capitalizeAndUnSnakeCase } from "../../helpers"

export type Position = "push" | "middle" | "pop"

type Props = import("./model").TimelineRowWithExtrema & {
position: Position
}

export default function TimelineRow(row: Props) {
return (
<div className="pdl-timeline-row">
<span className="pdl-timeline-cell" data-cell="kind">
<span className="pdl-mono">{treeSymbols(row)}</span>
<span className="pdl-timeline-kind">
{capitalizeAndUnSnakeCase(row.kind ?? "unknown")}
</span>
</span>

<span className="pdl-timeline-cell" data-cell="bar">
<TimelineBar {...row} />
</span>

<span className="pdl-timeline-cell pdl-duration" data-cell="duration">
{prettyMs((row.end_nanos - row.start_nanos) / 1000000)}
</span>
</div>
)
}

function treeSymbols(row: Props) {
return prefixSymbols(row) + finalSymbol(row)
}

function prefixSymbols(row: Props) {
return row.depth === 0
? ""
: Array(row.depth - 1)
.fill("")
.map(() => "│ ")
.join("")
}

function finalSymbol(row: Props) {
if (row.depth === 0) {
return ""
}

switch (row.position) {
case "push":
case "middle":
return "├── "
default:
case "pop":
return "└── "
}
}
72 changes: 72 additions & 0 deletions pdl-live-react/src/view/timeline/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { match } from "ts-pattern"

import { type PdlBlock } from "../../pdl_ast"
import {
hasTimingInformation,
nonNullable,
type PdlBlockWithTiming,
type NonScalarPdlBlock,
} from "../../helpers"

export type TimelineRow = Pick<
PdlBlockWithTiming,
"start_nanos" | "end_nanos" | "timezone" | "kind"
> & {
/** Call tree depth */
depth: number
}

export type TimelineRowWithExtrema = TimelineRow & {
/** Minimum timestamp across all rows */
min: number

/** Maximum timestamp across all rows */
max: number
}

export type TimelineModel = TimelineRow[]

export function computeModel(
block: unknown | PdlBlock,
depth = 0,
): TimelineModel {
if (!hasTimingInformation(block)) {
return []
}

return [
Object.assign({ depth }, block),
...childrenOf(block)
.filter(nonNullable)
.flatMap((child) => computeModel(child, depth + 1)),
]
}

function childrenOf(block: NonScalarPdlBlock) {
return match(block)
.with({ kind: "model" }, (data) => [data.input, data.result])
.with({ kind: "code" }, (data) => [data.result])
.with({ kind: "get" }, (data) => [data.result])
.with({ kind: "data" }, (data) => [data.result])
.with({ kind: "if" }, (data) =>
data.if_result ? [data.then] : [data.else],
)
.with({ kind: "read" }, (data) => [data.result])
.with({ kind: "include" }, (data) => [data.trace ?? data.result])
.with({ kind: "function" }, () => [])
.with({ kind: "call" }, (data) => [data.trace ?? data.result])
.with({ kind: "text" }, (data) => [data.text])
.with({ kind: "lastOf" }, (data) => [data.lastOf])
.with({ kind: "array" }, (data) => [data.array])
.with({ kind: "object" }, (data) => [data.object])
.with({ kind: "message" }, (data) => [data.content])
.with({ kind: "repeat" }, (data) => [data.trace ?? data.repeat])
.with({ kind: "repeat_until" }, (data) => [data.trace ?? data.repeat])
.with({ kind: "for" }, (data) => [data.trace ?? data.repeat])
.with({ kind: "empty" }, () => [])
.with({ kind: "error" }, () => []) // TODO show errors in trace
.with({ kind: undefined }, () => [])
.exhaustive()
.flat()
.filter(nonNullable)
}
Loading

0 comments on commit 4aee9d4

Please sign in to comment.