Files
openobserve/web/src/utils/dashboard/cartesianOptions.ts
T

602 lines
24 KiB
TypeScript

// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { escapeHtml } from "@/utils/html";
import { chartAxisTextColor, chartColor } from "@/utils/chartTheme";
import { calculateNiceTickValues, calculateWidthText } from "./chartDimensionUtils";
import { absValue, createSeriesOverrideLookup, type SeriesOverrideLookup } from "./seriesOverrides";
import { sortTooltipParams } from "./tooltipSort";
import { DEFAULT_HEATMAP_PALETTE, HEATMAP_PALETTES } from "./heatmapPalettes";
/** Cartesian chart types whose final ECharts options take the panel-level series/axis settings below. */
export const CARTESIAN_CHART_TYPES: ReadonlySet<string> = new Set([
"line",
"area",
"area-stacked",
"bar",
"h-bar",
"stacked",
"h-stacked",
"scatter",
]);
export const PERCENT_STACK_CHART_TYPES: ReadonlySet<string> = new Set([
"area-stacked",
"stacked",
"h-stacked",
]);
export const SORTABLE_BAR_CHART_TYPES: ReadonlySet<string> = new Set(["bar", "h-bar"]);
export const BAR_CHART_TYPES: ReadonlySet<string> = new Set([
"bar",
"h-bar",
"stacked",
"h-stacked",
]);
export const LINE_CHART_TYPES: ReadonlySet<string> = new Set(["line", "area", "area-stacked"]);
export const CARTESIAN_TOOLTIP_MODES = ["single", "all", "hidden"] as const;
export const Y_AXIS_SCALES = ["linear", "log"] as const;
export const Y_AXIS_LOG_BASES = [2, 10] as const;
export const LINE_STYLES = ["solid", "dashed", "dotted"] as const;
export const STACK_MODES = ["normal", "percent"] as const;
export const SORT_ORDERS = ["none", "asc", "desc"] as const;
export const MARK_AREA_AXES = ["x", "y"] as const;
// What an unset option renders as; the config panel names these in its Default choices.
export const DEFAULT_TOOLTIP_MODE = "all";
export const DEFAULT_Y_AXIS_SCALE = "linear";
export const DEFAULT_Y_AXIS_LOG_BASE = 10;
export const DEFAULT_STACK_MODE = "normal";
export const DEFAULT_LINE_STYLE = "solid";
export const DEFAULT_SORT_ORDER = "none";
export const DEFAULT_BAR_RADIUS = 0;
export const DEFAULT_SCATTER_SIZE_MIN = 4;
export const DEFAULT_SCATTER_SIZE_MAX = 24;
const MARK_AREA_OPACITY = 0.18;
const Y_AXIS_NAME_GAP_PAD = 12;
const Y_AXIS_NAME_ROOM = 24;
const CONTAINED_Y_AXIS_NAME_LEFT = 30;
const X_AXIS_NAME_GAP = 35;
const X_AXIS_NAME_ROOM = 25;
const VISUAL_MAP_ROOM = 70;
const isHorizontal = (panelType: string) => panelType === "h-bar" || panelType === "h-stacked";
const asArray = (value: any): any[] =>
value == null ? [] : Array.isArray(value) ? value : [value];
const toNumber = (value: unknown): number | null => {
if (value === null || value === undefined || value === "" || value === "-") return null;
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric) ? numeric : null;
};
// Annotation and mark-area helpers are nameless and plot nothing; a plain PromQL h-bar is nameless but plots.
const isDataSeries = (series: any): boolean => {
if (!series || !Array.isArray(series.data) || !series.data.length) return false;
const named = series.name !== null && series.name !== undefined && series.name !== "";
return named || series.data.some((point: any) => pointY(point) !== null);
};
const dataSeriesOf = (options: any): any[] => asArray(options?.series).filter(isDataSeries);
/** The y of a series point: `[x, y, ...]` tuples and `{ value }` items unwrap; other points are the value itself. */
export const tooltipPointValue = (value: any): any => {
if (Array.isArray(value)) return value[1];
if (value && typeof value === "object" && "value" in value) return tooltipPointValue(value.value);
return value;
};
const pointY = (point: any): number | null => toNumber(tooltipPointValue(point));
const withPointY = (point: any, y: number | null): any => {
if (Array.isArray(point)) return [point[0], y, ...point.slice(2)];
if (point && typeof point === "object" && "value" in point) {
return { ...point, value: withPointY(point.value, y) };
}
return y;
};
const pointTime = (point: any): number => {
const x = Array.isArray(point) ? point[0] : Array.isArray(point?.value) ? point.value[0] : null;
if (x === null || x === undefined) return NaN;
return x instanceof Date ? x.getTime() : new Date(x).getTime();
};
// Time-series points carry their x, so they bucket by time; category points bucket by position.
const bucketKey = (point: any, index: number): string => {
const time = pointTime(point);
return Number.isFinite(time) ? `t${time}` : `i${index}`;
};
/** The value axes of the options: x for horizontal bars, y otherwise. */
const valueAxesOf = (options: any, panelType: string): any[] =>
asArray(isHorizontal(panelType) ? options?.xAxis : options?.yAxis).filter(
(axis: any) => axis && (axis.type === "value" || axis.type === "log"),
);
const isSet = (value: unknown) => value !== null && value !== undefined && value !== "";
const isPercentStack = (panelSchema: any) =>
PERCENT_STACK_CHART_TYPES.has(panelSchema?.type) && panelSchema?.config?.stack_mode === "percent";
const applyScatterEncoding = (options: any, panelSchema: any) => {
const config = panelSchema?.config ?? {};
if (panelSchema?.type !== "scatter" || panelSchema?.queryType === "promql") return;
if ((panelSchema?.queries?.length ?? 0) !== 1) return;
const aliases: string[] = (panelSchema.queries[0]?.fields?.y ?? []).map((y: any) => y?.alias);
const sizeGroup = config.scatter_size_field ? aliases.indexOf(config.scatter_size_field) : -1;
const colorGroup = config.scatter_color_field ? aliases.indexOf(config.scatter_color_field) : -1;
if (sizeGroup < 0 && colorGroup < 0) return;
const all = dataSeriesOf(options);
const partner = (series: any, group: number) =>
group < 0
? undefined
: all.find(
(other) =>
other.yAxisGroup === group &&
(other.originalSeriesName ?? "") === (series.originalSeriesName ?? ""),
);
const plotted = all.filter((s) => s.yAxisGroup !== sizeGroup && s.yAxisGroup !== colorGroup);
if (!plotted.length) return;
const sizes: number[] = [];
const colors: number[] = [];
plotted.forEach((series) => {
const sizeSeries = partner(series, sizeGroup);
const colorSeries = partner(series, colorGroup);
series.data = series.data.map((point: any, index: number) => {
const x = Array.isArray(point) ? point[0] : index;
const size = sizeSeries ? pointY(sizeSeries.data?.[index]) : null;
const color = colorSeries ? pointY(colorSeries.data?.[index]) : null;
if (size !== null) sizes.push(size);
if (color !== null) colors.push(color);
return [x, pointY(point), size, color];
});
});
const removed = new Set(all.filter((s) => !plotted.includes(s)));
options.series = asArray(options.series).filter((s) => !removed.has(s));
if (sizeGroup >= 0) applyScatterSize(plotted, sizes, config);
if (colorGroup >= 0) applyScatterColor(options, plotted, colors);
};
const applyScatterSize = (series: any[], sizes: number[], config: any) => {
const minPx = toNumber(config.scatter_size_min) ?? DEFAULT_SCATTER_SIZE_MIN;
const maxPx = Math.max(minPx, toNumber(config.scatter_size_max) ?? DEFAULT_SCATTER_SIZE_MAX);
const low = sizes.length ? Math.min(...sizes) : 0;
const high = sizes.length ? Math.max(...sizes) : 0;
const symbolSize = (value: any) => {
const size = toNumber(Array.isArray(value) ? value[2] : null);
if (size === null) return minPx;
if (high === low) return (minPx + maxPx) / 2;
return minPx + ((size - low) / (high - low)) * (maxPx - minPx);
};
series.forEach((s) => (s.symbolSize = symbolSize));
};
const applyScatterColor = (options: any, series: any[], colors: number[]) => {
const seriesIndex = series.map((s) => options.series.indexOf(s)).filter((i) => i >= 0);
options.visualMap = {
type: "continuous",
dimension: 3,
seriesIndex,
min: colors.length ? Math.min(...colors) : 0,
max: colors.length ? Math.max(...colors) : 0,
calculable: true,
orient: "vertical",
right: 0,
top: "middle",
itemWidth: 10,
itemHeight: 80,
textStyle: { fontSize: 10, color: chartColor("--color-text-secondary") },
inRange: { color: [...HEATMAP_PALETTES[DEFAULT_HEATMAP_PALETTE]] },
};
const grid = options.grid;
if (grid && !Array.isArray(grid) && typeof grid.right === "number") {
grid.right = Math.max(grid.right, VISUAL_MAP_ROOM);
}
};
const applySortOrder = (options: any, panelSchema: any) => {
const order = panelSchema?.config?.sort_order;
if (!SORTABLE_BAR_CHART_TYPES.has(panelSchema?.type) || (order !== "asc" && order !== "desc")) {
return;
}
const categoryAxes = asArray(
isHorizontal(panelSchema.type) ? options?.yAxis : options?.xAxis,
).filter((axis: any) => axis?.type === "category");
if (categoryAxes.length !== 1 || !Array.isArray(categoryAxes[0].data)) return;
const axis = categoryAxes[0];
const series = dataSeriesOf(options);
if (series.some((s) => s.data.some((point: any) => Array.isArray(point)))) return;
const totals = axis.data.map((_: unknown, index: number) =>
series.reduce((sum: number, s: any) => sum + (pointY(s.data[index]) ?? 0), 0),
);
const direction = order === "asc" ? 1 : -1;
const indices = axis.data
.map((_: unknown, index: number) => index)
.sort((a: number, b: number) => direction * (totals[a] - totals[b]) || a - b);
axis.data = indices.map((index: number) => axis.data[index]);
series.forEach((s) => (s.data = indices.map((index: number) => s.data[index])));
};
const breakLongGaps = (data: any[], thresholdMs: number): any[] | null => {
const out: any[] = [];
let previousTime: number | null = null;
for (const point of data) {
const time = pointTime(point);
if (!Number.isFinite(time)) return null;
if (pointY(point) === null) continue;
if (previousTime !== null && time - previousTime > thresholdMs) {
out.push(withPointY(point, null));
}
out.push(point);
previousTime = time;
}
return out;
};
const applyConnectNullsThreshold = (options: any, panelSchema: any) => {
const config = panelSchema?.config ?? {};
const seconds = toNumber(config.connect_nulls_threshold);
if (!config.connect_nulls || seconds === null || seconds <= 0) return;
for (const series of dataSeriesOf(options)) {
if (series.connectNulls !== true) continue;
const data = breakLongGaps(series.data, seconds * 1000);
if (!data) continue;
series.data = data;
// The short gaps were dropped above, so ECharts must break at the null points left in.
series.connectNulls = false;
}
};
const normalisePercentStacks = (options: any) => {
const raw = new Map<any, any[]>();
const groups = new Map<string, any[]>();
for (const series of dataSeriesOf(options)) {
// Without a breakdown the bar converters leave series unstacked; a 100% stack still needs one.
if (!series.stack) series.stack = "percent";
const key = String(series.stack);
groups.set(key, [...(groups.get(key) ?? []), series]);
}
for (const members of groups.values()) {
const totals = new Map<string, number>();
for (const series of members) {
series.data.forEach((point: any, index: number) => {
const key = bucketKey(point, index);
totals.set(key, (totals.get(key) ?? 0) + Math.abs(pointY(point) ?? 0));
});
}
for (const series of members) {
raw.set(series, series.data);
series.data = series.data.map((point: any, index: number) => {
const y = pointY(point);
const total = totals.get(bucketKey(point, index)) ?? 0;
return y === null ? point : withPointY(point, total > 0 ? (y / total) * 100 : 0);
});
}
}
return raw;
};
const percentTooltip = (
options: any,
panelSchema: any,
raw: Map<any, any[]>,
lookup: SeriesOverrideLookup,
) => {
const original = options.tooltip?.formatter;
const decimals = toNumber(panelSchema?.config?.decimals) ?? 2;
const seriesOf = (param: any) => {
const byIndex = options.series?.[param?.seriesIndex];
if (byIndex?.name === param?.seriesName) return byIndex;
return asArray(options.series).find((s: any) => s?.name === param?.seriesName);
};
return (params: any, ...rest: any[]) => {
const list = asArray(params);
const base = typeof original === "function" ? original(params, ...rest) : "";
if (typeof original === "function" && !base) return base;
const header = typeof base === "string" ? base.split("<br/>")[0].trim() : "";
const rows = sortTooltipParams([...list], panelSchema?.config, (it: any) => pointY(it?.value))
.filter((it: any) => pointY(it?.value) !== null)
.map((it: any) => {
const series = seriesOf(it);
const rawPoint = raw.get(series)?.[it.dataIndex];
const share = `${(pointY(it.value) ?? 0).toFixed(decimals)}%`;
const value = lookup.formatValue(pointY(rawPoint), it.seriesName);
const name = escapeHtml(String(lookup.displayName(it.seriesName)));
return `${it.marker ?? ""} ${name} : ${escapeHtml(share)} (${escapeHtml(value)})`;
});
return [header, ...rows].filter(Boolean).join("<br/>");
};
};
const applyPercentStack = (options: any, panelSchema: any, lookup: SeriesOverrideLookup) => {
if (!isPercentStack(panelSchema)) return;
const raw = normalisePercentStacks(options);
if (!raw.size) return;
for (const axis of valueAxesOf(options, panelSchema.type)) {
axis.min = 0;
axis.max = 100;
axis.axisLabel = { ...axis.axisLabel, formatter: (value: number) => `${value}%` };
}
if (options.tooltip)
options.tooltip.formatter = percentTooltip(options, panelSchema, raw, lookup);
};
const applyLogScale = (options: any, panelSchema: any) => {
const config = panelSchema?.config ?? {};
if (config.y_axis_scale !== "log") return;
const base = Number(config.y_axis_log_base) === 2 ? 2 : DEFAULT_Y_AXIS_LOG_BASE;
for (const axis of valueAxesOf(options, panelSchema.type)) {
axis.type = "log";
axis.logBase = base;
if (typeof axis.min === "number" && axis.min <= 0) delete axis.min;
if (typeof axis.max === "number" && axis.max <= 0) delete axis.max;
}
for (const series of dataSeriesOf(options)) {
series.data = series.data.map((point: any) => {
const y = pointY(point);
return y !== null && y <= 0 ? withPointY(point, null) : point;
});
}
};
// Runs after the converters, whose y_axis_min/max only widen the axis, so a hard limit always wins.
const applyHardRange = (options: any, panelSchema: any) => {
const config = panelSchema?.config ?? {};
if (isPercentStack(panelSchema)) return;
const isLog = config.y_axis_scale === "log";
const usable = (value: unknown) => {
const bound = toNumber(value);
return bound === null || (isLog && bound <= 0) ? null : bound;
};
const hardMin = usable(config.y_axis_hard_min);
const hardMax = usable(config.y_axis_hard_max);
if (hardMin === null && hardMax === null) return;
for (const axis of valueAxesOf(options, panelSchema.type)) {
// A hidden series axis keeps its own scale; pinning it to the visible range would flatten the series.
if (axis.show === false) continue;
if (hardMin !== null) axis.min = hardMin;
if (hardMax !== null) axis.max = hardMax;
}
};
const widestLabel = (series: any[], formatter: unknown): number => {
if (typeof formatter !== "function") return 0;
let widest = 0;
for (const s of series) {
for (const point of s.data ?? []) {
const y = pointY(point);
if (y === null) continue;
widest = Math.max(widest, calculateWidthText(String(formatter(y))));
}
}
return widest;
};
const MAX_LOG_TICKS = 64;
const axisTickValues = (axis: any, series: any[]): number[] => {
const values = series
.flatMap((s) => (s.data ?? []).map(pointY))
.filter((y: number | null): y is number => y !== null);
const hardMin = typeof axis.min === "number" ? axis.min : null;
const hardMax = typeof axis.max === "number" ? axis.max : null;
if (axis.type === "log") {
const positive = [...values, hardMin, hardMax].filter((v): v is number => v !== null && v > 0);
if (!positive.length) return [];
const base = Number(axis.logBase) || 10;
const exponent = (v: number) => Math.log(v) / Math.log(base);
const from = Math.floor(exponent(Math.min(...positive)));
const to = Math.min(Math.ceil(exponent(Math.max(...positive))), from + MAX_LOG_TICKS);
return Array.from({ length: to - from + 1 }, (_, i) => base ** (from + i));
}
if (!values.length && hardMin === null && hardMax === null) return [];
const lo = hardMin ?? Math.min(0, ...values);
const hi = hardMax ?? Math.max(0, ...values);
return [...calculateNiceTickValues(lo, hi), ...[hardMin, hardMax].filter((v) => v !== null)];
};
// The name sits beyond the tick labels, so it is spaced by the ticks the axis draws, not by the raw data values.
const widestTickLabel = (axis: any, series: any[]): number => {
const formatter = axis.axisLabel?.formatter;
if (typeof formatter !== "function") return widestLabel(series, formatter);
const ticks = axisTickValues(axis, series);
if (!ticks.length) return widestLabel(series, formatter);
return Math.max(...ticks.map((tick) => calculateWidthText(String(formatter(tick)))));
};
const applyNegativeAxisLabels = (options: any, panelSchema: any, lookup: SeriesOverrideLookup) => {
if (!lookup.active) return;
const negated = dataSeriesOf(options).some(
(s) => lookup.get(s.name)?.style?.transform === "negative_y",
);
if (!negated) return;
for (const axis of valueAxesOf(options, panelSchema.type)) {
const formatter = axis.axisLabel?.formatter;
if (typeof formatter !== "function") continue;
axis.axisLabel = { ...axis.axisLabel, formatter: (v: any) => formatter(absValue(v)) };
}
};
const applyValueAxisLabel = (options: any, panelSchema: any) => {
const config = panelSchema?.config ?? {};
const label = typeof config.y_axis_label === "string" ? config.y_axis_label.trim() : "";
if (!label) return;
const horizontal = isHorizontal(panelSchema.type);
const axis = horizontal ? options?.xAxis : options?.yAxis;
if (!axis || Array.isArray(axis)) return;
const hadName = !!axis.name;
axis.name = label;
axis.nameLocation = "middle";
axis.nameTextStyle = {
color: chartAxisTextColor(),
fontWeight: "bold",
fontSize: 14,
...axis.nameTextStyle,
};
const grid = options.grid;
const canMoveGrid = grid && !Array.isArray(grid) && !isSet(config.axis_width);
if (horizontal) {
axis.nameGap = axis.nameGap || X_AXIS_NAME_GAP;
if (!hadName && canMoveGrid && typeof grid.bottom === "number") {
grid.bottom += X_AXIS_NAME_ROOM;
}
return;
}
const widest = widestTickLabel(axis, dataSeriesOf(options));
axis.nameGap = widest + Y_AXIS_NAME_GAP_PAD;
if (!canMoveGrid) return;
if (grid.containLabel) {
grid.left = Math.max(toNumber(grid.left) ?? 0, CONTAINED_Y_AXIS_NAME_LEFT);
} else {
grid.left = axis.nameGap + Y_AXIS_NAME_ROOM;
}
};
const applyLineStyle = (options: any, panelSchema: any) => {
const style = panelSchema?.config?.line_style;
if (style !== "dashed" && style !== "dotted" && style !== "solid") return;
for (const series of dataSeriesOf(options)) {
if (series.type !== "line") continue;
// A per-series override set its own type first and outranks the panel setting.
series.lineStyle = { ...series.lineStyle, type: series.lineStyle?.type ?? style };
}
};
const applyBarRadius = (options: any, panelSchema: any) => {
const radius = toNumber(panelSchema?.config?.bar_radius);
if (radius === null || radius <= 0) return;
const corners = isHorizontal(panelSchema.type) ? [0, radius, radius, 0] : [radius, radius, 0, 0];
const bars = dataSeriesOf(options).filter((s) => s.type === "bar");
const topOfStack = new Map<string, any>();
for (const series of bars) if (series.stack) topOfStack.set(String(series.stack), series);
for (const series of bars) {
if (series.stack && topOfStack.get(String(series.stack)) !== series) continue;
series.itemStyle = { ...series.itemStyle, borderRadius: corners };
}
};
const markAreaValue = (value: unknown, axis: any): unknown => {
const type = asArray(axis)[0]?.type;
const numeric = toNumber(value);
if (type === "value" || type === "log") return numeric;
if (type === "time") return numeric ?? value;
return value;
};
const applyMarkAreas = (options: any, panelSchema: any) => {
const areas = asArray(panelSchema?.config?.mark_area).filter(
(area: any) =>
area && (area.axis === "x" || area.axis === "y") && isSet(area.from) && isSet(area.to),
);
if (!areas.length || !Array.isArray(options?.series)) return;
const data = areas.map((area: any) => {
const key = area.axis === "y" ? "yAxis" : "xAxis";
const from = markAreaValue(area.from, options[key]);
const to = markAreaValue(area.to, options[key]);
return [
{
name: area.name ?? "",
[key]: from,
...(area.color ? { itemStyle: { color: area.color, opacity: MARK_AREA_OPACITY } } : {}),
},
{ [key]: to },
];
});
// A nameless helper series keeps the bands out of the legend and visible when a series is toggled off.
options.series.push({
type: "line",
data: [],
silent: true,
tooltip: { show: false },
markArea: {
silent: true,
itemStyle: { color: chartColor("--color-chart-series-6"), opacity: MARK_AREA_OPACITY },
label: { show: true, position: "insideTop", color: chartColor("--color-text-secondary") },
data,
},
});
};
const TOOLTIP_ORDER: Record<string, string> = {
asc: "valueAsc",
desc: "valueDesc",
none: "seriesAsc",
};
// An item tooltip only fires on a drawn symbol, so hidden line symbols become invisible hover targets.
const revealHoverTargets = (options: any) => {
let changed = false;
for (const series of options.series) {
if (series?.type !== "line" || series.showSymbol !== false) continue;
series.showSymbol = true;
series.itemStyle = { ...series.itemStyle, opacity: 0 };
series.emphasis = {
...series.emphasis,
itemStyle: { ...series.emphasis?.itemStyle, opacity: 1 },
};
changed = true;
}
if (!changed) return;
// Legend icons inherit the series item style and would lose their marker.
for (const legend of [].concat(options.legend ?? [])) {
if (legend) (legend as any).itemStyle = { ...(legend as any).itemStyle, opacity: 1 };
}
};
const applyTooltipMode = (options: any, panelSchema: any) => {
const tooltip = options?.tooltip;
if (!tooltip || Array.isArray(tooltip)) return;
const config = panelSchema?.config ?? {};
if (TOOLTIP_ORDER[config.tooltip_sort]) tooltip.order = TOOLTIP_ORDER[config.tooltip_sort];
if (config.tooltip_mode === "hidden") {
tooltip.show = false;
return;
}
if (config.tooltip_mode === "all") tooltip.trigger = "axis";
if (config.tooltip_mode !== "single") return;
tooltip.trigger = "item";
revealHoverTargets(options);
const formatter = tooltip.formatter;
// Every axis formatter reads a row list; an item tooltip hands it a single row.
if (typeof formatter === "function") {
tooltip.formatter = (params: any, ...rest: any[]) =>
formatter(Array.isArray(params) ? params : [params], ...rest);
}
};
/** Applies the panel-level cartesian settings to converted ECharts options; every setting is a no-op while its key is absent. */
export const applyCartesianOptions = (options: any, panelSchema: any): void => {
if (!options || !CARTESIAN_CHART_TYPES.has(panelSchema?.type)) return;
if (!Array.isArray(options.series)) return;
const lookup = createSeriesOverrideLookup(panelSchema?.config);
applyScatterEncoding(options, panelSchema);
applySortOrder(options, panelSchema);
applyConnectNullsThreshold(options, panelSchema);
applyPercentStack(options, panelSchema, lookup);
applyLogScale(options, panelSchema);
applyHardRange(options, panelSchema);
applyNegativeAxisLabels(options, panelSchema, lookup);
applyValueAxisLabel(options, panelSchema);
applyLineStyle(options, panelSchema);
applyBarRadius(options, panelSchema);
applyMarkAreas(options, panelSchema);
applyTooltipMode(options, panelSchema);
};