/* * 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 * */ /** * moved from sparkline module -- not sure why this needed a separate * module. although on reflection it might be better placed in the charts * module? (...) * * perhaps not because we use that module elsewhere and this one is only * ever used in spreadsheets. * */ import { IsHTMLColor, type Cell, type CellStyle } from 'treb-base-types'; export interface SparklineRenderOptions { context: CanvasRenderingContext2D; width: number; height: number; cell: Cell; } enum LineOperation { move, line, } export class Sparkline { // public static SingleColor = ['#333']; // public static TwoColors = ['green', 'red']; public static readonly default_color = '#888'; // should never be used, but jic /** * three possible cases: * * (1) array of numbers, possibly including missing values * (2) typed array * (3) nested array, so we need to recurse and concatenate * * there's actually one more case, where data comes in from serialized * representation (loading file); in that case it's an object with numeric * indexes (sadly) */ protected static UnpackValues(underlying: unknown): number[] { if (Array.isArray(underlying)) { const test = underlying[0]; if (Array.isArray(test) || test instanceof Float64Array || test instanceof Float32Array) { return underlying.reduce((a, subset) => a.concat(this.UnpackValues(subset)), []); } else { return underlying.map(test => isNaN(test) ? undefined : test); } } else if (underlying instanceof Float32Array || underlying instanceof Float64Array) { return Array.prototype.slice.call(underlying); } else if (underlying && typeof underlying === 'object') { const keys = Object.keys(underlying); const len = keys.length; // this is maybe overdoing it? (...) there's probably a smarter test // if (keys.every(key => !isNaN(Number(key)))) { // check first, last if (typeof ((underlying as Record)['0']) !== 'undefined' && typeof (underlying as Record)[(len - 1).toString()] !== 'undefined') { const data: number[] = []; // we probably don't have to explicitly use strings -- although it's not // clear that it would be any faster because someone still has to do the // conversion for (let i = 0; i < len; i++) { data[i] = (underlying as Record)[i.toString()]; } return data; } } return []; } protected static SparklineCommon(cell: Cell, style: CellStyle): { values: Array, colors: string[], } { // the cell function echoes back arguments. the first argument // should be an array, but it will be 2D... let values: Array = []; // use text color, or default. because this is called from renderer, // theme default _should_ always be passed in, so we should (theoretically) // never need our default. const text_color = IsHTMLColor(style.text) ? style.text.text : this.default_color; const colors: string[] = [ text_color, text_color ]; if (Array.isArray(cell.calculated)) { values = this.UnpackValues(cell.calculated[0]); if (typeof cell.calculated[1] === 'string') { colors[0] = cell.calculated[1]; } if (typeof cell.calculated[2] === 'string') { colors[1] = cell.calculated[2]; } } return { values, colors }; } public static RenderLine( width: number, height: number, context: CanvasRenderingContext2D, cell: Cell, style: CellStyle, ): void { const {values, colors} = this.SparklineCommon(cell, style); const x_margin = 0.05; // FIXME: parameterize? (...) const y_margin = 0.10; let line_width = 1; if (Array.isArray(cell.calculated) && typeof cell.calculated[2] === 'number') { line_width = cell.calculated[2]; } let min = 0; let max = 0; let first_index = -1; for (let i = 0; i < values.length; i++) { const value = values[i]; if (typeof value === 'number') { if (first_index >= 0) { min = Math.min(min, value); max = Math.max(max, value); } else { first_index = i; min = max = value; } } } if (min === max) { if (min) { min -= 1; max += 1; } else { max += 1; } } if (min !== max) { const step = (width * (1 - 2 * x_margin)) / (values.length - 1); const range = max - min; const pixel_range = height * (1 - 2 * y_margin); // ? const base = height * y_margin; context.strokeStyle = colors[0]; context.lineWidth = line_width; context.lineCap = 'round'; context.lineJoin = 'round'; // let x = width * x_margin + step * first_index; // let y = height - ((values[first_index] as number) - min) * pixel_range / range - base; context.beginPath(); let op = LineOperation.move; for (let i = first_index; i < values.length; i++) { const value = values[i]; if (typeof value === 'number') { const x = width * x_margin + step * i; const y = height - (value - min) * pixel_range / range - base; if (op === LineOperation.move) { context.moveTo(x, y); op = LineOperation.line; } else { context.lineTo(x, y); } } else { op = LineOperation.move; } } context.stroke(); } } public static RenderColumn( width: number, height: number, context: CanvasRenderingContext2D, cell: Cell, style: CellStyle, ): void { const {values, colors} = this.SparklineCommon(cell, style); // const x_margin = 0.05; // FIXME: parameterize? (...) // const y_margin = 0.10; // const y_margin = Math.max(1, Math.min(0.10 * height, 2)); const x_margin = 3; const y_margin = 2.5; let min = 0; let max = 0; let first_value = false; for (const value of values) { if (typeof value === 'number') { if (first_value) { min = Math.min(min, value); max = Math.max(max, value); } else { first_value = true; min = max = value; } } } if (min === max) { if (min) { min -= 1; max += 1; } else { max += 1; } } if (values.length) { // const step = (width - 2 * x_margin - 2) / (values.length-1); const step = (width - 2 * x_margin - 2) / (values.length-0); const pixel_range = (height - 2 * y_margin); // ? const base = y_margin; // let x = Math.round(width * x_margin); if (min !== max) { if (min < 0 && max > 0) { const range = max - min; const zero = base + max / range * pixel_range; // use an indexed loop so we can multiply to get x instead of adding for (let i = 0; i < values.length; i++) { const value = values[i]; if (typeof value === 'number') { const x = (x_margin + i * step); const bar_height = (Math.abs(value) / range) * pixel_range; if (value >= 0) { context.fillStyle = colors[0]; const top = zero - bar_height; context.fillRect(x + 2, top, step - 2, bar_height); } else { context.fillStyle = colors[1]; const top = zero; context.fillRect(x + 2, top, step - 2, bar_height); } } } } else if (max > 0) { // all positive context.fillStyle = colors[0]; const range = max - min; // use an indexed loop so we can multiply to get x instead of adding for (let i = 0; i < values.length; i++) { const value = values[i]; if (typeof value === 'number') { const x = (x_margin + i * step); const bar_height = Math.max(1, ((value - min) / range) * pixel_range); const top = height - base - bar_height; context.fillRect(x + 2, top, step - 2, bar_height); } } } else { // all negative context.fillStyle = colors[1]; const range = max - min; // use an indexed loop so we can multiply to get x instead of adding for (let i = 0; i < values.length; i++) { const value = values[i]; if (typeof value === 'number') { const x = (x_margin + i * step); const bar_height = Math.max( 1, (Math.abs(max - value) / range) * pixel_range); const top = base; context.fillRect(x + 2, top, step - 2, bar_height); } } } } } } }