/*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import $ from "jquery";
import * as _ from 'underscore';
import React from "react";
import './style/global';
import { Datapoint, ParamType, HiPlotExperiment, HiPlotLoadStatus, PSTATE_COLOR_BY, PSTATE_PARAMS, DatapointLookup, IDatasets, PSTATE_FILTERS } from "./types";
import { RowsDisplayTable } from "./rowsdisplaytable";
import { infertypes, colorScheme, ParamDefMap } from "./infertypes";
import { PersistentState, PersistentStateInMemory } from "./lib/savedstate";
import { ParallelPlot } from "./parallel/parallel";
import { PlotXY } from "./plotxy";
import { SelectedCountProgressBar, HiPlotDataControlProps } from "./controls";
import { ErrorDisplay, HeaderBar } from "./header";
import { HiPlotPluginData, DataProviderClass } from "./plugin";
import { StaticDataProvider } from "./dataproviders/static";
//@ts-ignore
import LogoSVG from "../hiplot/static/logo.svg";
//@ts-ignore
import LogoSVGW from "../hiplot/static/logo-w.svg";
//@ts-ignore
import style from "./hiplot.scss";
import { ContextMenu } from "./contextmenu";
import { HiPlotDistributionPlugin } from "./distribution/plugin";
import { Filter, FilterType, apply_filters, apply_filter } from "./filters";
// Exported from HiPlot
export { PlotXY } from "./plotxy";
export { ParallelPlot } from "./parallel/parallel";
export { RowsDisplayTable } from "./rowsdisplaytable";
export { HiPlotPluginData } from "./plugin";
export { Datapoint, HiPlotExperiment, IDatasets, HiPlotLoadStatus } from "./types";
type PluginComponent
= React.Component
;
type PluginComponentClass
= React.ComponentClass
;
type PluginClass = React.ClassType, PluginComponentClass>;
interface PluginsMap {[k: string]: PluginClass; };
type LoadURIPromiseResult = {experiment: HiPlotExperiment} | {error: string};
export type LoadURIPromise = Promise;
// Makes a Promise cancelable
interface CancelablePromise {
promise: LoadURIPromise;
cancel: () => void;
}
const makeCancelable = (promise: LoadURIPromise): CancelablePromise => {
let hasCanceled_ = false;
const wrappedPromise = new Promise((resolve: (r: LoadURIPromiseResult) => void, reject) => {
promise.then(
val => hasCanceled_ ? reject({isCanceled: true}) : resolve(val),
error => hasCanceled_ ? reject({isCanceled: true}) : reject(error)
);
});
return {
promise: wrappedPromise,
cancel() {
hasCanceled_ = true;
},
};
};
// BEGIN_HIPLOT_PROPS
export interface HiPlotProps {
// Experiment to be displayed. Can be created with `hip.Experiment.from_iterable`
experiment: HiPlotExperiment | null;
// Display plugins (by default parallel plot, plotxy, distribution and table)
plugins: PluginsMap;
// An object where we can persist changes
// If not provided, will create a `PersistentStateInMemory` object
persistentState?: PersistentState;
// Callbacks when selection changes, filtering, or brush extents change
onChange: {[k: string]: (type: string, data: any) => void};
// Enable dark-mode
dark: boolean;
// Adds extra assertions (disabled by default)
asserts: boolean;
/* A class that can be used to dynamically fetch experiments
Examples:
- WebserverDataProvider: textarea to input URI, fetches experiments from server
- UploadDataProvider: upload CSV files in your browser
*/
dataProvider: DataProviderClass;
};
// END_HIPLOT_PROPS
interface HiPlotState extends IDatasets {
experiment: HiPlotExperiment | null;
loadStatus: HiPlotLoadStatus;
loadPromise: CancelablePromise | null;
error: string;
params_def: ParamDefMap;
params_def_unfiltered: ParamDefMap;
dp_lookup: DatapointLookup;
colorby: string;
colormap: string;
rows_filtered_filters: Array; // `rows_all` -> `rows_filtered`
rows_selected_filter: Filter; // `rows_filtered` -> `rows_selected`
// Data that persists upon page reload, sharing link etc...
persistentState: PersistentState;
dark: boolean;
dataProvider: DataProviderClass;
}
function detectIsDarkTheme(): boolean {
// Hack: detect dark/light theme in Jupyter Lab
const jupyterLabAttrLightTheme = "data-jp-theme-light";
if (document.body.hasAttribute(jupyterLabAttrLightTheme)) {
return document.body.getAttribute(jupyterLabAttrLightTheme) == "false";
}
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
export enum DefaultPlugins {
// Names correspond to values of (python) hip.Displays
PARALLEL_PLOT = "PARALLEL_PLOT",
XY = "XY",
DISTRIBUTION = "DISTRIBUTION",
TABLE = "TABLE"
}
export const defaultPlugins: PluginsMap = {
// @ts-ignore
[DefaultPlugins.PARALLEL_PLOT]: ParallelPlot,
// @ts-ignore
[DefaultPlugins.XY]: PlotXY,
// @ts-ignore
[DefaultPlugins.DISTRIBUTION]: HiPlotDistributionPlugin,
// @ts-ignore
[DefaultPlugins.TABLE]: RowsDisplayTable,
};
export function createDefaultPlugins(): PluginsMap {
return Object.assign({}, defaultPlugins);
};
export class HiPlot extends React.Component {
// React refs
contextMenuRef = React.createRef();
plugins_window_state: {[plugin: string]: any} = {};
plugins_ref: Array> = []; // For debugging/tests
constructor(props: HiPlotProps) {
super(props);
this.state = {
experiment: props.experiment,
colormap: null,
loadStatus: HiPlotLoadStatus.None,
loadPromise: null,
error: null,
dp_lookup: {},
rows_all_unfiltered: [],
rows_filtered: [],
rows_filtered_filters: [],
rows_selected: [],
rows_selected_filter: null,
rows_highlighted: [],
params_def: {},
params_def_unfiltered: {},
colorby: null,
dark: this.props.dark === null ? detectIsDarkTheme() : this.props.dark,
persistentState: props.persistentState !== undefined && props.persistentState !== null ? props.persistentState : new PersistentStateInMemory("", {}),
dataProvider: this.props.dataProvider ? this.props.dataProvider : StaticDataProvider
};
Object.keys(props.plugins).forEach((name, index) => {
this.plugins_window_state[name] = {};
this.plugins_ref[index] = React.createRef();
});
}
static defaultProps = {
loadURI: null,
comm: null,
dark: false,
asserts: false,
plugins: defaultPlugins,
experiment: null,
dataProvider: null,
onChange: null,
};
static getDerivedStateFromError(error: Error) {
// Update state so the next render will show the fallback UI.
return {
experiment: null,
loadStatus: HiPlotLoadStatus.Error,
error: error.toString(),
};
}
makeDatasets(experiment: HiPlotExperiment | null, dp_lookup: DatapointLookup, initial_filters: Array): IDatasets {
if (experiment) {
const rows_all_unfiltered = experiment.datapoints.map(function(t) {
var obj_with_uid = $.extend({
"uid": t.uid,
"from_uid": t.from_uid,
}, t.values);
dp_lookup[t.uid] = obj_with_uid;
return obj_with_uid;
});
var rows_filtered = rows_all_unfiltered;
try {
rows_filtered = apply_filters(rows_all_unfiltered, initial_filters);
if (!rows_filtered.length) {
rows_filtered = rows_all_unfiltered;
console.log("Not reapplying filters (would filter out all rows)");
}
} catch (err) {
console.error("Error trying to apply filters", initial_filters, ":", err);
}
return {
rows_all_unfiltered: rows_all_unfiltered,
rows_filtered: rows_filtered,
rows_selected: rows_filtered,
rows_highlighted: []
};
}
return {
rows_all_unfiltered: [],
rows_filtered: [],
rows_selected: [],
rows_highlighted: []
};
}
sendMessage(type: string, get_data: () => any): void {
if (this.props.onChange !== null && this.props.onChange[type]) {
const data = get_data();
this.props.onChange[type](type, data);
}
}
callSelectedUidsHooks = _.debounce(function(this: HiPlot): void {
this.sendMessage("selected_uids", function() { return this.state.rows_selected.map(row => '' + row['uid'])}.bind(this));
}.bind(this), 200);
callFilteredUidsHooks = _.debounce(function(this: HiPlot): void {
this.sendMessage("filtered_uids", function() { return this.state.rows_filtered.map(row => '' + row['uid'])}.bind(this));
}.bind(this), 200);
_loadExperiment(experiment: HiPlotExperiment) {
// Generate dataset for Parallel Plot
var dp_lookup = {};
var initFilters = this.state.persistentState.get(PSTATE_FILTERS, []);
const datasets = this.makeDatasets(experiment, dp_lookup, initFilters);
if (datasets.rows_all_unfiltered == datasets.rows_filtered) {
initFilters = [];
}
const params_def = infertypes(this.state.persistentState.children(PSTATE_PARAMS), datasets.rows_filtered, experiment.parameters_definition);
const params_def_unfiltered = infertypes(this.state.persistentState.children(PSTATE_PARAMS), datasets.rows_all_unfiltered, experiment.parameters_definition);
// Color handling
function get_default_color() {
if (experiment.colorby && params_def[experiment.colorby]) {
return experiment.colorby;
}
function select_as_coloring_score(r) {
var pd = params_def[r];
var score = 0;
if (pd.colors || pd.colormap) {
score += 100;
}
if (pd.type == ParamType.CATEGORICAL) {
score -= 20;
}
if (pd.optional) {
score -= 40;
}
return score;
};
var possibles = Object.keys(params_def).sort((a, b) => select_as_coloring_score(b) - select_as_coloring_score(a));
return possibles[0];
}
var colorby = this.state.persistentState.get(PSTATE_COLOR_BY, get_default_color());
if (params_def[colorby] === undefined) {
colorby = get_default_color();
}
this.setState(function(state, props) { return {
experiment: experiment,
colormap: experiment.colormap,
loadStatus: HiPlotLoadStatus.Loaded,
dp_lookup: dp_lookup,
colorby: colorby,
params_def: params_def,
params_def_unfiltered: params_def_unfiltered,
rows_filtered_filters: initFilters,
...datasets,
}; });
}
getColorForRow(trial: Datapoint, alpha: number): string {
return colorScheme(this.state.params_def[this.state.colorby], trial[this.state.colorby], alpha, this.state.colormap);
};
loadWithPromise(prom: LoadURIPromise) {
var me = this;
me.setState({
loadStatus: HiPlotLoadStatus.Loading,
loadPromise: makeCancelable(prom)
});
}
componentWillUnmount() {
if (this.contextMenuRef.current) {
this.contextMenuRef.current.removeCallbacks(this);
}
if (this.state.loadPromise) {
this.state.loadPromise.cancel();
}
this.callSelectedUidsHooks.cancel();
this.callFilteredUidsHooks.cancel();
}
componentDidMount() {
// Setup contextmenu when we right-click a parameter
this.contextMenuRef.current.addCallback(this.columnContextMenu.bind(this), this);
// Load experiment provided in constructor if any
if (this.props.experiment) {
this.loadWithPromise(new Promise(function(resolve, reject) {
resolve({experiment: this.props.experiment});
}.bind(this)));
}
}
componentDidUpdate(prevProps: HiPlotProps, prevState: HiPlotState): void {
if (prevState.rows_filtered_filters != this.state.rows_filtered_filters) {
this.state.persistentState.set(PSTATE_FILTERS, this.state.rows_filtered_filters);
}
if (prevState.colorby != this.state.colorby && this.state.colorby) {
this.state.persistentState.set(PSTATE_COLOR_BY, this.state.colorby);
}
if (this.state.loadStatus != HiPlotLoadStatus.Loading) {
if (this.props.experiment !== null &&
((this.state.loadStatus == HiPlotLoadStatus.Error && this.props.experiment !== prevProps.experiment) ||
(this.state.loadStatus != HiPlotLoadStatus.Error && this.props.experiment !== this.state.experiment))) {
this.loadWithPromise(new Promise(function(resolve, reject) {
resolve({experiment: this.props.experiment});
}.bind(this)));
}
else {
if (prevState.rows_selected != this.state.rows_selected) {
this.callSelectedUidsHooks();
}
if (prevState.rows_filtered != this.state.rows_filtered) {
this.callFilteredUidsHooks();
}
}
}
if (this.state.loadStatus == HiPlotLoadStatus.Loading &&
this.state.loadPromise != prevState.loadPromise) {
const prom = this.state.loadPromise.promise;
const me = this;
prom.then(function(data: {error?: string, experiment?: HiPlotExperiment}) {
if (data.error !== undefined) {
console.log("Experiment loading failed", data);
me.setState({
loadStatus: HiPlotLoadStatus.Error,
experiment: null,
error: data.error,
});
return;
}
me._loadExperiment(data.experiment);
})
.catch(
error => {
if (error.isCanceled) {
return;
}
console.log('Error', error);
me.setState({loadStatus: HiPlotLoadStatus.Error, experiment: null, error: 'HTTP error, check server logs / javascript console'});
throw error;
}
);
}
}
columnContextMenu(column: string, cm: HTMLDivElement) {
const VAR_TYPE_TO_NAME = {
[ParamType.CATEGORICAL]: 'Categorical',
[ParamType.NUMERIC]: 'Number',
[ParamType.NUMERICLOG]: 'Number (log-scale)',
[ParamType.NUMERICPERCENTILE]: 'Number (percentile-scale)',
[ParamType.TIMESTAMP]: 'Timestamp',
};
var contextmenu = $(cm);
contextmenu.append($(''));
this.state.params_def[column].type_options.forEach(function(this: HiPlot, possible_type) {
var option = $('').text(VAR_TYPE_TO_NAME[possible_type]);
if (possible_type == this.state.params_def[column].type) {
option.addClass('disabled').css('pointer-events', 'none');
}
option.click(function(this: HiPlot, event) {
contextmenu.css('display', 'none');
this.setState(function(state: Readonly, props) { return {
params_def: {
...state.params_def,
[column]: {
...state.params_def[column],
type: possible_type
}
}
};
});
this.state.persistentState.children(PSTATE_PARAMS).children(column).set('type', possible_type);
event.preventDefault();
}.bind(this));
contextmenu.append(option);
}.bind(this));
contextmenu.append($(''));
// Color by
var link_colorize = $('Use for coloring');
link_colorize.click(function(this: HiPlot, event) {
this.setState({
colorby: column,
});
event.preventDefault();
}.bind(this));
if (this.state.colorby == column) {
link_colorize.addClass('disabled').css('pointer-events', 'none');
}
contextmenu.append(link_colorize);
}
createNewParamsDef(rows_filtered: Array): ParamDefMap {
var new_pd = Object.assign({}, this.state.params_def);
Object.assign(new_pd, infertypes(this.state.persistentState.children(PSTATE_PARAMS), rows_filtered, this.state.params_def))
return new_pd;
}
restoreAllRows(): void {
/**
* When we hit `Restore` button
*/
this.setState(function(this: HiPlot, state: Readonly, props): Partial {
const all_rows = state.rows_all_unfiltered;
const new_pd = this.createNewParamsDef(all_rows);
return {
rows_selected: all_rows,
rows_selected_filter: null,
rows_filtered: all_rows,
rows_filtered_filters: [],
params_def: new_pd,
};
}.bind(this));
};
filterRows(keep: boolean): void {
/**
* When we hit Keep (keep=true), or Exclude (keep=false) buttons
*/
this.setState(function(this: HiPlot, state: Readonly, props): Partial {
const new_filtered = keep ? state.rows_selected : _.difference(state.rows_filtered, state.rows_selected);
var filter: Filter = state.rows_selected_filter;
if (!keep) {
filter = {
type: FilterType.Not,
data: filter,
};
}
const new_pd = this.createNewParamsDef(new_filtered);
return {
rows_filtered: new_filtered,
params_def: new_pd,
rows_selected_filter: null,
rows_filtered_filters: state.rows_filtered_filters.concat([filter]),
};
}.bind(this));
};
setSelected(rows: Array, filter: Filter | null = null): void {
if (filter && _.isEqual(filter, this.state.rows_selected_filter)) {
return;
}
if (filter && this.props.asserts) {
const new_rows = apply_filter(this.state.rows_filtered, filter);
if (new_rows.length != rows.length || _.difference(new_rows, rows).length) {
console.error("Warning! Filter ", filter, " does not match given rows", rows, " Computed rows with filter:", new_rows);
}
}
this.setState({
rows_selected: rows,
rows_selected_filter: filter
});
}
setHighlighted(rows: Array): void {
this.setState({rows_highlighted: rows});
}
renderRowText(row: Datapoint): string {
return row.uid;
};
render() {
const datasets: IDatasets = {
rows_all_unfiltered: this.state.rows_all_unfiltered,
rows_filtered: this.state.rows_filtered,
rows_highlighted: this.state.rows_highlighted,
rows_selected: this.state.rows_selected
};
const controlProps: HiPlotDataControlProps = {
restoreAllRows: this.restoreAllRows.bind(this),
filterRows: this.filterRows.bind(this),
...datasets
};
const createPluginProps = function(this: HiPlot, idx: number, name: string): React.ClassAttributes> & HiPlotPluginData {
return {
ref: this.plugins_ref[idx],
...(this.state.experiment.display_data && this.state.experiment.display_data[name] ? this.state.experiment.display_data[name] : {}),
...datasets,
rows_selected_filter: this.state.rows_selected_filter,
name: name,
persistentState: this.state.persistentState.children(name),
window_state: this.plugins_window_state[name],
sendMessage: this.sendMessage.bind(this),
get_color_for_row: this.getColorForRow.bind(this),
experiment: this.state.experiment,
params_def: this.state.params_def,
params_def_unfiltered: this.state.params_def_unfiltered,
dp_lookup: this.state.dp_lookup,
colorby: this.state.colorby,
render_row_text: this.renderRowText.bind(this),
context_menu_ref: this.contextMenuRef,
setSelected: this.setSelected.bind(this),
setHighlighted: this.setHighlighted.bind(this),
asserts: this.props.asserts,
};
}.bind(this);
return (
{this.state.loadStatus == HiPlotLoadStatus.Error &&
}
{this.state.loadStatus != HiPlotLoadStatus.Loaded &&
}
{this.state.loadStatus == HiPlotLoadStatus.Loaded &&
{Object.entries(this.props.plugins).map((plugin, idx) => {React.createElement(plugin[1], createPluginProps(idx, plugin[0]))})}
}
);
}
getPlugin>(cls: React.ClassType
>): T {
const entries = Object.entries(this.props.plugins);
for (var i = 0; i < entries.length; ++i) {
if (entries[i][1] == cls) {
return this.plugins_ref[i].current as unknown as T;
}
}
throw new Error("Can not find plugin" + cls);
}
}
interface DocsCreditsProps {
dark: boolean;
};
class DocAndCredits extends React.Component {
render() {
return (
Controls
Brush: Drag vertically along an axis.
Remove Brush: Tap the axis background.
Reorder Axes: Drag a label horizontally.
Invert Axis: Tap an axis label.
Remove Axis: Drag axis label to the left edge.
);
}
};