Skip to content

Commit

Permalink
feat(partition): linked text overflow avoidance (opensearch-project#670)
Browse files Browse the repository at this point in the history
Truncates linked labels to avoid horizontal protrusion of text. Removes linked labels that have no remaining label text due to the truncation, or independent of this, which would protrude vertically.

Fix opensearch-project#633
  • Loading branch information
monfera authored May 19, 2020
1 parent df7f516 commit 59617db
Show file tree
Hide file tree
Showing 14 changed files with 124 additions and 28 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -31,30 +31,40 @@ export type Radius = Cartesian;
export type Radian = Cartesian; // we measure angle in radians, and there's unity between radians and cartesian distances which is the whole point of radians; this is also relevant as we use small-angle approximations
export type Distance = Cartesian;

/* @internal */
export interface PointObject {
x: Coordinate;
y: Coordinate;
}

/* @internal */
export type PointTuple = [Coordinate, Coordinate];

/* @internal */
export type PointTuples = [PointTuple, ...PointTuple[]]; // at least one point

/* @internal */
export class Circline {
x: Coordinate = NaN;
y: Coordinate = NaN;
r: Radius = NaN;
}

/* @internal */
export interface CirclinePredicate extends Circline {
inside: boolean;
}

/* @internal */
export interface CirclineArc extends Circline {
from: Radian;
to: Radian;
}

/* @internal */
type CirclinePredicateSet = CirclinePredicate[];

/* @internal */
export type RingSector = CirclinePredicateSet;

export type TimeMs = number;
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,17 @@
* under the License. */

import { Config } from './config_types';
import { Coordinate, Distance, Pixels, PointObject, PointTuple, Radian } from './geometry_types';
import { Coordinate, Distance, Pixels, PointObject, PointTuple, PointTuples, Radian } from './geometry_types';
import { Font } from './types';
import { config, ValueGetterName } from '../config/config';
import { ArrayNode, HierarchyOfArrays } from '../utils/group_by_rollup';
import { Color } from '../../../../utils/commons';
import { VerticalAlignments } from '../viewmodel/viewmodel';

/* @internal */
export type LinkLabelVM = {
link: [PointTuple, ...PointTuple[]]; // at least one point
translate: [number, number];
link: PointTuples;
translate: PointTuple;
textAlign: CanvasTextAlign;
text: string;
valueText: string;
Expand Down Expand Up @@ -120,7 +121,7 @@ interface AngleFromTo {
x1: Radian;
}

export interface TreeNode extends AngleFromTo {
interface TreeNode extends AngleFromTo {
x0: Radian;
x1: Radian;
y0: TreeLevel;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,23 @@
* specific language governing permissions and limitations
* under the License. */

import { Distance } from '../types/geometry_types';
import { Distance, PointTuple, PointTuples } from '../types/geometry_types';
import { Config } from '../types/config_types';
import { TAU, trueBearingToStandardPositionAngle } from '../utils/math';
import { LinkLabelVM, RawTextGetter, ShapeTreeNode, ValueGetterFunction } from '../types/viewmodel_types';
import { meanAngle } from '../geometry';
import { TextMeasure } from '../types/types';
import { Box, Font, TextAlign, TextMeasure } from '../types/types';
import { ValueFormatter } from '../../../../utils/commons';
import { Point } from '../../../../utils/point';

function cutToLength(s: string, maxLength: number) {
return s.length <= maxLength ? s : `${s.substr(0, maxLength - 1)}…`; // ellipsis is one char
}

/** @internal */
export function linkTextLayout(
rectWidth: Distance,
rectHeight: Distance,
measure: TextMeasure,
config: Config,
nodesWithoutRoom: ShapeTreeNode[],
Expand All @@ -35,6 +42,7 @@ export function linkTextLayout(
valueGetter: ValueGetterFunction,
valueFormatter: ValueFormatter,
maxTextLength: number,
diskCenter: Point,
): LinkLabelVM[] {
const { linkLabel } = config;
const maxDepth = nodesWithoutRoom.reduce((p: number, n: ShapeTreeNode) => Math.max(p, n.depth), 0);
Expand All @@ -54,15 +62,15 @@ export function linkTextLayout(
.map((node: ShapeTreeNode) => {
const midAngle = trueBearingToStandardPositionAngle(meanAngle(node.x0, node.x1));
const north = midAngle < TAU / 2 ? 1 : -1;
const side = TAU / 4 < midAngle && midAngle < (3 * TAU) / 4 ? 0 : 1;
const west = side ? 1 : -1;
const rightSide = TAU / 4 < midAngle && midAngle < (3 * TAU) / 4 ? 0 : 1;
const west = rightSide ? 1 : -1;
const cos = Math.cos(midAngle);
const sin = Math.sin(midAngle);
const x0 = cos * anchorRadius;
const y0 = sin * anchorRadius;
const x = cos * (anchorRadius + linkLabel.radiusPadding);
const y = sin * (anchorRadius + linkLabel.radiusPadding);
const poolIndex = side + (1 - north);
const poolIndex = rightSide + (1 - north);
const relativeY = north * y;
currentY[poolIndex] = Math.max(currentY[poolIndex] + rowPitch, relativeY + yRelativeIncrement, rowPitch / 2);
const cy = north * currentY[poolIndex];
Expand All @@ -71,43 +79,117 @@ export function linkTextLayout(
const stemToX = x + north * west * cy - west * relativeY;
const stemToY = cy;
const rawText = rawTextGetter(node);
const text = rawText.length <= maxTextLength ? rawText : `${rawText.substr(0, maxTextLength - 1)}…`; // ellipsis is one char
const labelText = cutToLength(rawText, maxTextLength);
const valueText = valueFormatter(valueGetter(node));
const labelFontSpec = {
const labelFontSpec: Font = {
fontStyle: 'normal',
fontVariant: 'normal',
fontFamily: config.fontFamily,
fontWeight: 'normal',
...linkLabel,
text,
};
const valueFontSpec = {
const valueFontSpec: Font = {
fontStyle: 'normal',
fontVariant: 'normal',
fontFamily: config.fontFamily,
fontWeight: 'normal',
...linkLabel,
...linkLabel.valueFont,
text: valueText,
};
const { width, emHeightAscent, emHeightDescent } = measure(linkLabel.fontSize, [labelFontSpec])[0];
const { width: valueWidth } = measure(linkLabel.fontSize, [valueFontSpec])[0];
const translateX = stemToX + west * (linkLabel.horizontalStemLength + linkLabel.gap);
const { width: valueWidth } = measure(linkLabel.fontSize, [{ ...valueFontSpec, text: valueText }])[0];
const widthAdjustment = valueWidth + 3 * linkLabel.fontSize; // gap between label and value, plus possibly 2em wide ellipsis
const allottedLabelWidth = rightSide
? rectWidth - diskCenter.x - translateX - widthAdjustment
: diskCenter.x + translateX - widthAdjustment;
const { text, width, verticalOffset } =
linkLabel.fontSize / 2 <= cy + diskCenter.y && cy + diskCenter.y <= rectHeight - linkLabel.fontSize / 2
? fitText(measure, labelText, allottedLabelWidth, linkLabel.fontSize, {
...labelFontSpec,
text: labelText,
})
: { text: '', width: 0, verticalOffset: 0 };
const link: PointTuples = [
[x0, y0],
[stemFromX, stemFromY],
[stemToX, stemToY],
[stemToX + west * linkLabel.horizontalStemLength, stemToY],
];
const translate: PointTuple = [translateX, stemToY];
const textAlign: TextAlign = rightSide ? 'left' : 'right';
return {
link: [
[x0, y0],
[stemFromX, stemFromY],
[stemToX, stemToY],
[stemToX + west * linkLabel.horizontalStemLength, stemToY],
],
translate: [stemToX + west * (linkLabel.horizontalStemLength + linkLabel.gap), stemToY],
textAlign: side ? 'left' : 'right',
link,
translate,
textAlign,
text,
valueText,
width,
valueWidth,
verticalOffset: -(emHeightDescent + emHeightAscent) / 2, // meaning, `middle`
verticalOffset,
labelFontSpec,
valueFontSpec,
};
});
})
.filter((l: LinkLabelVM) => l.text !== ''); // cull linked labels whose text was truncated to nothing
}

function monotonicMaximizer(
test: (n: number) => number,
maxVar: number,
maxWidth: number,
minVar: number = 0,
minVarWidth: number = 0,
) {
// Lowers iteration count by weakly assuming that there's a `pixelWidth(text) ~ charLength(text), ie. instead of pivoting
// at the 50% midpoint like a basic binary search would do, it takes proportions into account. Still works if assumption is false.
// It's usable for all problems where there's a monotonic relationship between the constrained output and the variable
// (eg. can maximize font size etc.)
let loVar = minVar;
let loWidth = minVarWidth;

let hiVar = maxVar;
let hiWidth = test(hiVar);

if (hiWidth <= maxWidth) return maxVar; // early bail if maxVar is compliant

let pivotVar: number = NaN;
while (loVar < hiVar && pivotVar !== loVar && pivotVar !== hiVar) {
const newPivotVar = loVar + ((hiVar - loVar) * (maxWidth - loWidth)) / (hiWidth - loWidth);
if (pivotVar === newPivotVar) {
return loVar; // early bail if we're not making progress
}
pivotVar = newPivotVar;
const pivotWidth = test(pivotVar);
const pivotIsCompliant = pivotWidth <= maxWidth;
if (pivotIsCompliant) {
loVar = pivotVar;
loWidth = pivotWidth;
} else {
hiVar = pivotVar;
hiWidth = pivotWidth;
}
}
return pivotVar;
}

function discreteLength(n: number) {
return Math.round(n);
}

function fitText(measure: TextMeasure, desiredText: string, allottedWidth: number, fontSize: number, box: Box) {
const desiredLength = desiredText.length;
const visibleLength = discreteLength(
monotonicMaximizer(
(v: number) => measure(fontSize, [{ ...box, text: box.text.substr(0, discreteLength(v)) }])[0].width,
desiredLength,
allottedWidth,
),
);
const text = visibleLength < 2 && desiredLength >= 2 ? '' : cutToLength(box.text, visibleLength);
const { width, emHeightAscent, emHeightDescent } = measure(fontSize, [{ ...box, text }])[0];
return {
width,
verticalOffset: -(emHeightDescent + emHeightAscent) / 2, // meaning, `middle`
text,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,8 @@ export function shapeViewModel(
const maxLinkedLabelTextLength = config.linkLabel.maxTextLength;

const linkLabelViewModels = linkTextLayout(
width,
height,
textMeasure,
config,
nodesWithoutRoom,
Expand All @@ -320,6 +322,7 @@ export function shapeViewModel(
valueGetter,
valueFormatter,
maxLinkedLabelTextLength,
diskCenter,
);

const pickQuads: PickFunction = (x, y) => {
Expand Down
4 changes: 2 additions & 2 deletions packages/osd-charts/stories/sunburst/7_zero_slice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import React from 'react';
import { indexInterpolatedFillColor, interpolatorCET2s, productLookup } from '../utils/utils';

export const example = () => (
<Chart className="story-chart">
<Chart className="story-chart" size={{ height: 180 }}>
<Partition
id="spec_1"
data={mocks.pie
Expand All @@ -42,7 +42,7 @@ export const example = () => (
},
},
]}
config={{ partitionLayout: PartitionLayout.sunburst }}
config={{ partitionLayout: PartitionLayout.sunburst, margin: { left: 0.2 } }}
/>
</Chart>
);

0 comments on commit 59617db

Please sign in to comment.