/*
* This file is part of TREB.
*
* TREB is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* TREB 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 General Public License for more
* details.
*
* You should have received a copy of the GNU General Public License along
* with TREB. If not, see .
*
* Copyright 2022-2026 trebco, llc.
* info@treb.app
*
*/
import { type UnionValue, ValueType, type ArrayUnion, IsComplex, type CellValue } from 'treb-base-types';
import { IsArrayUnion, IsMetadata, IsSeries, LegendStyle } from './chart-types';
import type { SubSeries, SeriesType, BarData, ChartDataBaseType, ChartData, ScatterData2, DonutSlice, BubbleChartData, BoxPlotData } from './chart-types';
import { NumberFormatCache } from 'treb-format';
import { Util } from './util';
import type { ReferenceSeries } from './chart-types';
import type { RangeScale } from 'treb-utils';
import { QuickSort } from './quicksort';
/**
* this file is the concrete translation from function arguments
* to chart data. chart data is a (somewhat complicated) type with
* specializations for various chart types. we're splitting the
* generation of that data from the actual layout/rendering with
* a view towards building a new (or several new) renderers.
*/
const DEFAULT_FORMAT = '#,##0.00'; // why not use "general", or whatever the usual default is?
export const ArrayMinMax = (data: number[]) => {
let min = data[0];
let max = data[0];
for (const entry of data) {
if (entry < min) { min = entry; }
if (entry > max) { max = entry; }
}
return { min, max };
/*
const copy = data.slice(0);
copy.sort((a, b) => a - b);
return {min: copy[0], max: copy[copy.length - 1]};
*/
};
export const ReadSeries = (data: ReferenceSeries['value']): SeriesType => {
// const label, x, y, z, index, subtype, data_labels] = data;
const { label, x, y, z, index, subtype, data_labels, axis } = data;
// series type is (now)
//
// [0] label, string
// [1] X, array, metadata [* could be single value?]
// [2] Y, array, metadata [* could be single value?]
// [3] Z, array, metadata [* could be single value?]
// [4] index, number
// [5] subtype, string
//
// in this case it's (label, X, Y)
const series: SeriesType = {
x: { data: [] },
y: { data: [] },
};
if (typeof axis === 'number') {
series.axis = axis;
}
if (typeof index === 'number') {
series.index = index;
}
if (subtype) {
series.subtype = subtype.toString();
}
if (data_labels) {
const labels = Util.Flatten(Array.isArray(data_labels) ? data_labels : [data_labels]);
series.labels = labels.map(value => (typeof value === 'undefined') ? '' : value.toString());
}
if (label) {
series.label = label.toString();
}
const ParseSubseries = (source?: UnionValue, apply_labels = false) => {
let subseries: SubSeries|undefined;
// convert single values -> array (is this still necessary?)
if (IsMetadata(source)) {
source = {
type: ValueType.array,
value: [[source]],
};
}
if (IsArrayUnion(source)) {
subseries = { data: [] };
const flat = Util.Flatten(source.value);
subseries.data = flat.map(item => {
if (IsMetadata(item)) {
if (typeof item.value.value === 'number') {
return item.value.value;
}
}
else if (item.type === ValueType.number) {
return item.value;
}
return undefined;
});
if (IsMetadata(flat[0]) && flat[0].value?.format) {
subseries.format = (flat[0].value.format);
if (apply_labels) {
const format = NumberFormatCache.Get(subseries.format);
subseries.labels = subseries.data.map(value => (value === undefined) ? undefined : format.Format(value));
}
}
}
return subseries;
};
// read [2] first, so we can default for [1] if necessary
series.y = ParseSubseries(y, true) || { data: [] };
series.x = ParseSubseries(x) || { data: [] };
series.z = ParseSubseries(z);
const entries = [series.x, series.y]
for (const subseries of entries) {
// in case of no values
if (subseries.data.length) {
const values = subseries.data.filter(value => value || value === 0) as number[];
subseries.range = ArrayMinMax(values);
}
}
return series;
};
export const ArrayToSeries = (array_data: ArrayUnion): SeriesType => {
// this is an array of Y, X not provided
const series: SeriesType = { x: { data: [] }, y: { data: [] }, };
const flat = Util.Flatten(array_data.value);
// series.y.data = flat.map(item => typeof item.value === 'number' ? item.value : undefined);
// console.trace();
const values: number[] = []; // filter any undefineds
for (const [index, item] of flat.entries()) {
let value = 0;
// why is this testing type instead of using the union type?
if (typeof item.value === 'number') {
value = item.value;
// series.y.data[index] = item.value;
values.push(item.value);
}
else if (IsMetadata(item)) {
if (IsComplex(item.value.value)) {
series.x.data[index] = item.value.value.real;
// series.y.data[index] = item.value.value.imaginary;
// values.push(item.value.value.imaginary);
value = item.value.value.imaginary;
}
else if (typeof item.value.value === 'number') {
// series.y.data[index] = item.value.value;
// values.push(item.value.value);
value = item.value.value;
}
else {
continue;
}
}
else {
// series.y.data[index] = undefined;
continue;
}
series.y.data[index] = value;
values.push(value);
}
/*
series.y.data = flat.map((item, index) => {
// if the data is passed in from the output of a function, it will not
// be inside a metadata structure
if (typeof item.value === 'number') { return item.value; }
// ... ok, it's metadata (why not just test?) ...
if (IsMetadata(item)) {
// experimenting with complex... put real in X axis and imaginary in Y axis
// note should also function w/ complex not in a metadata structure
// if (typeof item.value.value?.real === 'number') {
if (IsComplex(item.value.value)) {
series.x.data[index] = item.value.value.real;
return item.value.value.imaginary;
}
if (typeof item.value.value === 'number') {
return item.value.value;
}
}
// return typeof item.value.value === 'number' ? item.value.value : undefined;
return undefined;
});
*/
let first_format = '';
if (IsMetadata(flat[0])) {
first_format = flat[0].value.format || '';
}
if (first_format) {
series.y.format = first_format;
const format = NumberFormatCache.Get(series.y.format || '');
series.y.labels = series.y.data.map(value => (value === undefined) ? undefined : format.Format(value));
}
// moved up, integrated loops
// const values = series.y.data.filter(value => value || value === 0) as number[];
series.y.range = ArrayMinMax(values);
// experimenting with complex... this should only be set if we populated
// it from complex values
if (series.x.data.length) {
const filtered: number[] = series.x.data.filter(test => typeof test === 'number') as number[];
series.x.range = ArrayMinMax(filtered);
if (first_format) {
series.x.format = first_format;
const format = NumberFormatCache.Get(series.x.format || '');
series.x.labels = series.x.data.map(value => (value === undefined) ? undefined : format.Format(value));
}
}
return series;
};
/**
* composite data -> series. composite data can be
*
* (1) set of Y values, with X not provided;
* (2) SERIES(label, X, Y) with Y required, others optional
* (3) GROUP(a, b, ...), where entries are either arrays as (1) or SERIES as (2)
*
* FIXME: consider supporting GROUP(SERIES, [y], ...)
*
* NOTE: (1) could be an array of boxed (union) values...
*
*/
export const TransformSeriesData = (raw_data?: UnionValue, default_x?: UnionValue): SeriesType[] => {
if (!raw_data) { return []; }
const list: SeriesType[] = [];
if (raw_data.type === ValueType.object) {
if (raw_data.key === 'group') {
if (Array.isArray(raw_data.value)) {
for (const [series_index, entry] of raw_data.value.entries()) {
if (!!entry && (typeof entry === 'object')) {
if (IsSeries(entry)) {
const series = ReadSeries(entry.value);
if (typeof series.index === 'undefined') {
series.index = series_index + 1;
}
list.push(series);
}
else if (entry.type === ValueType.array) {
list.push(ArrayToSeries(entry));
}
}
}
}
}
else if (IsSeries(raw_data)) {
const series = ReadSeries(raw_data.value);
list.push(series);
}
}
else if (raw_data.type === ValueType.array) {
list.push(ArrayToSeries(raw_data));
}
// now we may or may not have X for each series, so we need
// to patch. it's also possible (as with older chart functions)
// that there's a common X -- not sure if we want to continue
// to support that or not...
let baseline_x: SubSeries | undefined;
let max_y_length = 0;
// if we have a default, use that (and range it)
if (default_x?.type === ValueType.array) {
const values = Util.Flatten(default_x.value);
let format = '0.00###';
if (IsMetadata(values[0]) && values[0].value.format) {
format = values[0].value.format;
}
const data = values.map(x => {
if (x.type === ValueType.number) { return x.value; }
if (IsMetadata(x) && typeof x.value.value === 'number') {
return x.value.value;
}
return undefined;
}) as Array;
const filtered = data.filter(x => typeof x === 'number') as number[];
baseline_x = {
data,
format,
range: ArrayMinMax(filtered),
}
}
// look for the first set that has values. at the same time, get max len
for (const entry of list) {
max_y_length = Math.max(max_y_length, entry.y.data.length);
if (entry.x.data.length) {
if (!baseline_x) {
baseline_x = entry.x;
}
}
}
// now default for any series missing X
if (!baseline_x) {
baseline_x = {
data: [],
range: {
min: 0,
max: Math.max(0, max_y_length - 1),
}
}
for (let i = 0; i < max_y_length; i++) { baseline_x.data.push(i); }
}
for (const entry of list) {
if (!entry.x.data.length) {
entry.x = baseline_x;
}
}
return list;
};
const AutoFormat = (scale: RangeScale): string => {
const zep = Math.abs(scale.step) % 1;
if (!zep) {
const log10 = Math.log10(Math.abs(scale.step));
if (log10 >= 5) {
return 'Scientific';
}
return '#,##0';
}
else {
const log10 = Math.log10(Math.abs(zep));
if (log10 < -4) {
return 'Scientific';
}
let count = 0;
for (let i = 0; i < scale.count; i++) {
const value = ((scale.min + scale.step * i) % 1).toFixed(6).replace(/0+$/, '');
count = Math.max(count, value.length - 2);
}
let format = '#,##0.';
for (let i = 0; i < count; i++) {
format += '0';
}
return format;
}
};
/** get a unified scale, and formats */
export const CommonData = (series: SeriesType[], y_floor?: number, y_ceiling?: number, x_floor?: number, x_ceiling?: number, auto_number_format?: boolean) => {
let x_format = '';
let y_format = '';
let y2_format = '';
for (const entry of series) {
if (entry.axis) {
if (entry.y.format && !y2_format) {
y2_format = entry.y.format;
}
}
else {
if (entry.y.format && !y_format) {
y_format = entry.y.format;
}
}
if (entry.x.format && !x_format) {
x_format = entry.x.format;
}
}
let legend: Array<{ label: string, index?: number }> | undefined; // string[]|undefined;
if (series.some(test => test.label && (test.label.length > 0))) {
legend = series.map((entry, i) => ({
label: entry.label || `Series ${i + 1}`,
index: typeof entry.index === 'number' ? entry.index : i + 1,
}));
}
const x = series.filter(test => test.x.range);
let x_min = Math.min.apply(0, x.map(test => test.x.range?.min || 0));
let x_max = Math.max.apply(0, x.map(test => test.x.range?.max || 0));
const x1 = x.filter(test => !test.axis);
const x2 = x.filter(test => test.axis);
let y_min = Math.min.apply(0, x1.map(test => test.y.range?.min || 0));
let y_max = Math.max.apply(0, x1.map(test => test.y.range?.max || 0));
let y2_min = -1;
let y2_max = -1;
if (x2.length) {
y2_min = Math.min.apply(0, x2.map(test => test.y.range?.min || 0));
y2_max = Math.max.apply(0, x2.map(test => test.y.range?.max || 0));
}
// if there's z data (used for bubble size), adjust x/y min/max to
// account for the z size so bubbles are contained within the grid
// this can't be used with axis (atm)
for (const subseries of series) {
if (subseries.z) {
for (const [index, z] of subseries.z.data.entries()) {
if (typeof z !== 'undefined') {
const x = subseries.x.data[index];
const half = Math.max(0, z/2); // accounting for negative values (which we don't use)
if (typeof x !== 'undefined') {
x_min = Math.min(x_min, x - half);
x_max = Math.max(x_max, x + half);
}
const y = subseries.y.data[index];
if (typeof y !== 'undefined') {
y_min = Math.min(y_min, y - half);
y_max = Math.max(y_max, y + half);
}
}
}
}
}
if (typeof x_floor !== 'undefined') {
x_min = Math.min(x_min, x_floor);
}
if (typeof x_ceiling !== 'undefined') {
x_min = Math.max(x_min, x_ceiling);
}
if (typeof y_floor !== 'undefined') {
y_min = Math.min(y_min, y_floor);
}
if (typeof y_ceiling !== 'undefined') {
y_max = Math.max(y_max, y_ceiling);
}
const x_scale = Util.Scale(x_min, x_max, 7);
const y_scale = Util.Scale(y_min, y_max, 7);
const y2_scale = Util.Scale(y2_min, y2_max, 7);
if (y2_min !== y2_max && y_scale && y2_scale) {
if (y_scale.count !== y2_scale.count) {
const max = Math.max(y_scale.count, y2_scale.count);
const target = y_scale.count < max ? y_scale : y2_scale;
for (let i = target.count; i < max; i += 2) {
// add high
target.max += target.step;
target.count++;
if (target.count < max) {
// add low
target.min -= target.step;
target.count++;
}
}
}
}
let x_labels: string[] | undefined;
let y_labels: string[] | undefined;
let y2_labels: string[] | undefined;
if (x_format) {
x_labels = [];
const format = NumberFormatCache.Get(x_format);
for (let i = 0; i <= x_scale.count; i++) {
x_labels.push(format.Format(x_scale.min + i * x_scale.step));
}
}
if (!y_format && auto_number_format) {
y_format = AutoFormat(y_scale);
}
if (!y2_format && auto_number_format) {
y2_format = AutoFormat(y2_scale);
}
if (y_format) {
y_labels = [];
const format = NumberFormatCache.Get(y_format);
for (let i = 0; i <= y_scale.count; i++) {
y_labels.push(format.Format(y_scale.min + i * y_scale.step));
}
}
if (y2_format) {
y2_labels = [];
const format = NumberFormatCache.Get(y2_format);
for (let i = 0; i <= y2_scale.count; i++) {
y2_labels.push(format.Format(y2_scale.min + i * y2_scale.step));
}
}
const result: {
legend?: { label: string, index?: number }[],
x: { format: string, scale: RangeScale, labels?: string[] },
y: { format: string, scale: RangeScale, labels?: string[] },
y2?: { format: string, scale: RangeScale, labels?: string[] },
} = {
x: {
format: x_format,
scale: x_scale,
labels: x_labels,
},
y: {
format: y_format,
scale: y_scale,
labels: y_labels,
},
legend,
};
if (y2_min !== y2_max) {
result.y2 = {
format: y2_format,
scale: y2_scale,
labels: y2_labels,
};
}
return result;
};
const ApplyLabels = (series_list: SeriesType[], pattern: string, category_labels?: string[]): void => {
for (const series of series_list) {
const format = {
x: NumberFormatCache.Get(series.x.format || ''),
y: NumberFormatCache.Get(series.y.format || ''),
};
series.y.labels = [];
for (let i = 0; i < series.y.data.length; i++) {
const x = category_labels ? category_labels[i] :
(typeof series.x.data[i] === 'number' ? format.x.Format(series.x.data[i]) : '');
const y = typeof series.y.data[i] === 'number' ? format.y.Format(series.y.data[i]) : '';
series.y.labels[i] = pattern.replace(/\bx\b/g, x).replace(/\by\b/g, y);
}
}
};
/**
* return quartiles. we use the Tukey hinge-style. See
*
* https://en.wikipedia.org/wiki/Quartile
*
* Specifically,
*
* - Use the median to divide the ordered data set into two halves. The median
* becomes the second quartiles.
*
* - If there are an odd number of data points in the original ordered data
* set, include the median (the central value in the ordered list) in both
* halves.
*
* - If there are an even number of data points in the original ordered data
* set, split this data set exactly in half.
*
* - The lower quartile value is the median of the lower half of the data. The
* upper quartile value is the median of the upper half of the data.
*
* @param data - must be sorted with no holes
*/
export const BoxStats = (data: number[]) => {
// removed copying. still has 3 loops though.
const median = (data: number[], start = 0, n = data.length) => {
if (n % 2) {
return data[Math.floor(n/2) + start];
}
else {
return (data[n/2 + start] + data[n/2 - 1 + start])/2;
}
};
const n = data.length;
const quartiles: [number, number, number] = [0, median(data), 0];
if (n % 2) {
const floor = Math.floor(n/2);
quartiles[0] = median(data, 0, Math.ceil(n/2));
quartiles[2] = median(data, floor, data.length - floor);
}
else {
quartiles[0] = median(data, 0, n/2);
quartiles[2] = median(data, n/2, data.length - n/2);
}
const iqr = quartiles[2] - quartiles[0];
const whiskers: [number, number] = [0, 0];
let sum = 0;
for (let i = 0; i < n; i++) {
sum += data[i];
}
for (let i = 0; i < n; i++) {
const pt = data[i];
if (pt >= quartiles[0] - iqr * 1.5) {
whiskers[0] = pt;
break;
}
}
for (let i = n-1; i >= 0; i--) {
const pt = data[i];
if (pt <= quartiles[2] + iqr * 1.5) {
whiskers[1] = pt;
break;
}
}
return {
data,
quartiles,
whiskers,
iqr,
n,
mean: n ? sum/n : 0,
min: data[0],
max: data[n-1],
};
};
//------------------------------------------------------------------------------
export const CreateBoxPlot = (args: UnionValue[]): ChartData => {
const series: SeriesType[] = TransformSeriesData(args[0]);
const common = CommonData(series, undefined, undefined, undefined, undefined, true);
let max_n = 0;
// change to min-max style
const minmax = !!args[2];
const stats: BoxPlotData['data'] = series.map(series => {
// const data = series.y.data.slice(0).filter((test): test is number => test !== undefined).sort((a, b) => a - b);
const data: number[] = [];
for (const entry of series.y.data) {
if (entry !== undefined) { data.push(entry); }
}
// data.sort((a, b) => a - b);
QuickSort(data);
const result = BoxStats(data);
max_n = Math.max(max_n, result.n);
if (minmax) {
result.whiskers[0] = result.min;
result.whiskers[1] = result.max;
}
return result;
});
const title = args[1]?.toString() || undefined;
const x_labels: string[] = [];
const series_names: string[] = [];
const format = NumberFormatCache.Get('#,##0');
for (const [index, entry] of stats.entries()) {
x_labels.push(format.Format(entry.n));
const s = series[index];
series_names.push(s.label || `Series ${index + 1}`);
}
const chart_data: BoxPlotData = {
type: 'box',
series,
title,
max_n,
data: stats,
x_labels,
series_names: series.some(test => !!test.label) ? series_names : undefined,
scale: common.y.scale,
y_labels: common.y.labels,
};
return chart_data;
};
//------------------------------------------------------------------------------
export const CreateBubbleChart = (args: UnionValue[]): ChartData => {
const series: SeriesType[] = TransformSeriesData(args[0]);
let y_floor: number|undefined = undefined;
let x_floor: number|undefined = undefined;
for (const entry of series) {
if (typeof entry.x.range?.min === 'number' && entry.x.range.min > 0 && entry.x.range.min < 50) {
x_floor = 0;
}
if (typeof entry.y.range?.min === 'number' && entry.y.range.min > 0 && entry.y.range.min < 50) {
y_floor = 0;
}
}
const common = CommonData(series, y_floor, undefined, x_floor);
const title = args[1]?.toString() || undefined;
// const options = args[2]?.toString() || undefined;
// console.info({ series, common, title, options });
const chart_data: BubbleChartData = {
legend: common.legend,
legend_style: LegendStyle.bubble,
type: 'bubble',
series,
title,
x_scale: common.x.scale,
x_labels: common.x.labels,
y_scale: common.y.scale,
y_labels: common.y.labels,
};
return chart_data;
/*
const [x, y, z] = [0,1,2].map(index => {
const arg = args[index];
if (arg.type === ValueType.array) {
return ArrayToSeries(arg).y;
}
return undefined;
});
let c: string[]|undefined = undefined;
if (Array.isArray(args[3])) {
c = Util.Flatten(args[3]).map(value => (value||'').toString());
}
const title = args[4]?.toString() || undefined;
// FIXME: need to pad out the axes by the values at the edges,
// so the whole circle is included in the chart area.
const [x_scale, y_scale] = [x, y].map(subseries => {
let series_min = 0;
let series_max = 1;
let first = false;
if (subseries?.data) {
for (const [index, value] of subseries.data.entries()) {
if (typeof value === 'number') {
if (!first) {
first = true;
series_min = value;
series_max = value;
}
const size = (z?.data?.[index]) || 0;
series_min = Math.min(series_min, value - size / 2);
series_max = Math.max(series_max, value + size / 2);
}
}
}
return Util.Scale(series_min, series_max, 7);
});
let x_labels: string[] | undefined;
let y_labels: string[] | undefined;
if (x?.format) {
x_labels = [];
const format = NumberFormatCache.Get(x.format);
for (let i = 0; i <= x_scale.count; i++) {
x_labels.push(format.Format(x_scale.min + i * x_scale.step));
}
}
if (y?.format) {
y_labels = [];
const format = NumberFormatCache.Get(y.format);
for (let i = 0; i <= y_scale.count; i++) {
y_labels.push(format.Format(y_scale.min + i * y_scale.step));
}
}
return {
type: 'bubble',
title,
x,
y,
z,
c,
x_scale,
y_scale,
x_labels,
y_labels,
};
*/
};
/**
* args is [data, title, options]
*
* args[0] is the scatter data. this can be
*
* (1) set of Y values, with X not provided;
* (2) SERIES(label, X, Y) with Y required, others optional
* (3) GROUP(SERIES(label, X, Y), SERIES(label, X, Y), ...), with same rule for each series
*
* @param args
*/
export const CreateScatterChart = (args: [UnionValue, string, string], style: 'plot' | 'line' = 'plot'): ChartData => {
// FIXME: transform the data, then have this function
// operate on clean data. that way the transform can
// be reused (and the function can be reused without the
// transform).
const series: SeriesType[] = TransformSeriesData(args[0]);
const common = CommonData(series);
const title = args[1]?.toString() || undefined;
const options = args[2]?.toString() || undefined;
const chart_data: ScatterData2 = {
legend: common.legend,
style,
type: 'scatter2',
series, // : [{x, y}],
title,
x_scale: common.x.scale,
x_labels: common.x.labels,
y_scale: common.y.scale,
y_labels: common.y.labels,
y2_scale: common.y2?.scale,
y2_labels: common.y2?.labels,
lines: style === 'line', // true,
points: style === 'plot',
};
if (options) {
chart_data.markers = /marker/i.test(options);
chart_data.smooth = /smooth/i.test(options);
chart_data.data_labels = /labels/i.test(options);
let match = options.match(/labels="(.*?)"/);
if (match && chart_data.series) {
ApplyLabels(chart_data.series, match[1]);
}
else {
match = options.match(/labels=([^\s\r\n,]+)(?:\W|$)/);
if (match && chart_data.series) {
ApplyLabels(chart_data.series, match[1]);
}
}
match = options.match(/class=([\w_-]+)(?:\W|$)/);
if (match) {
chart_data.class_name = match[1];
}
}
return chart_data;
};
/**
* column/bar chart, now using common Series data and routines
*
* @param args arguments: data, categories, title, options
* @param type
*/
export const CreateColumnChart = (
args: [UnionValue?, UnionValue?, string?, string?],
type: 'bar' | 'column'): ChartData => {
const [data, labels, args_title, args_options] = args;
const options = args_options?.toString() || undefined;
const stacked = /stacked/i.test(options || '');
let series: SeriesType[] = TransformSeriesData(data);
let common = CommonData(series);
let category_labels: string[] | undefined;
if (labels) {
const values = labels.type === ValueType.array ? Util.Flatten(labels.value) : Util.Flatten(labels);
category_labels = values.map((cell) => {
if (!cell || !cell.value) { return ''; }
if (IsMetadata(cell)) {
if (typeof cell.value.value === 'number') {
const format = NumberFormatCache.Get(cell.value.format || DEFAULT_FORMAT);
return format.Format(cell.value.value);
}
return cell.value.value?.toString() || '';
}
if (typeof cell.value === 'number') {
const format = NumberFormatCache.Get(DEFAULT_FORMAT);
return format.Format(cell.value);
}
return cell.value.toString();
});
const count = series.reduce((a, entry) => Math.max(a, entry.y.data.length), 0);
if (count < category_labels.length) {
category_labels = category_labels.slice(0, count);
}
while (count > category_labels.length) { category_labels.push(''); }
}
let stacked_series: SeriesType[]|undefined = undefined;
const legend = common.legend; // in case we munge it
if (stacked) {
stacked_series = series;
const map: Map = new Map();
// const label_map: Map = new Map();
for (const entry of series) {
for (const [index, key] of entry.x.data.entries()) {
if (key !== undefined) {
const value = entry.y.data[index] || 0;
map.set(key, value + (map.get(key) || 0));
}
}
}
const x_data = Array.from(map.keys()).sort((a, b) => a - b);
const y_data = x_data.map(key => map.get(key) || 0);
// console.info({stacked_series, map, x_data, y_data});
series = [{
x: { data: x_data, format: stacked_series[0]?.x?.format },
y: { data: y_data, format: stacked_series[0]?.y?.format },
}];
series[0].x.range = ArrayMinMax(x_data);
series[0].y.range = ArrayMinMax(y_data);
common = CommonData(series, Math.min(0, ...y_data));
}
const title = args_title?.toString() || undefined;
const chart_data = {
type,
legend, // legend: common.legend,
// legend_position: LegendPosition.right,
legend_style: LegendStyle.marker,
series2: series,
stacked_series,
scale: common.y.scale,
title,
y_labels: type === 'bar' ? category_labels : common.y.labels, // swapped
x_labels: type === 'bar' ? common.y.labels : category_labels, // swapped
};
if (options) {
(chart_data as BarData).stacked = stacked;
(chart_data as BarData).round = /round/i.test(options);
(chart_data as ChartDataBaseType).data_labels = /labels/i.test(options);
let match = options.match(/labels="(.*?)"/);
if (match && series) {
ApplyLabels(series, match[1], category_labels);
}
else {
match = options.match(/labels=([^\s\r\n,]+)(?:\W|$)/);
if (match && series) {
ApplyLabels(series, match[1], category_labels);
}
}
match = options.match(/class=([\w_-]+)(?:\W|$)/);
if (match) {
(chart_data as ChartDataBaseType).class_name = match[1];
}
}
return chart_data;
};
/**
* args: data, labels, title, callouts, "smooth"
*/
export const CreateLineChart = (args: [UnionValue, UnionValue, string, string], type: 'line' | 'area'): ChartData => {
const series: SeriesType[] = TransformSeriesData(args[0], args[1]);
const common = CommonData(series, 0, 0);
const title = args[2]?.toString() || undefined;
const options = args[3]?.toString() || undefined;
const chart_data: ChartData = {
legend: common.legend,
// style: type, // 'line',
type: 'scatter2',
series, // : [{x, y}],
title,
x_scale: common.x.scale,
x_labels: common.x.labels,
y_scale: common.y.scale,
y_labels: common.y.labels,
lines: true,
filled: type === 'area',
};
if (options) {
// this.chart_data.markers = /marker/i.test(options);
chart_data.smooth = /smooth/i.test(options);
// this.chart_data.data_labels = /labels/i.test(options);
const match = options.match(/class=([\w_-]+)(?:\W|$)/);
if (match) {
chart_data.class_name = match[1];
}
}
return chart_data;
};
/**
* arguments are values, labels, title, sort, label option, ...
*/
export const CreateDonut = (args: [UnionValue?, UnionValue?, string?, string?, string?], pie_chart = false): ChartData => {
const [_a, _c, title, _options, _slice_title] = args;
const raw_data = args[0]?.type === ValueType.array ? args[0].value : args[0];
const flat = raw_data ? Util.Flatten(raw_data) : [];
// we still need the aggregate for range, scale
// let data = flat.map((x) => (typeof x.value.value === 'number') ? x.value.value : undefined) as number[];
let data: (number|undefined)[] = flat.map(cell => {
if (IsMetadata(cell)) {
if (typeof cell.value.value === 'number') {
return cell.value.value;
}
}
return undefined;
})
// if labels are strings, just pass them in. if they're numbers then
// use the format (we're collecting metadata for this field now)
const raw_labels = args[1]?.type === ValueType.array ? args[1].value : args[1];
const labels = Util.Flatten(raw_labels||[]).map(label => {
if (IsMetadata(label)) {
if (typeof label.value.value === 'number' && label.value.format) {
return NumberFormatCache.Get(label.value.format).Format(label.value.value);
}
else return label.value.value?.toString() || '';
}
return label.value?.toString() || '';
});
// no negative numbers
let warned = false;
data = data.map((check) => {
if (check && check < 0) {
if (!warned) {
console.warn('pie/donut chart does not support negative values (omitted)');
warned = true;
}
return 0;
}
return check;
});
let sum = 0;
const slices: DonutSlice[] = data.map((value, i) => {
if (typeof value !== 'undefined') sum += value;
return {
value: value || 0,
label: labels[i] || '',
index: i + 1,
percent: 0,
};
});
if (sum) {
for (const slice of slices) {
slice.percent = (slice.value || 0) / sum;
}
}
// titles? label/value/percent
// FIXME: number format(s)
let format_pattern = '';
for (const value of flat) {
if (IsMetadata(value)) {
format_pattern = value.value.format || '';
break;
}
}
// const format_pattern = (flat.length && flat[0].value?.format) ? flat[0].value.format : '';
const format = NumberFormatCache.Get(format_pattern || DEFAULT_FORMAT);
const percent_format = NumberFormatCache.Get('percent');
// ensure label if we have labels array but no label format string
if (typeof args[4] === 'undefined' && args[1]) {
args[4] = 'label';
}
const slice_title = (args[4] || '');
if (slice_title) {
for (const slice of slices) {
const value = /*NumberFormatCache.Get('general')*/ format.Format(slice.value || 0);
const percent = percent_format.Format(slice.percent);
slice.title = slice_title
.replace(/value%/ig, percent_format.Format(slice.value || 0))
.replace(/value/ig, value)
.replace(/percent/ig, percent)
.replace(/label/ig, slice.label || '')
.trim();
}
}
// optionally sort...
// old-style...
let sort = (_options||'').toUpperCase();
if (sort === 'ASC' || sort === 'ASCENDING' || sort === 'INC') {
slices.sort((a, b) => { return (a.value || 0) - (b.value || 0); });
}
else if (sort === 'DESC' || sort === 'DESCENDING' || sort === 'DEC') {
slices.sort((a, b) => { return (b.value || 0) - (a.value || 0); });
}
else {
const match = (_options||'').match(/sort=([\w]+)(?:\W|$)/i);
if (match) {
sort = match[1];
if (/^(asc|inc)/i.test(sort)) {
slices.sort((a, b) => { return (a.value || 0) - (b.value || 0); });
}
else if (/^(desc|dec)/i.test(sort)) {
slices.sort((a, b) => { return (b.value || 0) - (a.value || 0); });
}
}
}
const chart_data: ChartData = {
type: pie_chart ? 'pie' : 'donut',
slices,
title: title || '',
};
if (_options) {
const match = _options.match(/class=([_-\w]+)(?:\W|$)/);
if (match) {
chart_data.class_name = match[1];
}
}
return chart_data;
};