Skip to content

Commit

Permalink
fix: timeline scroll should be fixed in initial render (#571)
Browse files Browse the repository at this point in the history
* fix: scrolling timeline on reearth/core

* wip

* wip

* fix: old timeline

* test: fix

* test

* Update src/components/atoms/Timeline/hooks.ts

Co-authored-by: rot1024 <[email protected]>

---------

Co-authored-by: rot1024 <[email protected]>
  • Loading branch information
keiya01 and rot1024 authored Apr 4, 2023
1 parent 897868e commit 6a0aed1
Show file tree
Hide file tree
Showing 8 changed files with 204 additions and 63 deletions.
2 changes: 2 additions & 0 deletions src/components/atoms/Timeline/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ export const DAY_SECS = 86400;
export const MINUTES_SEC = 60;
export const SCALE_ZOOM_INTERVAL = 5;
export const KNOB_SIZE = 25;

export const SCALE_LABEL_WIDTH = 111;
97 changes: 50 additions & 47 deletions src/components/atoms/Timeline/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, {
ChangeEventHandler,
MouseEvent,
MouseEventHandler,
RefObject,
useCallback,
useEffect,
useMemo,
Expand All @@ -16,22 +17,19 @@ import {
BORDER_WIDTH,
DAY_SECS,
EPOCH_SEC,
SCALE_ZOOM_INTERVAL,
GAP_HORIZONTAL,
MAX_ZOOM_RATIO,
PADDING_HORIZONTAL,
SCALE_INTERVAL,
MINUTES_SEC,
HOURS_SECS,
NORMAL_SCALE_WIDTH,
STRONG_SCALE_WIDTH,
} from "./constants";
import { TimeEventHandler, Range } from "./types";
import { formatDateForTimeline } from "./utils";
import { calcScaleInterval, formatDateForTimeline } from "./utils";

const convertPositionToTime = (e: MouseEvent, { start, end }: Range, gapHorizontal: number) => {
const convertPositionToTime = (e: MouseEvent, { start, end }: Range) => {
const curTar = e.currentTarget;
const width = curTar.scrollWidth - (PADDING_HORIZONTAL + BORDER_WIDTH) * 2 + gapHorizontal / 2;
const width = curTar.scrollWidth - (PADDING_HORIZONTAL + BORDER_WIDTH) * 2;
const rect = curTar.getBoundingClientRect();
const clientX = e.clientX - rect.x;
const scrollX = curTar.scrollLeft;
Expand All @@ -44,22 +42,22 @@ const convertPositionToTime = (e: MouseEvent, { start, end }: Range, gapHorizont

type InteractionOption = {
range: Range;
gapHorizontal: number;
zoom: number;
isRangeLessThanHalfHours: boolean;
scaleElement: RefObject<HTMLDivElement>;
setScaleWidth: React.Dispatch<React.SetStateAction<number>>;
setZoom: React.Dispatch<React.SetStateAction<number>>;
onClick?: TimeEventHandler;
onDrag?: TimeEventHandler;
};

const useTimelineInteraction = ({
range: { start, end },
gapHorizontal,
zoom,
scaleElement,
setScaleWidth,
setZoom,
onClick,
onDrag,
isRangeLessThanHalfHours,
}: InteractionOption) => {
const [isMouseDown, setIsMouseDown] = useState(false);
const handleOnMouseDown = useCallback(() => {
Expand All @@ -74,7 +72,7 @@ const useTimelineInteraction = ({
return;
}

onDrag(convertPositionToTime(e, { start, end }, gapHorizontal));
onDrag(convertPositionToTime(e, { start, end }));

const scrollThreshold = 30;
const scrollAmount = 20;
Expand All @@ -90,7 +88,7 @@ const useTimelineInteraction = ({
curTar.scroll(curTar.scrollLeft - scrollAmount, 0);
}
},
[onDrag, start, end, isMouseDown, gapHorizontal],
[onDrag, start, end, isMouseDown],
);

useEffect(() => {
Expand All @@ -103,26 +101,37 @@ const useTimelineInteraction = ({
const handleOnClick: MouseEventHandler = useCallback(
e => {
if (!onClick) return;
onClick(convertPositionToTime(e, { start, end }, gapHorizontal));
onClick(convertPositionToTime(e, { start, end }));
},
[onClick, end, start, gapHorizontal],
[onClick, end, start],
);

const handleOnWheel: WheelEventHandler = useCallback(
e => {
if (isRangeLessThanHalfHours) {
return;
}

const { deltaX, deltaY } = e;
const isHorizontal = Math.abs(deltaX) > 0 || Math.abs(deltaX) < 0;
if (isHorizontal) return;

setZoom(() => Math.min(Math.max(1, zoom + deltaY * -0.01), MAX_ZOOM_RATIO));
},
[zoom, setZoom, isRangeLessThanHalfHours],
[zoom, setZoom],
);

useEffect(() => {
const elm = scaleElement.current;
if (!elm) return;

const obs = new ResizeObserver(m => {
const target = m[0].target;
setScaleWidth(target.clientWidth);
});
obs.observe(elm);

return () => {
obs.disconnect();
};
}, [setScaleWidth, scaleElement]);

return {
onMouseDown: handleOnMouseDown,
onMouseMove: handleOnMouseMove,
Expand Down Expand Up @@ -241,7 +250,6 @@ export const useTimeline = ({
onPlayReversed,
onSpeedChange,
}: Option) => {
const [zoom, setZoom] = useState(1);
const range = useMemo(() => {
const range = getRange(_range);
if (process.env.NODE_ENV !== "production") {
Expand All @@ -251,57 +259,50 @@ export const useTimeline = ({
}
return {
start: truncMinutes(new Date(range.start)).getTime(),
end: range.end,
end: truncMinutes(new Date(range.end)).getTime(),
};
}, [_range]);
const { start, end } = range;
const [zoom, setZoom] = useState(1);
const startDate = useMemo(() => new Date(start), [start]);
const gapHorizontal = GAP_HORIZONTAL * (zoom - Math.trunc(zoom) + 1);
const scaleInterval = Math.max(
SCALE_INTERVAL - Math.trunc(zoom - 1) * SCALE_ZOOM_INTERVAL * MINUTES_SEC,
MINUTES_SEC,
);
const zoomedGap = GAP_HORIZONTAL * (zoom - Math.trunc(zoom) + 1);
const epochDiff = end - start;

// convert epoch diff to minutes.
const scaleCount = Math.trunc(epochDiff / EPOCH_SEC / scaleInterval);
const scaleElement = useRef<HTMLDivElement | null>(null);
const [scaleWidth, setScaleWidth] = useState(0);

const {
scaleCount,
scaleInterval,
strongScaleMinutes,
gap: gapHorizontal,
} = useMemo(
() => calcScaleInterval(epochDiff, zoom, { gap: zoomedGap, width: scaleWidth }),
[epochDiff, zoom, scaleWidth, zoomedGap],
);

// Count hours scale
const hoursCount = Math.trunc(HOURS_SECS / scaleInterval);

const strongScaleMinutes = Math.max(scaleInterval / MINUTES_SEC + Math.trunc(zoom) + 1, 10);

// Convert scale count to pixel.
const currentPosition = useMemo(() => {
const diff = Math.min((currentTime - start) / EPOCH_SEC / scaleInterval, scaleCount);
const strongScaleCount = diff / strongScaleMinutes;
const strongScaleCount = diff / strongScaleMinutes - 1;
return Math.max(
diff * gapHorizontal +
(diff - strongScaleCount) * NORMAL_SCALE_WIDTH +
strongScaleCount * STRONG_SCALE_WIDTH,
(diff - strongScaleCount) * (NORMAL_SCALE_WIDTH + gapHorizontal) +
strongScaleCount * (STRONG_SCALE_WIDTH + gapHorizontal),
0,
);
}, [currentTime, start, scaleCount, gapHorizontal, scaleInterval, strongScaleMinutes]);

const isRangeLessThanHalfHours = useMemo(
() => epochDiff < (DAY_SECS / 4) * EPOCH_SEC,
[epochDiff],
);

useEffect(() => {
if (isRangeLessThanHalfHours) {
setZoom(MAX_ZOOM_RATIO);
}
}, [isRangeLessThanHalfHours]);

const events = useTimelineInteraction({
range,
zoom,
setZoom,
gapHorizontal,
onClick,
onDrag,
isRangeLessThanHalfHours,
scaleElement,
setScaleWidth,
});
const player = useTimelinePlayer({ currentTime, onPlay, onPlayReversed, onSpeedChange });

Expand All @@ -315,5 +316,7 @@ export const useTimeline = ({
currentPosition,
events,
player,
scaleElement,
shouldScroll: zoom !== 1,
};
};
20 changes: 10 additions & 10 deletions src/components/atoms/Timeline/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ import { expect, test, vi, vitest } from "vitest";

import { render, screen, fireEvent } from "@reearth/test/utils";

import { PADDING_HORIZONTAL, BORDER_WIDTH, KNOB_SIZE, GAP_HORIZONTAL } from "./constants";
import { PADDING_HORIZONTAL, BORDER_WIDTH, GAP_HORIZONTAL } from "./constants";

import Timeline from ".";

global.ResizeObserver = class {
observe() {}
disconnect() {}
} as any;

const CURRENT_TIME = new Date("2022-07-03T00:00:00.000").getTime();
// This is width when range is one day.
const SCROLL_WIDTH = 2208;
Expand Down Expand Up @@ -53,16 +58,15 @@ test("it should get time from clicked position", () => {
});

const iconWrapper = screen.getByTestId("knob-icon");
const actualLeft = Math.trunc(parseInt(iconWrapper.style.left.split("px")[0]));
expect(actualLeft).toBe(Math.trunc(currentPosition + PADDING_HORIZONTAL - KNOB_SIZE / 2));
expect(iconWrapper.style.left).toBe("-0.5px");
});

test("it should get time from mouse moved position", () => {
render(<TimelineWrapper />);
const slider = screen.getAllByRole("slider")[1];
const currentPosition = 12;
const clientX = PADDING_HORIZONTAL + BORDER_WIDTH + currentPosition;
const expectedLeft = Math.trunc(currentPosition + PADDING_HORIZONTAL - KNOB_SIZE / 2);
const expectedLeft = "-0.5px";

const scroll = vi.fn();
window.HTMLElement.prototype.scroll = scroll;
Expand All @@ -88,17 +92,13 @@ test("it should get time from mouse moved position", () => {
clientX,
});
fireEvent.mouseUp(slider);
expect(Math.trunc(parseInt(screen.getByTestId("knob-icon").style.left.split("px")[0], 10))).toBe(
expectedLeft,
);
expect(screen.getByTestId("knob-icon").style.left).toBe(expectedLeft);

// It should not move
fireEvent.mouseMove(slider, {
clientX: clientX * 2,
});
expect(Math.trunc(parseInt(screen.getByTestId("knob-icon").style.left.split("px")[0], 10))).toBe(
expectedLeft,
);
expect(screen.getByTestId("knob-icon").style.left).toBe(expectedLeft);
});

test("it should get correct strongScaleHours from amount of scroll", () => {
Expand Down
9 changes: 6 additions & 3 deletions src/components/atoms/Timeline/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ const Timeline: React.FC<Props> = memo(function TimelinePresenter({
scaleInterval,
strongScaleMinutes,
currentPosition,
scaleElement,
events,
shouldScroll,
player: {
formattedCurrentTime,
isPlaying,
Expand Down Expand Up @@ -130,7 +132,7 @@ const Timeline: React.FC<Props> = memo(function TimelinePresenter({
* TODO: Support keyboard operation for accessibility
* see: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/slider_role
*/}
<ScaleBox role="slider" {...events}>
<ScaleBox role="slider" {...events} ref={scaleElement} shouldScroll={shouldScroll}>
<ScaleList
start={startDate}
scaleCount={scaleCount}
Expand Down Expand Up @@ -259,6 +261,7 @@ const CurrentTimeWrapper = styled.div`
padding: ${({ theme }) => `0 ${theme.metrics.s}px`};
margin: ${({ theme }) => `${theme.metrics.xs}px 0`};
flex-shrink: 0;
width: 70px;
@media (max-width: 768px) {
display: none;
Expand All @@ -271,12 +274,12 @@ const CurrentTime = styled(Text)<StyledColorProps>`
white-space: pre-line;
`;

const ScaleBox = styled.div`
const ScaleBox = styled.div<{ shouldScroll: boolean }>`
border: ${({ theme }) => `${BORDER_WIDTH}px solid ${theme.main.weak}`};
border-radius: 5px;
box-sizing: border-box;
position: relative;
overflow-x: overlay;
overflow-x: ${({ shouldScroll }) => (shouldScroll ? "overlay" : "hidden")};
overflow-y: hidden;
contain: strict;
width: 100%;
Expand Down
28 changes: 28 additions & 0 deletions src/components/atoms/Timeline/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { expect, test } from "vitest";

import { calcScaleInterval } from "./utils";

test("calcScaleInterval()", () => {
expect(
calcScaleInterval(new Date("2023-01-02").getTime() - new Date("2023-01-01").getTime(), 1, {
gap: 10,
width: 300,
}),
).toEqual({
gap: 10.358333333333333,
scaleCount: 24,
scaleInterval: 3600,
strongScaleMinutes: 10,
});
expect(
calcScaleInterval(new Date("2023-01-02").getTime() - new Date("2023-01-01").getTime(), 2, {
gap: 10,
width: 300,
}),
).toEqual({
gap: 10,
scaleCount: 48,
scaleInterval: 1800,
strongScaleMinutes: 15,
});
});
Loading

0 comments on commit 6a0aed1

Please sign in to comment.