From 019fb8c6d047aed79a86abb813ee7f9da62583f5 Mon Sep 17 00:00:00 2001 From: planadecu Date: Tue, 14 Jul 2026 10:29:54 +0200 Subject: [PATCH 1/2] Fix raw-format axes: auto-size y-axis, honor showAxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the pixel-perfect (kitty/sixels/iterm2) axis path: - Right/large y-axis labels (e.g. "13,000") were clipped at the canvas edge — uPlot's default ~50px y-axis width isn't enough at the scaled-up font. Now auto-size vertical axes to their widest measured label. - showAxes={false} still drew uPlot's canvas axes and grid. Thread showAxes through renderToImageData/renderToPNG; when false, hide axes/grid/ticks. Also default to [x, y] when no axes are defined, since uPlot draws implicit axes for an empty array. --- src/InkUPlot.tsx | 4 +-- src/renderer.ts | 74 +++++++++++++++++++++++++++++++++++++----------- 2 files changed, 60 insertions(+), 18 deletions(-) diff --git a/src/InkUPlot.tsx b/src/InkUPlot.tsx index 984a9e4..f091bfd 100644 --- a/src/InkUPlot.tsx +++ b/src/InkUPlot.tsx @@ -117,11 +117,11 @@ export function InkUPlot({ if (format === 'iterm2') { // Fast path: node-canvas encodes PNG natively (C), skip chafa WASM entirely. - const png = await renderToPNG(opts, data, canvasWidth, canvasHeight, format); + const png = await renderToPNG(opts, data, canvasWidth, canvasHeight, format, showAxes); if (cancelled) return; ansi = iterm2Escape(png, chartCols, chartRows); } else { - const imageData = await renderToImageData(opts, data, canvasWidth, canvasHeight, format); + const imageData = await renderToImageData(opts, data, canvasWidth, canvasHeight, format, showAxes); if (cancelled) return; ansi = await pixelsToTerminal(imageData, { width: chartCols, diff --git a/src/renderer.ts b/src/renderer.ts index 3d87207..830139a 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -10,6 +10,7 @@ function sanitizeOpts( width: number, height: number, format: RenderFormat, + showAxes: boolean, ): uPlotType.Options { const base: uPlotType.Options = { ...opts, @@ -22,8 +23,10 @@ function sanitizeOpts( }; if (format === 'symbols') { - // Braille mode: hide axes (rendered as text by the component), thin lines. - base.axes = (opts.axes ?? []).map(a => ({ + // Braille mode: hide canvas axes (rendered as text by the component), thin lines. + // Default to [x, y] so an undefined/empty axes array still hides uPlot's implicit axes + // (uPlot draws default axes when given []). + base.axes = (opts.axes?.length ? opts.axes : [{}, {}]).map(a => ({ ...a, show: false, grid: { ...a?.grid, show: false }, @@ -34,23 +37,59 @@ function sanitizeOpts( if (i === 0) return s; return { ...s, width: Math.max((s as any).width ?? 1, 3) }; }); - } else { - // Pixel-perfect mode (kitty/sixels/iterm2): keep uPlot's canvas axes. - base.axes = (opts.axes ?? []).map(a => ({ + + return base; + } + + // Pixel-perfect mode (kitty/sixels/iterm2): uPlot draws the axes on the canvas. + if (!showAxes) { + // Borderless chart — hide axes, grid, and ticks entirely. + // Default to [x, y] so an undefined/empty axes array still hides uPlot's implicit axes + // (uPlot draws default axes when given []). + base.axes = (opts.axes?.length ? opts.axes : [{}, {}]).map(a => ({ ...a, - stroke: a?.stroke ?? '#888', - grid: { show: true, stroke: '#333', ...a?.grid }, - ticks: { show: true, stroke: '#555', ...a?.ticks }, - font: `${Math.max(10, Math.round(height / 40))}px sans-serif`, + show: false, + grid: { ...a?.grid, show: false }, + ticks: { ...a?.ticks, show: false }, })); + } else { + const fontPx = Math.max(10, Math.round(height / 40)); + const fontStr = `${fontPx}px sans-serif`; + + // Auto-size vertical (y) axes to their widest label. uPlot's default ~50px width + // clips large labels (e.g. "13,000") at the canvas edge, especially at bigger fonts. + const autoSizeY = (self: any, values: string[] | null, axisIdx: number, cycleNum: number): number => { + const axis = self.axes[axisIdx]; + if (cycleNum > 1 && axis._size != null) return axis._size; + self.ctx.font = fontStr; + let maxW = 0; + for (const v of values ?? []) { + const w = self.ctx.measureText(String(v)).width; + if (w > maxW) maxW = w; + } + return Math.ceil(maxW) + 20; // label + tick + gap + }; - base.series = (opts.series ?? []).map((s, i) => { - if (i === 0) return s; - const w = (s as any).width ?? 2; - return { ...s, width: w * 3 }; + base.axes = (opts.axes ?? []).map((a, i) => { + // side 1/3 are vertical; an undefined-side axis after index 0 defaults to a left y-axis. + const isY = a?.side === 1 || a?.side === 3 || (i > 0 && a?.side == null); + return { + ...a, + stroke: a?.stroke ?? '#888', + grid: { show: true, stroke: '#333', ...a?.grid }, + ticks: { show: true, stroke: '#555', ...a?.ticks }, + font: fontStr, + ...(isY && (a as any)?.size == null ? { size: autoSizeY } : {}), + }; }); } + base.series = (opts.series ?? []).map((s, i) => { + if (i === 0) return s; + const w = (s as any).width ?? 2; + return { ...s, width: w * 3 }; + }); + return base; } @@ -69,6 +108,7 @@ async function renderChart( canvasWidth: number, canvasHeight: number, format: RenderFormat, + showAxes: boolean, ) { const shim = installDOMShim(canvasWidth, canvasHeight); @@ -85,7 +125,7 @@ async function renderChart( ctx.fillRect(0, 0, canvasWidth, canvasHeight); } - const sanitized = sanitizeOpts(opts, canvasWidth, canvasHeight, format); + const sanitized = sanitizeOpts(opts, canvasWidth, canvasHeight, format, showAxes); const chart = new uPlotCtor(sanitized, data, (self, init) => { init(); @@ -106,8 +146,9 @@ export async function renderToImageData( canvasWidth: number, canvasHeight: number, format: RenderFormat = 'symbols', + showAxes = true, ): Promise<{ data: Uint8ClampedArray; width: number; height: number }> { - const { canvas, chart } = await renderChart(opts, data, canvasWidth, canvasHeight, format); + const { canvas, chart } = await renderChart(opts, data, canvasWidth, canvasHeight, format, showAxes); try { const ctx = canvas.getContext('2d'); @@ -134,8 +175,9 @@ export async function renderToPNG( canvasWidth: number, canvasHeight: number, format: RenderFormat = 'iterm2', + showAxes = true, ): Promise { - const { canvas, chart } = await renderChart(opts, data, canvasWidth, canvasHeight, format); + const { canvas, chart } = await renderChart(opts, data, canvasWidth, canvasHeight, format, showAxes); try { const png = canvas.toBuffer('image/png'); From 493a8e5dd53ffe484486373e8f4f1cb61c64ae7e Mon Sep 17 00:00:00 2001 From: planadecu Date: Tue, 14 Jul 2026 10:41:19 +0200 Subject: [PATCH 2/2] Improve raw-format rendering: crispness, axis contrast, x-scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Render at 2x cell density (supersample) so charts stay crisp when the terminal upscales the image — at 1x they looked blurry on small/hi-DPI terminals. - Brighten default axis stroke (#aaa) and example axis colors (#555 -> #999) for readable labels on dark terminals. - Address code review: apply the [x, y] default to the visible-axes branch too, so implicit axes get styling + auto-sizing (not uPlot's tiny defaults); add showAxes to the render effect deps. - Examples with index x-data (shaded-area, multi-series, basic-line) now set scales.x.time=false so the x-axis shows plain numbers, not bogus 1970 dates. --- examples/basic-line.tsx | 5 +++-- examples/dual-y-axis.tsx | 2 +- examples/live-trading.tsx | 4 ++-- examples/multi-series.tsx | 5 +++-- examples/shaded-area.tsx | 5 +++-- examples/timestamps.tsx | 4 ++-- src/InkUPlot.tsx | 10 ++++++---- src/renderer.ts | 8 +++++--- 8 files changed, 25 insertions(+), 18 deletions(-) diff --git a/examples/basic-line.tsx b/examples/basic-line.tsx index 3a36ec2..1e33fac 100644 --- a/examples/basic-line.tsx +++ b/examples/basic-line.tsx @@ -7,13 +7,14 @@ const timestamps = Array.from({ length: count }, (_, i) => i); const values = timestamps.map(t => Math.sin(t / 5) * 40 + 50); const opts = { + scales: { x: { time: false } }, series: [ {}, { stroke: 'cyan', label: 'Value', width: 2 }, ], axes: [ - { stroke: '#555', grid: { stroke: '#333' } }, - { stroke: '#555', grid: { stroke: '#333' } }, + { stroke: '#999', grid: { stroke: '#333' } }, + { stroke: '#999', grid: { stroke: '#333' } }, ], }; diff --git a/examples/dual-y-axis.tsx b/examples/dual-y-axis.tsx index fbb1ac0..da16af2 100644 --- a/examples/dual-y-axis.tsx +++ b/examples/dual-y-axis.tsx @@ -22,7 +22,7 @@ const opts = { { stroke: '#ffaa00', label: 'Volume', width: 1, scale: 'volume' }, ], axes: [ - { stroke: '#555', grid: { stroke: '#222' } }, + { stroke: '#999', grid: { stroke: '#222' } }, { stroke: '#00ccff', grid: { stroke: '#222' }, scale: 'y' }, { stroke: '#ffaa00', grid: { show: false }, scale: 'volume', side: 1 }, ], diff --git a/examples/live-trading.tsx b/examples/live-trading.tsx index ea1a9bd..d38dfe2 100644 --- a/examples/live-trading.tsx +++ b/examples/live-trading.tsx @@ -95,7 +95,7 @@ function App({ exit }: { exit: () => void }) { ], axes: [ { - stroke: '#555', + stroke: '#999', grid: { stroke: '#222' }, values: (_u: any, vals: number[]) => vals.map(v => { const m = Math.floor(Math.abs(v) / 60); @@ -103,7 +103,7 @@ function App({ exit }: { exit: () => void }) { return `${v < 0 ? '-' : ''}${m}:${String(s).padStart(2, '0')}`; }), }, - { stroke: '#555', grid: { stroke: '#222' }, scale: 'y', side: 1 }, + { stroke: '#999', grid: { stroke: '#222' }, scale: 'y', side: 1 }, ], }; diff --git a/examples/multi-series.tsx b/examples/multi-series.tsx index abd46fd..0a49e0b 100644 --- a/examples/multi-series.tsx +++ b/examples/multi-series.tsx @@ -10,6 +10,7 @@ const cos = x.map(t => Math.cos(t / 8) * 30 + 50); const saw = x.map(t => ((t % 40) / 40) * 80 + 10); const opts = { + scales: { x: { time: false } }, series: [ {}, { stroke: 'cyan', label: 'Sin', width: 2 }, @@ -17,8 +18,8 @@ const opts = { { stroke: '#aa44ff', label: 'Saw', width: 2 }, ], axes: [ - { stroke: '#555', grid: { stroke: '#222' } }, - { stroke: '#555', grid: { stroke: '#222' } }, + { stroke: '#999', grid: { stroke: '#222' } }, + { stroke: '#999', grid: { stroke: '#222' } }, ], }; diff --git a/examples/shaded-area.tsx b/examples/shaded-area.tsx index 248d654..0da0747 100644 --- a/examples/shaded-area.tsx +++ b/examples/shaded-area.tsx @@ -9,6 +9,7 @@ const y1 = x.map(t => Math.sin(t / 12) * 30 + 60); const y2 = x.map(t => Math.cos(t / 15) * 20 + 30); const opts = { + scales: { x: { time: false } }, series: [ {}, { @@ -25,8 +26,8 @@ const opts = { }, ], axes: [ - { stroke: '#555', grid: { stroke: '#222' } }, - { stroke: '#555', grid: { stroke: '#222' } }, + { stroke: '#999', grid: { stroke: '#222' } }, + { stroke: '#999', grid: { stroke: '#222' } }, ], }; diff --git a/examples/timestamps.tsx b/examples/timestamps.tsx index 54d9da0..b0b6b2d 100644 --- a/examples/timestamps.tsx +++ b/examples/timestamps.tsx @@ -20,8 +20,8 @@ const opts = { { stroke: '#f7931a', label: 'BTC/USD', width: 2 }, ], axes: [ - { stroke: '#555', grid: { stroke: '#222' } }, - { stroke: '#555', grid: { stroke: '#222' } }, + { stroke: '#999', grid: { stroke: '#222' } }, + { stroke: '#999', grid: { stroke: '#222' } }, ], }; diff --git a/src/InkUPlot.tsx b/src/InkUPlot.tsx index f091bfd..f28fb2d 100644 --- a/src/InkUPlot.tsx +++ b/src/InkUPlot.tsx @@ -83,11 +83,13 @@ export function InkUPlot({ const chartCols = rawMode ? termCols : Math.max(1, termCols - leftLabelWidth - rightLabelWidth); const chartRows = rawMode ? effHeight : Math.max(1, showAxes ? effHeight - 2 : effHeight); - // Cap canvas pixel dimensions to avoid WASM memory issues. + // Render at 2x cell density (supersample) so the chart stays crisp when the terminal + // upscales the image — at 1x it looks blurry on small/hi-DPI terminals. Display size is + // unchanged; only the pixel resolution goes up. Capped to avoid WASM memory issues. const MAX_DIM = 4096; const MAX_PIXELS = 2_000_000; - let canvasWidth = Math.min(chartCols * 8, MAX_DIM); - let canvasHeight = Math.min(chartRows * 16, MAX_DIM); + let canvasWidth = Math.min(chartCols * 16, MAX_DIM); + let canvasHeight = Math.min(chartRows * 32, MAX_DIM); if (canvasWidth * canvasHeight > MAX_PIXELS) { const scale = Math.sqrt(MAX_PIXELS / (canvasWidth * canvasHeight)); canvasWidth = Math.floor(canvasWidth * scale); @@ -160,7 +162,7 @@ export function InkUPlot({ }); return () => { cancelled = true; }; - }, [opts, data, canvasWidth, canvasHeight, chartCols, chartRows, format, color, resizeTick]); + }, [opts, data, canvasWidth, canvasHeight, chartCols, chartRows, format, color, showAxes, resizeTick]); if (error) { return Error rendering chart: {error}; diff --git a/src/renderer.ts b/src/renderer.ts index 830139a..38c5ec9 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -70,14 +70,16 @@ function sanitizeOpts( return Math.ceil(maxW) + 20; // label + tick + gap }; - base.axes = (opts.axes ?? []).map((a, i) => { + // Default to [x, y] so implicit axes (undefined/empty array — uPlot draws them anyway) + // still get styling and auto-sizing, not uPlot's tiny unstyled defaults. + base.axes = (opts.axes?.length ? opts.axes : [{}, {}]).map((a, i) => { // side 1/3 are vertical; an undefined-side axis after index 0 defaults to a left y-axis. const isY = a?.side === 1 || a?.side === 3 || (i > 0 && a?.side == null); return { ...a, - stroke: a?.stroke ?? '#888', + stroke: a?.stroke ?? '#aaa', grid: { show: true, stroke: '#333', ...a?.grid }, - ticks: { show: true, stroke: '#555', ...a?.ticks }, + ticks: { show: true, stroke: '#666', ...a?.ticks }, font: fontStr, ...(isY && (a as any)?.size == null ? { size: autoSizeY } : {}), };