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.by(() => {24 if (25 !gradio.props.color ||26 !gradio.props.value ||27 gradio.props.value.datatypes[gradio.props.color] !== "nominal"28 ) {29 return [];30 }31 const color_index = gradio.props.value.columns.indexOf(gradio.props.color);32 if (color_index === -1) return [];33 return Array.from(34 new Set(gradio.props.value.data.map((row) => row[color_index]))35 );36 });37 38 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 needed39 let y_lim = $derived(gradio.props.y_lim || null);40 let x_start = $derived(x_lim?.[0] !== null ? x_lim?.[0] : undefined);41 let x_end = $derived(x_lim?.[1] !== null ? x_lim?.[1] : undefined);42 let y_start = $derived(y_lim?.[0] !== null ? y_lim?.[0] : undefined);43 let y_end = $derived(y_lim?.[1] !== null ? y_lim?.[1] : undefined);44 45 let fullscreen = $state(false);46 47 function reformat_sort(48 _sort: NativePlotProps["sort"]49 ):50 | string51 | "ascending"52 | "descending"53 | { field: string; order: "ascending" | "descending" }54 | string[]55 | null56 | undefined {57 if (_sort === "x") {58 return "ascending";59 } else if (_sort === "-x") {60 return "descending";61 } else if (_sort === "y") {62 return { field: gradio.props.y, order: "ascending" };63 } else if (_sort === "-y") {64 return { field: gradio.props.y, order: "descending" };65 } else if (_sort === null) {66 return null;67 } else if (Array.isArray(_sort)) {68 return _sort;69 }70 }71 let _sort = $derived(reformat_sort(gradio.props.sort));72 73 let _data: {74 [x: string]: string | number;75 }[] = $state([]);76 77 function escape_field_name(fieldName: string): string {78 // Escape special characters in field names according to Vega-Lite spec:79 // https://vega.github.io/vega-lite/docs/field.html80 return fieldName81 .replace(/\./g, "\\.")82 .replace(/\[/g, "\\[")83 .replace(/\]/g, "\\]");84 }85 86 let x_temporal = $derived(87 gradio.props.value &&88 gradio.props.value.datatypes[gradio.props.x] === "temporal"89 );90 let _x_lim = $derived(91 x_temporal92 ? [93 x_start !== undefined ? x_start * 1000 : null,94 x_end !== undefined ? x_end * 1000 : null95 ]96 : x_lim97 );98 let mouse_down_on_chart = $state(false);99 const SUFFIX_DURATION: Record<string, number> = {100 s: 1,101 m: 60,102 h: 60 * 60,103 d: 24 * 60 * 60104 };105 let _x_bin = $derived(106 gradio.props.x_bin107 ? typeof gradio.props.x_bin === "string"108 ? 1000 *109 parseInt(110 gradio.props.x_bin.substring(0, gradio.props.x_bin.length - 1)111 ) *112 SUFFIX_DURATION[gradio.props.x_bin[gradio.props.x_bin.length - 1]]113 : gradio.props.x_bin114 : undefined115 );116 117 let _y_aggregate = $derived.by(() => {118 if (gradio.props.value) {119 if (gradio.props.value.mark === "point") {120 const aggregating = _x_bin !== undefined;121 return gradio.props.y_aggregate || aggregating ? "sum" : undefined;122 } else {123 return gradio.props.y_aggregate ? gradio.props.y_aggregate : "sum";124 }125 }126 return undefined;127 });128 129 let aggregating = $derived.by(() => {130 if (gradio.props.value) {131 if (gradio.props.value.mark === "point") {132 return _x_bin !== undefined;133 } else {134 return (135 _x_bin !== undefined ||136 gradio.props.value.datatypes[gradio.props.x] === "nominal"137 );138 }139 }140 return false;141 });142 143 function downsample(144 data: PlotData["data"],145 x_index: number,146 y_index: number,147 color_index: number | null,148 x_start: number | undefined,149 x_end: number | undefined150 ): PlotData["data"] {151 if (152 data.length < 1000 ||153 gradio.props.x_bin !== null ||154 gradio.props.value?.mark !== "line" ||155 gradio.props.value?.datatypes[gradio.props.x] === "nominal"156 ) {157 return data;158 }159 const bin_count = 250;160 let min_max_bins_per_color: Record<161 string,162 [number | null, number, number | null, number][]163 > = {};164 if (x_start === undefined || x_end === undefined) {165 data.forEach((row) => {166 let x_value = row[x_index] as number;167 if (x_start === undefined || x_value < x_start) {168 x_start = x_value;169 }170 if (x_end === undefined || x_value > x_end) {171 x_end = x_value;172 }173 });174 }175 if (x_start === undefined || x_end === undefined) {176 return data;177 }178 const x_range = x_end - x_start;179 const bin_size = x_range / bin_count;180 data.forEach((row, i) => {181 const x_value = row[x_index] as number;182 const y_value = row[y_index] as number;183 const color_value =184 color_index !== null ? (row[color_index] as string) : "any";185 const bin_index = Math.floor((x_value - (x_start as number)) / bin_size);186 if (min_max_bins_per_color[color_value] === undefined) {187 min_max_bins_per_color[color_value] = [];188 }189 min_max_bins_per_color[color_value][bin_index] = min_max_bins_per_color[190 color_value191 ][bin_index] || [192 null,193 Number.POSITIVE_INFINITY,194 null,195 Number.NEGATIVE_INFINITY196 ];197 if (y_value < min_max_bins_per_color[color_value][bin_index][1]) {198 min_max_bins_per_color[color_value][bin_index][0] = i;199 min_max_bins_per_color[color_value][bin_index][1] = y_value;200 }201 if (y_value > min_max_bins_per_color[color_value][bin_index][3]) {202 min_max_bins_per_color[color_value][bin_index][2] = i;203 min_max_bins_per_color[color_value][bin_index][3] = y_value;204 }205 });206 const downsampled_data: PlotData["data"] = [];207 Object.values(min_max_bins_per_color).forEach((bins) => {208 bins.forEach(([min_index, _, max_index, __]) => {209 let indices: number[] = [];210 if (min_index !== null && max_index !== null) {211 indices = [212 Math.min(min_index, max_index),213 Math.max(min_index, max_index)214 ];215 } else if (min_index !== null) {216 indices = [min_index];217 } else if (max_index !== null) {218 indices = [max_index];219 }220 indices.forEach((index) => {221 downsampled_data.push(data[index]);222 });223 });224 });225 return downsampled_data;226 }227 function reformat_data(228 data: PlotData,229 x_start: number | undefined,230 x_end: number | undefined231 ): {232 [x: string]: string | number;233 }[] {234 let x_index = data.columns.indexOf(gradio.props.x);235 let y_index = data.columns.indexOf(gradio.props.y);236 let color_index = gradio.props.color237 ? data.columns.indexOf(gradio.props.color)238 : null;239 let datatable = data.data;240 241 if (x_start !== undefined && x_end !== undefined) {242 const time_factor =243 data.datatypes[gradio.props.x] === "temporal" ? 1000 : 1;244 const _x_start = x_start * time_factor;245 const _x_end = x_end * time_factor;246 let largest_before_start: Record<string, [number, number]> = {};247 let smallest_after_end: Record<string, [number, number]> = {};248 const _datatable = datatable.filter((row, i) => {249 const x_value = row[x_index] as number;250 const color_value =251 color_index !== null ? (row[color_index] as string) : "any";252 if (253 x_value < _x_start &&254 (largest_before_start[color_value] === undefined ||255 x_value > largest_before_start[color_value][1])256 ) {257 largest_before_start[color_value] = [i, x_value];258 }259 if (260 x_value > _x_end &&261 (smallest_after_end[color_value] === undefined ||262 x_value < smallest_after_end[color_value][1])263 ) {264 smallest_after_end[color_value] = [i, x_value];265 }266 return x_value >= _x_start && x_value <= _x_end;267 });268 datatable = [269 ...Object.values(largest_before_start).map(([i, _]) => datatable[i]),270 ...downsample(271 _datatable,272 x_index,273 y_index,274 color_index,275 _x_start,276 _x_end277 ),278 ...Object.values(smallest_after_end).map(([i, _]) => datatable[i])279 ];280 } else {281 datatable = downsample(282 datatable,283 x_index,284 y_index,285 color_index,286 undefined,287 undefined288 );289 }290 291 if (gradio.props.tooltip == "all" || Array.isArray(gradio.props.tooltip)) {292 return datatable.map((row) => {293 const obj: { [x: string]: string | number } = {};294 data.columns.forEach((col, i) => {295 obj[col] = row[i];296 });297 return obj;298 });299 }300 return datatable.map((row) => {301 const obj = {302 [gradio.props.x]: row[x_index],303 [gradio.props.y]: row[y_index]304 };305 if (gradio.props.color && color_index !== null) {306 obj[gradio.props.color] = row[color_index];307 }308 return obj;309 });310 }311 312 $effect(() => {313 _data = gradio.props.value314 ? reformat_data(gradio.props.value, x_start, x_end)315 : [];316 });317 318 let old_value = $state<PlotData | null>(gradio.props.value);319 $effect(() => {320 if (old_value !== gradio.props.value && view) {321 old_value = gradio.props.value;322 view.data("data", _data).runAsync();323 }324 });325 326 const is_browser = typeof window !== "undefined";327 let chart_element = $state<HTMLDivElement>();328 let computed_style = $derived(329 chart_element ? window.getComputedStyle(chart_element) : null330 );331 let view = $state<View>();332 let mounted = $state(false);333 let old_width = $state<number>(0);334 let old_height = $state<number>(0);335 let resizeObserver = $state<ResizeObserver>();336 337 let vegaEmbed: typeof import("vega-embed").default;338 339 async function load_chart(): Promise<void> {340 if (mouse_down_on_chart) {341 refresh_pending = true;342 return;343 }344 if (view) {345 view.finalize();346 }347 if (!gradio.props.value || !chart_element) return;348 old_width = chart_element.offsetWidth;349 old_height = chart_element.offsetHeight;350 const spec = create_vega_lite_spec();351 if (!spec) return;352 resizeObserver = new ResizeObserver((el) => {353 if (!el[0].target || !(el[0].target instanceof HTMLElement)) return;354 if (355 old_width === 0 &&356 chart_element!.offsetWidth !== 0 &&357 gradio.props.value!.datatypes[gradio.props.x] === "nominal"358 ) {359 // a bug where when a nominal chart is first loaded, the width is 0, it doesn't resize360 load_chart();361 } else {362 const width_change = Math.abs(old_width - el[0].target.offsetWidth);363 const height_change = Math.abs(old_height - el[0].target.offsetHeight);364 if (width_change > 100 || height_change > 100) {365 old_width = el[0].target.offsetWidth;366 old_height = el[0].target.offsetHeight;367 load_chart();368 } else {369 view.signal("width", el[0].target.offsetWidth).run();370 if (fullscreen) {371 view.signal("height", el[0].target.offsetHeight).run();372 }373 }374 }375 });376 377 if (!vegaEmbed) {378 vegaEmbed = (await import("vega-embed")).default;379 }380 vegaEmbed(chart_element, spec, { actions: false }).then(function (result) {381 view = result.view;382 resizeObserver!.observe(chart_element!);383 var debounceTimeout: NodeJS.Timeout;384 var lastSelectTime = 0;385 view.addEventListener("dblclick", () => {386 gradio.dispatch("double_click");387 });388 // prevent double-clicks from highlighting text389 chart_element!.addEventListener(390 "mousedown",391 function (e) {392 if (e.detail > 1) {393 e.preventDefault();394 }395 },396 false397 );398 if (gradio.props._selectable) {399 view.addSignalListener("brush", function (_, value) {400 if (Date.now() - lastSelectTime < 1000) return;401 mouse_down_on_chart = true;402 if (Object.keys(value).length === 0) return;403 clearTimeout(debounceTimeout);404 let range: [number, number] = value[Object.keys(value)[0]];405 if (x_temporal) {406 range = [range[0] / 1000, range[1] / 1000];407 }408 debounceTimeout = setTimeout(function () {409 mouse_down_on_chart = false;410 lastSelectTime = Date.now();411 gradio.dispatch("select", {412 value: range,413 index: range,414 selected: true415 });416 if (refresh_pending) {417 refresh_pending = false;418 load_chart();419 }420 }, 250);421 });422 }423 });424 }425 426 let refresh_pending = $state(false);427 428 onMount(() => {429 mounted = true;430 return () => {431 mounted = false;432 if (view) {433 view.finalize();434 }435 if (resizeObserver) {436 resizeObserver.disconnect();437 }438 };439 });440 441 function export_chart(): void {442 if (!view || !computed_style) return;443 444 const block_background = computed_style.getPropertyValue(445 "--block-background-fill"446 );447 const export_background = block_background || "white";448 449 view.background(export_background).run();450 451 view452 .toImageURL("png", 2)453 .then(function (url) {454 view.background("transparent").run();455 456 const link = document.createElement("a");457 link.setAttribute("href", url);458 link.setAttribute("download", "chart.png");459 link.style.display = "none";460 document.body.appendChild(link);461 link.click();462 document.body.removeChild(link);463 })464 .catch(function (err) {465 console.error("Export failed:", err);466 view.background("transparent").run();467 });468 }469 470 let _color_map = $derived(JSON.stringify(gradio.props.color_map));471 472 $effect(() => {473 // Track dependencies to trigger chart reload474 void gradio.props.title;475 void gradio.props.x_title;476 void gradio.props.y_title;477 void gradio.props.color_title;478 void gradio.props.x;479 void gradio.props.y;480 void gradio.props.color;481 void gradio.props.x_bin;482 void _y_aggregate;483 void _color_map;484 void gradio.props.colors_in_legend;485 void x_start;486 void x_end;487 void y_start;488 void y_end;489 void gradio.props.caption;490 void gradio.props.sort;491 void mounted;492 void chart_element;493 void fullscreen;494 void computed_style;495 496 if (mounted && chart_element) {497 untrack(() => {498 load_chart();499 });500 }501 });502 503 function create_vega_lite_spec(): Spec | null {504 if (!gradio.props.value || !computed_style) return null;505 let accent_color = computed_style.getPropertyValue("--color-accent");506 let body_text_color = computed_style.getPropertyValue("--body-text-color");507 let borderColorPrimary = computed_style.getPropertyValue(508 "--border-color-primary"509 );510 let font_family = computed_style.fontFamily;511 let title_weight = computed_style.getPropertyValue(512 "--block-title-text-weight"513 ) as514 | "bold"515 | "normal"516 | 100517 | 200518 | 300519 | 400520 | 500521 | 600522 | 700523 | 800524 | 900;525 const font_to_px_val = (font: string): number => {526 return font.endsWith("px") ? parseFloat(font.slice(0, -2)) : 12;527 };528 let text_size_md = font_to_px_val(529 computed_style.getPropertyValue("--text-md")530 );531 let text_size_sm = font_to_px_val(532 computed_style.getPropertyValue("--text-sm")533 );534 535 /* eslint-disable complexity */536 return {537 $schema: "https://vega.github.io/schema/vega-lite/v5.17.0.json",538 background: "transparent",539 config: {540 autosize: { type: "fit", contains: "padding" },541 axis: {542 labelFont: font_family,543 labelColor: body_text_color,544 titleFont: font_family,545 titleColor: body_text_color,546 titlePadding: 8,547 tickColor: borderColorPrimary,548 labelFontSize: text_size_sm,549 gridColor: borderColorPrimary,550 titleFontWeight: "normal",551 titleFontSize: text_size_sm,552 labelFontWeight: "normal",553 domain: false,554 labelAngle: 0,555 titleLimit: chart_element.offsetHeight * 0.8556 },557 legend: {558 labelColor: body_text_color,559 labelFont: font_family,560 titleColor: body_text_color,561 titleFont: font_family,562 titleFontWeight: "normal",563 titleFontSize: text_size_sm,564 labelFontWeight: "normal",565 offset: 2566 },567 title: {568 color: body_text_color,569 font: font_family,570 fontSize: text_size_md,571 fontWeight: title_weight,572 anchor: "middle"573 },574 view: { stroke: borderColorPrimary },575 mark: {576 stroke: gradio.props.value.mark !== "bar" ? accent_color : undefined,577 fill: gradio.props.value.mark === "bar" ? accent_color : undefined,578 cursor: "crosshair"579 }580 },581 data: { name: "data" },582 datasets: {583 data: _data584 },585 layer: [586 "plot",587 ...(gradio.props.value.mark === "line" ? ["hover"] : [])588 ].map((mode) => {589 return {590 encoding: {591 size:592 gradio.props.value!.mark === "line"593 ? mode == "plot"594 ? {595 condition: {596 empty: false,597 param: "hoverPlot",598 value: 3599 },600 value: 2601 }602 : {603 condition: { empty: false, param: "hover", value: 100 },604 value: 0605 }606 : undefined,607 opacity:608 mode === "plot"609 ? undefined610 : {611 condition: { empty: false, param: "hover", value: 1 },612 value: 0613 },614 x: {615 axis: {616 ...(gradio.props.x_label_angle !== null && {617 labelAngle: gradio.props.x_label_angle618 }),619 labels: gradio.props.x_axis_labels_visible,620 ticks: gradio.props.x_axis_labels_visible,621 ...(gradio.props.x_axis_format !== null && {622 format: gradio.props.x_axis_format623 })624 },625 field: escape_field_name(gradio.props.x),626 title: gradio.props.x_title || gradio.props.x,627 type: gradio.props.value!.datatypes[gradio.props.x],628 scale: {629 zero: false,630 domainMin: _x_lim?.[0] !== null ? _x_lim?.[0] : undefined,631 domainMax: _x_lim?.[1] !== null ? _x_lim?.[1] : undefined632 },633 bin: _x_bin ? { step: _x_bin } : undefined,634 sort: _sort635 },636 y: {637 axis: {638 ...(gradio.props.y_label_angle && {639 labelAngle: gradio.props.y_label_angle640 }),641 ...(gradio.props.y_axis_format !== null && {642 format: gradio.props.y_axis_format643 })644 },645 field: escape_field_name(gradio.props.y),646 title: gradio.props.y_title || gradio.props.y,647 type: gradio.props.value!.datatypes[gradio.props.y],648 scale: {649 zero: false,650 domainMin: y_start ?? undefined,651 domainMax: y_end ?? undefined652 },653 aggregate: aggregating ? _y_aggregate : undefined654 },655 color: gradio.props.color656 ? {657 field: escape_field_name(gradio.props.color),658 legend: {659 orient: "bottom",660 title: gradio.props.color_title,661 values: gradio.props.colors_in_legend?.length662 ? [...gradio.props.colors_in_legend]663 : undefined664 },665 scale:666 gradio.props.value!.datatypes[gradio.props.color] ===667 "nominal"668 ? {669 domain: unique_colors,670 range: gradio.props.color_map671 ? unique_colors.map(672 (c) => gradio.props.color_map![c]673 )674 : undefined675 }676 : {677 range: [678 100, 200, 300, 400, 500, 600, 700, 800, 900679 ].map((n) =>680 computed_style!.getPropertyValue("--primary-" + n)681 ),682 interpolate: "hsl"683 },684 type: gradio.props.value!.datatypes[gradio.props.color]685 }686 : undefined,687 tooltip:688 gradio.props.tooltip == "none"689 ? undefined690 : [691 {692 field: escape_field_name(gradio.props.y),693 type: gradio.props.value!.datatypes[gradio.props.y],694 aggregate: aggregating ? _y_aggregate : undefined,695 title: gradio.props.y_title || gradio.props.y696 },697 {698 field: escape_field_name(gradio.props.x),699 type: gradio.props.value!.datatypes[gradio.props.x],700 title: gradio.props.x_title || gradio.props.x,701 format: x_temporal ? "%Y-%m-%d %H:%M:%S" : undefined,702 bin: _x_bin ? { step: _x_bin } : undefined703 },704 ...(gradio.props.color705 ? [706 {707 field: gradio.props.color,708 type: gradio.props.value!.datatypes[709 gradio.props.color710 ]711 }712 ]713 : []),714 ...(gradio.props.tooltip === "axis"715 ? []716 : gradio.props.value?.columns717 .filter(718 (col) =>719 col !== gradio.props.x &&720 col !== gradio.props.y &&721 col !== gradio.props.color &&722 (gradio.props.tooltip === "all" ||723 gradio.props.tooltip.includes(col))724 )725 .map((column) => ({726 field: column,727 type: gradio.props.value!.datatypes[column]728 })))729 ]730 },731 strokeDash: {},732 mark: {733 clip: true,734 type: mode === "hover" ? "point" : gradio.props.value.mark735 },736 name: mode737 };738 }),739 // @ts-ignore740 params: [741 ...(gradio.props.value!.mark === "line"742 ? [743 {744 name: "hoverPlot",745 select: {746 clear: "mouseout",747 fields: gradio.props.color ? [gradio.props.color] : [],748 nearest: true,749 on: "mouseover",750 type: "point" as "point"751 },752 views: ["hover"]753 },754 {755 name: "hover",756 select: {757 clear: "mouseout",758 nearest: true,759 on: "mouseover",760 type: "point" as "point"761 },762 views: ["hover"]763 }764 ]765 : []),766 ...(gradio.props._selectable767 ? [768 {769 name: "brush",770 select: {771 encodings: ["x"],772 mark: { fill: "gray", fillOpacity: 0.3, stroke: "none" },773 type: "interval" as "interval"774 },775 views: ["plot"]776 }777 ]778 : [])779 ],780 width: chart_element!.offsetWidth,781 height: gradio.props.height || fullscreen ? "container" : undefined,782 title: gradio.props.title || undefined783 } as Spec;784 }785 /* eslint-enable complexity */786 $inspect("visible", gradio.shared.visible);787</script>788 789<Block790 visible={gradio.shared.visible}791 elem_id={gradio.shared.elem_id}792 elem_classes={gradio.shared.elem_classes}793 scale={gradio.shared.scale}794 min_width={gradio.shared.min_width}795 allow_overflow={false}796 padding={true}797 height={gradio.props.height}798 bind:fullscreen799>800 {#if gradio.shared.loading_status}801 <StatusTracker802 autoscroll={gradio.shared.autoscroll}803 i18n={gradio.i18n}804 {...gradio.shared.loading_status}805 on_clear_status={() =>806 gradio.dispatch("clear_status", gradio.shared.loading_status)}807 />808 {/if}809 {#if gradio.props.buttons?.length}810 <IconButtonWrapper811 buttons={gradio.props.buttons}812 on_custom_button_click={(id) => {813 gradio.dispatch("custom_button_click", { id });814 }}815 >816 {#if gradio.props.buttons?.some((btn) => typeof btn === "string" && btn === "export")}817 <IconButton Icon={Download} label="Export" onclick={export_chart} />818 {/if}819 {#if gradio.props.buttons?.some((btn) => typeof btn === "string" && btn === "fullscreen")}820 <FullscreenButton821 {fullscreen}822 on:fullscreen={({ detail }) => {823 fullscreen = detail;824 }}825 />826 {/if}827 </IconButtonWrapper>828 {/if}829 <BlockTitle show_label={gradio.shared.show_label} info={undefined}830 >{gradio.shared.label}</BlockTitle831 >832 833 {#if gradio.props.value && is_browser}834 <div bind:this={chart_element}></div>835 836 {#if gradio.props.caption}837 <p class="caption">{gradio.props.caption}</p>838 {/if}839 {:else}840 <Empty unpadded_box={true}><LabelIcon /></Empty>841 {/if}842</Block>843 844<style>845 div {846 width: 100%;847 height: 100%;848 }849 :global(#vg-tooltip-element) {850 font-family: var(--font) !important;851 font-size: var(--text-xs) !important;852 box-shadow: none !important;853 background-color: var(--block-background-fill) !important;854 border: 1px solid var(--border-color-primary) !important;855 color: var(--body-text-color) !important;856 }857 :global(#vg-tooltip-element .key) {858 color: var(--body-text-color-subdued) !important;859 }860 .caption {861 padding: 0 4px;862 margin: 0;863 text-align: center;864 }865</style>866 