|
<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; |
|
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; |
|
}[] { |
|
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" |
|
) { |
|
|
|
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"); |
|
}); |
|
|
|
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") |
|
); |
|
|
|
|
|
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: [ |
|
{ |
|
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] |
|
} |
|
] |
|
: []) |
|
] |
|
}, |
|
strokeDash: {}, |
|
mark: { clip: true, type: mode === "hover" ? "point" : value.mark }, |
|
name: mode |
|
}; |
|
} |
|
), |
|
|
|
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 |
|
}; |
|
|
|
} |
|
|
|
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> |
|
|