Repository navigation
feat(Scrubber): add labels to beacons when scrubbing #933
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
ab65415
add initial impl for web
aquelemiguel 59ce0e3
add stories + mdx
aquelemiguel 303a3e9
add more distinct custom beacon label
aquelemiguel 699ade2
migrate to rnative impl
aquelemiguel 588d414
add tests to react impl
aquelemiguel adf117f
add release plans
aquelemiguel 608d27a
use sync external store
aquelemiguel 9760e3e
remove the textmeasurer utility, not needed
aquelemiguel 117578f
remove code smells
aquelemiguel f4db525
apply pr-review skill
aquelemiguel ea64362
Merge branch 'main' into dls-1104
aquelemiguel 09a314e
copilot suggestions
aquelemiguel c109d4f
update body weight requested by laurine
aquelemiguel 9259a6d
Merge remote-tracking branch 'origin/main' into dls-1104
aquelemiguel 4806ca9
update docs
aquelemiguel 079e841
add DefaultBeaconLabel for react
aquelemiguel aa6b91d
same approach on defaultbeaconlabel for RN
aquelemiguel da0a70c
remove tspans
aquelemiguel 6c47f87
add fully custom label
aquelemiguel 9657a87
forward text and rect fill props
aquelemiguel 9a5a8db
update dominant baseline
aquelemiguel d7e749d
push slotted beacon label
aquelemiguel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
146 changes: 146 additions & 0 deletions
146
libs/ui-react/src/lib/Components/visualization/Scrubber/BeaconLabel/BeaconLabel.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]); | ||
|
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> | ||
| ); | ||
| } | ||
49 changes: 49 additions & 0 deletions
49
libs/ui-react/src/lib/Components/visualization/Scrubber/BeaconLabel/utils.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
56 changes: 56 additions & 0 deletions
56
libs/ui-react/src/lib/Components/visualization/Scrubber/BeaconLabel/utils.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.