81 lines
2.9 KiB
TypeScript
81 lines
2.9 KiB
TypeScript
/**
|
|
* Chart primitives — pure string builders (no React), THE charting layer for
|
|
* widget apps and demos alike. Everything returns plain strings the caller
|
|
* colors with theme tones; everything auto-scales to the series' min/max.
|
|
*/
|
|
|
|
const BLOCKS = '▁▂▃▄▅▆▇█'
|
|
|
|
const normalize = (series: number[], window: number): { min: number; range: number; window: number[] } => {
|
|
const view = series.slice(-Math.max(1, window))
|
|
const min = Math.min(...view)
|
|
|
|
return { min, range: Math.max(...view) - min || 1, window: view }
|
|
}
|
|
|
|
/** One-row sparkline: `▂▃▅▇█▆…`, last `width` samples. ALWAYS exactly
|
|
* `width` cells — short series pad-left so the card never resizes while
|
|
* history warms up (latest sample stays pinned to the right edge). */
|
|
export function sparkline(series: number[], width = series.length): string {
|
|
if (!series.length) {
|
|
return ' '.repeat(Math.max(0, width))
|
|
}
|
|
|
|
const { min, range, window } = normalize(series, width)
|
|
|
|
return window
|
|
.map(v => BLOCKS[Math.min(BLOCKS.length - 1, Math.floor(((v - min) / range) * BLOCKS.length))])
|
|
.join('')
|
|
.padStart(width)
|
|
}
|
|
|
|
/**
|
|
* Multi-row column chart, top line first — the streams-demo panel chart.
|
|
* Each column resolves to `rows * 8` vertical levels (full blocks below the
|
|
* value, a partial eighth-block at it), so taller cells genuinely gain
|
|
* resolution.
|
|
*/
|
|
export function sparkRows(series: number[], width: number, rows: number): string[] {
|
|
if (!series.length) {
|
|
return Array.from({ length: rows }, () => ' '.repeat(width))
|
|
}
|
|
|
|
const { min, range, window } = normalize(series, width)
|
|
const levels = window.map(v => Math.max(1, Math.round(((v - min) / range) * rows * 8)))
|
|
|
|
return Array.from({ length: rows }, (_, lineIdx) => {
|
|
const rowFromBottom = rows - 1 - lineIdx
|
|
|
|
return levels
|
|
.map(level => {
|
|
const filled = Math.min(8, Math.max(0, level - rowFromBottom * 8))
|
|
|
|
return filled === 0 ? ' ' : BLOCKS[filled - 1]
|
|
})
|
|
.join('')
|
|
.padStart(width) // warm-up pads left: chart grows from the right, card never resizes
|
|
})
|
|
}
|
|
|
|
/** Horizontal fill gauge: `█████░░░` for a 0..1 ratio. */
|
|
export function gauge(ratio: number, width: number): string {
|
|
const filled = Math.round(Math.min(1, Math.max(0, ratio)) * width)
|
|
|
|
return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
|
|
}
|
|
|
|
/** Horizontal bar chart: one `███▌`-style bar per value, scaled to the max,
|
|
* each padded to exactly `width` (stable card sizing). Eighth-block tips
|
|
* keep adjacent values distinguishable. */
|
|
export function hbars(values: number[], width: number): string[] {
|
|
const max = Math.max(...values, 0) || 1
|
|
|
|
return values.map(v => {
|
|
const cells = (Math.min(max, Math.max(0, v)) / max) * width
|
|
const full = Math.floor(cells)
|
|
const rest = Math.round((cells - full) * 8)
|
|
|
|
return ('█'.repeat(full) + (rest > 0 ? '▏▎▍▌▋▊▉█'[rest - 1] : '')).padEnd(width)
|
|
})
|
|
}
|