Fix sparkline axis label scaling

This commit is contained in:
rcourtman
2026-04-12 13:23:56 +01:00
parent bf30a094bb
commit 53b050f674
4 changed files with 84 additions and 24 deletions
@@ -189,7 +189,10 @@ work extends shared components instead of creating new local variants.
attributes and shared state/model helpers for cursor, axis-label, and
tooltip positioning, but it must not write inline `style=` attributes for
tick labels, tooltip placement, or per-series transitions on the public
shell.
shell. Axis labels must render in fixed-size SVG shells or another
non-scaled primitive boundary; the shared sparkline must not put axis glyphs
inside `preserveAspectRatio="none"` label viewBoxes that stretch text as the
chart resizes.
The same shared presentation boundary also owns reusable scroll containers:
`frontend-modern/src/components/shared/Table.tsx` must keep touch-scroll
behavior on classes and shared CSS in `frontend-modern/src/index.css`
@@ -15,6 +15,17 @@ const verticalTextBaseline = (anchor: 'top' | 'middle' | 'bottom') =>
anchor === 'top' ? 'hanging' : anchor === 'bottom' ? 'text-bottom' : 'middle';
const horizontalTextAnchor = (anchor: 'start' | 'middle' | 'end') => anchor;
const axisPositionPercent = (value: number, total: number) => `${(value / total) * 100}%`;
const sparklineLeftInsetClass = (size?: InteractiveSparklineProps['size']) =>
size === 'lg' ? 'ml-10' : 'ml-8';
const sparklineYAxisWidthClass = (size?: InteractiveSparklineProps['size']) =>
size === 'lg' ? 'w-10' : 'w-8';
const sparklineXAxisHeightClass = (size?: InteractiveSparklineProps['size']) =>
size === 'lg' ? 'h-5' : 'h-4';
const sparklineYAxisFontSize = (size?: InteractiveSparklineProps['size']) =>
size === 'lg' ? '12' : '10';
const sparklineXAxisFontSize = (size?: InteractiveSparklineProps['size']) =>
size === 'lg' ? '12' : '10';
const tooltipWidth = (hover: InteractiveSparklineHoverState) => (hover.focusedTooltip ? 112 : 138);
@@ -51,7 +62,7 @@ export const InteractiveSparkline: Component<InteractiveSparklineProps> = (props
data-summary-chart-state={interactionState()}
>
<div class="relative flex-1 min-h-0">
<div class="h-full ml-7 mr-3" ref={canvasHostRef}>
<div class={`h-full mr-3 ${sparklineLeftInsetClass(props.size)}`} ref={canvasHostRef}>
<Show
when={sparkline.shouldUseCanvas()}
fallback={
@@ -224,20 +235,21 @@ export const InteractiveSparkline: Component<InteractiveSparklineProps> = (props
</Show>
</svg>
</div>
<div class="absolute inset-y-0 left-0 w-7 pointer-events-none">
<div
class={`absolute inset-y-0 left-0 pointer-events-none ${sparklineYAxisWidthClass(props.size)}`}
>
<svg
class="h-full w-full overflow-visible text-muted"
viewBox={`0 0 28 ${sparkline.vbH}`}
preserveAspectRatio="none"
data-sparkline-y-axis="true"
class="h-full w-full overflow-visible text-muted tabular-nums"
aria-hidden="true"
>
<For each={sparkline.axisTicks()}>
{(tick) => (
<text
x="0"
y={tick.y}
y={axisPositionPercent(tick.y, sparkline.vbH)}
fill="currentColor"
font-size="8"
font-size={sparklineYAxisFontSize(props.size)}
class="transition-all duration-300 ease-out"
dominant-baseline={verticalTextBaseline(tick.anchor)}
>
@@ -248,20 +260,21 @@ export const InteractiveSparkline: Component<InteractiveSparklineProps> = (props
</svg>
</div>
</div>
<div class="relative pointer-events-none ml-7 mr-3 h-4">
<div
class={`relative pointer-events-none mr-3 ${sparklineLeftInsetClass(props.size)} ${sparklineXAxisHeightClass(props.size)}`}
>
<svg
class="h-full w-full overflow-visible text-muted"
viewBox={`0 0 ${sparkline.vbW} ${sparkline.xAxisBandPx}`}
preserveAspectRatio="none"
data-sparkline-x-axis="true"
class="h-full w-full overflow-visible text-muted tabular-nums"
aria-hidden="true"
>
<For each={sparkline.xAxisTicks()}>
{(tick) => (
<text
x={tick.x}
x={axisPositionPercent(tick.x, sparkline.vbW)}
y="2"
fill="currentColor"
font-size="9"
font-size={sparklineXAxisFontSize(props.size)}
font-weight="500"
class="transition-all duration-300 ease-out"
text-anchor={horizontalTextAnchor(tick.anchor)}
@@ -368,13 +368,9 @@ describe('shared primitive guardrails', () => {
expect(summaryMetricCardSource).toContain('props.headerValue');
expect(summaryMetricCardSource).not.toContain('data-summary-sync-readout');
expect(summarySynchronizedReadoutSource).toContain(
'export const SummarySynchronizedReadout',
);
expect(summarySynchronizedReadoutSource).toContain('export const SummarySynchronizedReadout');
expect(summarySynchronizedReadoutSource).toContain('data-summary-sync-readout="true"');
expect(summarySynchronizedReadoutSource).toContain(
'formatSummarySynchronizedReadoutTime',
);
expect(summarySynchronizedReadoutSource).toContain('formatSummarySynchronizedReadoutTime');
expect(summarySynchronizedReadoutSource).not.toContain('Portal');
expect(interactiveSparklineModelSource).toContain(
@@ -784,8 +780,16 @@ describe('shared primitive guardrails', () => {
expect(interactiveSparklineSource).toContain('data-active-series-display');
expect(interactiveSparklineSource).toContain('data-active-hover-cursor-x');
expect(interactiveSparklineSource).toContain('data-sparkline-tooltip="true"');
expect(interactiveSparklineSource).toContain('data-sparkline-y-axis="true"');
expect(interactiveSparklineSource).toContain('data-sparkline-x-axis="true"');
expect(interactiveSparklineSource).toContain('axisPositionPercent(tick.y, sparkline.vbH)');
expect(interactiveSparklineSource).toContain('axisPositionPercent(tick.x, sparkline.vbW)');
expect(interactiveSparklineSource).toContain('x1={sparkline.activeHoverCursorX() ?? 0}');
expect(interactiveSparklineSource).toContain('y1={0}');
expect(interactiveSparklineSource).not.toContain('viewBox={`0 0 28 ${sparkline.vbH}`}');
expect(interactiveSparklineSource).not.toContain(
'viewBox={`0 0 ${sparkline.vbW} ${sparkline.xAxisBandPx}`}',
);
expect(interactiveSparklineSource).not.toContain('style={{');
expect(interactiveSparklineSource).not.toContain('style={');
expect(interactiveSparklineSource).not.toContain('{(cursorX) => (');
@@ -829,8 +833,12 @@ describe('shared primitive guardrails', () => {
expect(densityMapSource).not.toContain('data-density-map-tooltip-sparkline="true"');
expect(densityMapSource).toContain('grid-cols-[auto_minmax(0,1fr)_auto]');
expect(densityMapSource).not.toContain('max-w-[94px]');
expect(densityMapSource).toContain('whitespace-nowrap text-[11px] font-semibold text-emerald-400');
expect(densityMapSource).toContain('whitespace-nowrap text-[11px] font-semibold text-base-content');
expect(densityMapSource).toContain(
'whitespace-nowrap text-[11px] font-semibold text-emerald-400',
);
expect(densityMapSource).toContain(
'whitespace-nowrap text-[11px] font-semibold text-base-content',
);
expect(densityMapSource).not.toContain('timeRangeToMs');
expect(densityMapSource).not.toContain('createSignal');
expect(densityMapSource).not.toContain('ctx.fillRect');
@@ -1179,8 +1187,12 @@ describe('shared primitive guardrails', () => {
expect(summaryMetricCardSource).toContain("density?: 'default' | 'compact'");
expect(summaryMetricCardSource).toContain("props.density === 'compact'");
expect(summaryMetricCardSource).toContain("props.bodyLayout ?? 'chart'");
expect(summaryMetricCardSource).toContain("isCompact() ? 'mb-1 min-h-[20px]' : 'mb-1.5 min-h-[24px]'");
expect(summaryMetricCardSource).toContain("isCompact() ? 'h-[108px] sm:h-[120px]' : 'h-[136px] sm:h-[150px]'");
expect(summaryMetricCardSource).toContain(
"isCompact() ? 'mb-1 min-h-[20px]' : 'mb-1.5 min-h-[24px]'",
);
expect(summaryMetricCardSource).toContain(
"isCompact() ? 'h-[108px] sm:h-[120px]' : 'h-[136px] sm:h-[150px]'",
);
expect(summaryMetricCardSource).toContain('!p-1.5 sm:!p-2');
expect(summaryMetricCardSource).not.toContain('Recovery Posture');
expect(summaryMetricCardSource).not.toContain('Freshness');
@@ -25,6 +25,38 @@ describe('InteractiveSparkline hover behavior', () => {
expect(interactiveSparklineModelSource).toContain('computeInteractiveSparklineHoverState');
});
it('renders axis labels in fixed-size SVG shells so tick text does not stretch', () => {
const now = Date.now();
const { container } = render(() => (
<InteractiveSparkline
size="lg"
timeRange="1h"
rangeLabel="1h"
series={[
{
name: 'CPU',
color: '#ff0000',
data: [
{ timestamp: now - 30_000, value: 40 },
{ timestamp: now - 10_000, value: 50 },
],
},
]}
/>
));
const yAxis = container.querySelector('svg[data-sparkline-y-axis="true"]');
const xAxis = container.querySelector('svg[data-sparkline-x-axis="true"]');
expect(yAxis).toBeInTheDocument();
expect(xAxis).toBeInTheDocument();
expect(yAxis?.hasAttribute('viewBox')).toBe(false);
expect(yAxis?.hasAttribute('preserveAspectRatio')).toBe(false);
expect(xAxis?.hasAttribute('viewBox')).toBe(false);
expect(xAxis?.hasAttribute('preserveAspectRatio')).toBe(false);
expect(yAxis?.querySelector('text')?.getAttribute('y')).toBe('0%');
expect(xAxis?.querySelectorAll('text')[1]?.getAttribute('x')).toBe('50%');
});
it('shows a vertical dashed hover line and a tooltip', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));