-
Notifications
You must be signed in to change notification settings - Fork 134
feat(legend): Add option to truncate legend labels in the middle #2771
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
base: main
Are you sure you want to change the base?
Changes from 4 commits
3600a4c
adde860
2b2650b
a6e8f6c
b12791b
c00b88a
e041d76
9dd1b8d
666ee61
0f0961e
884f1c8
49da38b
da843bd
425aec4
f9ebd91
76efdd5
a4a4140
b433cfe
c5fecb8
8f0bfc0
cd1f423
89d78ea
1621252
9ba7a8e
2b398a5
5f55058
c60ab74
55cd67c
9b51d67
7f0ea01
1981921
85cbe84
1007f14
5d6a890
aa42c29
4994565
5667c59
fdbb58b
5a1c0f4
7e3cf54
873a67d
4be1d9a
686324d
6ae77af
ad65ceb
c36bd88
4c445a1
42f4200
c971199
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| /* | ||
| * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
| * or more contributor license agreements. Licensed under the Elastic License | ||
| * 2.0 and the Server Side Public License, v 1; you may not use this file except | ||
| * in compliance with, at your election, the Elastic License 2.0 or the Server | ||
| * Side Public License, v 1. | ||
| */ | ||
|
|
||
| import { useCallback, useLayoutEffect, useRef, useState } from 'react'; | ||
|
|
||
| import type { Font } from '../../common/text_utils'; | ||
| import { withTextMeasure } from '../../utils/bbox/canvas_text_bbox_calculator'; | ||
|
|
||
| const ELLIPSIS = '…'; | ||
|
|
||
| interface UseMiddleTruncatedLabelProps { | ||
| label: string; | ||
| maxLines: number; | ||
| /** | ||
| * When true, applies middle truncation via JS. | ||
| * When false, returns the original label (CSS will handle end truncation). | ||
| */ | ||
| shouldTruncate: boolean; | ||
| } | ||
|
|
||
| interface UseMiddleTruncatedLabelResult { | ||
| /** Ref to attach to the label element. Undefined when truncation is disabled. */ | ||
| labelRef: React.RefObject<HTMLDivElement> | undefined; | ||
| /** The truncated label - middle (JS) or end (CSS) truncated based on shouldTruncate */ | ||
| truncatedLabel: string; | ||
| } | ||
|
|
||
| /** | ||
| * Extracts font properties from computed styles to create a Font object | ||
| * compatible with the TextMeasure utilities. | ||
| */ | ||
| function getFontFromComputedStyle(computedStyle: CSSStyleDeclaration): { | ||
| font: Omit<Font, 'textColor'>; | ||
| fontSize: number; | ||
| } { | ||
| const fontSize = parseFloat(computedStyle.fontSize) || 12; | ||
| const fontWeight = computedStyle.fontWeight as Font['fontWeight']; | ||
|
|
||
| const font: Omit<Font, 'textColor'> = { | ||
| fontStyle: (computedStyle.fontStyle || 'normal') as Font['fontStyle'], | ||
| fontVariant: (computedStyle.fontVariant || 'normal') as Font['fontVariant'], | ||
| fontWeight: fontWeight || 'normal', | ||
| fontFamily: computedStyle.fontFamily || 'sans-serif', | ||
| }; | ||
|
|
||
| return { font, fontSize }; | ||
| } | ||
|
|
||
| /** | ||
| * Simple middle truncation based on character count. | ||
| * Splits the label to show beginning and end with ellipsis in the middle. | ||
| */ | ||
| function truncateMiddle(label: string, maxChars: number): string { | ||
| if (label.length <= maxChars) return label; | ||
| if (maxChars < 2) return ELLIPSIS; | ||
|
|
||
| // Reserve 1 char for ellipsis | ||
| const availableChars = maxChars - 1; | ||
| const startChars = Math.ceil(availableChars / 2); | ||
| const endChars = Math.floor(availableChars / 2); | ||
|
|
||
| return `${label.slice(0, startChars)}${ELLIPSIS}${label.slice(-endChars)}`; | ||
| } | ||
|
|
||
| /** | ||
| * Hook to compute middle-truncated label text based on container width. | ||
| * Uses average character width estimation for a simple and efficient approach (O(1) complexity). | ||
| * | ||
| * Middle truncation preserves both the beginning and end of the label text. | ||
| * | ||
| * @internal | ||
| */ | ||
| export function useMiddleTruncatedLabel({ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should this also include some debouncing check? or can we check that all external resizes are decounbed already? |
||
| label, | ||
| maxLines, | ||
| shouldTruncate, | ||
| }: UseMiddleTruncatedLabelProps): UseMiddleTruncatedLabelResult { | ||
| const labelRef = useRef<HTMLDivElement>(null); | ||
| const [truncatedLabel, setTruncatedLabel] = useState(label); | ||
|
|
||
| const computeTruncatedLabel = useCallback(() => { | ||
| if (!shouldTruncate || maxLines === 0) { | ||
| setTruncatedLabel(label); | ||
| return; | ||
| } | ||
|
|
||
| const element = labelRef.current; | ||
| if (!element) { | ||
| setTruncatedLabel(label); | ||
| return; | ||
| } | ||
|
|
||
| const computedStyle = window.getComputedStyle(element); | ||
| const { font, fontSize } = getFontFromComputedStyle(computedStyle); | ||
| const containerWidth = element.clientWidth; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This triggers page reflows and you don't need these as you already have these infos:
|
||
|
|
||
| if (containerWidth <= 0) { | ||
| setTruncatedLabel(label); | ||
| return; | ||
| } | ||
|
|
||
| const result = withTextMeasure((measure) => { | ||
| const fullTextWidth = measure(label, font, fontSize).width; | ||
|
|
||
| // If text fits, no truncation needed | ||
| if (fullTextWidth <= containerWidth * maxLines) { | ||
| return label; | ||
| } | ||
|
|
||
| // Use average character width estimation - simple and loop-free | ||
| const avgCharWidth = measure('x', font, fontSize).width; | ||
| const charsPerLine = Math.floor(containerWidth / avgCharWidth); | ||
| // Apply 4% safety margin to account for browser rendering differences (e.g., Firefox) | ||
| const totalChars = Math.floor(charsPerLine * maxLines * 0.96); | ||
|
|
||
| return truncateMiddle(label, totalChars); | ||
| }); | ||
|
|
||
| setTruncatedLabel(result); | ||
| }, [label, maxLines, shouldTruncate]); | ||
|
|
||
| useLayoutEffect(() => { | ||
| if (!shouldTruncate) { | ||
| setTruncatedLabel(label); | ||
| return; | ||
| } | ||
|
|
||
| computeTruncatedLabel(); | ||
|
|
||
| const element = labelRef.current; | ||
| if (!element) return; | ||
|
|
||
| // Per-element ResizeObserver is necessary since label width varies by rendering mode (list, table, horizontal) | ||
| const resizeObserver = new ResizeObserver(() => { | ||
| computeTruncatedLabel(); | ||
| }); | ||
|
|
||
| resizeObserver.observe(element); | ||
|
|
||
| return () => { | ||
| resizeObserver.disconnect(); | ||
| }; | ||
| }, [computeTruncatedLabel, shouldTruncate, label]); | ||
|
|
||
| return { | ||
| labelRef: shouldTruncate ? labelRef : undefined, | ||
| truncatedLabel, | ||
| }; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. when computing things with canvas measureText you don't need all that, you can do it upfront |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: This creates
--singleline--middlewhich isn't proper BEM terminology. Could we do--middleas a separate class and apply both or would that breat things?