gradio/frontend
1442k
1<script lang="ts">2 import { Gradio } from "@gradio/utils";3 import { BlockTitle } from "@gradio/atoms";4 import { Block } from "@gradio/atoms";5 import {6 FullscreenButton,7 IconButtonWrapper,8 IconButton9 } from "@gradio/atoms";10 import { StatusTracker } from "@gradio/statustracker";11 import { onMount, untrack } from "svelte";12 import { Download } from "@gradio/icons";13 14 import type { TopLevelSpec as Spec } from "vega-lite";15 import type { View } from "vega";16 import { LineChart as LabelIcon } from "@gradio/icons";17 import { Empty } from "@gradio/atoms";18 import type { NativePlotProps, NativePlotEvents, PlotData } from "./types";19 20 let props = $props();21 const gradio = new Gradio<NativePlotEvents, NativePlotProps>(props);22 23 let unique_colors = $derived(24 gradio.props.color &&25 gradio.props.value &&26 gradio.props.value.datatypes[gradio.props.color] === "nominal"27 ? Array.from(new Set(_data.map((d) => d[gradio.props.color!])))28 : []29 );30 31 let x_lim = $derived(gradio.props.x_lim || null); // for some unknown reason, x_lim was getting set to undefined when used in re-render, so this line is needed32 let y_lim = $derived(gradio.props.y_lim || null);33 let x_start = $derived(x_lim?.[0] !== null ? x_lim?.[0] : undefined);34 let x_end = $derived(x_lim?.[1] !== null ? x_lim?.[1] : undefined);35 let y_start = $derived(y_lim?.[0] !== null ? y_lim?.[0] : undefined);36 let y_end = $derived(y_lim?.[1] !== null ? y_lim?.[1] : undefined);37 38 let fullscreen = $state(false);39 40 function reformat_sort(41 _sort: NativePlotProps["sort"]42 ):43 | string44 | "ascending"45 | "descending"46 | { field: string; order: "ascending" | "descending" }47 | string[]48 | null49 | undefined {50 if (_sort === "x") {51 return "ascending";52 } else if (_sort === "-x") {53 return "descending";54 } else if (_sort === "y") {55 return { field: gradio.props.y, order: "ascending" };56 } else if (_sort === "-y") {57 return { field: gradio.props.y, order: "descending" };58 } else if (_sort === null) {59 return null;60 } else if (Array.isArray(_sort)) {61 return _sort;62 }63 }64 let _sort = $derived(reformat_sort(gradio.props.sort));65 66 let _data: {67 [x: string]: string | number;68 }[] = $state([]);69 70 function escape_field_name(fieldName: string): string {71 // Escape special characters in field names according to Vega-Lite spec:72 // https://vega.github.io/vega-lite/docs/field.html73 return fieldName74 .replace(/\./g, "\\.")75 .replace(/\[/g, "\\[")76 .replace(/\]/g, "\\]");77 }78 79 let x_temporal = $derived(80 gradio.props.value &&81 gradio.props.value.datatypes[gradio.props.x] === "temporal"82 );83 let _x_lim = $derived(84 x_temporal85 ? [86 x_start !== undefined ? x_start * 1000 : null,87 x_end !== undefined ? x_end * 1000 : null88 ]89 : x_lim90 );91 let mouse_down_on_chart = $state(false);92 const SUFFIX_DURATION: Record<string, number> = {93 s: 1,94 m: 60,95 h: 60 * 60,96 d: 24 * 60 * 6097 };98 let _x_bin = $derived(99 gradio.props.x_bin100 ? typeof gradio.props.x_bin === "string"101 ? 1000 *102 parseInt(103 gradio.props.x_bin.substring(0, gradio.props.x_bin.length - 1)104 ) *105 SUFFIX_DURATION[gradio.props.x_bin[gradio.props.x_bin.length - 1]]106 : gradio.props.x_bin107 : undefined108 );109 110 let _y_aggregate = $derived.by(() => {111 if (gradio.props.value) {112 if (gradio.props.value.mark === "point") {113 const aggregating = _x_bin !== undefined;114 return gradio.props.y_aggregate || aggregating ? "sum" : undefined;115 } else {116 return gradio.props.y_aggregate ? gradio.props.y_aggregate : "sum";117 }118 }119 return undefined;120 });121 122 let aggregating = $derived.by(() => {123 if (gradio.props.value) {124 if (gradio.props.value.mark === "point") {125 return _x_bin !== undefined;126 } else {127 return (128 _x_bin !== undefined ||129 gradio.props.value.datatypes[gradio.props.x] === "nominal"130 );131 }132 }133 return false;134 });135 136 function downsample(137 data: PlotData["data"],138 x_index: number,139 y_index: number,140 color_index: number | null,141 x_start: number | undefined,142 x_end: number | undefined143 ): PlotData["data"] {144 if (145 data.length < 1000 ||146 gradio.props.x_bin !== null ||147 gradio.props.value?.mark !== "line" ||148 gradio.props.value?.datatypes[gradio.props.x] === "nominal"149 ) {150 return data;151 }152 const bin_count = 250;153 let min_max_bins_per_color: Record<154 string,155 [number | null, number, number | null, number][]156 > = {};157 if (x_start === undefined || x_end === undefined) {158 data.forEach((row) => {159 let x_value = row[x_index] as number;160 if (x_start === undefined || x_value < x_start) {161 x_start = x_value;162 }163 if (x_end === undefined || x_value > x_end) {164 x_end = x_value;165 }166 });167 }168 if (x_start === undefined || x_end === undefined) {169 return data;170 }171 const x_range = x_end - x_start;172 const bin_size = x_range / bin_count;173 data.forEach((row, i) => {174 const x_value = row[x_index] as number;175 const y_value = row[y_index] as number;176 const color_value =177 color_index !== null ? (row[color_index] as string) : "any";178 const bin_index = Math.floor((x_value - (x_start as number)) / bin_size);179 if (min_max_bins_per_color[color_value] === undefined) {180 min_max_bins_per_color[color_value] = [];181 }182 min_max_bins_per_color[color_value][bin_index] = min_max_bins_per_color[183 color_value184 ][bin_index] || [185 null,186 Number.POSITIVE_INFINITY,187 null,188 Number.NEGATIVE_INFINITY189 ];190 if (y_value < min_max_bins_per_color[color_value][bin_index][1]) {191 min_max_bins_per_color[color_value][bin_index][0] = i;192 min_max_bins_per_color[color_value][bin_index][1] = y_value;193 }194 if (y_value > min_max_bins_per_color[color_value][bin_index][3]) {195 min_max_bins_per_color[color_value][bin_index][2] = i;196 min_max_bins_per_color[color_value][bin_index][3] = y_value;197 }198 });199 const downsampled_data: PlotData["data"] = [];200 Object.values(min_max_bins_per_color).forEach((bins) => {201 bins.forEach(([min_index, _, max_index, __]) => {202 let indices: number[] = [];203 if (min_index !== null && max_index !== null) {204 indices = [205 Math.min(min_index, max_index),206 Math.max(min_index, max_index)207 ];208 } else if (min_index !== null) {209 indices = [min_index];210 } else if (max_index !== null) {211 indices = [max_index];212 }213 indices.forEach((index) => {214 downsampled_data.push(data[index]);215 });216 });217 });218 return downsampled_data;219 }220 function reformat_data(221 data: PlotData,222 x_start: number | undefined,223 x_end: number | undefined224 ): {225 [x: string]: string | number;226 }[] {227 let x_index = data.columns.indexOf(gradio.props.x);228 let y_index = data.columns.indexOf(gradio.props.y);229 let color_index = gradio.props.color230 ? data.columns.indexOf(gradio.props.color)231 : null;232 let datatable = data.data;233 234 if (x_start !== undefined && x_end !== undefined) {235 const time_factor =236 data.datatypes[gradio.props.x] === "temporal" ? 1000 : 1;237 const _x_start = x_start * time_factor;238 const _x_end = x_end * time_factor;239 let largest_before_start: Record<string, [number, number]> = {};240 let smallest_after_end: Record<string, [number, number]> = {};241 const _datatable = datatable.filter((row, i) => {242 const x_value = row[x_index] as number;243 const color_value =244 color_index !== null ? (row[color_index] as string) : "any";245 if (246 x_value < _x_start &&247 (largest_before_start[color_value] === undefined ||248 x_value > largest_before_start[color_value][1])249 ) {250 largest_before_start[color_value] = [i, x_value];251 }252 if (253 x_value > _x_end &&254 (smallest_after_end[color_value] === undefined ||255 x_value < smallest_after_end[color_value][1])256 ) {257 smallest_after_end[color_value] = [i, x_value];258 }259 return x_value >= _x_start && x_value <= _x_end;260 });261 datatable = [262 ...Object.values(largest_before_start).map(([i, _]) => datatable[i]),263 ...downsample(264 _datatable,265 x_index,266 y_index,267 color_index,268 _x_start,269 _x_end270 ),271 ...Object.values(smallest_after_end).map(([i, _]) => datatable[i])272 ];273 } else {274 datatable = downsample(275 datatable,276 x_index,277 y_index,278 color_index,279 undefined,280 undefined281 );282 }283 284 if (gradio.props.tooltip == "all" || Array.isArray(gradio.props.tooltip)) {285 return datatable.map((row) => {286 const obj: { [x: string]: string | number } = {};287 data.columns.forEach((col, i) => {288 obj[col] = row[i];289 });290 return obj;291 });292 }293 return datatable.map((row) => {294 const obj = {295 [gradio.props.x]: row[x_index],296 [gradio.props.y]: row[y_index]297 };298 if (gradio.props.color && color_index !== null) {299 obj[gradio.props.color] = row[color_index];300 }301 return obj;302 });303 }304 305 $effect(() => {306 _data = gradio.props.value307 ? reformat_data(gradio.props.value, x_start, x_end)308 : [];309 });310 311 let old_value = $state<PlotData | null>(gradio.props.value);312 $effect(() => {313 if (old_value !== gradio.props.value && view) {314 old_value = gradio.props.value;315 view.data("data", _data).runAsync();316 }317 });318 319 const is_browser = typeof window !== "undefined";320 let chart_element = $state<HTMLDivElement>();321 let computed_style = $derived(322 chart_element ? window.getComputedStyle(chart_element) : null323 );324 let view = $state<View>();325 let mounted = $state(false);326 let old_width = $state<number>(0);327 let old_height = $state<number>(0);328 let resizeObserver = $state<ResizeObserver>();329 330 let vegaEmbed: typeof import("vega-embed").default;331 332 async function load_chart(): Promise<void> {333 if (mouse_down_on_chart) {334 refresh_pending = true;335 return;336 }337 if (view) {338 view.finalize();339 }340 if (!gradio.props.value || !chart_element) return;341 old_width = chart_element.offsetWidth;342 old_height = chart_element.offsetHeight;343 const spec = create_vega_lite_spec();344 if (!spec) return;345 resizeObserver = new ResizeObserver((el) => {346 if (!el[0].target || !(el[0].target instanceof HTMLElement)) return;347 if (348 old_width === 0 &&349 chart_element!.offsetWidth !== 0 &&350 gradio.props.value!.datatypes[gradio.props.x] === "nominal"351 ) {352 // a bug where when a nominal chart is first loaded, the width is 0, it doesn't resize353 load_chart();354 } else {355 const width_change = Math.abs(old_width - el[0].target.offsetWidth);356 const height_change = Math.abs(old_height - el[0].target.offsetHeight);357 if (width_change > 100 || height_change > 100) {358 old_width = el[0].target.offsetWidth;359 old_height = el[0].target.offsetHeight;360 load_chart();361 } else {362 view.signal("width", el[0].target.offsetWidth).run();363 if (fullscreen) {364 view.signal("height", el[0].target.offsetHeight).run();365 }366 }367 }368 });369 370 if (!vegaEmbed) {371 vegaEmbed = (await import("vega-embed")).default;372 }373 vegaEmbed(chart_element, spec, { actions: false }).then(function (result) {374 view = result.view;375 resizeObserver!.observe(chart_element!);376 var debounceTimeout: NodeJS.Timeout;377 var lastSelectTime = 0;378 view.addEventListener("dblclick", () => {379 gradio.dispatch("double_click");380 });381 // prevent double-clicks from highlighting text382 chart_element!.addEventListener(383 "mousedown",384 function (e) {385 if (e.detail > 1) {386 e.preventDefault();387 }388 },389 false390 );391 if (gradio.props._selectable) {392 view.addSignalListener("brush", function (_, value) {393 if (Date.now() - lastSelectTime < 1000) return;394 mouse_down_on_chart = true;395 if (Object.keys(value).length === 0) return;396 clearTimeout(debounceTimeout);397 let range: [number, number] = value[Object.keys(value)[0]];398 if (x_temporal) {399 range = [range[0] / 1000, range[1] / 1000];400 }401 debounceTimeout = setTimeout(function () {402 mouse_down_on_chart = false;403 lastSelectTime = Date.now();404 gradio.dispatch("select", {405 value: range,406 index: range,407 selected: true408 });409 if (refresh_pending) {410 refresh_pending = false;411 load_chart();412 }413 }, 250);414 });415 }416 });417 }418 419 let refresh_pending = $state(false);420 421 onMount(() => {422 mounted = true;423 return () => {424 mounted = false;425 if (view) {426 view.finalize();427 }428 if (resizeObserver) {429 resizeObserver.disconnect();430 }431 };432 });433 434 function export_chart(): void {435 if (!view || !computed_style) return;436 437 const block_background = computed_style.getPropertyValue(438 "--block-background-fill"439 );440 const export_background = block_background || "white";441 442 view.background(export_background).run();443 444 view445 .toImageURL("png", 2)446 .then(function (url) {447 view.background("transparent").run();448 449 const link = document.createElement("a");450 link.setAttribute("href", url);451 link.setAttribute("download", "chart.png");452 link.style.display = "none";453 document.body.appendChild(link);454 link.click();455 document.body.removeChild(link);456 })457 .catch(function (err) {458 console.error("Export failed:", err);459 view.background("transparent").run();460 });461 }462 463 let _color_map = $derived(JSON.stringify(gradio.props.color_map));464 465 $effect(() => {466 // Track dependencies to trigger chart reload467 void gradio.props.title;468 void gradio.props.x_title;469 void gradio.props.y_title;470 void gradio.props.color_title;471 void gradio.props.x;472 void gradio.props.y;473 void gradio.props.color;474 void gradio.props.x_bin;475 void _y_aggregate;476 void _color_map;477 void gradio.props.colors_in_legend;478 void x_start;479 void x_end;480 void y_start;481 void y_end;482 void gradio.props.caption;483 void gradio.props.sort;484 void mounted;485 void chart_element;486 void fullscreen;487 void computed_style;488 489 if (mounted && chart_element) {490 untrack(() => {491 load_chart();492 });493 }494 });495 496 function create_vega_lite_spec(): Spec | null {497 if (!gradio.props.value || !computed_style) return null;498 let accent_color = computed_style.getPropertyValue("--color-accent");499 let body_text_color = computed_style.getPropertyValue("--body-text-color");500 let borderColorPrimary = computed_style.getPropertyValue(501 "--border-color-primary"502 );503 let font_family = computed_style.fontFamily;504 let title_weight = computed_style.getPropertyValue(505 "--block-title-text-weight"506 ) as507 | "bold"508 | "normal"509 | 100510 | 200511 | 300512 | 400513 | 500514 | 600515 | 700516 | 800517 | 900;518 const font_to_px_val = (font: string): number => {519 return font.endsWith("px") ? parseFloat(font.slice(0, -2)) : 12;520 };521 let text_size_md = font_to_px_val(522 computed_style.getPropertyValue("--text-md")523 );524 let text_size_sm = font_to_px_val(525 computed_style.getPropertyValue("--text-sm")526 );527 528 /* eslint-disable complexity */529 return {530 $schema: "https://vega.github.io/schema/vega-lite/v5.17.0.json",531 background: "transparent",532 config: {533 autosize: { type: "fit", contains: "padding" },534 axis: {535 labelFont: font_family,536 labelColor: body_text_color,537 titleFont: font_family,538 titleColor: body_text_color,539 titlePadding: 8,540 tickColor: borderColorPrimary,541 labelFontSize: text_size_sm,542 gridColor: borderColorPrimary,543 titleFontWeight: "normal",544 titleFontSize: text_size_sm,545 labelFontWeight: "normal",546 domain: false,547 labelAngle: 0,548 titleLimit: chart_element.offsetHeight * 0.8549 },550 legend: {551 labelColor: body_text_color,552 labelFont: font_family,553 titleColor: body_text_color,554 titleFont: font_family,555 titleFontWeight: "normal",556 titleFontSize: text_size_sm,557 labelFontWeight: "normal",558 offset: 2559 },560 title: {561 color: body_text_color,562 font: font_family,563 fontSize: text_size_md,564 fontWeight: title_weight,565 anchor: "middle"566 },567 view: { stroke: borderColorPrimary },568 mark: {569 stroke: gradio.props.value.mark !== "bar" ? accent_color : undefined,570 fill: gradio.props.value.mark === "bar" ? accent_color : undefined,571 cursor: "crosshair"572 }573 },574 data: { name: "data" },575 datasets: {576 data: _data577 },578 layer: [579 "plot",580 ...(gradio.props.value.mark === "line" ? ["hover"] : [])581 ].map((mode) => {582 return {583 encoding: {584 size:585 gradio.props.value!.mark === "line"586 ? mode == "plot"587 ? {588 condition: {589 empty: false,590 param: "hoverPlot",591 value: 3592 },593 value: 2594 }595 : {596 condition: { empty: false, param: "hover", value: 100 },597 value: 0598 }599 : undefined,600 opacity:601 mode === "plot"602 ? undefined603 : {604 condition: { empty: false, param: "hover", value: 1 },605 value: 0606 },607 x: {608 axis: {609 ...(gradio.props.x_label_angle !== null && {610 labelAngle: gradio.props.x_label_angle611 }),612 labels: gradio.props.x_axis_labels_visible,613 ticks: gradio.props.x_axis_labels_visible614 },615 field: escape_field_name(gradio.props.x),616 title: gradio.props.x_title || gradio.props.x,617 type: gradio.props.value!.datatypes[gradio.props.x],618 scale: {619 zero: false,620 domainMin: _x_lim?.[0] !== null ? _x_lim?.[0] : undefined,621 domainMax: _x_lim?.[1] !== null ? _x_lim?.[1] : undefined622 },623 bin: _x_bin ? { step: _x_bin } : undefined,624 sort: _sort625 },626 y: {627 axis: gradio.props.y_label_angle628 ? { labelAngle: gradio.props.y_label_angle }629 : {},630 field: escape_field_name(gradio.props.y),631 title: gradio.props.y_title || gradio.props.y,632 type: gradio.props.value!.datatypes[gradio.props.y],633 scale: {634 zero: false,635 domainMin: y_start ?? undefined,636 domainMax: y_end ?? undefined637 },638 aggregate: aggregating ? _y_aggregate : undefined639 },640 color: gradio.props.color641 ? {642 field: escape_field_name(gradio.props.color),643 legend: {644 orient: "bottom",645 title: gradio.props.color_title,646 values: [...gradio.props.colors_in_legend] || undefined647 },648 scale:649 gradio.props.value!.datatypes[gradio.props.color] ===650 "nominal"651 ? {652 domain: unique_colors,653 range: gradio.props.color_map654 ? unique_colors.map(655 (c) => gradio.props.color_map![c]656 )657 : undefined658 }659 : {660 range: [661 100, 200, 300, 400, 500, 600, 700, 800, 900662 ].map((n) =>663 computed_style!.getPropertyValue("--primary-" + n)664 ),665 interpolate: "hsl"666 },667 type: gradio.props.value!.datatypes[gradio.props.color]668 }669 : undefined,670 tooltip:671 gradio.props.tooltip == "none"672 ? undefined673 : [674 {675 field: escape_field_name(gradio.props.y),676 type: gradio.props.value!.datatypes[gradio.props.y],677 aggregate: aggregating ? _y_aggregate : undefined,678 title: gradio.props.y_title || gradio.props.y679 },680 {681 field: escape_field_name(gradio.props.x),682 type: gradio.props.value!.datatypes[gradio.props.x],683 title: gradio.props.x_title || gradio.props.x,684 format: x_temporal ? "%Y-%m-%d %H:%M:%S" : undefined,685 bin: _x_bin ? { step: _x_bin } : undefined686 },687 ...(gradio.props.color688 ? [689 {690 field: gradio.props.color,691 type: gradio.props.value!.datatypes[692 gradio.props.color693 ]694 }695 ]696 : []),697 ...(gradio.props.tooltip === "axis"698 ? []699 : gradio.props.value?.columns700 .filter(701 (col) =>702 col !== gradio.props.x &&703 col !== gradio.props.y &&704 col !== gradio.props.color &&705 (gradio.props.tooltip === "all" ||706 gradio.props.tooltip.includes(col))707 )708 .map((column) => ({709 field: column,710 type: gradio.props.value!.datatypes[column]711 })))712 ]713 },714 strokeDash: {},715 mark: {716 clip: true,717 type: mode === "hover" ? "point" : gradio.props.value.mark718 },719 name: mode720 };721 }),722 // @ts-ignore723 params: [724 ...(gradio.props.value!.mark === "line"725 ? [726 {727 name: "hoverPlot",728 select: {729 clear: "mouseout",730 fields: gradio.props.color ? [gradio.props.color] : [],731 nearest: true,732 on: "mouseover",733 type: "point" as "point"734 },735 views: ["hover"]736 },737 {738 name: "hover",739 select: {740 clear: "mouseout",741 nearest: true,742 on: "mouseover",743 type: "point" as "point"744 },745 views: ["hover"]746 }747 ]748 : []),749 ...(gradio.props._selectable750 ? [751 {752 name: "brush",753 select: {754 encodings: ["x"],755 mark: { fill: "gray", fillOpacity: 0.3, stroke: "none" },756 type: "interval" as "interval"757 },758 views: ["plot"]759 }760 ]761 : [])762 ],763 width: chart_element!.offsetWidth,764 height: gradio.props.height || fullscreen ? "container" : undefined,765 title: gradio.props.title || undefined766 } as Spec;767 }768 769 /* eslint-enable complexity */770</script>771 772<Block773 visible={gradio.shared.visible}774 elem_id={gradio.shared.elem_id}775 elem_classes={gradio.shared.elem_classes}776 scale={gradio.shared.scale}777 min_width={gradio.shared.min_width}778 allow_overflow={false}779 padding={true}780 height={gradio.props.height}781 bind:fullscreen782>783 {#if gradio.shared.loading_status}784 <StatusTracker785 autoscroll={gradio.shared.autoscroll}786 i18n={gradio.i18n}787 {...gradio.shared.loading_status}788 on_clear_status={() =>789 gradio.dispatch("clear_status", gradio.shared.loading_status)}790 />791 {/if}792 {#if gradio.props.buttons?.length}793 <IconButtonWrapper>794 {#if gradio.props.buttons?.includes("export")}795 <IconButton Icon={Download} label="Export" on:click={export_chart} />796 {/if}797 {#if gradio.props.buttons?.includes("fullscreen")}798 <FullscreenButton799 {fullscreen}800 on:fullscreen={({ detail }) => {801 fullscreen = detail;802 }}803 />804 {/if}805 </IconButtonWrapper>806 {/if}807 <BlockTitle show_label={gradio.shared.show_label} info={undefined}808 >{gradio.shared.label}</BlockTitle809 >810 811 {#if gradio.props.value && is_browser}812 <div bind:this={chart_element}></div>813 814 {#if gradio.props.caption}815 <p class="caption">{gradio.props.caption}</p>816 {/if}817 {:else}818 <Empty unpadded_box={true}><LabelIcon /></Empty>819 {/if}820</Block>821 822<style>823 div {824 width: 100%;825 height: 100%;826 }827 :global(#vg-tooltip-element) {828 font-family: var(--font) !important;829 font-size: var(--text-xs) !important;830 box-shadow: none !important;831 background-color: var(--block-background-fill) !important;832 border: 1px solid var(--border-color-primary) !important;833 color: var(--body-text-color) !important;834 }835 :global(#vg-tooltip-element .key) {836 color: var(--body-text-color-subdued) !important;837 }838 .caption {839 padding: 0 4px;840 margin: 0;841 text-align: center;842 }843</style>844 