Skip to content
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

feat(partition): linked text overflow avoidance #670

Merged
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.
10 changes: 10 additions & 0 deletions src/chart_types/partition_chart/layout/types/geometry_types.ts
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
126 changes: 104 additions & 22 deletions src/chart_types/partition_chart/layout/viewmodel/link_text_layout.ts
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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Could you please add a jsdoc describing what this function does?
It's a little cryptic and I've difficulties understanding what is going on there

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, also add the return type

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Marco, will do, the upcoming PR deals with and extracts out this function

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,
};
}
3 changes: 3 additions & 0 deletions src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts
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 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>
);