Skip to content

Commit

Permalink
Merge pull request #1 from ArthurDarkstone/feat/waterfall-echarts
Browse files Browse the repository at this point in the history
refactor: waterfall via echarts
  • Loading branch information
KermanX authored Sep 18, 2024
2 parents f0d93ef + aa16759 commit 9550157
Showing 1 changed file with 220 additions and 115 deletions.
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>

0 comments on commit 9550157

Please sign in to comment.