extends LegacyElementMixin()

in tensorboard/plugins/custom_scalar/tf_custom_scalar_dashboard/tf-custom-scalar-margin-chart-card.ts [73:724]


  extends LegacyElementMixin(PolymerElement)
  implements TfCustomScalarMarginChartCard
{
  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]]"
        fill-area="[[_fillArea]]"
        ignore-y-outliers="[[ignoreYOutliers]]"
        load-key="[[_tagFilter]]"
        data-to-load="[[runs]]"
        request-data="[[_requestData]]"
        log-scale-active="[[_logScaleActive]]"
        load-data-callback="[[_createProcessDataFunction(marginChartSeries)]]"
        request-manager="[[requestManager]]"
        symbol-function="[[_createSymbolFunction()]]"
        tooltip-columns="[[_tooltipColumns]]"
        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>

    <!-- here -->
    <template is="dom-if" if="[[_missingTags.length]]">
      <div class="collapsible-list-title">
        <paper-icon-button
          icon="[[_getToggleCollapsibleIcon(_missingTagsCollapsibleOpened)]]"
          on-click="_toggleMissingTagsCollapsibleOpen"
          class="toggle-collapsible-button"
        >
        </paper-icon-button>
        <span class="collapsible-title-text">
          <iron-icon icon="icons:error"></iron-icon> Missing Tags
        </span>
      </div>
      <iron-collapse opened="[[_missingTagsCollapsibleOpened]]">
        <div class="error-content">
          <iron-icon class="error-icon" icon="icons:error"></iron-icon>
          <template is="dom-repeat" items="[[_missingTags]]" as="missingEntry">
            <div class="missing-tags-for-run-container">
              Run "[[missingEntry.run]]" lacks data for tags
              <ul>
                <template
                  is="dom-repeat"
                  items="[[missingEntry.tags]]"
                  as="tag"
                >
                  <li>[[tag]]</li>
                </template>
              </ul>
            </div>
          </template>
        </div>
      </iron-collapse>
    </template>

    <template is="dom-if" if="[[_tagFilterInvalid]]">
      <div class="error-content">
        <iron-icon class="error-icon" icon="icons:error"></iron-icon>
        This regular expresion is invalid:<br />
        <span class="invalid-regex">[[_tagFilter]]</span>
      </div>
    </template>

    <template is="dom-if" if="[[_stepsMismatch]]">
      <div class="error-content">
        <iron-icon class="error-icon" icon="icons:error"></iron-icon>
        The steps for value, lower, and upper tags do not match:
        <ul>
          <li>
            <span class="tag-name">[[_stepsMismatch.seriesObject.value]]</span>:
            [[_separateWithCommas(_stepsMismatch.valueSteps)]]
          </li>
          <li>
            <span class="tag-name">[[_stepsMismatch.seriesObject.lower]]</span>:
            [[_separateWithCommas(_stepsMismatch.lowerSteps)]]
          </li>
          <li>
            <span class="tag-name">[[_stepsMismatch.seriesObject.upper]]</span>:
            [[_separateWithCommas(_stepsMismatch.upperSteps)]]
          </li>
        </ul>
      </div>
    </template>

    <div id="matches-container">
      <div class="collapsible-list-title">
        <template is="dom-if" if="[[_seriesNames.length]]">
          <paper-icon-button
            icon="[[_getToggleCollapsibleIcon(_matchesListOpened)]]"
            on-click="_toggleMatchesOpen"
            class="toggle-matches-button"
          >
          </paper-icon-button>
        </template>

        <span class="collapsible-title-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>
      .error-content {
        background: #f00;
        border-radius: 5px;
        color: #fff;
        margin: 10px 0 0 0;
        padding: 10px;
      }

      .error-icon {
        display: block;
        fill: #fff;
        margin: 0 auto 5px auto;
      }

      .invalid-regex {
        font-weight: bold;
      }

      .error-content ul {
        margin: 1px 0 0 0;
        padding: 0 0 0 19px;
      }

      .tag-name {
        font-weight: bold;
      }

      .collapsible-list-title {
        margin: 10px 0 5px 0;
      }

      .collapsible-title-text {
        vertical-align: middle;
      }

      #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;
      }

      .missing-tags-for-run-container {
        margin: 8px 0 0 0;
      }
    </style>
  `;

  @property({type: Array})
  runs: unknown[];

  @property({type: String})
  xType: string;

  @property({type: Boolean})
  active: boolean = true;

  @property({type: String})
  override title: string;

  @property({type: Array})
  marginChartSeries: MarginChartSeries[];

  @property({type: Boolean})
  ignoreYOutliers: boolean;

  @property({type: Object})
  requestManager: RequestManager;

  @property({type: Boolean})
  showDownloadLinks: boolean;

  @property({type: Object})
  tagMetadata: object;

  @property({type: String})
  tooltipSortingMethod: string;

  @property({type: Object})
  _colorScale: object = new DataSeriesColorScale({
    scale: runsColorScale,
  } as any);

  @property({type: Boolean})
  _tagFilterInvalid: boolean;

  @property({type: Object})
  _nameToDataSeries: Record<string, DataSeries> = {};

  @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;

  @property({type: Object})
  _fillArea: object = {
    lowerAccessor: (d) => d.lower,
    higherAccessor: (d) => d.upper,
  };

  @property({type: Array})
  _tooltipColumns: unknown[] = (() => {
    const valueFormatter = multiscaleFormatter(Y_TOOLTIP_FORMATTER_PRECISION);
    const formatValueOrNaN = (x) => (isNaN(x) ? 'NaN' : valueFormatter(x));
    return [
      {
        title: 'Name',
        evaluate: (d) => d.dataset.metadata().name,
      },
      {
        title: 'Value',
        evaluate: (d) => formatValueOrNaN(d.datum.scalar),
      },
      {
        title: 'Lower Margin',
        evaluate: (d) => formatValueOrNaN(d.datum.lower),
      },
      {
        title: 'Upper Margin',
        evaluate: (d) => formatValueOrNaN(d.datum.upper),
      },
      {
        title: 'Step',
        evaluate: (d) => stepFormatter(d.datum.step),
      },
      {
        title: 'Time',
        evaluate: (d) => timeFormatter(d.datum.wall_time),
      },
      {
        title: 'Relative',
        evaluate: (d) =>
          relativeFormatter(relativeAccessor(d.datum, -1, d.dataset)),
      },
    ];
  })();

  @property({type: Array})
  _missingTags: Array<{run: string; tags: string[]}> = [];

  @property({type: Boolean})
  _missingTagsCollapsibleOpened: boolean = false;

  /**
   * This field is only set if data retrieved from the server exhibits a
   * step mismatch: if the lists of values, lower bounds, and upper bounds
   * do not match in step.
   */
  @property({type: Object})
  _stepsMismatch: StepsMismatch | null;

  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(_nameToDataSeries, dataSeriesName) {
    if (!dataSeriesName) return '';
    const baseUrl = this._downloadDataUrl(_nameToDataSeries, dataSeriesName);
    return addParams(baseUrl, {format: 'csv'});
  }
  _jsonUrl(_nameToDataSeries, dataSeriesName) {
    if (!dataSeriesName) return '';
    const baseUrl = this._downloadDataUrl(_nameToDataSeries, dataSeriesName);
    return addParams(baseUrl, {format: 'json'});
  }
  _downloadDataUrl(_nameToDataSeries, dataSeriesName) {
    const dataSeries = _nameToDataSeries[dataSeriesName];
    const getVars = {
      tag: dataSeries.getTag(),
      run: dataSeries.getRun(),
    };
    return addParams(
      getRouter().pluginRoute('custom_scalars', '/download_data'),
      getVars
    );
  }

  _createProcessDataFunction(marginChartSeries) {
    // This function is called when data is received from the backend.
    return (scalarChart, run, data) => {
      if (!data.regex_valid) {
        // The regular expression is constructed from frontend logic that
        // pieces together different tags. Hence, this case should never be
        // reached if the dashboard behaves correctly.
        this.set('_tagFilterInvalid', true);
        return;
      }
      // The user's regular expression was valid.
      // Incorporate these newly loaded values.
      const newMapping = _.clone(this._nameToDataSeries);
      const tagsNotFound = [];
      _.forEach(marginChartSeries, (tagsObject) => {
        let tagNotFound = false;
        const scalarEvents = data.tag_to_events[tagsObject.value];
        const lowerBounds = data.tag_to_events[tagsObject.lower];
        const upperBounds = data.tag_to_events[tagsObject.upper];
        // Make sure that data is found for each of the tags.
        if (_.isUndefined(scalarEvents)) {
          tagsNotFound.push(tagsObject.value);
          tagNotFound = true;
        }
        if (_.isUndefined(lowerBounds)) {
          tagsNotFound.push(tagsObject.lower);
          tagNotFound = true;
        }
        if (_.isUndefined(upperBounds)) {
          tagsNotFound.push(tagsObject.upper);
          tagNotFound = true;
        }
        // At least one of the tags lacks data. We terminate early because
        // the line chart requires all 3 pieces of data (value, lower bound,
        // and upper bound).
        if (tagNotFound) {
          return;
        }
        // Make sure that steps for all the lists correspond with each
        // other. Otherwise, display an error message.
        const obtainStep = (datum) => datum[1];
        const stepsMismatch = this._findStepMismatch(
          tagsObject,
          scalarEvents.map(obtainStep),
          lowerBounds.map(obtainStep),
          upperBounds.map(obtainStep)
        );
        if (stepsMismatch) {
          this.set('_stepsMismatch', stepsMismatch);
          return;
        }
        // Create data points that the line chart can parse.
        const obtainNumber = (datum) => datum[2];
        const dataPoints = scalarEvents.map((datum, i) => ({
          wall_time: new Date(datum[0] * 1000),
          step: obtainStep(datum),
          scalar: obtainNumber(datum),
          lower: obtainNumber(lowerBounds[i]),
          upper: obtainNumber(upperBounds[i]),
        }));
        // Compute the series name, which is based on both the run and the
        // tag of the value.
        const seriesName = generateDataSeriesName(run, tagsObject.value);
        const series = newMapping[seriesName];
        if (series) {
          // This series already exists.
          series.setData(dataPoints);
        } else {
          const series = this._createNewDataSeries(
            run,
            tagsObject.value,
            seriesName,
            dataPoints
          );
          newMapping[seriesName] = series;
        }
      });
      this.set('_nameToDataSeries', newMapping);
      const entryIndex = _.findIndex(this._missingTags, (entry) => {
        return entry.run === run;
      });
      if (tagsNotFound.length && tagsNotFound.length != 3) {
        // Some but not all tags were found. Show a warning message.
        const entry = {
          run: run,
          tags: tagsNotFound,
        };
        if (entryIndex >= 0) {
          // Remove the previous entry. Insert the new one.
          this.splice('_missingTags', entryIndex, 1, entry);
        } else {
          // Insert a new entry.
          this.push('_missingTags', entry);
        }
      } else if (entryIndex >= 0) {
        // Remove the previous entry if it exists.
        this.splice('_missingTags', entryIndex, 1);
      }
    };
  }

  _findStepMismatch(tagsObject, valueSteps, lowerSteps, upperSteps) {
    if (
      _.isEqual(lowerSteps, valueSteps) &&
      _.isEqual(upperSteps, valueSteps)
    ) {
      // There is no mismatch.
      return null;
    }
    return {
      seriesObject: tagsObject,
      valueSteps: valueSteps,
      lowerSteps: lowerSteps,
      upperSteps: upperSteps,
    };
  }

  _createNewDataSeries(run, tag, seriesName, dataPoints) {
    // If 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,
      dataPoints,
      lineChartSymbol
    );
    // Loop back to the beginning if we are out of symbols.
    const numSymbols = SYMBOLS_LIST.length;
    this._runToNextAvailableSymbolIndex[run] =
      (this._runToNextAvailableSymbolIndex[run] + 1) % numSymbols;
    return series;
  }

  @observe('_nameToDataSeries')
  _updateChart() {
    var _nameToDataSeries = this._nameToDataSeries;
    // Add new data series.
    _.forOwn(_nameToDataSeries, (dataSeries) => {
      (this.$.loader as TfLineChartDataLoader).setSeriesData(
        dataSeries.getName(),
        dataSeries.getData()
      );
    });
    (this.$.loader as TfLineChartDataLoader).commitChanges();
  }

  @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(_nameToDataSeries, seriesName) {
    return _nameToDataSeries[seriesName].getSymbol().character;
  }

  @computed('marginChartSeries')
  get _tagFilter(): string {
    var marginChartSeries = this.marginChartSeries;
    const tags = _.flatten(
      marginChartSeries.map((series) => [
        series.value,
        series.lower,
        series.upper,
      ])
    );
    const escapedTags = tags.map(
      (r) => '(' + this._escapeRegexCharacters(r) + ')'
    );
    // Combine the different regexes into a single regex.
    return escapedTags.join('|');
  }

  _escapeRegexCharacters(stringValue) {
    return stringValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  }

  _getToggleCollapsibleIcon(listOpened) {
    return listOpened ? '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 the tag filter, which is a combination
    // of the tags for the value, lower, and upper fields of each series.
    return title || 'untitled';
  }

  _separateWithCommas(numbers) {
    return numbers.join(', ');
  }

  _toggleMissingTagsCollapsibleOpen() {
    this.set(
      '_missingTagsCollapsibleOpened',
      !this._missingTagsCollapsibleOpened
    );
  }

  _matchListEntryColorUpdated() {
    const domRepeat = this.$$('#match-list-repeat') as DomRepeat | null;
    if (!domRepeat) {
      return;
    }
    this.root
      .querySelectorAll('.match-list-entry')
      .forEach((entryElement: HTMLDivElement) => {
        const seriesName = domRepeat.itemForElement(entryElement);
        entryElement.style.color = this._determineColor(
          this._colorScale,
          seriesName
        );
      });
  }
}