in tensorboard/plugins/custom_scalar/tf_custom_scalar_dashboard/tf-custom-scalar-multi-line-chart-card.ts [54:457]
extends LegacyElementMixin(PolymerElement)
implements TfCustomScalarMultiLineChartCard
{
static readonly template = html`
<tf-card-heading display-name="[[_titleDisplayString]]"></tf-card-heading>
<div id="tf-line-chart-data-loader-container">
<tf-line-chart-data-loader
id="loader"
active="[[active]]"
color-scale="[[_colorScale]]"
data-series="[[_seriesNames]]"
ignore-y-outliers="[[ignoreYOutliers]]"
load-key="[[_tagFilter]]"
data-to-load="[[runs]]"
request-data="[[_requestData]]"
log-scale-active="[[_logScaleActive]]"
load-data-callback="[[_createProcessDataFunction()]]"
request-manager="[[requestManager]]"
smoothing-enabled="[[smoothingEnabled]]"
smoothing-weight="[[smoothingWeight]]"
symbol-function="[[_createSymbolFunction()]]"
tooltip-sorting-method="[[tooltipSortingMethod]]"
x-type="[[xType]]"
>
</tf-line-chart-data-loader>
</div>
<div id="buttons">
<paper-icon-button
selected$="[[_expanded]]"
icon="fullscreen"
on-tap="_toggleExpanded"
></paper-icon-button>
<paper-icon-button
selected$="[[_logScaleActive]]"
icon="line-weight"
on-tap="_toggleLogScale"
title="Toggle y-axis log scale"
></paper-icon-button>
<paper-icon-button
icon="settings-overscan"
on-tap="_resetDomain"
title="Fit domain to data"
></paper-icon-button>
<span style="flex-grow: 1"></span>
<template is="dom-if" if="[[showDownloadLinks]]">
<div class="download-links">
<paper-dropdown-menu
no-label-float="true"
label="series to download"
selected-item-label="{{_dataSeriesNameToDownload}}"
>
<paper-listbox class="dropdown-content" slot="dropdown-content">
<template
is="dom-repeat"
items="[[_seriesNames]]"
as="dataSeriesName"
>
<paper-item no-label-float="true"
>[[dataSeriesName]]</paper-item
>
</template>
</paper-listbox>
</paper-dropdown-menu>
<a
download="[[_dataSeriesNameToDownload]].csv"
href="[[_csvUrl(_nameToDataSeries, _dataSeriesNameToDownload)]]"
>CSV</a
>
<a
download="[[_dataSeriesNameToDownload]].json"
href="[[_jsonUrl(_nameToDataSeries, _dataSeriesNameToDownload)]]"
>JSON</a
>
</div>
</template>
</div>
<div id="matches-container">
<div id="matches-list-title">
<template is="dom-if" if="[[_seriesNames.length]]">
<paper-icon-button
icon="[[_getToggleMatchesIcon(_matchesListOpened)]]"
on-click="_toggleMatchesOpen"
class="toggle-matches-button"
>
</paper-icon-button>
</template>
<span class="matches-text"> Matches ([[_seriesNames.length]]) </span>
</div>
<template is="dom-if" if="[[_seriesNames.length]]">
<iron-collapse opened="[[_matchesListOpened]]">
<div id="matches-list">
<template
is="dom-repeat"
items="[[_seriesNames]]"
as="seriesName"
id="match-list-repeat"
on-dom-change="_matchListEntryColorUpdated"
>
<div class="match-list-entry">
<span class="match-entry-symbol">
[[_determineSymbol(_nameToDataSeries, seriesName)]]
</span>
[[seriesName]]
</div>
</template>
</div>
</iron-collapse>
</template>
</div>
<style include="tf-custom-scalar-card-style"></style>
<style>
#matches-list-title {
margin: 10px 0 5px 0;
}
#matches-list {
max-height: 200px;
overflow-y: auto;
}
.match-list-entry {
margin: 0 0 5px 0;
}
.match-entry-symbol {
font-family: arial, sans-serif;
display: inline-block;
width: 10px;
}
.matches-text {
vertical-align: middle;
}
</style>
`;
@property({type: Array})
runs: string[];
@property({type: String})
xType: string;
@property({type: Boolean})
active: boolean = true;
@property({type: String})
override title: string;
@property({type: Array})
tagRegexes: string[];
@property({type: Boolean})
ignoreYOutliers: boolean;
@property({type: Object})
requestManager: RequestManager;
@property({type: Boolean})
showDownloadLinks: boolean;
@property({type: Boolean})
smoothingEnabled: boolean;
@property({type: Number})
smoothingWeight: number;
@property({type: Object})
tagMetadata: object;
@property({type: String})
tooltipSortingMethod: string;
@property({type: Object})
_colorScale: DataSeriesColorScale = new DataSeriesColorScale({
scale: runsColorScale,
} as any);
@property({type: Object})
_nameToDataSeries: object = {};
@property({
type: Boolean,
reflectToAttribute: true,
})
_expanded: boolean = false;
@property({type: Boolean})
_logScaleActive: boolean;
@property({type: Object})
_requestData: RequestDataCallback<RunItem, CustomScalarsDatum> = (
items,
onLoad,
onFinish
) => {
const router = getRouter();
const baseUrl = router.pluginRoute('custom_scalars', '/scalars');
Promise.all(
items.map((item) => {
const run = item;
const tag = this._tagFilter;
const url = addParams(baseUrl, {tag, run});
return this.requestManager
.request(url)
.then((data) => void onLoad({item, data}));
})
).finally(() => void onFinish());
};
@property({type: Object})
_runToNextAvailableSymbolIndex: object = {};
@property({type: Boolean})
_matchesListOpened: boolean = false;
reload() {
(this.$.loader as TfLineChartDataLoader).reload();
}
redraw() {
(this.$.loader as TfLineChartDataLoader).redraw();
}
_toggleExpanded(e) {
this.set('_expanded', !this._expanded);
this.redraw();
}
_toggleLogScale() {
this.set('_logScaleActive', !this._logScaleActive);
}
_resetDomain() {
const chart = this.$.loader as TfLineChartDataLoader;
if (chart) {
chart.resetDomain();
}
}
_csvUrl(nameToSeries, dataSeriesName) {
if (!dataSeriesName) return '';
const baseUrl = this._downloadDataUrl(nameToSeries, dataSeriesName);
return addParams(baseUrl, {format: 'csv'});
}
_jsonUrl(nameToSeries, dataSeriesName) {
if (!dataSeriesName) return '';
const baseUrl = this._downloadDataUrl(nameToSeries, dataSeriesName);
return addParams(baseUrl, {format: 'json'});
}
_downloadDataUrl(nameToSeries, dataSeriesName) {
const dataSeries = nameToSeries[dataSeriesName];
const getVars = {
tag: dataSeries.getTag(),
run: dataSeries.getRun(),
};
return addParams(
getRouter().pluginRoute('custom_scalars', '/download_data'),
getVars
);
}
_createProcessDataFunction() {
// This function is called when data is received from the backend.
return (scalarChart, run, data) => {
if (data.regex_valid) {
// The user's regular expression was valid.
// Incorporate these newly loaded values.
const newMapping = _.clone(this._nameToDataSeries);
_.forOwn(data.tag_to_events, (scalarEvents, tag) => {
const data = scalarEvents.map((datum) => ({
wall_time: new Date(datum[0] * 1000),
step: datum[1],
scalar: datum[2],
}));
const seriesName = generateDataSeriesName(run, tag);
const datum = newMapping[seriesName];
if (datum) {
// This series already exists.
datum.setData(data);
} else {
if (_.isUndefined(this._runToNextAvailableSymbolIndex[run])) {
// The run has not been seen before. Define the next available
// marker index.
this._runToNextAvailableSymbolIndex[run] = 0;
}
// Every data series within a run has a unique symbol.
const lineChartSymbol =
SYMBOLS_LIST[this._runToNextAvailableSymbolIndex[run]];
// Create a series with this name.
const series = new DataSeries(
run,
tag,
seriesName,
data,
lineChartSymbol
);
newMapping[seriesName] = series;
// Loop back to the beginning if we are out of symbols.
const numSymbols = SYMBOLS_LIST.length;
this._runToNextAvailableSymbolIndex[run] =
(this._runToNextAvailableSymbolIndex[run] + 1) % numSymbols;
}
});
this.set('_nameToDataSeries', newMapping);
} else {
// The user's regular expression was invalid.
// TODO(chihuahua): Handle this.
}
};
}
@observe('_nameToDataSeries')
_updateChart() {
var _nameToDataSeries = this._nameToDataSeries;
// Add new data series.
Object.entries(_nameToDataSeries).forEach(([name, series]) => {
(this.$.loader as TfLineChartDataLoader).setSeriesData(
name,
series.getData()
);
});
(this.$.loader as TfLineChartDataLoader).commitChanges();
}
_computeSelectedRunsSet(runs) {
const mapping = {};
_.forEach(runs, (run) => {
mapping[run] = 1;
});
return mapping;
}
@computed('_nameToDataSeries', 'runs')
get _seriesNames(): object {
const runLookup = new Set(this.runs);
return Object.entries(this._nameToDataSeries)
.filter(([_, series]) => runLookup.has(series.run))
.map(([name]) => name);
}
_determineColor(colorScale, seriesName) {
return colorScale.scale(seriesName);
}
@observe('_tagFilter')
_refreshDataSeries() {
var _tagFilter = this._tagFilter;
this.set('_nameToDataSeries', {});
}
_createSymbolFunction() {
return (seriesName) =>
this._nameToDataSeries[seriesName].getSymbol().method();
}
_determineSymbol(nameToSeries, seriesName) {
return nameToSeries[seriesName].getSymbol().character;
}
@computed('tagRegexes')
get _tagFilter(): string {
var tagRegexes = this.tagRegexes;
if (tagRegexes.length === 1) {
return tagRegexes[0];
}
// Combine the different regexes into a single regex.
return tagRegexes.map((r) => '(' + r + ')').join('|');
}
_getToggleMatchesIcon(matchesListOpened) {
return matchesListOpened ? 'expand-less' : 'expand-more';
}
_toggleMatchesOpen() {
this.set('_matchesListOpened', !this._matchesListOpened);
}
@computed('title')
get _titleDisplayString(): string {
var title = this.title;
// If no title is provided, use a placeholder string.
return title || 'untitled';
}
_matchListEntryColorUpdated(event) {
const domRepeat = this.$$('#match-list-repeat') as DomRepeat | null;
if (!domRepeat) {
return;
}
this.root
.querySelectorAll('.match-list-entry')
.forEach((entryElement: HTMLElement) => {
const seriesName = domRepeat.itemForElement(entryElement);
entryElement.style.color = this._determineColor(
this._colorScale,
seriesName
);
});
}
}