/* * Copyright (C) 2025 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import {LitElement, html, css, unsafeCSS, nothing} from "https://unpkg.com/lit@2.8.0?module"; const kUntitledPlaceholder = "Untitled View"; // CSS constants const FOREGROUND_COLOR = '#fafafa'; const INACTIVE_COLOR = '#6f6f6f'; const UNSELECTED_COLOR = '#dfdfdf'; const BACKGROUND_COLOR = '#5362e5'; const REGULAR_FONT_SIZE = 12; // Constants for color operations const READ_COLOR = '#3ac224'; const WRITE_COLOR = '#d43232'; const NO_ACCESS_COLOR = '#d8dde754'; const READ_WRITE_COLOR = '#ffeb99'; const DEFAULT_COLOR = '#ffffff'; const SUBRESOURCE_COLOR = '#d3d3d3'; const RESOURCE_USAGE_TYPE_READ = 'read'; const RESOURCE_USAGE_TYPE_WRITE = 'write'; const RESOURCE_USAGE_TYPE_NO_ACCESS = 'no-access'; const RESOURCE_USAGE_TYPE_READ_WRITE = 'read-write'; const IS_SUBRESOURCE_KEY = 'is_subresource_of' class MenuSection extends LitElement { static get properties() { return { showing: {type: Boolean, state: true}, title: {type: String, attribute: 'title'}, }; } static get styles() { return css` :host { font-size: ${unsafeCSS(REGULAR_FONT_SIZE)}px; color: ${unsafeCSS(UNSELECTED_COLOR)}; } .section-title { font-size: 16px; color: ${unsafeCSS(UNSELECTED_COLOR)}; cursor: pointer; } .container { margin-bottom: 20px; } hr { display: block; height: 1px; border: 0px; border-top: 1px solid ${unsafeCSS(UNSELECTED_COLOR)}; padding: 0; width: 100%; margin: 3px 0 8px 0; } .expander { display: flex; flex-direction: row; align-items: center; justify-content: space-between; } `; } _showClick() { this.showing = !this.showing; } constructor() { super(); this.showing = true; } render() { const expandedIcon = this.showing ? '-' : '+'; const slot = (() => html` `)(); return html`
${this.title} ${expandedIcon}

${this.showing ? slot : []}
`; } } customElements.define('menu-section', MenuSection); class FrameGraphSidePanel extends LitElement { // Setting the style in render() has poor performance implications. We use it simply to avoid // having another container descending from the root to host the background color. dynamicStyle() { return ` :host { position: fixed; background: ${this.connected ? BACKGROUND_COLOR : INACTIVE_COLOR}; width: 20%; height: 100%; max-width: 250px; min-width: 250px; padding: 10px 20px; overflow-y: auto; } .title { color: white; width: 100%; text-align: center; margin: 0 0 10px 0; font-size: 20px; } .framegraphs { display: flex; flex-direction: column; margin-bottom: 20px; font-size: ${REGULAR_FONT_SIZE}px; color: ${UNSELECTED_COLOR}; } .framegraph:hover { text-decoration: underline; } .framegraph { cursor: pointer; } .selected { font-weight: bolder; color: ${FOREGROUND_COLOR}; } .resource-title { display: flex; flex-direction: column; margin-bottom: 5px; font-size: ${REGULAR_FONT_SIZE}px; color: ${UNSELECTED_COLOR}; } .resource-content { display: flex; flex-direction: column; font-size: ${REGULAR_FONT_SIZE}px; color: ${UNSELECTED_COLOR}; } `; } static get properties() { return { connected: {type: Boolean, attribute: 'connected'}, selectedFrameGraph: {type: String, attribute: 'selected-framegraph'}, selectedResourceId: {type: Number, attribute: 'selected-resource'}, database: {type: Object, state: true}, framegraphs: {type: Array, state: true}, } } constructor() { super(); this.connected = false; this.framegraphs = []; this.database = {}; } updated(props) { if (props.has('database')) { const fgids = Object.keys(this.database).sort(); const labelCount = {}; this.framegraphs = fgids.map(fgid => { const name = this.database[fgid].viewName || kUntitledPlaceholder; const uniqueName = labelCount[name] !== undefined ? `${name} (${labelCount[name]++})` : (labelCount[name] = 0, name); return { fgid, name: uniqueName }; }); } } _handleFrameGraphClick(ev) { this.dispatchEvent(new CustomEvent('select-framegraph', { detail: ev, bubbles: true, composed: true })); } _findCurrentResource() { if (!this.selectedFrameGraph) return null; const frameGraph = this.database[this.selectedFrameGraph]; return Object.values(frameGraph?.resources) .find(resource => resource.id === this.selectedResourceId) || null; } render() { const renderFrameGraphs = (title) => { if (!this.framegraphs.length) return nothing; return html` ${this.framegraphs.map(({ fgid, name }) => html`
${fgid === this.selectedFrameGraph ? '● ' : ''}${name}
`)}
`; }; const renderResourceDetails = (title) => { const currentResource = this._findCurrentResource(); if (!currentResource) return nothing; return html`
${currentResource.id}: ${currentResource.name}
${currentResource.properties?.map(({ key, value }) => html`
${key}: ${value}
`)}
`; }; return html`
fgviewer
${renderFrameGraphs("Views") ?? nothing} ${renderResourceDetails("Resource Details") ?? nothing}
`; } } customElements.define("framegraph-sidepanel", FrameGraphSidePanel); class FrameGraphTable extends LitElement { static get styles() { return css` :host { display: block; flex-grow: 1; width: 80%; } .table-container { max-height: 100%; max-width: 100%; overflow: auto; border: 1px solid #ddd; margin-left: 300px; } .scrollable-table { width: 100%; height: 100%; border-collapse: collapse; } .collapsible { background-color: #f9f9f9; } .toggle-icon { cursor: pointer; margin-right: 5px; user-select: none; } .scrollable-table td, .scrollable-table th { padding: 12px; text-align: left; border: 1px solid #ddd; } .selected { text-decoration: underline; } .scrollable-table tr { position: sticky; padding: 12px; text-align: left; border: 1px solid #ddd; left: 0; z-index: 1; } .sticky-col { position: sticky; left: 0; z-index: 1; } .resource:hover { text-decoration: underline; } .resource { cursor: pointer; } th { min-width: 100px; background-color: #f2f2f2; padding: 12px; text-align: left; border: 1px solid #ddd; } `; } static get properties() { return { frameGraphData: {type: Object, state: true}, // Expecting a JSON frame graph structure selectedResourceId: {type: Number, attribute: 'selected-resource'}, }; } constructor() { super(); this.frameGraphData = null; this.selectedResourceId = -1; this.expandedResourceSet = new Set(); } updated(props) { if (props.has('frameGraphData')) { this.requestUpdate(); } } _getCellColor(type) { return { [RESOURCE_USAGE_TYPE_READ]: READ_COLOR, [RESOURCE_USAGE_TYPE_WRITE]: WRITE_COLOR, [RESOURCE_USAGE_TYPE_NO_ACCESS]: NO_ACCESS_COLOR, [RESOURCE_USAGE_TYPE_READ_WRITE]: READ_WRITE_COLOR }[type] || DEFAULT_COLOR; } _toggleCollapse(resourceIndex) { if (this.expandedResourceSet.has(resourceIndex)) { this.expandedResourceSet.delete(resourceIndex); } else { this.expandedResourceSet.add(resourceIndex); } this.requestUpdate(); } _handleResourceClick(ev) { this.dispatchEvent(new CustomEvent('select-resource', { detail: ev, bubbles: true, composed: true })); } _renderResourceUsage(allPasses, resourceIds, defaultColor) { return allPasses.map((passData, index) => { const isRead = resourceIds.some(resourceId => passData?.reads.includes(resourceId)); const isWrite = resourceIds.some(resourceId => passData?.writes.includes(resourceId)); let type = null; const hasUsed = (passData) => { return resourceIds.some(resourceId => passData?.reads.includes(resourceId) || passData?.writes.includes(resourceId) ); }; const hasBeenUsedBefore = allPasses.slice(0, index).some(hasUsed); const willBeUsedLater = allPasses.slice(index + 1).some(hasUsed); if (isRead && isWrite) type = RESOURCE_USAGE_TYPE_READ_WRITE; else if (isRead) type = RESOURCE_USAGE_TYPE_READ; else if (isWrite) type = RESOURCE_USAGE_TYPE_WRITE; else if (hasBeenUsedBefore && willBeUsedLater) type = RESOURCE_USAGE_TYPE_NO_ACCESS; return html` ${type ?? nothing} `; }); } _isSubresourceOfParent(resource, parentResource){ return resource.properties?.some(prop => prop.key === IS_SUBRESOURCE_KEY && Number(prop.value) === parentResource.id) } _isSubresource(resource) { return resource.properties?.some(prop => prop.key === IS_SUBRESOURCE_KEY); } _renderResourceRows(resources, allPasses) { return resources .filter(resource => !this._isSubresource(resource)) .map((resource, resourceIndex) => this._renderResourceRow(resource, resourceIndex, resources, allPasses)); } _renderResourceRow(resource, resourceIndex, resources, allPasses) { const subresourceIds = resources .filter(subresource => this._isSubresourceOfParent(subresource, resource)) .map(subresource => subresource.id); const hasSubresources = subresourceIds.length > 0; const isExpanded = this.expandedResourceSet.has(resourceIndex); // Show the aggregated resource usage when the subresources are collapsed. const resourceIds = isExpanded ? [resource.id]:[resource.id, ...subresourceIds]; const onClickResource = () => this._handleResourceClick(resource.id); const selectedStyle = resource.id === this.selectedResourceId ? "selected" : ""; return html` ${hasSubresources ? html` ${isExpanded ? '▼' : '▶'} ` : nothing} ${resource.name} ${hasSubresources && !isExpanded ? html`(${subresourceIds.length})` : nothing} ${this._renderResourceUsage(allPasses, resourceIds, DEFAULT_COLOR)} ${isExpanded ? this._renderSubresourceRows(resources, resource, resourceIndex, allPasses) : nothing} `; } _renderSubresourceRows(resources, parentResource, resourceIndex, allPasses) { return resources .filter(subresource => this._isSubresourceOfParent(subresource, parentResource)) .map((subresource, subIndex) => this._renderSubresourceRow(subresource, resourceIndex, subIndex, allPasses)); } _renderSubresourceRow(subresource, resourceIndex, subIndex, allPasses) { const onClickResource = () => this._handleResourceClick(subresource.id); const selectedStyle = subresource.id === this.selectedResourceId ? "selected" : ""; return html` ${subresource.name} ${this._renderResourceUsage(allPasses, [subresource.id], SUBRESOURCE_COLOR)} `; } render() { if (!this.frameGraphData?.passes || !this.frameGraphData?.resources) return nothing; const allPasses = this.frameGraphData.passes; const resources = Object.values(this.frameGraphData.resources); return html`
${allPasses.map(pass => html``)} ${this._renderResourceRows(resources, allPasses)}
Resources/Passes${pass.name}
`; } } customElements.define("framegraph-table", FrameGraphTable); class FrameGraphViewer extends LitElement { static get styles() { return css` :host { height: 100%; width: 100%; display: flex; } `; } get _sidePanel() { return this.renderRoot.querySelector('#sidepanel'); } get _framegraphTable() { return this.renderRoot.querySelector('#table'); } async init() { const isConnected = () => this.connected; statusLoop( isConnected, async (status, fgid) => { this.connected = status == STATUS_CONNECTED || status == STATUS_FRAMEGRAPH_UPDATED; if (status == STATUS_FRAMEGRAPH_UPDATED) { let fgInfo = await fetchFrameGraph(fgid); this.database[fgInfo.fgid] = fgInfo; this._framegraphTable.frameGraphData = fgInfo; } } ); let framegraphs = await fetchFrameGraphs(); this.database = framegraphs; } _getFrameGraph() { const framegraph = (this.database && this.selectedFrameGraph) ? this.database[this.selectedFrameGraph] : null; return framegraph; } constructor() { super(); this.connected = false; this.database = {}; this.selectedFrameGraph = null; this.selectedResourceId = -1; this.init(); this.addEventListener('select-framegraph', (ev) => { this.selectedFrameGraph = ev.detail; } ); this.addEventListener('select-resource', (ev) => { this.selectedResourceId = ev.detail; } ); } static get properties() { return { connected: {type: Boolean, state: true}, database: {type: Object, state: true}, selectedFrameGraph: {type: String, state: true}, selectedResourceId: {type: Number, state: true}, } } updated(props) { if (props.has('selectedFrameGraph') || props.has('database')) { const framegraph = this._getFrameGraph(); this._framegraphTable.frameGraphData = framegraph; this._sidePanel.database = this.database; } } render() { return html` `; } } customElements.define("framegraph-viewer", FrameGraphViewer);