diff --git a/CHANGELOG.md b/CHANGELOG.md index 2459d96..4f4653c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v0.2.0 + +- Show a simple sentence by default. Keep the advanced interface in another tab. +- Add the source asset gallery in a tab. + ## v0.1.10 - Fix swapped data point display toggle setting when off by default. diff --git a/assets/demo_batch_some_codec_exp.json b/assets/demo_batch_some_codec_exp.json index 9dfdb3b..65a21d3 100644 --- a/assets/demo_batch_some_codec_exp.json +++ b/assets/demo_batch_some_codec_exp.json @@ -4,17 +4,21 @@ { "codec": "Name of the codec used to generate this data" }, { "version": "Version of the codec used to generate this data" }, { "time": "Timestamp of when this data was generated" }, + { "source data set": "The origin of the images used as input" }, { "original_path": "The path to the original image" }, + { "preview": "The path to the original image preview" }, { "encoded_path": "The path to the encoded image" }, { "encoding_cmd": "The command used to encode the original image" }, { "decoding_cmd": "The command used to decode the encoded image" } ], "constant_values": [ "Some codec experiment", - "some_codec", + "some_codec_exp", "experiment", "2023-05-22T13:02:15.137053618Z", + "https://testimages.org/sampling/", "/rainbow.png", + "assets/rainbow_q0.webp", "/rainbow_q50.webp", "encode ${original_name} ${encoded_name}", "decode ${encoded_name} ${encoded_name}.png" diff --git a/assets/demo_batches.json b/assets/demo_batches.json index 2964d2a..0055a59 100644 --- a/assets/demo_batches.json +++ b/assets/demo_batches.json @@ -1,6 +1,5 @@ [ ["demo_batch_some_codec_exp.json"], - ["demo_batch_some_codec_effort0.json"], ["demo_batch_some_codec_effort1.json"], ["demo_batch_some_codec_effort2.json"], ["demo_batch_other_codec_settingA.json"], diff --git a/index.html b/index.html index 59a9297..26af711 100644 --- a/index.html +++ b/index.html @@ -58,7 +58,6 @@ } codec-compare { min-width: 600px; - overflow: auto; flex: 0; } #plotly_div { diff --git a/package.json b/package.json index 21f52a2..e960fdd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codec_compare", - "version": "0.1.10", + "version": "0.2.0", "description": "Codec performance comparison tool", "publisher": "Google LLC", "author": "Yannis Guyon", @@ -34,6 +34,8 @@ "@material/mwc-menu": "^0.27.0", "@material/mwc-slider": "^0.27.0", "@material/mwc-switch": "^0.27.0", + "@material/mwc-tab": "^0.27.0", + "@material/mwc-tab-bar": "^0.27.0", "@material/mwc-textfield": "^0.27.0", "lit": "^3.1.0", "plotly.js-dist": "^2.27.1" diff --git a/readme_preview.png b/readme_preview.png index 74afb96..32e33d1 100644 Binary files a/readme_preview.png and b/readme_preview.png differ diff --git a/src/batch_merger.ts b/src/batch_merger.ts new file mode 100644 index 0000000..42f4103 --- /dev/null +++ b/src/batch_merger.ts @@ -0,0 +1,183 @@ +// Copyright 2024 Google LLC +// +// 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 +// +// https://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 {BatchSelection} from './batch_selection'; +import {hexStringToRgb, rgbToHexString} from './color_setter'; +import {Batch} from './entry'; +import {GeometricMean} from './geometric_mean'; +import {FieldMetricStats, SourceCount} from './metric'; + +function mergeBatchesWithSameCodec(batches: BatchSelection[]): BatchSelection| + undefined { + const mergedBatch = + new Batch(batches[0].batch.url, batches[0].batch.folderPath); + mergedBatch.index = batches[0].batch.index; // Used to access fields through + // FieldMetric.fieldIndices. + mergedBatch.codec = batches[0].batch.codec; + mergedBatch.name = mergedBatch.codec; + mergedBatch.version = batches[0].batch.version; + for (const batch of batches) { + if (batch.batch.version !== batches[0].batch.version) return undefined; + } + + // Use the time field as a way of signaling the aggregation. + mergedBatch.time = undefined; + mergedBatch.timeStringShort = 'aggregate of'; + for (const batch of batches) { + mergedBatch.timeStringShort += ' ' + batch.batch.name; + } + mergedBatch.timeStringLong = mergedBatch.timeStringShort; + + // Shallow copy the fields and check their consistency accross the batches of + // the same codec. + mergedBatch.fields = batches[0].batch.fields; + for (const batch of batches) { + if (batch.batch.fields.length !== mergedBatch.fields.length) { + console.log(batch.batch.name); + return undefined; + } + for (let i = 0; i < mergedBatch.fields.length; i++) { + if (batch.batch.fields[i].id !== mergedBatch.fields[i].id) { + return undefined; + } + } + } + + // Average the colors. + let [rSum, gSum, bSum] = [0, 0, 0]; + for (const batch of batches) { + const [r, g, b] = hexStringToRgb(batch.batch.color); + rSum += r; + gSum += g; + bSum += b; + } + mergedBatch.color = rgbToHexString( + rSum / batches.length, gSum / batches.length, bSum / batches.length); + + // Ignore all BatchSelection fields but the stats. + const mergedBatchSelection = new BatchSelection(mergedBatch); + for (const batch of batches) { + if (batch.stats.length !== batches[0].stats.length) return undefined; + } + for (let stat = 0; stat < batches[0].stats.length; stat++) { + const mergedStats = new FieldMetricStats(); + mergedStats.minRatio = batches[0].stats[stat].minRatio; + mergedStats.maxRatio = batches[0].stats[stat].maxRatio; + mergedStats.arithmeticMean = 0; + + const geometricMean = new GeometricMean(); + let weightSum = 0; + for (const batch of batches) { + // Weigh each batch by its match count. + const weight = batch.matchedDataPoints.rows.length; + // TODO: Check the validity of the aggregation methods. + for (let p = 0; p < batch.matchedDataPoints.rows.length; ++p) { + geometricMean.add(batch.stats[stat].geometricMean); + } + mergedStats.minRatio = + Math.min(mergedStats.minRatio, batch.stats[stat].minRatio); + mergedStats.maxRatio = + Math.max(mergedStats.maxRatio, batch.stats[stat].maxRatio); + mergedStats.arithmeticMean += batch.stats[stat].arithmeticMean * weight; + weightSum += weight; + } + + if (weightSum === 0) return undefined; + mergedStats.geometricMean = geometricMean.get(); + mergedStats.arithmeticMean /= weightSum; + + mergedBatchSelection.stats.push(mergedStats); + } + + // No UI need for mergeHistograms() here. Skip it. + + return mergedBatchSelection; +} + +/* Combine Batch stats based on same codec. + * Returns partial data with shallow copies of members. + * Returns an empty array in case of error or if there is nothing to merge. */ +export function mergeBatches( + batches: BatchSelection[], skipIndex: number): BatchSelection[] { + const map = new Map(); + for (const batch of batches) { + const batchesWithSameCodec = map.get(batch.batch.codec); + if (batchesWithSameCodec === undefined) { + map.set(batch.batch.codec, [batch]); + } else { + batchesWithSameCodec.push(batch); + } + } + + const skipCodec = (skipIndex >= 0 && skipIndex < batches.length) ? + batches[skipIndex].batch.codec : + undefined; + let atLeastOneMerge = false; + const mergedBatches: BatchSelection[] = []; + for (const [codec, batches] of map) { + if (codec === skipCodec) continue; + + if (batches.length === 1) { + mergedBatches.push(batches[0]); + } else { + const mergedBatch = mergeBatchesWithSameCodec(batches); + if (mergedBatch === undefined) { + return []; + } + mergedBatches.push(mergedBatch); + atLeastOneMerge = true; + } + } + return atLeastOneMerge ? mergedBatches : []; +} + +/** Aggregates histograms by sourceName. */ +export function mergeHistograms(histograms: SourceCount[][]): SourceCount[] { + const aggHisto = new Map(); + for (const histogram of histograms) { + for (const sourceCount of histogram) { + let aggSourceCount = aggHisto.get(sourceCount.sourceName); + if (aggSourceCount === undefined) { + aggSourceCount = new SourceCount(); + aggSourceCount.sourceName = sourceCount.sourceName; + aggSourceCount.sourcePath = sourceCount.sourcePath; + aggSourceCount.previewPath = sourceCount.previewPath; + aggSourceCount.count = sourceCount.count; + aggHisto.set(aggSourceCount.sourceName, aggSourceCount); + } else { + // Keep the first set field/constant values and make sure they are + // consistent across batches. + if (aggSourceCount.sourcePath === undefined) { + aggSourceCount.sourcePath = sourceCount.sourcePath; + } else if ( + sourceCount.sourcePath !== undefined && + aggSourceCount.sourcePath !== sourceCount.sourcePath) { + return []; // Should not happen. + } + if (aggSourceCount.previewPath === undefined) { + aggSourceCount.previewPath = sourceCount.previewPath; + } else if ( + sourceCount.previewPath !== undefined && + aggSourceCount.previewPath !== sourceCount.previewPath) { + return []; // Should not happen. + } + + aggSourceCount.count += sourceCount.count; + } + } + } + + // Sort by decreasing occurrences. + return Array.from(aggHisto.values()).sort((a, b) => b.count - a.count); +} diff --git a/src/batch_selection.ts b/src/batch_selection.ts index db9a480..e62a9ea 100644 --- a/src/batch_selection.ts +++ b/src/batch_selection.ts @@ -16,7 +16,7 @@ import {Batch} from './entry'; import {dispatch, EventType, listen} from './events'; import {enableDefaultFilters, FieldFilter, getFilteredRowIndices} from './filter'; import {MatchedDataPoints} from './matcher'; -import {FieldMetricStats} from './metric'; +import {FieldMetricStats, SourceCount} from './metric'; /** One Batch (~ codec experiment results) to compare with another. */ export class BatchSelection { @@ -37,6 +37,7 @@ export class BatchSelection { /** The statistics to display. */ stats: FieldMetricStats[] = []; // As many as State.metrics. + histogram: SourceCount[] = []; // As many as distinct source media inputs. constructor(selectedBatch: Batch) { this.batch = selectedBatch; diff --git a/src/codec_compare.ts b/src/codec_compare.ts index d1bb493..f942a4e 100644 --- a/src/codec_compare.ts +++ b/src/codec_compare.ts @@ -14,16 +14,20 @@ import '@material/mwc-icon'; import '@material/mwc-fab'; +import '@material/mwc-tab-bar'; +import '@material/mwc-tab'; import './batch_name_ui'; import './batch_selection_ui'; import './batch_selections_ui'; import './batch_ui'; +import './gallery_ui'; import './loading_ui'; import './match_ui'; import './matchers_ui'; import './matches_ui'; import './metrics_ui'; import './mwc_button_fit'; +import './sentence_ui'; import './settings_ui'; import './help_ui'; @@ -40,6 +44,12 @@ import {SettingsUi} from './settings_ui'; import {State} from './state'; import {UrlState} from './url_state'; +enum Tab { + SUMMARY = 0, // A simple sentence to describe the plot. + STATS = 1, // Advanced interface with tunable comparison parameters. + GALLERY = 2, // Source data set grid. +} + /** Main component of the codec comparison static viewer. */ @customElement('codec-compare') export class CodecCompare extends LitElement { @@ -52,6 +62,8 @@ export class CodecCompare extends LitElement { private failure = false; /** Set once the top-level JSON has been parsed. */ private numExpectedBatches = 0; + /** Currently displayed component. */ + private currentTab = Tab.SUMMARY; /** The object rendering the state into a plot. */ private readonly plotUi = new PlotUi(this.state); @@ -59,16 +71,53 @@ export class CodecCompare extends LitElement { @query('help-ui') private readonly helpUi!: HelpUi; @query('loading-ui') private readonly loadingUi!: LoadingUi; + private renderReference(referenceBatch: Batch) { + return html` +

