[Logs UI] Allow dragging of the log minimap to change visible logs (#40092) (#43781)

* Make log minimap draggable, tied to jumpToTarget

* Link dragging minimap to scrolling w/ accuracy/performance issues

* WIP

* Revert back to drag-and-jump behavior

* Reduce UI jankiness on jumpToTarget

* Fix prettier

* WIP

* Add overscan to time ruler and density chart

* Fix typechecking and add comment

* Update typecheck

* Begin implementing minimap redesign

* Add target marker

* Refine drag target area

* Fix jumping minimap on reload

* Fix unused declarations

* Remove unused NegativeAreaMap

* Change move cursor to grab/grabbing

* Change overscan boundaries to dark gray instead of diagonal lines
This commit is contained in:
Zacqary Adam Xeper 2019-08-22 11:55:04 -05:00 committed by GitHub
parent 82054a280d
commit cce2274781
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 305 additions and 78 deletions

View file

@ -38,7 +38,7 @@ export const DensityChart: React.SFC<DensityChartProps> = ({
const xMax = max(buckets.map(bucket => bucket.entriesCount)) || 0;
const xScale = scaleLinear()
.domain([0, xMax])
.range([0, width / 2]);
.range([0, width * (2 / 3)]);
const path = area<SummaryBucket>()
.x0(xScale(0))
@ -47,24 +47,37 @@ export const DensityChart: React.SFC<DensityChartProps> = ({
.curve(curveMonotoneY);
const pathData = path(buckets);
const highestPathCoord = String(pathData)
.replace(/[^.0-9,]/g, ' ')
.split(/[ ,]/)
.reduce((result, num) => (Number(num) > result ? Number(num) : result), 0);
return (
<g transform={`translate(${width / 2}, 0)`}>
<g transform={`translate(${width / 3}, 0)`}>
<DensityChartNegativeBackground
transform={`translate(${-width / 3}, 0)`}
width={width / 2}
height={highestPathCoord}
/>
<DensityChartPositiveBackground width={width * (2 / 3)} height={highestPathCoord} />
<PositiveAreaPath d={pathData || ''} />
<NegativeAreaPath transform="scale(-1, 1)" d={pathData || ''} />
</g>
);
};
const DensityChartNegativeBackground = euiStyled.rect`
fill: white;
`;
const DensityChartPositiveBackground = euiStyled.rect`
fill: ${props =>
props.theme.darkMode
? props.theme.eui.euiColorLightShade
: props.theme.eui.euiColorLightestShade};
`;
const PositiveAreaPath = euiStyled.path`
fill: ${props =>
props.theme.darkMode
? props.theme.eui.euiColorMediumShade
: props.theme.eui.euiColorLightShade};
`;
const NegativeAreaPath = euiStyled.path`
fill: ${props =>
props.theme.darkMode
? props.theme.eui.euiColorLightShade
: props.theme.eui.euiColorLightestShade};
`;

View file

@ -14,6 +14,7 @@ interface HighlightedIntervalProps {
start: number;
end: number;
width: number;
target: number | null;
}
export const HighlightedInterval: React.SFC<HighlightedIntervalProps> = ({
@ -22,20 +23,38 @@ export const HighlightedInterval: React.SFC<HighlightedIntervalProps> = ({
getPositionOfTime,
start,
width,
target,
}) => {
const yStart = getPositionOfTime(start);
const yEnd = getPositionOfTime(end);
const yTarget = target && getPositionOfTime(target);
return (
<HighlightPolygon
className={className}
points={`0,${yStart} ${width},${yStart} ${width},${yEnd} 0,${yEnd}`}
/>
<>
{yTarget && (
<HighlightTargetMarker
className={className}
x1={0}
x2={width / 3}
y1={yTarget}
y2={yTarget}
/>
)}
<HighlightPolygon
className={className}
points={` ${width / 3},${yStart} ${width},${yStart} ${width},${yEnd} ${width / 3},${yEnd}`}
/>
</>
);
};
HighlightedInterval.displayName = 'HighlightedInterval';
const HighlightTargetMarker = euiStyled.line`
stroke: ${props => props.theme.eui.euiColorPrimary};
stroke-width: 1;
`;
const HighlightPolygon = euiStyled.polygon`
fill: ${props => props.theme.eui.euiColorPrimary};
fill-opacity: 0.3;

View file

@ -15,13 +15,20 @@ import { SearchMarkers } from './search_markers';
import { TimeRuler } from './time_ruler';
import { SummaryBucket, SummaryHighlightBucket } from './types';
interface Interval {
end: number;
start: number;
}
interface DragRecord {
startY: number;
currentY: number | null;
}
interface LogMinimapProps {
className?: string;
height: number;
highlightedInterval: {
end: number;
start: number;
} | null;
highlightedInterval: Interval | null;
jumpToTarget: (params: LogEntryTime) => any;
intervalSize: number;
summaryBuckets: SummaryBucket[];
@ -31,33 +38,114 @@ interface LogMinimapProps {
}
interface LogMinimapState {
target: number | null;
drag: DragRecord | null;
svgPosition: ClientRect;
timeCursorY: number;
}
export class LogMinimap extends React.Component<LogMinimapProps, LogMinimapState> {
public readonly state = {
timeCursorY: 0,
};
function calculateYScale(target: number | null, height: number, intervalSize: number) {
const domainStart = target ? target - intervalSize / 2 : 0;
const domainEnd = target ? target + intervalSize / 2 : 0;
return scaleLinear()
.domain([domainStart, domainEnd])
.range([0, height]);
}
public handleClick: React.MouseEventHandler<SVGSVGElement> = event => {
const svgPosition = event.currentTarget.getBoundingClientRect();
export class LogMinimap extends React.Component<LogMinimapProps, LogMinimapState> {
constructor(props: LogMinimapProps) {
super(props);
this.state = {
timeCursorY: 0,
target: props.target,
drag: null,
svgPosition: {
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
},
};
}
private dragTargetArea: SVGElement | null = null;
public static getDerivedStateFromProps({ target }: LogMinimapProps, { drag }: LogMinimapState) {
if (!drag) {
return { target };
}
return null;
}
public handleClick = (event: MouseEvent) => {
const { svgPosition } = this.state;
const clickedYPosition = event.clientY - svgPosition.top;
const clickedTime = Math.floor(this.getYScale().invert(clickedYPosition));
this.setState({
drag: null,
});
this.props.jumpToTarget({
tiebreaker: 0,
time: clickedTime,
});
};
public getYScale = () => {
const { height, intervalSize, target } = this.props;
private handleMouseDown: React.MouseEventHandler<SVGSVGElement> = event => {
const { clientY, target } = event;
if (target === this.dragTargetArea) {
const svgPosition = event.currentTarget.getBoundingClientRect();
this.setState({
drag: {
startY: clientY,
currentY: null,
},
svgPosition,
});
window.addEventListener('mousemove', this.handleDragMove);
}
window.addEventListener('mouseup', this.handleMouseUp);
};
const domainStart = target ? target - intervalSize / 2 : 0;
const domainEnd = target ? target + intervalSize / 2 : 0;
return scaleLinear()
.domain([domainStart, domainEnd])
.range([0, height]);
private handleMouseUp = (event: MouseEvent) => {
window.removeEventListener('mousemove', this.handleDragMove);
window.removeEventListener('mouseup', this.handleMouseUp);
const { drag, svgPosition } = this.state;
if (!drag || !drag.currentY) {
this.handleClick(event);
return;
}
const getTime = (pos: number) => Math.floor(this.getYScale().invert(pos));
const startYPosition = drag.startY - svgPosition.top;
const endYPosition = event.clientY - svgPosition.top;
const startTime = getTime(startYPosition);
const endTime = getTime(endYPosition);
const timeDifference = endTime - startTime;
const newTime = (this.props.target || 0) - timeDifference;
this.setState({ drag: null, target: newTime });
this.props.jumpToTarget({
tiebreaker: 0,
time: newTime,
});
};
private handleDragMove = (event: MouseEvent) => {
const { drag } = this.state;
if (!drag) return;
this.setState({
drag: {
...drag,
currentY: event.clientY,
},
});
};
public getYScale = () => {
const { target } = this.state;
const { height, intervalSize } = this.props;
return calculateYScale(target, height, intervalSize);
};
public getPositionOfTime = (time: number) => {
@ -65,7 +153,7 @@ export class LogMinimap extends React.Component<LogMinimapProps, LogMinimapState
const [minTime] = this.getYScale().domain();
return ((time - minTime) * height) / intervalSize;
return ((time - minTime) * height) / intervalSize; //
};
private updateTimeCursor: React.MouseEventHandler<SVGSVGElement> = event => {
@ -84,12 +172,20 @@ export class LogMinimap extends React.Component<LogMinimapProps, LogMinimapState
summaryBuckets,
summaryHighlightBuckets,
width,
intervalSize,
} = this.props;
const { timeCursorY } = this.state;
const [minTime, maxTime] = this.getYScale().domain();
const { timeCursorY, drag, target } = this.state;
// Render the time ruler and density map beyond the visible range of time, so that
// the user doesn't run out of ruler when they click and drag
const overscanHeight = Math.round(window.screen.availHeight * 2.5) || height * 3;
const [minTime, maxTime] = calculateYScale(
target,
overscanHeight,
intervalSize * (overscanHeight / height)
).domain();
const tickCount = height ? Math.round((overscanHeight / height) * 144) : 12;
const overscanTranslate = height ? -(overscanHeight - height) / 2 : 0;
const dragTransform = !drag || !drag.currentY ? 0 : drag.currentY - drag.startY;
return (
<MinimapWrapper
className={className}
@ -97,25 +193,35 @@ export class LogMinimap extends React.Component<LogMinimapProps, LogMinimapState
preserveAspectRatio="none"
viewBox={`0 0 ${width} ${height}`}
width={width}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onMouseMove={this.updateTimeCursor}
showOverscanBoundaries={Boolean(height && summaryBuckets.length)}
>
<MinimapBackground x={width / 2} y="0" width={width / 2} height={height} />
<DensityChart
buckets={summaryBuckets}
start={minTime}
end={maxTime}
width={width}
height={height}
/>
<MinimapBorder x1={width / 2} y1={0} x2={width / 2} y2={height} />
<TimeRuler start={minTime} end={maxTime} width={width} height={height} tickCount={12} />
<g transform={`translate(0, ${dragTransform + overscanTranslate})`}>
<DensityChart
buckets={summaryBuckets}
start={minTime}
end={maxTime}
width={width}
height={overscanHeight}
/>
<MinimapBorder x1={width / 3} y1={0} x2={width / 3} y2={overscanHeight} />
<TimeRuler
start={minTime}
end={maxTime}
width={width}
height={overscanHeight}
tickCount={tickCount}
/>
</g>
{highlightedInterval ? (
<HighlightedInterval
end={highlightedInterval.end}
getPositionOfTime={this.getPositionOfTime}
start={highlightedInterval.start}
width={width}
target={target}
/>
) : null}
<g transform={`translate(${width * 0.5}, 0)`}>
@ -128,14 +234,25 @@ export class LogMinimap extends React.Component<LogMinimapProps, LogMinimapState
jumpToTarget={jumpToTarget}
/>
</g>
<TimeCursor x1={0} x2={width} y1={timeCursorY} y2={timeCursorY} />
<TimeCursor x1={width / 3} x2={width} y1={timeCursorY} y2={timeCursorY} />
<DragTargetArea
isGrabbing={Boolean(drag)}
innerRef={node => {
this.dragTargetArea = node;
}}
x={0}
y={0}
width={width / 3}
height={height}
/>
</MinimapWrapper>
);
}
}
const MinimapBackground = euiStyled.rect`
fill: ${props => props.theme.eui.euiColorLightestShade};
const DragTargetArea = euiStyled.rect<{ isGrabbing: boolean }>`
fill: transparent;
cursor: ${({ isGrabbing }) => (isGrabbing ? 'grabbing' : 'grab')};
`;
const MinimapBorder = euiStyled.line`
@ -152,7 +269,9 @@ const TimeCursor = euiStyled.line`
: props.theme.eui.euiColorDarkShade};
`;
const MinimapWrapper = euiStyled.svg`
const MinimapWrapper = euiStyled.svg<{ showOverscanBoundaries: boolean }>`
background: ${props =>
props.showOverscanBoundaries ? props.theme.eui.euiColorMediumShade : 'transparent'};
& ${TimeCursor} {
visibility: hidden;
}

View file

@ -25,16 +25,35 @@ export const TimeRuler: React.SFC<TimeRulerProps> = ({ end, height, start, tickC
const ticks = yScale.ticks(tickCount);
const formatTick = yScale.tickFormat();
const dateModLabel = (() => {
for (let i = 0; i < ticks.length; i++) {
const tickLabel = formatTick(ticks[i]);
if (!tickLabel[0].match(/[0-9]/)) {
return i % 12;
}
}
})();
return (
<g>
{ticks.map((tick, tickIndex) => {
const y = yScale(tick);
const isLabeledTick = tickIndex % 12 === dateModLabel;
const tickStartX = isLabeledTick ? 0 : width / 3 - 4;
return (
<g key={`tick${tickIndex}`}>
<TimeRulerTickLabel x={2} y={y - 4}>
{formatTick(tick)}
</TimeRulerTickLabel>
<TimeRulerGridLine x1={0} y1={y} x2={width} y2={y} />
{isLabeledTick && (
<TimeRulerTickLabel x={0} y={y - 4}>
{formatTick(tick)}
</TimeRulerTickLabel>
)}
<TimeRulerGridLine
isDark={isLabeledTick}
x1={tickStartX}
y1={y}
x2={width / 3}
y2={y}
/>
</g>
);
})}
@ -45,16 +64,22 @@ export const TimeRuler: React.SFC<TimeRulerProps> = ({ end, height, start, tickC
TimeRuler.displayName = 'TimeRuler';
const TimeRulerTickLabel = euiStyled.text`
font-size: ${props => props.theme.eui.euiFontSizeXS};
font-size: 9px;
line-height: ${props => props.theme.eui.euiLineHeight};
fill: ${props => props.theme.eui.textColors.subdued};
user-select: none;
pointer-events: none;
`;
const TimeRulerGridLine = euiStyled.line`
const TimeRulerGridLine = euiStyled.line<{ isDark: boolean }>`
stroke: ${props =>
props.theme.darkMode ? props.theme.eui.euiColorDarkShade : props.theme.eui.euiColorMediumShade};
stroke-dasharray: 2, 2;
props.isDark
? props.theme.darkMode
? props.theme.eui.euiColorDarkestShade
: props.theme.eui.euiColorDarkShade
: props.theme.darkMode
? props.theme.eui.euiColorDarkShade
: props.theme.eui.euiColorMediumShade};
stroke-opacity: 0.5;
stroke-width: 1px;
`;

View file

@ -44,6 +44,7 @@ interface ScrollableLogTextStreamViewProps {
startKey: TimeKey | null;
middleKey: TimeKey | null;
endKey: TimeKey | null;
fromScroll: boolean;
}) => any;
loadNewerItems: () => void;
setFlyoutItem: (id: string) => void;
@ -253,12 +254,14 @@ class ScrollableLogTextStreamViewClass extends React.PureComponent<
bottomChild,
pagesAbove,
pagesBelow,
fromScroll,
}: {
topChild: string;
middleChild: string;
bottomChild: string;
pagesAbove: number;
pagesBelow: number;
fromScroll: boolean;
}) => {
this.props.reportVisibleInterval({
endKey: parseStreamItemId(bottomChild),
@ -266,6 +269,7 @@ class ScrollableLogTextStreamViewClass extends React.PureComponent<
pagesAfterEnd: pagesBelow,
pagesBeforeStart: pagesAbove,
startKey: parseStreamItemId(topChild),
fromScroll,
});
}
);

View file

@ -22,6 +22,7 @@ interface VerticalScrollPanelProps<Child> {
bottomChild: Child;
pagesAbove: number;
pagesBelow: number;
fromScroll: boolean;
}) => void;
target: Child | undefined;
height: number;
@ -52,11 +53,17 @@ export class VerticalScrollPanel<Child> extends React.PureComponent<
public scrollRef = React.createRef<HTMLDivElement>();
public childRefs = new Map<Child, MeasurableChild>();
public childDimensions = new Map<Child, Rect>();
private nextScrollEventFromCenterTarget = false;
public handleScroll: React.UIEventHandler<HTMLDivElement> = throttle(
SCROLL_THROTTLE_INTERVAL,
() => {
this.reportVisibleChildren();
// If this event was fired by the centerTarget method modifying the scrollTop,
// then don't send `fromScroll: true` to reportVisibleChildren. The rest of the
// app needs to respond differently depending on whether the user is scrolling through
// the pane manually, versus whether the pane is updating itself in response to new data
this.reportVisibleChildren(!this.nextScrollEventFromCenterTarget);
this.nextScrollEventFromCenterTarget = false;
}
);
@ -121,7 +128,7 @@ export class VerticalScrollPanel<Child> extends React.PureComponent<
};
};
public reportVisibleChildren = () => {
public reportVisibleChildren = (fromScroll: boolean = false) => {
const { onVisibleChildrenChange } = this.props;
const visibleChildren = this.getVisibleChildren();
const scrollPosition = this.getScrollPosition();
@ -134,6 +141,7 @@ export class VerticalScrollPanel<Child> extends React.PureComponent<
bottomChild: visibleChildren.bottomChild,
middleChild: visibleChildren.middleChild,
topChild: visibleChildren.topChild,
fromScroll,
...scrollPosition,
});
};
@ -153,6 +161,9 @@ export class VerticalScrollPanel<Child> extends React.PureComponent<
if (targetDimensions) {
const targetOffset = typeof offset === 'undefined' ? targetDimensions.height / 2 : offset;
// Flag the scrollTop change that's about to happen as programmatic, as
// opposed to being in direct response to user input
this.nextScrollEventFromCenterTarget = true;
scrollRef.current.scrollTop = targetDimensions.top + targetOffset - scrollViewHeight / 2;
}
};

