gradio/js/nativeplot/Index.svelte
aliabid94 d1cfe1e9de
Allow plot tooltip to show extra columns (#9800)
* changes

* add changeset

* changes

* changes

* chanes

* changes

---------

Co-authored-by: Ali Abid <aliabid94@gmail.com>
Co-authored-by: gradio-pr-bot <gradio-pr-bot@users.noreply.github.com>
Co-authored-by: Abubakar Abid <abubakar@huggingface.co>
2024-11-01 19:00:22 -07:00

580 lines
15 KiB
Svelte

<script lang="ts">
import type { Gradio, SelectData } from "@gradio/utils";
import { BlockTitle } from "@gradio/atoms";
import { Block } from "@gradio/atoms";
import { StatusTracker } from "@gradio/statustracker";
import type { LoadingStatus } from "@gradio/statustracker";
import { onMount } from "svelte";
import type { TopLevelSpec as Spec } from "vega-lite";
import type { View } from "vega";
import { LineChart as LabelIcon } from "@gradio/icons";
import { Empty } from "@gradio/atoms";
interface PlotData {
columns: string[];
data: [string | number][];
datatypes: Record<string, "quantitative" | "temporal" | "nominal">;
mark: "line" | "point" | "bar";
}
export let value: PlotData | null;
export let x: string;
export let y: string;
export let color: string | null = null;
export let root: string;
$: unique_colors =
color && value && value.datatypes[color] === "nominal"
? Array.from(new Set(_data.map((d) => d[color])))
: [];
export let title: string | null = null;
export let x_title: string | null = null;
export let y_title: string | null = null;
export let color_title: string | null = null;
export let x_bin: string | number | null = null;
export let y_aggregate:
| "sum"
| "mean"
| "median"
| "min"
| "max"
| undefined = undefined;
export let color_map: Record<string, string> | null = null;
export let x_lim: [number, number] | null = null;
export let y_lim: [number, number] | null = null;
export let x_label_angle: number | null = null;
export let y_label_angle: number | null = null;
export let x_axis_labels_visible = true;
export let caption: string | null = null;
export let sort: "x" | "y" | "-x" | "-y" | string[] | null = null;
export let tooltip: "axis" | "none" | "all" | string[] = "axis";
function reformat_sort(
_sort: typeof sort
):
| string
| "ascending"
| "descending"
| { field: string; order: "ascending" | "descending" }
| string[]
| undefined {
if (_sort === "x") {
return "ascending";
} else if (_sort === "-x") {
return "descending";
} else if (_sort === "y") {
return { field: y, order: "ascending" };
} else if (_sort === "-y") {
return { field: y, order: "descending" };
} else if (_sort === null) {
return undefined;
} else if (Array.isArray(_sort)) {
return _sort;
}
}
$: _sort = reformat_sort(sort);
export let _selectable = false;
let _data: {
[x: string]: string | number;
}[];
export let gradio: Gradio<{
select: SelectData;
double_click: undefined;
clear_status: LoadingStatus;
}>;
$: x_temporal = value && value.datatypes[x] === "temporal";
$: _x_lim = x_lim && x_temporal ? [x_lim[0] * 1000, x_lim[1] * 1000] : x_lim;
let _x_bin: number | undefined;
let mouse_down_on_chart = false;
const SUFFIX_DURATION: Record<string, number> = {
s: 1,
m: 60,
h: 60 * 60,
d: 24 * 60 * 60
};
$: _x_bin = x_bin
? typeof x_bin === "string"
? 1000 *
parseInt(x_bin.substring(0, x_bin.length - 1)) *
SUFFIX_DURATION[x_bin[x_bin.length - 1]]
: x_bin
: undefined;
let _y_aggregate: typeof y_aggregate;
let aggregating: boolean;
$: {
if (value) {
if (value.mark === "point") {
aggregating = _x_bin !== undefined;
_y_aggregate = y_aggregate || aggregating ? "sum" : undefined;
} else {
aggregating = _x_bin !== undefined || value.datatypes[x] === "nominal";
_y_aggregate = y_aggregate ? y_aggregate : "sum";
}
}
}
function reformat_data(data: PlotData): {
[x: string]: string | number;
}[] {
if (tooltip == "all" || Array.isArray(tooltip)) {
return data.data.map((row) => {
const obj: { [x: string]: string | number } = {};
data.columns.forEach((col, i) => {
obj[col] = row[i];
});
return obj;
});
}
let x_index = data.columns.indexOf(x);
let y_index = data.columns.indexOf(y);
let color_index = color ? data.columns.indexOf(color) : null;
return data.data.map((row) => {
const obj = {
[x]: row[x_index],
[y]: row[y_index]
};
if (color && color_index !== null) {
obj[color] = row[color_index];
}
return obj;
});
}
$: _data = value ? reformat_data(value) : [];
const is_browser = typeof window !== "undefined";
let chart_element: HTMLDivElement;
$: computed_style = chart_element
? window.getComputedStyle(chart_element)
: null;
let view: View;
let mounted = false;
let old_width: number;
let resizeObserver: ResizeObserver;
let vegaEmbed: typeof import("vega-embed").default;
async function load_chart(): Promise<void> {
if (view) {
view.finalize();
}
if (!value || !chart_element) return;
old_width = chart_element.offsetWidth;
const spec = create_vega_lite_spec();
if (!spec) return;
resizeObserver = new ResizeObserver((el) => {
if (!el[0].target || !(el[0].target instanceof HTMLElement)) return;
if (
old_width === 0 &&
chart_element.offsetWidth !== 0 &&
value.datatypes[x] === "nominal"
) {
// a bug where when a nominal chart is first loaded, the width is 0, it doesn't resize
load_chart();
} else {
view.signal("width", el[0].target.offsetWidth).run();
}
});
if (!vegaEmbed) {
vegaEmbed = (await import("vega-embed")).default;
}
vegaEmbed(chart_element, spec, { actions: false }).then(function (result) {
view = result.view;
resizeObserver.observe(chart_element);
var debounceTimeout: NodeJS.Timeout;
view.addEventListener("dblclick", () => {
gradio.dispatch("double_click");
});
// prevent double-clicks from highlighting text
chart_element.addEventListener(
"mousedown",
function (e) {
if (e.detail > 1) {
e.preventDefault();
}
},
false
);
if (_selectable) {
view.addSignalListener("brush", function (_, value) {
if (Object.keys(value).length === 0) return;
clearTimeout(debounceTimeout);
let range: [number, number] = value[Object.keys(value)[0]];
if (x_temporal) {
range = [range[0] / 1000, range[1] / 1000];
}
let callback = (): void => {
gradio.dispatch("select", {
value: range,
index: range,
selected: true
});
};
if (mouse_down_on_chart) {
release_callback = callback;
} else {
debounceTimeout = setTimeout(function () {
gradio.dispatch("select", {
value: range,
index: range,
selected: true
});
}, 250);
}
});
}
});
}
let release_callback: (() => void) | null = null;
onMount(() => {
mounted = true;
chart_element.addEventListener("mousedown", () => {
mouse_down_on_chart = true;
});
chart_element.addEventListener("mouseup", () => {
mouse_down_on_chart = false;
if (release_callback) {
release_callback();
release_callback = null;
}
});
return () => {
mounted = false;
if (view) {
view.finalize();
}
if (resizeObserver) {
resizeObserver.disconnect();
}
};
});
$: title,
x_title,
y_title,
color_title,
x,
y,
color,
x_bin,
_y_aggregate,
color_map,
x_lim,
y_lim,
caption,
sort,
value,
mounted,
chart_element,
computed_style && requestAnimationFrame(load_chart);
function create_vega_lite_spec(): Spec | null {
if (!value || !computed_style) return null;
let accent_color = computed_style.getPropertyValue("--color-accent");
let body_text_color = computed_style.getPropertyValue("--body-text-color");
let borderColorPrimary = computed_style.getPropertyValue(
"--border-color-primary"
);
let font_family = computed_style.fontFamily;
let title_weight = computed_style.getPropertyValue(
"--block-title-text-weight"
) as
| "bold"
| "normal"
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900;
const font_to_px_val = (font: string): number => {
return font.endsWith("px") ? parseFloat(font.slice(0, -2)) : 12;
};
let text_size_md = font_to_px_val(
computed_style.getPropertyValue("--text-md")
);
let text_size_sm = font_to_px_val(
computed_style.getPropertyValue("--text-sm")
);
/* eslint-disable complexity */
return {
$schema: "https://vega.github.io/schema/vega-lite/v5.17.0.json",
background: "transparent",
config: {
autosize: { type: "fit", contains: "padding" },
axis: {
labelFont: font_family,
labelColor: body_text_color,
titleFont: font_family,
titleColor: body_text_color,
titlePadding: 8,
tickColor: borderColorPrimary,
labelFontSize: text_size_sm,
gridColor: borderColorPrimary,
titleFontWeight: "normal",
titleFontSize: text_size_sm,
labelFontWeight: "normal",
domain: false,
labelAngle: 0
},
legend: {
labelColor: body_text_color,
labelFont: font_family,
titleColor: body_text_color,
titleFont: font_family,
titleFontWeight: "normal",
titleFontSize: text_size_sm,
labelFontWeight: "normal",
offset: 2
},
title: {
color: body_text_color,
font: font_family,
fontSize: text_size_md,
fontWeight: title_weight,
anchor: "middle"
},
view: { stroke: borderColorPrimary },
mark: {
stroke: value.mark !== "bar" ? accent_color : undefined,
fill: value.mark === "bar" ? accent_color : undefined,
cursor: "crosshair"
}
},
data: { name: "data" },
datasets: {
data: _data
},
layer: ["plot", ...(value.mark === "line" ? ["hover"] : [])].map(
(mode) => {
return {
encoding: {
size:
value.mark === "line"
? mode == "plot"
? {
condition: {
empty: false,
param: "hoverPlot",
value: 3
},
value: 2
}
: {
condition: { empty: false, param: "hover", value: 100 },
value: 0
}
: undefined,
opacity:
mode === "plot"
? undefined
: {
condition: { empty: false, param: "hover", value: 1 },
value: 0
},
x: {
axis: {
...(x_label_angle !== null && { labelAngle: x_label_angle }),
labels: x_axis_labels_visible,
ticks: x_axis_labels_visible
},
field: x,
title: x_title || x,
type: value.datatypes[x],
scale: _x_lim ? { domain: _x_lim } : undefined,
bin: _x_bin ? { step: _x_bin } : undefined,
sort: _sort
},
y: {
axis: y_label_angle ? { labelAngle: y_label_angle } : {},
field: y,
title: y_title || y,
type: value.datatypes[y],
scale: y_lim ? { domain: y_lim } : undefined,
aggregate: aggregating ? _y_aggregate : undefined
},
color: color
? {
field: color,
legend: { orient: "bottom", title: color_title },
scale:
value.datatypes[color] === "nominal"
? {
domain: unique_colors,
range: color_map
? unique_colors.map((c) => color_map[c])
: undefined
}
: {
range: [
100, 200, 300, 400, 500, 600, 700, 800, 900
].map((n) =>
computed_style.getPropertyValue("--primary-" + n)
),
interpolate: "hsl"
},
type: value.datatypes[color]
}
: undefined,
tooltip:
tooltip == "none"
? undefined
: [
{
field: y,
type: value.datatypes[y],
aggregate: aggregating ? _y_aggregate : undefined,
title: y_title || y
},
{
field: x,
type: value.datatypes[x],
title: x_title || x,
format: x_temporal ? "%Y-%m-%d %H:%M:%S" : undefined,
bin: _x_bin ? { step: _x_bin } : undefined
},
...(color
? [
{
field: color,
type: value.datatypes[color]
}
]
: []),
...(tooltip === "axis"
? []
: value?.columns
.filter(
(col) =>
col !== x &&
col !== y &&
col !== color &&
(tooltip === "all" || tooltip.includes(col))
)
.map((column) => ({
field: column,
type: value.datatypes[column]
})))
]
},
strokeDash: {},
mark: { clip: true, type: mode === "hover" ? "point" : value.mark },
name: mode
};
}
),
// @ts-ignore
params: [
...(value.mark === "line"
? [
{
name: "hoverPlot",
select: {
clear: "mouseout",
fields: color ? [color] : [],
nearest: true,
on: "mouseover",
type: "point" as "point"
},
views: ["hover"]
},
{
name: "hover",
select: {
clear: "mouseout",
nearest: true,
on: "mouseover",
type: "point" as "point"
},
views: ["hover"]
}
]
: []),
...(_selectable
? [
{
name: "brush",
select: {
encodings: ["x"],
mark: { fill: "gray", fillOpacity: 0.3, stroke: "none" },
type: "interval" as "interval"
},
views: ["plot"]
}
]
: [])
],
width: chart_element.offsetWidth,
title: title || undefined
};
/* eslint-enable complexity */
}
export let label = "Textbox";
export let elem_id = "";
export let elem_classes: string[] = [];
export let visible = true;
export let show_label: boolean;
export let scale: number | null = null;
export let min_width: number | undefined = undefined;
export let loading_status: LoadingStatus | undefined = undefined;
export let height: number | undefined = undefined;
</script>
<Block
{visible}
{elem_id}
{elem_classes}
{scale}
{min_width}
allow_overflow={false}
padding={true}
{height}
>
{#if loading_status}
<StatusTracker
autoscroll={gradio.autoscroll}
i18n={gradio.i18n}
{...loading_status}
on:clear_status={() => gradio.dispatch("clear_status", loading_status)}
/>
{/if}
<BlockTitle {root} {show_label} info={undefined}>{label}</BlockTitle>
{#if value && is_browser}
<div bind:this={chart_element}></div>
{#if caption}
<p class="caption">{caption}</p>
{/if}
{:else}
<Empty unpadded_box={true}><LabelIcon /></Empty>
{/if}
</Block>
<style>
div {
width: 100%;
}
:global(#vg-tooltip-element) {
font-family: var(--font) !important;
font-size: var(--text-xs) !important;
box-shadow: none !important;
background-color: var(--block-background-fill) !important;
border: 1px solid var(--border-color-primary) !important;
color: var(--body-text-color) !important;
}
:global(#vg-tooltip-element .key) {
color: var(--body-text-color-subdued) !important;
}
.caption {
padding: 0 4px;
margin: 0;
text-align: center;
}
</style>