+ compared to . +

`; + } + + private renderSentence() { + // #sentenceContainer is an overlay of the whole #comparisons block. + return html` +
+ +
`; + } + + private renderGallery() { + // #galleryContainer is an overlay of the whole #comparisons block. + return html` +
+ +
`; + } + + private renderTruncatedResults() { + return html` +
+ warning +

+ The results are partial because there are too many possible comparisons. + Consider filtering input rows out. +

+
`; + } + override render() { let numComparisons = 0; let truncatedResults = false; + let hasHistograms = false; for (const [index, batchSelection] of this.state.batchSelections .entries()) { if (index !== this.state.referenceBatchSelectionIndex) { numComparisons += batchSelection.matchedDataPoints.rows.length; truncatedResults ||= batchSelection.matchedDataPoints.limited; } + hasHistograms ||= batchSelection.histogram.length > 0; } + let referenceBatch: Batch|undefined = undefined; if (this.state.referenceBatchSelectionIndex >= 0 && this.state.referenceBatchSelectionIndex < @@ -77,30 +126,44 @@ export class CodecCompare extends LitElement { this.state.batchSelections[this.state.referenceBatchSelectionIndex] .batch; } + + const activeIndex: number = this.currentTab; + const displaySentence = this.currentTab === Tab.SUMMARY; + const displayGallery = this.currentTab === Tab.GALLERY; + // The advanced interface is always displayed, hidden by the other + // components, because drop-down menu anchors are messed up otherwise. + + // The nested divs below seem necessary to have proper scroll bars. + // Feel free to fix them. + return html` -
- ${ - truncatedResults ? html` -
- warning -

- The results are partial because there are too many possible comparisons. - Consider filtering input rows out. -

-
` : - ''} - -