View file

@ -121,19 +121,30 @@ export const LogsPageLogsContent: React.FunctionComponent = () => {
<WithSummary>
{({ buckets }) => (
<WithLogPosition>
{({ jumpToTargetPosition, visibleMidpointTime, visibleTimeInterval }) => (
<LogMinimap
height={height}
width={width}
highlightedInterval={visibleTimeInterval}
intervalSize={intervalSize}
jumpToTarget={jumpToTargetPosition}
summaryBuckets={buckets}
summaryHighlightBuckets={
logSummaryHighlights.length > 0 ? logSummaryHighlights[0].buckets : []
}
target={visibleMidpointTime}
/>
{({
isAutoReloading,
jumpToTargetPosition,
visibleMidpointTime,
visibleTimeInterval,
}) => (
<WithStreamItems initializeOnMount={!isAutoReloading}>
{({ isReloading }) => (
<LogMinimap
height={height}
width={width}
highlightedInterval={isReloading ? null : visibleTimeInterval}
intervalSize={intervalSize}
jumpToTarget={jumpToTargetPosition}
summaryBuckets={buckets}
summaryHighlightBuckets={
logSummaryHighlights.length > 0
? logSummaryHighlights[0].buckets
: []
}
target={visibleMidpointTime}
/>
)}
</WithStreamItems>
)}
</WithLogPosition>
)}

View file

@ -126,6 +126,7 @@ export const LogsToolbar = injectI18n(({ intl }) => {
jumpToTargetPositionTime,
startLiveStreaming,
stopLiveStreaming,
targetPosition,
}) => (
<LogTimeControls
currentTime={visibleMidpointTime}

View file

@ -24,6 +24,7 @@ export interface ReportVisiblePositionsPayload {
endKey: TimeKey | null;
middleKey: TimeKey | null;
startKey: TimeKey | null;
fromScroll: boolean;
}
export const reportVisiblePositions = actionCreator<ReportVisiblePositionsPayload>(

View file

@ -36,6 +36,7 @@ export interface LogPositionState {
middleKey: TimeKey | null;
endKey: TimeKey | null;
};
controlsShouldDisplayTargetPosition: boolean;
}
export const initialLogPositionState: LogPositionState = {
@ -48,6 +49,7 @@ export const initialLogPositionState: LogPositionState = {
middleKey: null,
startKey: null,
},
controlsShouldDisplayTargetPosition: false,
};
const targetPositionReducer = reducerWithInitialState(initialLogPositionState.targetPosition).case(
@ -74,8 +76,23 @@ const visiblePositionReducer = reducerWithInitialState(
startKey,
}));
// Determines whether to use the target position or the visible midpoint when
// displaying a timestamp or time range in the toolbar and log minimap. When the
// user jumps to a new target, the final visible midpoint is indeterminate until
// all the new data has finished loading, so using this flag reduces the perception
// that the UI is jumping around inaccurately
const controlsShouldDisplayTargetPositionReducer = reducerWithInitialState(
initialLogPositionState.controlsShouldDisplayTargetPosition
)
.case(jumpToTargetPosition, () => true)
.case(reportVisiblePositions, (state, { fromScroll }) => {
if (fromScroll) return false;
return state;
});
export const logPositionReducer = combineReducers<LogPositionState>({
targetPosition: targetPositionReducer,
updatePolicy: targetPositionUpdatePolicyReducer,
visiblePositions: visiblePositionReducer,
controlsShouldDisplayTargetPosition: controlsShouldDisplayTargetPositionReducer,
});

View file

@ -22,11 +22,17 @@ export const selectMiddleVisiblePosition = (state: LogPositionState) =>
export const selectLastVisiblePosition = (state: LogPositionState) =>
state.visiblePositions.endKey ? state.visiblePositions.endKey : null;
export const selectControlsShouldDisplayTargetPosition = (state: LogPositionState) =>
state.controlsShouldDisplayTargetPosition;
export const selectVisibleMidpointOrTarget = createSelector(
selectMiddleVisiblePosition,
selectTargetPosition,
(middleVisiblePosition, targetPosition) => {
if (middleVisiblePosition) {
selectControlsShouldDisplayTargetPosition,
(middleVisiblePosition, targetPosition, displayTargetPosition) => {
if (displayTargetPosition) {
return targetPosition;
} else if (middleVisiblePosition) {
return middleVisiblePosition;
} else if (targetPosition) {
return targetPosition;