mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
Fix sparkline axis label scaling
This commit is contained in:
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user