|
| 1 | +import {Widget, WidgetPlacement, WidgetProps} from '@deck.gl/core'; |
| 2 | +import {luma} from '@luma.gl/core'; |
| 3 | +import {render} from 'preact'; |
| 4 | +import type {Stats, Stat} from '@probe.gl/stats'; |
| 5 | + |
| 6 | +const RIGHT_ARROW = '\u25b6'; |
| 7 | +const DOWN_ARROW = '\u2b07'; |
| 8 | + |
| 9 | +const DEFAULT_COUNT_FORMATTER = (stat: Stat): string => `${stat.name}: ${stat.count}`; |
| 10 | + |
| 11 | +function formatTime(time: number): string { |
| 12 | + return time < 1000 ? `${time.toFixed(2)}ms` : `${(time / 1000).toFixed(2)}s`; |
| 13 | +} |
| 14 | + |
| 15 | +function formatMemory(bytes: number): string { |
| 16 | + const mb = bytes / 1e6; |
| 17 | + return `${mb.toFixed(1)} MB`; |
| 18 | +} |
| 19 | + |
| 20 | +export const DEFAULT_FORMATTERS: Record<string, (stat: Stat) => string> = { |
| 21 | + count: DEFAULT_COUNT_FORMATTER, |
| 22 | + averageTime: (stat: Stat) => `${stat.name}: ${formatTime(stat.getAverageTime())}`, |
| 23 | + totalTime: (stat: Stat) => `${stat.name}: ${formatTime(stat.time)}`, |
| 24 | + fps: (stat: Stat) => `${stat.name}: ${Math.round(stat.getHz())}fps`, |
| 25 | + memory: (stat: Stat) => `${stat.name}: ${formatMemory(stat.count)}` |
| 26 | +}; |
| 27 | + |
| 28 | +export type StatsWidgetProps = WidgetProps & { |
| 29 | + type?: 'deck' | 'luma' | 'device' | 'custom'; |
| 30 | + /** Stats object to visualize. */ |
| 31 | + stats?: Stats; |
| 32 | + /** Title shown in the header of the pop-up. Defaults to stats.id. */ |
| 33 | + title?: string; |
| 34 | + /** How many redraws to wait between updates. */ |
| 35 | + framesPerUpdate?: number; |
| 36 | + /** Custom formatters for stat values. */ |
| 37 | + formatters?: Record<string, string | ((stat: Stat) => string)>; |
| 38 | + /** Whether to reset particular stats after each update. */ |
| 39 | + resetOnUpdate?: Record<string, boolean>; |
| 40 | +}; |
| 41 | + |
| 42 | +/** Displays probe.gl stats in a floating pop-up. */ |
| 43 | +export class StatsWidget extends Widget<StatsWidgetProps> { |
| 44 | + static defaultProps: Required<StatsWidgetProps> = { |
| 45 | + ...Widget.defaultProps, |
| 46 | + type: 'deck', |
| 47 | + stats: undefined!, |
| 48 | + title: 'Stats', |
| 49 | + framesPerUpdate: 1, |
| 50 | + formatters: {}, |
| 51 | + resetOnUpdate: {}, |
| 52 | + id: 'stats' |
| 53 | + }; |
| 54 | + |
| 55 | + className = 'deck-widget-stats'; |
| 56 | + placement = 'top-left' as WidgetPlacement; |
| 57 | + |
| 58 | + private _counter = 0; |
| 59 | + private _formatters: Record<string, (stat: Stat) => string>; |
| 60 | + private _resetOnUpdate: Record<string, boolean>; |
| 61 | + collapsed: boolean = true; |
| 62 | + _stats: Stats; |
| 63 | + |
| 64 | + constructor(props: StatsWidgetProps = {}) { |
| 65 | + super(props, StatsWidget.defaultProps); |
| 66 | + this._formatters = {...DEFAULT_FORMATTERS}; |
| 67 | + this.setProps(props); |
| 68 | + this._resetOnUpdate = {...this.props.resetOnUpdate}; |
| 69 | + this._stats = this.props.stats; |
| 70 | + } |
| 71 | + |
| 72 | + setProps(props: Partial<StatsWidgetProps>): void { |
| 73 | + super.setProps(props); |
| 74 | + this._stats = this._getStats(); |
| 75 | + if (props.formatters) { |
| 76 | + for (const name in props.formatters) { |
| 77 | + const f = props.formatters[name]; |
| 78 | + this._formatters[name] = |
| 79 | + typeof f === 'string' ? DEFAULT_FORMATTERS[f] || DEFAULT_COUNT_FORMATTER : f; |
| 80 | + } |
| 81 | + } |
| 82 | + if (props.resetOnUpdate) { |
| 83 | + this._resetOnUpdate = {...props.resetOnUpdate}; |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | + onAdd(): void { |
| 88 | + this._stats = this._getStats(); |
| 89 | + this.updateHTML(); |
| 90 | + } |
| 91 | + |
| 92 | + onRenderHTML(rootElement: HTMLElement): void { |
| 93 | + const stats = this._stats; |
| 94 | + const collapsed = this.collapsed; |
| 95 | + const title = this.props.title || stats?.id || 'Stats'; |
| 96 | + const items: JSX.Element[] = []; |
| 97 | + |
| 98 | + if (!collapsed && stats) { |
| 99 | + stats.forEach(stat => { |
| 100 | + const lines = this._getLines(stat); |
| 101 | + if (this._resetOnUpdate && this._resetOnUpdate[stat.name]) { |
| 102 | + stat.reset(); |
| 103 | + } |
| 104 | + lines.forEach((line, i) => { |
| 105 | + items.push( |
| 106 | + <div key={`${stat.name}-${i}`} style={{whiteSpace: 'pre'}}> |
| 107 | + {line} |
| 108 | + </div> |
| 109 | + ); |
| 110 | + }); |
| 111 | + }); |
| 112 | + } |
| 113 | + |
| 114 | + render( |
| 115 | + <div className="deck-widget-stats-container" style={{cursor: 'default'}}> |
| 116 | + <div |
| 117 | + className="deck-widget-stats-header" |
| 118 | + style={{cursor: 'pointer', pointerEvents: 'auto'}} |
| 119 | + onClick={this._toggleCollapsed} |
| 120 | + > |
| 121 | + {collapsed ? RIGHT_ARROW : DOWN_ARROW} {title} |
| 122 | + </div> |
| 123 | + {!collapsed && <div className="deck-widget-stats-content deck-widget-common">{items}</div>} |
| 124 | + </div>, |
| 125 | + rootElement |
| 126 | + ); |
| 127 | + } |
| 128 | + |
| 129 | + onRedraw(): void { |
| 130 | + const framesPerUpdate = Math.max(1, this.props.framesPerUpdate || 1); |
| 131 | + if (this._counter++ % framesPerUpdate === 0) { |
| 132 | + console.log('Redrawing stats widget'); |
| 133 | + this.updateHTML(); |
| 134 | + } |
| 135 | + } |
| 136 | + |
| 137 | + protected _getStats(): Stats { |
| 138 | + switch (this.props.type) { |
| 139 | + case 'deck': |
| 140 | + // @ts-expect-error stats is protected |
| 141 | + return this.deck?.stats; |
| 142 | + case 'luma': |
| 143 | + return Array.from(luma.stats.stats.values())[0]; |
| 144 | + case 'device': |
| 145 | + // @ts-expect-error is protected |
| 146 | + const device = this.deck?.device; |
| 147 | + const stats = device?.statsManager.stats.values(); |
| 148 | + return stats ? Array.from(stats)[0] : undefined; |
| 149 | + case 'custom': |
| 150 | + return this.props.stats; |
| 151 | + default: |
| 152 | + throw new Error(`Unknown stats type: ${this.props.type}`); |
| 153 | + } |
| 154 | + } |
| 155 | + |
| 156 | + protected _toggleCollapsed = (): void => { |
| 157 | + this.collapsed = !this.collapsed; |
| 158 | + this.updateHTML(); |
| 159 | + }; |
| 160 | + |
| 161 | + protected _getLines(stat: Stat): string[] { |
| 162 | + const formatter = |
| 163 | + this._formatters[stat.name] || this._formatters[stat.type || ''] || DEFAULT_COUNT_FORMATTER; |
| 164 | + return formatter(stat).split('\n'); |
| 165 | + } |
| 166 | +} |
0 commit comments