Based on ${numComparisons} comparisons,

- - - - ${ - referenceBatch ? html` -

- compared to . -

` : - ''} +
+
+
+
+ ${truncatedResults ? this.renderTruncatedResults() : ''} +

Based on ${numComparisons} comparisons,

+ + + + ${referenceBatch ? this.renderReference(referenceBatch) : ''} +
+
+
+ ${displaySentence ? this.renderSentence() : ''} + ${displayGallery ? this.renderGallery() : ''} + + ) => { + this.currentTab = event.detail.index; + this.requestUpdate(); + }}> + + + + +
@@ -116,7 +179,8 @@ export class CodecCompare extends LitElement { - { + { this.helpUi.onOpen(); }}> help Help @@ -143,7 +207,7 @@ export class CodecCompare extends LitElement {

- Codec Compare beta version 0.1.10
+ Codec Compare beta version 0.2.0
Sources on GitHub @@ -155,7 +219,7 @@ export class CodecCompare extends LitElement { - + ${this.isLoaded ? html`` : html``} `; } @@ -243,10 +307,7 @@ export class CodecCompare extends LitElement { static override styles = css` :host { margin: 0; - padding: 20px 0; - background: var(--mdc-theme-surface); - /* Simulate the shadow of the plot on the right. */ - box-shadow: inset -4px 0 8px 0 rgba(0, 0, 0, 0.2); + padding: 0 0; overflow: hidden; } p { @@ -255,15 +316,52 @@ export class CodecCompare extends LitElement { font-size: 20px; } - #comparisons { + mwc-tab-bar { + position: absolute; + top: 0; + left: 60px; /* Width of #leftBar. */ + width: 540px; /* = 600px from index.html - 60px. */ + height: 50px; + } + + #sentenceContainer, #galleryContainer, #advancedInterfaceContainerContainer { + position: absolute; + top: 50px; + bottom: 0; + left: 0; + } + #sentenceContainer, #galleryContainer { + width: 506px; /* = 600px (from index.html) - 20 - 74 (padding below). + * width:100% would require host's position:relative + * but it messes with the leftBar's overflow. */ + } + #advancedInterfaceContainerContainer { + width: 600px; + } + #advancedInterfaceContainer { + width: 100%; + height: 100%; + overflow-y: auto; + overflow-x: hidden; + } + #advancedInterface { + width: 506px; min-height: 100%; + } + .scrollFriendlyPadding { height: 8px; } + #sentenceContainer, #galleryContainer, #advancedInterface { + padding: 0px 20px 0px 74px; display: flex; flex-direction: column; gap: 12px; - padding: 0px 20px 0px 74px; overflow-y: auto; overflow-x: hidden; } + mwc-tab-bar, #sentenceContainer, #galleryContainer, #advancedInterfaceContainerContainer { + background: var(--mdc-theme-surface); + /* Simulate the shadow of the plot on the right. */ + box-shadow: inset -4px 0 8px 0 rgba(0, 0, 0, 0.2); + } batch-selections-ui { overflow-x: auto; } diff --git a/src/color_setter.ts b/src/color_setter.ts index 4486a66..82ce76c 100644 --- a/src/color_setter.ts +++ b/src/color_setter.ts @@ -25,9 +25,7 @@ function hslToHexString( const r = f(0, saturation, lightness); const g = f(8, saturation, lightness); const b = f(4, saturation, lightness); - return '#' + Math.round(r * 255).toString(16).padStart(2, '0') + - Math.round(g * 255).toString(16).padStart(2, '0') + - Math.round(b * 255).toString(16).padStart(2, '0'); + return rgbToHexString(r * 255, g * 255, b * 255); } /** @@ -58,3 +56,20 @@ export function setColors(state: State) { } } } + +/* Hex color to number array. */ +export function hexStringToRgb(hexString: string): [number, number, number] { + return [ + parseInt(hexString.substring(1, 3), 16), + parseInt(hexString.substring(3, 5), 16), + parseInt(hexString.substring(5, 7), 16) + ]; +} + +/* Numbers to hex string. */ +export function rgbToHexString(r: number, g: number, b: number): string { + return '#' + + Math.min(Math.max(Math.round(r), 0), 255).toString(16).padStart(2, '0') + + Math.min(Math.max(Math.round(g), 0), 255).toString(16).padStart(2, '0') + + Math.min(Math.max(Math.round(b), 0), 255).toString(16).padStart(2, '0'); +} diff --git a/src/constant.ts b/src/constant.ts index 48be25b..5a9f77c 100644 --- a/src/constant.ts +++ b/src/constant.ts @@ -14,7 +14,7 @@ import {Batch, Entry, FieldId} from './entry'; -function getFinalName( +function getFinalConstantValue( batch: Batch, entry: Entry, finalValues: string[], startedIndices: Set, finishedIndices: Set, constantIndex: number) { @@ -45,7 +45,7 @@ function getFinalName( const subConstantIndex = batch.constants.findIndex( (potentialSubConstant) => potentialSubConstant.name === arg); if (subConstantIndex !== -1) { - return getFinalName( + return getFinalConstantValue( batch, entry, finalValues, startedIndices, finishedIndices, subConstantIndex); } @@ -64,19 +64,29 @@ function getFinalName( * with its formatted string. Loops are handled by skipping some replacements. */ export function getFinalConstantValues(batch: Batch, entry: Entry): string[] { - const finalValues: string[] = []; // As many as Batch.constants. + const finalValues = batch.constants.map(constant => constant.value); const startedIndices = new Set(); const finishedIndices = new Set(); - - // Prefill with raw values. - for (const [constantIndex, constant] of batch.constants.entries()) { - finalValues[constantIndex] = constant.value; + for (let constant = 0; constant < batch.constants.length; ++constant) { + getFinalConstantValue( + batch, entry, finalValues, startedIndices, finishedIndices, constant); } + return finalValues; +} - for (const [constantIndex, _] of batch.constants.entries()) { - getFinalName( - batch, entry, finalValues, startedIndices, finishedIndices, - constantIndex); +/** + * Gets the field or constant value associated with the FieldId. + * Slow convenience function. + */ +export function getFinalValue(batch: Batch, entry: Entry, id: FieldId) { + const field = batch.fields.findIndex(field => field.id === id); + if (field !== -1) return entry[field]; + + const constant = batch.constants.findIndex(constant => constant.id === id); + if (constant !== -1) { + return getFinalConstantValue( + batch, entry, batch.constants.map(constant => constant.value), + new Set(), new Set(), constant); } - return finalValues; + return undefined; } diff --git a/src/entry.ts b/src/entry.ts index edfc5bb..0a499ca 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -23,9 +23,11 @@ export enum FieldId { CODEC_NAME, CODEC_VERSION, DATE, + SOURCE_DATA_SET, // For example the URL to access the corpus. SOURCE_IMAGE_PATH, ENCODED_IMAGE_PATH, DECODED_IMAGE_PATH, + PREVIEW_PATH, // Thumbnail. ENCODING_COMMAND, DECODING_COMMAND, // Batch values (usually fields). @@ -55,7 +57,7 @@ export enum FieldId { } /** - * Maps common field keys as seen in raw JSON data to a FieldId. + * Maps common constant/field keys as seen in raw JSON data to a FieldId. * The key is case-sensitive and compared to lower caps input, so make sure to * have at least one lower cap key per FieldId. */ @@ -67,16 +69,21 @@ const NAME_TO_FIELD_ID = new Map([ // than "time" but "time" is shown to the user // because it also contains the hour so it is more // accurate than "date". + ['source data set', FieldId.SOURCE_DATA_SET], ['source image path', FieldId.SOURCE_IMAGE_PATH], + ['original path', FieldId.SOURCE_IMAGE_PATH], ['encoded image', FieldId.ENCODED_IMAGE_PATH], ['encoded path', FieldId.ENCODED_IMAGE_PATH], ['decoded image', FieldId.DECODED_IMAGE_PATH], ['decoded path', FieldId.DECODED_IMAGE_PATH], + ['preview', FieldId.PREVIEW_PATH], + ['thumbnail', FieldId.PREVIEW_PATH], ['encoding command', FieldId.ENCODING_COMMAND], ['encoding cmd', FieldId.ENCODING_COMMAND], ['decoding command', FieldId.DECODING_COMMAND], ['decoding cmd', FieldId.DECODING_COMMAND], ['source image', FieldId.SOURCE_IMAGE_NAME], + ['original name', FieldId.SOURCE_IMAGE_NAME], ['encoded name', FieldId.ENCODED_IMAGE_NAME], ['encoded image name', FieldId.ENCODED_IMAGE_NAME], ['decoded name', FieldId.DECODED_IMAGE_NAME], @@ -99,8 +106,6 @@ const NAME_TO_FIELD_ID = new Map([ ['bpp', FieldId.ENCODED_BITS_PER_PIXEL], ['encoding time', FieldId.ENCODING_DURATION], ['decoding time', FieldId.DECODING_DURATION], - ['original path', FieldId.SOURCE_IMAGE_PATH], - ['original name', FieldId.SOURCE_IMAGE_NAME], ]); /** diff --git a/src/entry_loader_test.ts b/src/entry_loader_test.ts index 51a5c34..d1e06f1 100644 --- a/src/entry_loader_test.ts +++ b/src/entry_loader_test.ts @@ -125,7 +125,6 @@ describe('loadJsonContainingBatchJsonPaths', () => { await loadJsonContainingBatchJsonPaths('/assets/demo_batches.json'); expect(paths).toEqual(jasmine.arrayWithExactContents([ '/assets/demo_batch_some_codec_exp.json', - '/assets/demo_batch_some_codec_effort0.json', '/assets/demo_batch_some_codec_effort1.json', '/assets/demo_batch_some_codec_effort2.json', '/assets/demo_batch_other_codec_settingA.json', diff --git a/src/gallery_ui.ts b/src/gallery_ui.ts new file mode 100644 index 0000000..ca4e6df --- /dev/null +++ b/src/gallery_ui.ts @@ -0,0 +1,208 @@ +// Copyright 2024 Google LLC +// +// 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 +// +// https://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 '@material/mwc-icon'; +import './batch_name_ui'; +import './matcher_ui'; + +import {css, html, LitElement} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; + +import {mergeHistograms} from './batch_merger'; +import {FieldId} from './entry'; +import {EventType, listen} from './events'; +import {SourceCount} from './metric'; +import {State} from './state'; + +/** Component displaying a grid of unique source media input. */ +@customElement('gallery-ui') +export class GalleryUi extends LitElement { + @property({attribute: false}) state!: State; + + override connectedCallback() { + super.connectedCallback(); + listen(EventType.MATCHED_DATA_POINTS_CHANGED, () => { + this.requestUpdate(); + }); + } + + private renderSourceDataSet() { + // Assume all batches share the same source data set. + for (const batch of this.state.batches) { + const constant = batch.constants.find( + constant => constant.id === FieldId.SOURCE_DATA_SET); + if (constant) { + return html` + + + + + + + +
${constant.displayName}
${constant.value}
`; + } + } + return ''; + } + + private renderAsset(asset: SourceCount) { + const title = `${asset.sourceName} used in ${asset.count} matches`; + if (asset.previewPath !== undefined) { + // Use a preview image tag. + + if (asset.sourcePath !== undefined) { + // Use a link to open the image in a new tab. + return html` + + ${asset.sourceName} + ${asset.count} +

+ `; + } + + return html` +
+ ${asset.sourceName} + ${asset.count} +
`; + } + + if (asset.sourcePath !== undefined) { + // Use a link to open the image in a new tab. + return html` + + ${asset.sourceName} + ${asset.count} +
open_in_new
+
`; + } + return html` + + ${asset.sourceName} + ${asset.count} + `; + } + + override render() { + const histogram = mergeHistograms(this.state.batchSelections.map( + batchSelection => batchSelection.histogram)); + + return html` + ${this.renderSourceDataSet()} + `; + } + + static override styles = css` + :host { + padding: 20px 0; + display: flex; + flex-direction: column; + gap: 20px; + } + + table { + color: var(--mdc-theme-text); + border-collapse: collapse; + box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.2); + } + th, + td { + padding: 3px; + border-width: 1px; + border-style: solid; + } + th { + border-color: var(--mdc-theme-background); + background: var(--mdc-theme-surface); + } + td { + border-color: var(--mdc-theme-surface); + font-family: monospace; + word-break: break-word; + } + tr { + background: var(--mdc-theme-background); + } + + #gallery { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 5px; + } + .constrainedSize { + max-width: 90px; + max-height: 90px; + } + #gallery > * { + overflow: hidden; + overflow-wrap: break-word; + text-align: center; + color: var(--mdc-theme-text); + box-shadow: rgba(0, 0, 0, 0.2) 0px 0px 4px 0px; + } + #gallery > a { + transition: box-shadow 0.2s; + text-decoration: none; + position: relative; /* For linkOverlay absolute position to work. */ + } + #gallery > a:hover { + /* Elevate the image when the cursor hovers it. */ + box-shadow: rgba(0, 0, 0, 0.4) 0px 0px 4px 0px; + cursor: zoom-in; + } + #gallery img { + display: block; /* Fix blank space below img. */ + background-image: url('/transparency_checkerboard.webp'); + } + + .linkOverlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + opacity: 0; + transition: opacity 0.2s; + } + .linkOverlay:hover{ + opacity: 1; + } + .linkOverlay > mwc-icon { + color: var(--mdc-theme-background); + font-size: 16px; + } + + .countBubble { + position: absolute; + top: 5px; + right: 5px; + padding: 2px 4px; + border-radius: 3px; + font-size: 12px; + color: var(--mdc-theme-background); + background: var(--mdc-theme-primary); + } + `; +} diff --git a/src/help_ui.ts b/src/help_ui.ts index c60a8e6..bbdd39a 100644 --- a/src/help_ui.ts +++ b/src/help_ui.ts @@ -16,7 +16,7 @@ import '@material/mwc-fab'; import '@material/mwc-icon'; import {css, html, LitElement} from 'lit'; -import {customElement, query} from 'lit/decorators.js'; +import {customElement, property, query} from 'lit/decorators.js'; /** Returns the element at domPath or null. */ function getShadowElement(domPath: string[]): Element|null { @@ -32,6 +32,8 @@ function getShadowElement(domPath: string[]): Element|null { /** Overlay element describing the other elements behind it. */ @customElement('help-ui') export class HelpUi extends LitElement { + @property() displaySentence!: boolean; + // Always returned by render() so cannot be null. @query('#closeButton') private readonly closeButton!: HTMLElement; @@ -72,15 +74,24 @@ export class HelpUi extends LitElement { // The main interactive user interface elements are on the fixed left menu. // Position the "tooltips" on the right of them to keep them visible. // The "tooltips" cover parts of the graph but this works visually. - positionElementAtTheRightOf( - this.numComparisonsDescription, ['codec-compare', '#numComparisons']); - positionElementAtTheRightOf( - this.matchersDescription, ['codec-compare', 'matchers-ui']); - positionElementAtTheRightOf( - this.metricsDescription, ['codec-compare', 'metrics-ui']); - positionElementAtTheRightOf( - this.batchSelectionsDescription, - ['codec-compare', 'batch-selections-ui']); + if (this.displaySentence) { + positionElementAtTheRightOf( + this.matchersDescription, + ['codec-compare', 'sentence-ui', '#matchers']); + positionElementAtTheRightOf( + this.batchSelectionsDescription, + ['codec-compare', 'sentence-ui', '#batches']); + } else { + positionElementAtTheRightOf( + this.numComparisonsDescription, ['codec-compare', '#numComparisons']); + positionElementAtTheRightOf( + this.matchersDescription, ['codec-compare', 'matchers-ui']); + positionElementAtTheRightOf( + this.metricsDescription, ['codec-compare', 'metrics-ui']); + positionElementAtTheRightOf( + this.batchSelectionsDescription, + ['codec-compare', 'batch-selections-ui']); + } // This is not the description of the #referenceBatch

but it is used as // a convenient top anchor to fill the remaining graph area till the bottom @@ -98,16 +109,34 @@ export class HelpUi extends LitElement { this.requestUpdate(); } - override render() { - const onClose = () => { - this.style.display = 'none'; - this.requestUpdate(); - }; - + private renderSentenceHelp() { return html` -

+
+
+ +
+
+

+ This page compares image formats and codecs by matching each data point + from a reference batch to a data point from another batch while + respecting these constraints. +

+
+
+ +
+
+

+ Batches are aggregated by codec.
+ The advanced interface can be enabled in the settings (left bar). +

+
`; + } + + private renderTuneableComparisonHelp() { + return html`

@@ -153,8 +182,23 @@ export class HelpUi extends LitElement { as metrics are displayed in the right-most columns. The aggregation method can be changed in the Settings.

+
`; + } + + override render() { + const onClose = () => { + this.style.display = 'none'; + this.requestUpdate(); + }; + + return html` +
+ ${ + this.displaySentence ? this.renderSentenceHelp() : + this.renderTuneableComparisonHelp()} +

The codecs are plotted on this graph as large disks, with the metric diff --git a/src/histogram_test.ts b/src/histogram_test.ts new file mode 100644 index 0000000..d70d204 --- /dev/null +++ b/src/histogram_test.ts @@ -0,0 +1,96 @@ +// Copyright 2024 Google LLC +// +// 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 +// +// https://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 'jasmine'; + +import {mergeHistograms} from './batch_merger'; +import {BatchSelection} from './batch_selection'; +import {Batch, Field, FieldId} from './entry'; +import {FieldMatcher, getDataPointsSymmetric, Match} from './matcher'; +import {computeHistogram} from './metric'; + +function createMyData( + leftValues: number[], rightValues: number[]): [Batch, Batch, Match[]] { + const leftBatch = new Batch('url', 'path/to/left/folder'); + leftBatch.fields.push(new Field('source image', 'description')); + leftBatch.index = 0; + const rightBatch = new Batch('url', 'path/to/right/folder'); + rightBatch.fields.push(new Field('source image', 'description')); + rightBatch.index = 1; + for (const value of leftValues) leftBatch.rows.push([value]); + for (const value of rightValues) rightBatch.rows.push([value]); + leftBatch.fields[0].addValues(leftBatch.rows, 0); + rightBatch.fields[0].addValues(rightBatch.rows, 0); + + const leftBatchSelection = new BatchSelection(leftBatch); + const rightBatchSelection = new BatchSelection(rightBatch); + leftBatchSelection.updateFilteredRows(); + rightBatchSelection.updateFilteredRows(); + + const matcher = new FieldMatcher([0, 0], 0); + matcher.enabled = true; + const dataPoints = getDataPointsSymmetric( + leftBatchSelection, rightBatchSelection, [matcher]); + return [leftBatch, rightBatch, dataPoints.rows]; +} + +function createHistogram(leftValues: number[], rightValues: number[]) { + const [leftBatch, unused, dataPoints] = createMyData(leftValues, rightValues); + return computeHistogram(leftBatch, dataPoints); +} + +describe('computeHistogram', () => { + it('does not find source media', () => { + const [leftBatch, unused, dataPoints] = createMyData([1, 2, 3], [1, 2, 3]); + leftBatch.fields[0].id = FieldId.CUSTOM; + expect(computeHistogram(leftBatch, dataPoints)).toHaveSize(0); + }); + + it('finds source media', () => { + const histogram = createHistogram([1, 2, 3], [1, 2, 3]); + expect(histogram).toHaveSize(3); + for (const sourceCount of histogram) { + expect(sourceCount.count).toBe(1); + } + }); + + it('aggregates properly', () => { + const histogram = createHistogram([81, 81, 81, 62], [81, 81, 23, 23]); + expect(histogram).toHaveSize(2); + expect(histogram[0].sourceName).toBe('81'); + expect(histogram[0].count).toBe(2); + expect(histogram[1].sourceName).toBe('62'); + expect(histogram[1].count).toBe(0); // 62 has no pair. + // 23 is not part of the left batch. + }); +}); + +describe('mergeHistograms', () => { + it('aggregates properly', () => { + const histogram1 = createHistogram([1, 2, 3], [1, 2, 3]); + const histogram2 = createHistogram([81, 81, 81, 62], [81, 81, 23, 23]); + const histogram3 = createHistogram([81, 81, 81, 23], [81, 81, 23, 23]); + const histogram = mergeHistograms([histogram1, histogram2, histogram3]); + + expect(histogram).toHaveSize(6); + expect(histogram[0].sourceName).toBe('81'); + expect(histogram[0].count).toBe(2 + 2); + expect(histogram[1].count).toBe(1); // probably 1 + expect(histogram[2].count).toBe(1); // probably 2 + expect(histogram[3].count).toBe(1); // probably 3 + expect(histogram[4].count).toBe(1); // probably 23 + expect(histogram[5].sourceName).toBe('62'); + expect(histogram[5].count).toBe(0); + }); +}); diff --git a/src/match_image_ui.ts b/src/match_image_ui.ts index 7c60142..c9f4eb3 100644 --- a/src/match_image_ui.ts +++ b/src/match_image_ui.ts @@ -22,8 +22,8 @@ import {css, html, LitElement} from 'lit'; import {customElement, property} from 'lit/decorators.js'; import {BatchSelection} from './batch_selection'; -import {getFinalConstantValues} from './constant'; -import {Batch, FieldId} from './entry'; +import {getFinalValue} from './constant'; +import {FieldId} from './entry'; import {State} from './state'; /** Component displaying the source image of one selected Match. */ @@ -42,43 +42,49 @@ export class MatchImageUi extends LitElement { .batch; const match = this.batchSelection.matchedDataPoints.rows[this.matchIndex]; - const sourceImagePath = findInFieldsOrConstants( - FieldId.SOURCE_IMAGE_PATH, reference, match.rightIndex); - if (sourceImagePath === '') return html``; + const sourceImagePath = getFinalValue( + reference, reference.rows[match.rightIndex], FieldId.SOURCE_IMAGE_PATH); + if (sourceImagePath === undefined) return html``; - let referenceImagePath = findInFieldsOrConstants( - FieldId.DECODED_IMAGE_PATH, reference, match.rightIndex); - if (referenceImagePath === '') { - referenceImagePath = findInFieldsOrConstants( - FieldId.ENCODED_IMAGE_PATH, reference, match.rightIndex); + let referenceImagePath = getFinalValue( + reference, reference.rows[match.rightIndex], + FieldId.DECODED_IMAGE_PATH); + if (referenceImagePath === undefined) { + referenceImagePath = getFinalValue( + reference, reference.rows[match.rightIndex], + FieldId.ENCODED_IMAGE_PATH); } - let selectionImagePath = findInFieldsOrConstants( - FieldId.DECODED_IMAGE_PATH, this.batchSelection.batch, match.leftIndex); - if (selectionImagePath === '') { - selectionImagePath = findInFieldsOrConstants( - FieldId.ENCODED_IMAGE_PATH, this.batchSelection.batch, - match.leftIndex); + let selectionImagePath = getFinalValue( + this.batchSelection.batch, + this.batchSelection.batch.rows[match.leftIndex], + FieldId.DECODED_IMAGE_PATH); + if (selectionImagePath === undefined) { + selectionImagePath = getFinalValue( + this.batchSelection.batch, + this.batchSelection.batch.rows[match.leftIndex], + FieldId.ENCODED_IMAGE_PATH); } let link; - const compare = referenceImagePath !== '' && selectionImagePath !== ''; + const compare = + referenceImagePath !== undefined && selectionImagePath !== undefined; if (compare) { const params = new URLSearchParams(); - params.set('bimg', sourceImagePath); + params.set('bimg', String(sourceImagePath)); params.set('btxt', 'original'); - params.set('rimg', referenceImagePath); + params.set('rimg', String(referenceImagePath)); params.set('rtxt', reference.name); - params.set('limg', selectionImagePath); + params.set('limg', String(selectionImagePath)); params.set('ltxt', this.batchSelection.batch.name); link = `visualizer.html?${params.toString()}`; } else { - link = sourceImagePath; + link = String(sourceImagePath); } return html` - +

${compare ? 'compare' : 'image'} open_in_new @@ -133,20 +139,3 @@ export class MatchImageUi extends LitElement { } `; } - -function findInFieldsOrConstants( - fieldId: FieldId, batch: Batch, rowIndex: number): string { - const fieldIndex = batch.fields.findIndex((field) => field.id === fieldId); - if (fieldIndex !== -1) { - return String(batch.rows[rowIndex][fieldIndex]); - } - const constantIndex = - batch.constants.findIndex((constant) => constant.id === fieldId); - if (constantIndex !== -1) { - const finalValues = getFinalConstantValues(batch, batch.rows[rowIndex]); - if (constantIndex < finalValues.length) { - return finalValues[constantIndex]; - } - } - return ''; -} diff --git a/src/match_test.ts b/src/match_test.ts index e76e87a..58a0430 100644 --- a/src/match_test.ts +++ b/src/match_test.ts @@ -72,6 +72,33 @@ describe('Matcher', () => { ]); }); + it('pairs rows at most once each', () => { + // Add rows that can be matched in multiple valid ways. + state.batches[0].rows.push(['value A-10', 10]); + state.batches[0].rows.push(['value A-10', 10]); + state.batches[0].rows.push(['value A-11', 11]); + state.batches[0].rows.push(['value A-11', 11]); + state.batches[1].rows.push(['value A-10', 10]); + state.batches[1].rows.push(['value A-11', 11]); + state.batches[1].rows.push(['value A-11', 11]); + state.batches[1].rows.push(['value A-11', 11]); + for (const batchSelection of state.batchSelections) { + batchSelection.updateFilteredRows(); + } + + const matches = getDataPoints( + state.batchSelections[0], state.batchSelections[1], state.matchers); + expect(matches.rows).toEqual([ + new Match(0, 1, 0), // value A-1 + new Match(1, 0, 0), // value A-1 + new Match(2, 2, 0.5), // value A-2 + new Match(3, 3, 0.5), // value A-2 + new Match(5, 6, 0), // value A-10 + new Match(7, 7, 0), // value A-11 + new Match(8, 8, 0) // value A-11 + ]); + }); + it('aggregates relative error', () => { const matches = getDataPoints( state.batchSelections[0], state.batchSelections[1], state.matchers); diff --git a/src/metric.ts b/src/metric.ts index 878be68..933d569 100644 --- a/src/metric.ts +++ b/src/metric.ts @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import {getFinalValue} from './constant'; import {areFieldsComparable, Batch, Field, FieldId} from './entry'; import {GeometricMean} from './geometric_mean'; import {Match} from './matcher'; @@ -39,6 +40,15 @@ export class FieldMetricStats { } } +/** Aggregated stats for a unique source media. */ +export class SourceCount { + sourceName: string = ''; // FieldId.SOURCE_IMAGE_NAME + sourcePath: string|undefined = undefined; // FieldId.SOURCE_IMAGE_PATH + previewPath: string|undefined = undefined; // FieldId.PREVIEW_PATH + + count = 0; // The number of data points with this value for the fieldId. +} + /** Returns all possible metrics given two batches. */ export function createMetrics(batches: Batch[]): FieldMetric[] { const metrics: FieldMetric[] = []; @@ -182,3 +192,45 @@ export function computeStats( } return stats; } + +/** Returns the histogram of unique source media. */ +export function computeHistogram( + batch: Batch, dataPoints: Match[]): SourceCount[] { + const sourceNameFieldIndex = + batch.fields.findIndex(field => field.id === FieldId.SOURCE_IMAGE_NAME); + if (sourceNameFieldIndex === -1) return []; + + // Maps FieldId.SOURCE_IMAGE_NAME to SourceCount. + const histogram = new Map(); + + // Start by referencing all unique source media values. + for (const row of batch.rows) { + const sourceName = String(row[sourceNameFieldIndex]); + + let sourceCount = histogram.get(sourceName); + if (sourceCount === undefined) { + sourceCount = new SourceCount(); + sourceCount.sourceName = sourceName; + const sourcePath = getFinalValue(batch, row, FieldId.SOURCE_IMAGE_PATH); + sourceCount.sourcePath = + sourcePath === undefined ? undefined : String(sourcePath); + const previewPath = getFinalValue(batch, row, FieldId.PREVIEW_PATH); + sourceCount.previewPath = + previewPath === undefined ? undefined : String(previewPath); + sourceCount.count = 0; + histogram.set(sourceName, sourceCount); + } + } + + // Just count matches now. + for (const dataPoint of dataPoints) { + let sourceCount = histogram.get( + String(batch.rows[dataPoint.leftIndex][sourceNameFieldIndex])); + if (sourceCount === undefined) { + return []; // Should not happen. + } + sourceCount.count++; + } + + return Array.from(histogram.values()); +} diff --git a/src/sentence_ui.ts b/src/sentence_ui.ts new file mode 100644 index 0000000..02c238a --- /dev/null +++ b/src/sentence_ui.ts @@ -0,0 +1,202 @@ +// Copyright 2024 Google LLC +// +// 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 +// +// https://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 './batch_name_ui'; +import './matcher_ui'; + +import {css, html, LitElement} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; + +import {mergeBatches} from './batch_merger'; +import {BatchSelection} from './batch_selection'; +import {Batch, FieldId, QUALITY_METRIC_FIELD_IDS} from './entry'; +import {EventType, listen} from './events'; +import {FieldMatcher} from './matcher'; +import {FieldMetric, FieldMetricStats} from './metric'; +import {State} from './state'; + +/** + * Component displaying a summary of the whole comparison ("simple interface"). + */ +@customElement('sentence-ui') +export class SentenceUi extends LitElement { + @property({attribute: false}) state!: State; + + override connectedCallback() { + super.connectedCallback(); + listen(EventType.MATCHED_DATA_POINTS_CHANGED, () => { + this.requestUpdate(); + }); + } + + // Matchers + + private renderMatcher( + matcher: FieldMatcher, index: number, numEnabledMatchers: number) { + let displayName: string|undefined = undefined; + let subName: string|undefined = undefined; + for (const batch of this.state.batches) { + const field = batch.fields[matcher.fieldIndices[batch.index]]; + if (field.displayName === '') continue; + if (QUALITY_METRIC_FIELD_IDS.includes(field.id)) { + displayName = 'distortion'; + subName = field.displayName; + } else { + displayName = field.displayName; + } + break; + } + if (displayName === '') return html``; + + const isFirst = index === 0; + const isLast = index === numEnabledMatchers - 1; + const tolerance: string|undefined = matcher.tolerance !== 0 ? + `±${(matcher.tolerance * 100).toFixed(1)}%` : + undefined; + const parenthesis = subName ? + tolerance ? ` (${subName} ${tolerance})` : ` (${subName})` : + tolerance ? ` (${tolerance})` : + ''; + + return html` +

+ ${isFirst ? 'For' : ''} the same ${displayName}${parenthesis}${ + isLast ? ',' : ' and'} +

`; + } + + private renderMatchers() { + const numEnabledMatchers = + this.state.matchers.filter(matcher => matcher.enabled).length; + let index = 0; + return html` + ${ + this.state.matchers.map( + matcher => matcher.enabled ? + this.renderMatcher(matcher, index++, numEnabledMatchers) : + '')}`; + } + + // Batches and metrics + + private renderMetric( + batch: Batch, metric: FieldMetric, stats: FieldMetricStats) { + const field = batch.fields[metric.fieldIndices[batch.index]]; + const mean = stats.getMean(this.state.useGeometricMean); + + if (mean === 1) { + switch (field.id) { + case FieldId.ENCODED_SIZE: + case FieldId.ENCODED_BITS_PER_PIXEL: + return html`as big`; + case FieldId.ENCODING_DURATION: + return html`as fast to encode`; + case FieldId.DECODING_DURATION: + return html`as slow to decode`; + default: + return html`of the same ${field.displayName}`; + } + } + + const neg = mean < 1; + const xTimes = String( + neg ? (1 / mean).toFixed(2) + ' times' : mean.toFixed(2) + ' times'); + + switch (field.id) { + case FieldId.ENCODED_SIZE: + case FieldId.ENCODED_BITS_PER_PIXEL: + return html`${xTimes} ${neg ? 'smaller' : 'bigger'}`; + case FieldId.ENCODING_DURATION: + return html`${xTimes} ${neg ? 'faster' : 'slower'} to encode`; + case FieldId.DECODING_DURATION: + return html`${xTimes} ${neg ? 'faster' : 'slower'} to decode`; + default: + return html`${xTimes} ${neg ? 'lower' : 'higher'} on the ${ + field.displayName} scale`; + } + } + + private renderBatch(batchSelection: BatchSelection) { + const batch = batchSelection.batch; + return html` +

+ images encoded with are + ${ + this.state.metrics.map((metric: FieldMetric, metricIndex: number) => { + return metric.enabled ? + html`
${ + this.renderMetric( + batch, metric, batchSelection.stats[metricIndex])},` : + ''; + })} +

`; + } + + private renderBatches() { + const mergedBatches = mergeBatches( + this.state.batchSelections, + /*skipIndex=*/ this.state.referenceBatchSelectionIndex); + if (mergedBatches.length > 0) { + return html`${mergedBatches.map((batchSelection, index) => { + return this.renderBatch(batchSelection); + })}`; + } + return html`${this.state.batchSelections.map((batchSelection, index) => { + return this.state.referenceBatchSelectionIndex === index || + batchSelection.matchedDataPoints.rows.length === 0 ? + '' : + this.renderBatch(batchSelection); + })}`; + } + + // Reference + + private renderReference(referenceBatch: Batch) { + return html` +

+ compared to . +

`; + } + + override render() { + let referenceBatch: Batch|undefined = undefined; + if (this.state.referenceBatchSelectionIndex >= 0 && + this.state.referenceBatchSelectionIndex < + this.state.batchSelections.length) { + referenceBatch = + this.state.batchSelections[this.state.referenceBatchSelectionIndex] + .batch; + } + + return html` +
+ ${this.renderMatchers()} +
+
+ ${this.renderBatches()} +
+ ${referenceBatch ? this.renderReference(referenceBatch) : ''}`; + } + + static override styles = css` + :host { + padding: 10px 0; + } + p { + margin: 10px 0; + color: var(--mdc-theme-text); + font-size: 20px; + } + `; +} diff --git a/src/state.ts b/src/state.ts index fb94f74..b3286ab 100644 --- a/src/state.ts +++ b/src/state.ts @@ -18,7 +18,7 @@ import {setColors} from './color_setter'; import {Batch, FieldId} from './entry'; import {dispatch, EventType, listen} from './events'; import {createMatchers, enableDefaultMatchers, FieldMatcher, getDataPointsSymmetric, isLossless} from './matcher'; -import {computeStats, createMetrics, enableDefaultMetrics, FieldMetric, selectPlotMetrics} from './metric'; +import {computeHistogram, computeStats, createMetrics, enableDefaultMetrics, FieldMetric, selectPlotMetrics} from './metric'; /** The root data object containing the full state. */ export class State { @@ -130,6 +130,12 @@ export class State { batchSelection.stats = computeStats( batchSelection.batch, referenceBatchSelection.batch, batchSelection.matchedDataPoints.rows, this.metrics); + batchSelection.histogram = computeHistogram( + batchSelection.batch, + // Avoid self-matches to be part of the asset usage counts. + batchSelection.batch.index === this.referenceBatchSelectionIndex ? + [] : + batchSelection.matchedDataPoints.rows); }; listen(EventType.FILTERED_DATA_CHANGED, (event) => {