// deck.gl // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors import {Widget, type WidgetPlacement, type WidgetProps} from '@deck.gl/core'; import {luma} from '@luma.gl/core'; import {render, type JSX} from 'preact'; import {useEffect, useState} from 'preact/hooks'; import type {Stats, Stat} from '@probe.gl/stats'; import {IconButton} from './lib/components/icon-button'; const DEFAULT_COUNT_FORMATTER = (stat: Stat): string => `${stat.name}: ${stat.count}`; function formatTime(time: number): string { return time < 1000 ? `${time.toFixed(2)}ms` : `${(time / 1000).toFixed(2)}s`; } function formatMemory(bytes: number): string { const mb = bytes / 1e6; return `${mb.toFixed(1)} MB`; } export const DEFAULT_FORMATTERS: Record string> = { count: DEFAULT_COUNT_FORMATTER, averageTime: (stat: Stat) => `${stat.name}: ${formatTime(stat.getAverageTime())}`, totalTime: (stat: Stat) => `${stat.name}: ${formatTime(stat.time)}`, fps: (stat: Stat) => `${stat.name}: ${Math.round(stat.getHz())}fps`, memory: (stat: Stat) => `${stat.name}: ${formatMemory(stat.count)}` }; export type StatsWidgetProps = WidgetProps & { /** Widget positioning within the view. Default 'top-left'. */ placement?: WidgetPlacement; /** View to attach to and interact with. Required when using multiple views. */ viewId?: string | null; /** Type of stats to display. */ type?: 'deck' | 'luma' | 'device' | 'custom'; /** Expand the stats UI by default. * @default false */ initialExpanded?: boolean; /** Stats object to visualize. */ stats?: Stats; /** Title shown in the header of the pop-up. Defaults to stats.id. */ title?: string; /** How many redraws to wait between updates. */ framesPerUpdate?: number; /** Custom formatters for stat values. */ formatters?: Record string)>; /** Whether to reset particular stats after each update. */ resetOnUpdate?: Record; /** * Controlled expanded state. When provided, the widget is in controlled mode. */ expanded?: boolean; /** * Callback when the expanded state changes (user clicks header). * In controlled mode, use this to update the expanded prop. */ onExpandedChange?: (expanded: boolean) => void; }; /** Displays probe.gl stats in a floating pop-up. */ export class StatsWidget extends Widget { static defaultProps: Required = { ...Widget.defaultProps, type: 'deck', placement: 'top-left', viewId: null, initialExpanded: false, stats: undefined!, title: 'Stats', framesPerUpdate: 1, formatters: {}, resetOnUpdate: {}, id: 'stats', expanded: undefined!, onExpandedChange: () => {} }; className = 'deck-widget-stats'; placement = 'top-left' as WidgetPlacement; private _counter = 0; private _formatters: Record string>; private _resetOnUpdate: Record; private _expanded: boolean = false; /** * Returns the current expanded state. * In controlled mode, returns the expanded prop. * In uncontrolled mode, returns the internal state. */ getExpanded(): boolean { return this.props.expanded ?? this._expanded; } constructor(props: StatsWidgetProps = {}) { super(props); this._formatters = {...DEFAULT_FORMATTERS}; this._resetOnUpdate = {...this.props.resetOnUpdate}; this._expanded = Boolean(props.initialExpanded); this.setProps(props); } setProps(props: Partial): void { this.placement = props.placement ?? this.placement; this.viewId = props.viewId ?? this.viewId; if (props.formatters) { for (const name in props.formatters) { const f = props.formatters[name]; this._formatters[name] = typeof f === 'string' ? DEFAULT_FORMATTERS[f] || DEFAULT_COUNT_FORMATTER : f; } } if (props.resetOnUpdate) { this._resetOnUpdate = {...props.resetOnUpdate}; } super.setProps(props); } onRemove() { if (this.rootElement) { // Make sure all preact hooks are finalized render(null, this.rootElement); } } onRenderHTML(rootElement: HTMLElement): void { const isExpanded = this.getExpanded(); if (!isExpanded) { render(, rootElement); return; } const stats = this._getStats(); const title = this.props.title || ('id' in stats ? stats.id : null) || 'Stats'; const deviceLabel = this._getDeviceLabel(); const items: JSX.Element[] = []; if (stats) { stats.forEach(stat => { const lines = this._getLines(stat).split('\n'); if (this._resetOnUpdate && this._resetOnUpdate[stat.name]) { stat.reset(); } lines.forEach((line, i) => { items.push(
{line}
); }); }); } render(
{title} {deviceLabel && {deviceLabel}}
{items}
, rootElement ); } onRedraw(): void { if (this.getExpanded()) { const framesPerUpdate = Math.max(1, this.props.framesPerUpdate || 1); if (this._counter++ % framesPerUpdate === 0) { this.updateHTML(); } } } protected _getStats(): Stats | [key: string, value: number][] { switch (this.props.type) { case 'deck': // @ts-expect-error metrics is protected const metrics = this.deck?.metrics ?? {}; return Object.entries(metrics); case 'luma': return Array.from(luma.stats.stats.values())[0]; case 'device': // @ts-expect-error is protected const device = this.deck?.device; const stats = device?.statsManager.stats.values(); return stats ? Array.from(stats)[0] : []; case 'custom': return this.props.stats; default: throw new Error(`Unknown stats type: ${this.props.type}`); } } protected _toggleExpanded = (): void => { const nextExpanded = !this.getExpanded(); // Always call callback if provided this.props.onExpandedChange?.(nextExpanded); // Only update internal state if uncontrolled if (this.props.expanded === undefined) { this._expanded = nextExpanded; this.updateHTML(); } // In controlled mode, parent will update expanded prop which triggers updateHTML via setProps }; protected _getFps = (): number => { // @ts-expect-error metrics is protected return Math.round(this.deck?.metrics.fps ?? 0); }; protected _getDeviceLabel(): string | null { // @ts-expect-error device is protected const deviceType = this.deck?.device?.type; if (!deviceType) { return null; } switch (deviceType) { case 'webgpu': return 'WebGPU'; case 'webgl': return 'WebGL'; default: return String(deviceType); } } protected _getLines(stat: Stat | [key: string, value: number]): string { if ('count' in stat) { const formatter = this._formatters[stat.name] || this._formatters[stat.type || ''] || DEFAULT_COUNT_FORMATTER; return formatter(stat); } const [key, value] = stat; const formattedValue = key.endsWith('Memory') ? formatMemory(value) : key.includes('Time') ? formatTime(value) : `${value.toFixed(2)}`; return `${key}: ${formattedValue}`; } } function FpsIcon({getFps, onClick}: {getFps: () => number; onClick: () => void}) { const [fps, setFps] = useState(getFps()); useEffect(() => { const onUpdate = () => { setFps(getFps()); timer = requestAnimationFrame(onUpdate); }; let timer = requestAnimationFrame(onUpdate); return () => { cancelAnimationFrame(timer); }; }, [getFps]); return (
FPS
{fps}
); }