Skip to content
Merged
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
335 changes: 220 additions & 115 deletions src/client/pages/index/waterfall.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,38 @@
<script setup lang="ts">
import { range } from '@antfu/utils'
import { inspectSSR, onRefetch, root, waterfallShowResolveId } from '../../logic'
import { getHot } from '../../logic/hot'
import { graphic, use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import type { CustomSeriesOption } from 'echarts/charts'
import { BarChart, CustomChart } from 'echarts/charts'
import type {
SingleAxisComponentOption,
TooltipComponentOption,
} from 'echarts/components'
import {
DataZoomComponent,
GridComponent,
LegendComponent,
TitleComponent,
TooltipComponent,
VisualMapComponent,
} from 'echarts/components'
import VChart from 'vue-echarts'
import type { CustomSeriesRenderItemAPI, CustomSeriesRenderItemParams, CustomSeriesRenderItemReturn, LegendComponentOption, TopLevelFormatterParams } from 'echarts/types/dist/shared'
import { rpc } from '../../logic/rpc'
import { getHot } from '../../logic/hot'
import { inspectSSR, onRefetch, waterfallShowResolveId } from '../../logic'

const container = ref<HTMLDivElement | null>()

const dataZoomBar = 100
const zoomBarOffset = 100

const { height } = useElementSize(container)

const data = shallowRef(await rpc.getWaterfallInfo(inspectSSR.value))
const startTime = computed(() => Math.min(...Object.values(data.value).map(i => i[0]?.start ?? Infinity)))
const endTime = computed(() => Math.max(...Object.values(data.value).map(i => i[i.length - 1]?.end ?? -Infinity)) + 1000)
const scale = ref(0.3)

// const reversed = ref(false)
const searchText = ref('')
const searchFn = computed(() => {
const text = searchText.value.trim()
Expand All @@ -19,67 +43,70 @@ const searchFn = computed(() => {
return (name: string) => regex.test(name)
})

const container = ref<HTMLElement>()
const { x: containerScrollX } = useScroll(container)
const { width: containerWidth } = useElementSize(container)
const visibleMin = computed(() => (containerScrollX.value - 500) / scale.value)
const visibleMax = computed(() => (containerScrollX.value + containerWidth.value + 500) / scale.value)
const tickNum = computed(() => range(
Math.floor(Math.max(0, visibleMin.value) / 1000),
Math.ceil(Math.min(endTime.value - startTime.value, visibleMax.value) / 1000),
))

interface WaterfallSpan {
kind: keyof typeof classNames
fade: boolean
start: number
end: number
id: string
name: string
const categories = computed(() => {
return Object.keys(data.value).filter(searchFn.value)
})

// const legendData = computed(() => {
// const l = categories.value.map((id) => {
// return {
// name: id,
// icon: 'circle',
// }
// })

// console.log(l)

// return l
// })

function generatorHashColorByString(str: string) {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash)
}
let color = '#'
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xFF
color += (`00${value.toString(16)}`).substr(-2)
}
return color
}

const waterfallData = computed(() => {
const result: WaterfallSpan[][] = []
const rowsEnd: number[] = []
for (let [id, steps] of Object.entries(data.value)) {
if (!waterfallShowResolveId.value) {
steps = steps.filter(i => !i.isResolveId)
}
if (steps.length === 0) {
continue
const types = computed(() => {
return Object.keys(data.value).map((id) => {
return {
name: id,
color: generatorHashColorByString(id),
}
const start = steps[0].start - startTime.value
const end = steps[steps.length - 1].end - startTime.value
const spans: WaterfallSpan[] = steps.map(v => ({
kind: v.isResolveId ? 'resolve' : 'transform',
start: v.start - startTime.value,
end: v.end - startTime.value,
name: v.name,
id,
fade: !searchFn.value(v.name) && !searchFn.value(id),
}))
const groupSpan: WaterfallSpan = {
kind: 'group',
fade: spans.every(i => i.fade),
start,
end,
id,
name: 'group',
}
spans.push(groupSpan)
const row = rowsEnd.findIndex((rowEnd, i) => {
if (rowEnd <= start) {
rowsEnd[i] = end
result[i].push(...spans)
return true
})
})

const waterfallData = computed(() => {
const result: any = []

Object.entries(data.value).forEach(([id, steps], index) => {
steps.forEach((s) => {
const typeItem = types.value.find(i => i.name === id)

const duration = s.end - s.start

if (searchFn.value(id) && searchFn.value(s.name)) {
result.push({
name: typeItem ? typeItem.name : id,
value: [index, s.start, (s.end - s.start) < 1 ? 1 : s.end, duration],
itemStyle: {
normal: {
color: typeItem ? typeItem.color : '#000',
},
},
})
}
return false
})
if (row === -1) {
result.push(spans)
rowsEnd.push(end)
}
}
})

// console.log(result)

return result
})

Expand All @@ -96,21 +123,131 @@ getHot().then((hot) => {
}
})

function getPositionStyle(start: number, end: number) {
return {
left: `${start * scale.value}px`,
width: `${Math.max((end - start) * scale.value, 1)}px`,
}
}
use([
VisualMapComponent,
CanvasRenderer,
BarChart,
TooltipComponent,
TitleComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
CustomChart,
])

const classNames = {
resolve: 'outline-red-200 outline-offset--1 bg-gray-300 dark:bg-gray-500 bg-op-80 z-1',
transform: 'outline-blue-200 outline-offset--1 bg-gray-300 dark:bg-gray-500 bg-op-80 z-1',
group: 'outline-orange-700 dark:outline-orange-200',
function renderItem(params: CustomSeriesRenderItemParams | any, api: CustomSeriesRenderItemAPI): CustomSeriesRenderItemReturn {
const categoryIndex = api.value(0)
const start = api.coord([api.value(1), categoryIndex])
const end = api.coord([api.value(2), categoryIndex])
const height = (api.size?.([0, 1]) as number[])[1] * 0.6
const rectShape = graphic.clipRectByRect(
{
x: start[0],
y: start[1] - height / 2,
width: end[0] - start[0],
height,
},
{
x: params.coordSys.x,
y: params.coordSys.y,
width: params.coordSys.width,
height: params.coordSys.height,
},
)

return (
rectShape && {
type: 'rect',
transition: ['shape'],
shape: rectShape,
style: api.style(),
}
)
}

watch(scale, (newScale, oldScale) => {
containerScrollX.value = (containerScrollX.value - 40) * newScale / oldScale + 40
const option = computed(() => ({
tooltip: {
formatter(params: TopLevelFormatterParams | any) {
return `${params.marker + params.name}: ${params.value[3] <= 1 ? '<1' : params.value[3]}ms}`
},

} satisfies TooltipComponentOption,
legendData: {
top: 'center',
data: ['c'],
} satisfies LegendComponentOption,

title: {
text: 'Waterfall',
// left: 'center',
},
visualMap: {
type: 'piecewise',
// show: false,
orient: 'horizontal',
left: 'center',
bottom: 10,
pieces: [

],
seriesIndex: 1,
dimension: 1,
},
dataZoom: [
// 最多支持放大到1ms

{
type: 'slider',
filterMode: 'weakFilter',
showDataShadow: false,
top: height.value - dataZoomBar,
labelFormatter: '',
},
{
type: 'inside',
filterMode: 'weakFilter',
},
],
grid: {
height: height.value - dataZoomBar - zoomBarOffset,
},
xAxis: {
min: startTime.value,
max: endTime.value,
// type: 'value',

scale: true,
axisLabel: {
formatter(val: number) {
return `${(val - startTime.value).toFixed(val % 1 ? 2 : 0)} ms`
},
},
} satisfies SingleAxisComponentOption,
yAxis: {
data: categories.value,
} satisfies SingleAxisComponentOption,
series: [
{
type: 'custom',
name: 'c',
renderItem,
itemStyle: {
opacity: 0.8,
},
encode: {
x: [1, 2],
y: 0,
},
data: waterfallData.value,
},
] satisfies CustomSeriesOption[],

}))

const chartStyle = computed(() => {
return {
height: `${height.value}px`,
}
})
</script>

Expand All @@ -130,51 +267,19 @@ watch(scale, (newScale, oldScale) => {
<button class="text-lg icon-btn" title="Show resolveId" @click="waterfallShowResolveId = !waterfallShowResolveId">
<div i-carbon-connect-source :class="waterfallShowResolveId ? 'opacity-100' : 'opacity-25'" />
</button>
<button text-lg icon-btn title="Zoom in" @click="scale += 0.1">
<div i-carbon-zoom-in />
</button>
<button text-lg icon-btn title="Zoom in" :disabled="scale <= 0.11" @click="scale -= 0.1">
<div i-carbon-zoom-out />
</button>

<!-- <button class="text-lg icon-btn" title="Show resolveId" @click="reversed = !reversed">
<div i-carbon-arrows-vertical :class="reversed ? 'opacity-100' : 'opacity-25'" />
</button> -->
<div flex-auto />
</NavBar>
<Container of-auto @element="el => container = el">
<div relative m-4 w-full flex flex-col gap-1 pb-2 pt-8>
<div absolute left-8 top-0 h-full w-0 of-x-visible>
<div v-for="i in tickNum" :key="i" absolute h-full bg-gray-400 bg-op-30 :style="{ left: `${1000 * i * scale - 2}px`, width: '2px' }">
<span absolute left-1 top--1 w-max op-80>
{{ i }}
<span op-70>s</span>
</span>
</div>
</div>
<div v-for="spans, i in waterfallData" :key="i" h-5 flex>
<div w-8 overflow-hidden pr-4 text-right text-nowrap text-xs tabular-nums>
{{ i }}
</div>
<div absolute h-full :style="getPositionStyle(0, endTime - startTime)" />
<div relative flex-grow>
<template v-for="{ kind, fade, start, end, id, name }, j in spans" :key="j">
<div
v-if="visibleMin <= end && start <= visibleMax"
:title="`${kind === 'group' ? '' : `${kind === 'resolve' ? '(resolve)' : ''} ${name} - `}${id} (${start}+${end - start}ms)`"
:class="(classNames[kind]) + (fade ? ' op-20 bg-op-20' : '')"
:style="getPositionStyle(start, end)"
absolute h-full flex items-center overflow-hidden pl-1 text-nowrap font-mono outline-1 outline-solid
>
<template v-if="kind !== 'group'">
<PluginName :name />
<span mx-.5 op-50>-</span>
<template v-if="id.startsWith(root)">
<span class="op50">.</span>
<span>{{ id.slice(root.length) }}</span>
</template>
<span v-else>{{ id }}</span>
</template>
</div>
</template>
</div>

<div ref="container" h-full p4>
<div v-if="!waterfallData.length" flex="~" h-40 w-full>
<div ma italic op50>
No data
</div>
</div>
</Container>
<VChart class="w-100%" :style="chartStyle" :option="option" autoresize />
</div>
</template>