Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .nx/version-plans/version-plan-1792670217792-ui-react.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ledgerhq/lumen-ui-react': patch
---

feat(Scrubber): add labels to beacons when scrubbing
5 changes: 5 additions & 0 deletions .nx/version-plans/version-plan-1792670217792-ui-rnative.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ledgerhq/lumen-ui-rnative': patch
---

feat(Scrubber): add labels to beacons when scrubbing
35 changes: 34 additions & 1 deletion apps/app-sandbox-rnative/src/app/(blocks)/LineCharts.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Box, Button, Text } from '@ledgerhq/lumen-ui-rnative';
import { useTheme } from '@ledgerhq/lumen-ui-rnative/styles';
import {
DefaultBeaconLabel,
LineChart,
Point,
ReferenceLine,
Expand All @@ -9,7 +10,6 @@ import {
} from '@ledgerhq/lumen-ui-rnative/visualization';
import type { ReactNode } from 'react';
import { useEffect, useState } from 'react';

export default function LineCharts() {
return (
<Box
Expand Down Expand Up @@ -39,6 +39,8 @@ export default function LineCharts() {
<PointWithAxes />
<ScrubberBasic />
<ScrubberMultiSeriesWithBeacons />
<ScrubberWithBeaconLabels />
<ScrubberWithCustomBeaconLabels />
<ScrubberIdlePulse />
<InfiniteLoading />
<ScrubberWithAxes />
Expand Down Expand Up @@ -464,6 +466,37 @@ const ScrubberMultiSeriesWithBeacons = () => (
</Section>
);

const ScrubberWithBeaconLabels = () => (
<Section title='Scrubber - beacon labels'>
<LineChart
series={multiSeries}
height={200}
enableScrubbing
inset={{ top: 20 }}
>
<Scrubber showBeaconLabel />
</LineChart>
</Section>
);

const ScrubberWithCustomBeaconLabels = () => (
<Section title='Scrubber - custom beacon labels'>
<LineChart
series={multiSeries}
height={200}
enableScrubbing
inset={{ top: 20 }}
>
<Scrubber
showBeaconLabel
beaconLabel={({ series, value }) => (
<DefaultBeaconLabel label={`${series.label}: $${value}`} />
Comment thread
aquelemiguel marked this conversation as resolved.
Resolved
)}
/>
</LineChart>
</Section>
);

const ScrubberIdlePulse = () => (
<Section title='Scrubber - idle pulse'>
<LineChart series={multiSeries} height={125} enableScrubbing>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import type { ReactNode } from 'react';
import { useLayoutEffect, useRef, useState } from 'react';

import { chartConfig } from '../../config';
import type { DrawingArea } from '../../utils/types';
import { safeGetBBoxWidth } from '../DefaultScrubberTooltip/utils';
import type { DefaultBeaconLabelProps, ScrubberProps } from '../types';
import type { Beacon } from '../useScrubberGeometry';
import { computeBeaconLabelX, computeBeaconLabelYs } from './utils';

const { beaconLabel, font } = chartConfig;

/**
* Default beacon label: a pill sized to its `label`, drawn from its top-left
* corner.
*/
export function DefaultBeaconLabel({
label,
background = beaconLabel.background,
color = beaconLabel.valueColor,
}: Readonly<DefaultBeaconLabelProps>) {
const textRef = useRef<SVGTextElement>(null);
const [textWidth, setTextWidth] = useState(0);

useLayoutEffect(() => {
setTextWidth(safeGetBBoxWidth(textRef.current));
}, [label]);

return (
<g>
<rect
width={textWidth + beaconLabel.paddingX * 2}
height={beaconLabel.height}
fill={background}
style={{ rx: beaconLabel.borderRadius }}
/>
<text
ref={textRef}
x={beaconLabel.paddingX}
y={beaconLabel.height / 2}
dominantBaseline='central'
fill={color}
fontWeight={font.bodyWeightMedium}
style={{ fontSize: font.bodySize, fontFamily: font.family }}
>
{label}
</text>
</g>
);
}

const seriesLabel = (beacon: Beacon): DefaultBeaconLabelProps['label'] => (
<tspan>
<tspan fill={beaconLabel.titleColor}>
{beacon.series.label ?? beacon.series.id}
</tspan>
<tspan dx={beaconLabel.contentGap}>{beacon.value}</tspan>
</tspan>
);

type ScrubberBeaconLabelsProps = {
pixelX: number;
drawingArea: DrawingArea;
beacons: Beacon[];
renderLabel: ScrubberProps['beaconLabel'];
};

export function ScrubberBeaconLabels({
pixelX,
drawingArea,
beacons,
renderLabel,
}: Readonly<ScrubberBeaconLabelsProps>) {
const sorted = [...beacons].sort((a, b) => a.pixelY - b.pixelY);
const ys = computeBeaconLabelYs(
sorted.map((b) => b.pixelY),
drawingArea,
);

return sorted.map((beacon, i) => (
<PositionedLabel
key={beacon.id}
testId={`scrubber-beacon-label-${beacon.id}`}
value={beacon.value}
pixelX={pixelX}
y={ys[i]}
drawingArea={drawingArea}
>
{renderLabel ? (
renderLabel({ series: beacon.series, value: beacon.value })
) : (
<DefaultBeaconLabel label={seriesLabel(beacon)} />
)}
</PositionedLabel>
));
}

type PositionedLabelProps = {
testId: string;
value: number;
pixelX: number;
y: number;
drawingArea: DrawingArea;
children: ReactNode;
};

function PositionedLabel({
testId,
value,
pixelX,
y,
drawingArea,
children,
}: Readonly<PositionedLabelProps>) {
const ref = useRef<SVGGElement>(null);
const [width, setWidth] = useState(0);

useLayoutEffect(() => {
setWidth(safeGetBBoxWidth(ref.current));
}, [testId, value]);
Comment thread
aquelemiguel marked this conversation as resolved.
Resolved

useLayoutEffect(() => {
const element = ref.current;
if (!element || typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver(() => {
setWidth(safeGetBBoxWidth(element));
});
observer.observe(element);
return () => observer.disconnect();
}, []);

const x = computeBeaconLabelX(pixelX, width, drawingArea);

return (
<g
ref={ref}
data-testid={testId}
transform={`translate(${x} ${y})`}
pointerEvents='none'
>
{children}
</g>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';

import { chartConfig } from '../../config';
import { computeBeaconLabelX, computeBeaconLabelYs } from './utils';

const { height, rowGap, gap } = chartConfig.beaconLabel;
const area = { x: 0, y: 0, width: 200, height: 100 };

describe('computeBeaconLabelYs', () => {
it('centers an isolated label on its beacon', () => {
expect(computeBeaconLabelYs([50], area)).toEqual([50 - height / 2]);
});

it('pushes overlapping labels apart by height + rowGap', () => {
const [a, b] = computeBeaconLabelYs([40, 42], area);
expect(b - a).toBe(height + rowGap);
});

it('keeps the last label inside the drawing area', () => {
const ys = computeBeaconLabelYs([95, 98], area);
expect(ys[1]).toBe(area.height - height);
expect(ys[1] - ys[0]).toBe(height + rowGap);
});

it('keeps the first label inside the drawing area', () => {
const [first] = computeBeaconLabelYs([0, 1], area);
expect(first).toBe(area.y);
});

it('returns an empty list without beacons', () => {
expect(computeBeaconLabelYs([], area)).toEqual([]);
});
});

describe('computeBeaconLabelX', () => {
const offset = chartConfig.scrubber.beaconRadius + gap;

it('places the label to the right when it fits', () => {
expect(computeBeaconLabelX(50, 60, area)).toBe(50 + offset);
});

it('flips to the left when it would overflow', () => {
expect(computeBeaconLabelX(180, 60, area)).toBe(180 - offset - 60);
});

it('never places a label left of the drawing area', () => {
expect(computeBeaconLabelX(30, 300, area)).toBe(area.x);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { chartConfig } from '../../config';
import type { DrawingArea } from '../../utils/types';

const { beaconLabel, scrubber } = chartConfig;

/**
* Stacks labels top to bottom around their beacons, pushing them apart so they
* never overlap, then back up so the last one stays inside the drawing area.
* Expects `beaconYs` sorted ascending and returns the top edge of each label.
*/
export const computeBeaconLabelYs = (
beaconYs: number[],
drawingArea: DrawingArea,
): number[] => {
const { height, rowGap } = beaconLabel;
const ys = beaconYs.map((y) => y - height / 2);

for (let i = 1; i < ys.length; i++) {
ys[i] = Math.max(ys[i], ys[i - 1] + height + rowGap);
}

let limit = drawingArea.y + drawingArea.height - height;
for (let i = ys.length - 1; i >= 0; i--) {
ys[i] = Math.min(ys[i], limit);
limit = ys[i] - height - rowGap;
}

if (ys.length === 0) {
return ys;
}

ys[0] = Math.max(ys[0], drawingArea.y);
for (let i = 1; i < ys.length; i++) {
ys[i] = Math.max(ys[i], ys[i - 1] + height + rowGap);
}

return ys;
};

/**
* Places a label to the right of the beacon, flipping to the left when it
* would overflow the drawing area.
*/
export const computeBeaconLabelX = (
pixelX: number,
width: number,
drawingArea: DrawingArea,
): number => {
const offset = scrubber.beaconRadius + beaconLabel.gap;
const fitsRight =
pixelX + offset + width <= drawingArea.x + drawingArea.width;

return fitsRight
? pixelX + offset
: Math.max(drawingArea.x, pixelX - offset - width);
};
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ the placeholder or the muted lines of a refetch.

<Canvas of={ScrubberStories.WithLoading} />

## Beacon labels

`showBeaconLabel` adds a label next to each beacon while scrubbing, showing the series label and
value. Labels sit to the right of the beacon and flip to the left when there is
no room, and are spaced apart so they never overlap.

<Canvas of={ScrubberStories.WithBeaconLabels} />

Pass `beaconLabel` to render your own content. It receives the series and
value as context. Return `DefaultBeaconLabel` with your own `label` to keep the
default pill, and set `background` and `color` to recolor it.

<Canvas of={ScrubberStories.WithCustomBeaconLabel} />

For full control, skip `DefaultBeaconLabel` and draw any SVG from the origin,
as long as it stays within 24px of height.

<Canvas of={ScrubberStories.WithFullyCustomBeaconLabel} />

## Conditional tooltips

Return `{ items: [] }` from the `tooltip` callback to hide the tooltip at a
Expand Down
Loading
Loading