From 39663461bfe0662c8e4ca17c214bf11d809c0c23 Mon Sep 17 00:00:00 2001 From: Jamie Wong Date: Sun, 26 Nov 2017 17:54:55 -0800 Subject: [PATCH] Label rendering --- flamechart.tsx | 193 +++++++++++++++++++++++++++++++++++++++---------- math.ts | 9 +++ 2 files changed, 165 insertions(+), 37 deletions(-) diff --git a/flamechart.tsx b/flamechart.tsx index 835a2aa..3532f43 100644 --- a/flamechart.tsx +++ b/flamechart.tsx @@ -61,42 +61,113 @@ interface FlamechartViewProps { flamechart: Flamechart } +interface FlamechartFrameLabel { + configSpaceBounds: Rect + frame: Frame +} + +function binarySearch(lo: number, hi: number, f: (val: number) => number, target: number, targetRangeSize = 1): [number, number] { + while (true) { + if (hi - lo <= targetRangeSize) return [lo, hi] + const mid = (hi + lo) / 2 + const val = f(mid) + if (val < target) lo = mid + if (val > target) hi = mid + } +} + +const ELLIPSIS = '\u2026' + +function buildTrimmedText(text: string, length: number) { + const prefixLength = Math.floor(length / 2) + const suffixLength = Math.ceil(length / 2) + const prefix = text.substr(0, prefixLength) + const suffix = text.substr(text.length - prefixLength, prefixLength) + return prefix + ELLIPSIS + suffix +} + +const measureTextCache = new Map() +function cachedMeasureTextWidth(ctx: CanvasRenderingContext2D, text: string): number { + if (!measureTextCache.has(text)) { + measureTextCache.set(text, ctx.measureText(text).width) + } + return measureTextCache.get(text)! +} + +function trimTextMid(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) { + if (cachedMeasureTextWidth(ctx, text) <= maxWidth) return text + const [lo, hi] = binarySearch(0, text.length, (n) => { + return cachedMeasureTextWidth(ctx, buildTrimmedText(text, n)) + }, maxWidth) + return buildTrimmedText(text, lo) +} + export class FlamechartView extends Component { renderer: ReglCommand | null = null canvas: HTMLCanvasElement | null = null + overlayCanvas: HTMLCanvasElement | null = null + overlayCtx: CanvasRenderingContext2D | null = null + worldSpaceViewportRect = new Rect() + labels: FlamechartFrameLabel[] = [] + + private preprocess() { + if (!this.canvas) return + + const {flamechart} = this.props + const configSpaceRects: Rect[] = [] + const colors: vec3[] = [] + + const layers = flamechart.getLayers() + const duration = flamechart.getDuration() + const maxStackHeight = layers.length + + const configSpaceToWorldSpace = this.configSpaceToWorldSpace() + + for (let i = 0; i < layers.length; i++) { + const layer = layers[i] + for (let flamechartFrame of layer) { + const configSpaceBounds = new Rect( + new Vec2(flamechartFrame.start, i), + new Vec2(flamechartFrame.end - flamechartFrame.start, 1) + ) + configSpaceRects.push(configSpaceBounds) + colors.push([Math.random(), Math.random(), 0]) + + this.labels.push({ + configSpaceBounds, + frame: flamechartFrame.frame + }) + } + } + + this.worldSpaceViewportRect = new Rect( + new Vec2(0, 0), + new Vec2(this.viewportWidth(), this.viewportHeight()) + ) + + const ctx = this.canvas.getContext('webgl')! + this.renderer = rectangleBatchRenderer(ctx, configSpaceRects, colors) + } private canvasRef = (element?: Element) => { if (element) { - const {flamechart} = this.props - const rects: Rect[] = [] - const colors: vec3[] = [] - - const layers = flamechart.getLayers() - const duration = flamechart.getDuration() - const maxStackHeight = layers.length - - for (let i = 0; i < layers.length; i++) { - const layer = layers[i] - for (let frame of layer) { - rects.push(new Rect( - new Vec2(frame.start, i), - new Vec2(frame.end - frame.start, 1) - )) - colors.push([Math.random(), Math.random(), 0]) - } - } - this.canvas = element as HTMLCanvasElement - - this.worldSpaceViewportRect = new Rect( - new Vec2(0, 0), - new Vec2(this.viewportWidth(), this.viewportHeight()) - ) - - const ctx = this.canvas.getContext('webgl')! - this.renderer = rectangleBatchRenderer(ctx, rects, colors) + this.preprocess() this.renderGL() + this.renderLabels() + } else { + this.canvas = null + } + } + + private overlayCanvasRef = (element?: Element) => { + if (element) { + this.overlayCanvas = element as HTMLCanvasElement + this.overlayCtx = this.overlayCanvas.getContext('2d') + } else { + this.overlayCanvas = null + this.overlayCtx = null } } @@ -142,14 +213,47 @@ export class FlamechartView extends Component { .times(this.configSpaceToWorldSpace()) } - private renderGL() { - if (this.renderer && this.canvas) { - this.renderer({ - configSpaceToNDC: this.configSpaceToNDC() - }) + private renderLabels() { + const ctx = this.overlayCtx + if (!ctx) return + + const configSpaceToViewSpace = this.worldSpaceToViewSpace().times(this.configSpaceToWorldSpace()) + + ctx.clearRect(0, 0, this.viewportWidth(), this.viewportHeight()) + + ctx.font = "12px/16px Courier, monospace" + ctx.fillStyle = 'rgba(0, 0, 0, 1)' + ctx.textBaseline = 'top' + + const minWidthToRender = cachedMeasureTextWidth(ctx, 'M' + ELLIPSIS + 'M') + const viewportRect = new Rect(new Vec2(0, 0), this.viewportSize()) + + for (let label of this.labels) { + const LABEL_PADDING_PX = 2 + let viewSpaceBounds = configSpaceToViewSpace.transformRect(label.configSpaceBounds) + + viewSpaceBounds = viewSpaceBounds + .withOrigin(viewSpaceBounds.origin.plus(new Vec2(LABEL_PADDING_PX, LABEL_PADDING_PX))) + .withSize(viewSpaceBounds.size.minus(new Vec2(2 * LABEL_PADDING_PX, 2 * LABEL_PADDING_PX))) + + if (viewSpaceBounds.width() < minWidthToRender) continue + + // Cull text outside the viewport + if (viewportRect.intersectWith(viewSpaceBounds).isEmpty()) continue + + const trimmedText = trimTextMid(ctx, label.frame.name, viewSpaceBounds.width()) + ctx.fillText(trimmedText, viewSpaceBounds.left(), viewSpaceBounds.top()) } } + private renderGL() { + if (!this.renderer || !this.canvas) return + this.renderer({ + configSpaceToNDC: this.configSpaceToNDC() + }) + this.renderLabels() + } + private pan(viewSpaceDelta: Vec2) { const worldSpaceDelta = this.worldSpaceToViewSpace().inverseTransformVector(viewSpaceDelta) if (!worldSpaceDelta) return @@ -207,15 +311,23 @@ export class FlamechartView extends Component { } render() { + // TODO(jlfwong): Handle node and/or window resizing + const width = window.innerWidth const height = window.innerHeight return ( - + onWheel={this.onWheel}> + * + + ) } } @@ -224,6 +336,13 @@ const style = StyleSheet.create({ fullscreen: { width: '100vw', height: '100vh', + }, + fill: { + width: '100%', + height: '100%', + position: 'absolute', + left: 0, + top: 0 } }) diff --git a/math.ts b/math.ts index bf57dc8..4c3b2bb 100644 --- a/math.ts +++ b/math.ts @@ -182,6 +182,8 @@ export class Rect { readonly size = new Vec2() ) {} + isEmpty() { return this.width() == 0 || this.height() == 0 } + width() { return this.size.x } height() { return this.size.y } @@ -205,4 +207,11 @@ export class Rect { clamp(p.y, this.top(), this.bottom()) ) } + + intersectWith(other: Rect) { + const topLeft = Vec2.max(this.topLeft(), other.topLeft()) + const bottomRight = Vec2.max(topLeft, Vec2.min(this.bottomRight(), other.bottomRight())) + + return new Rect(topLeft, bottomRight.minus(topLeft)) + } } \ No newline at end of file