diff --git a/README.md b/README.md index 1519676e2..a6e94566e 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ Props marked with `*` are **mandatory**: | isConnectionOpened | Sets the connection status. If set, upon reconnection smartcharts will either patch missing tick data or refresh the chart, depending on granularity; if not set, it is assumed that connection is always opened. Defaults to `undefined`. | | onMessage | SmartCharts will send notifications via this callback, should it be provided. Each notification will have the following structure: `{ text, type, category }`. | | isAnimationEnabled | Determine whether chart animation is enabled or disabled. It may needs to be disabled for better performance. Defaults to `true`. | +| isVerticalScrollEnabled | Determine whether verticall scroll on the chart outside Y-axis is disabled. It may need to be disabled for mobile app version to scroll the page up or down instead of the chart. Defaults to `true`. | | showLastDigitStats | Shows last digits stats. Defaults to `false`. | | scrollToEpoch | Scrolls the chart to the leftmost side and sets the last spot/bar as the first visible spot/bar in the chart. Also, it disables scrolling until the chart reaches the 3/4 of the width of the main pane of the chart. Defaults to `null`. | | diff --git a/src/store/ChartAdapterStore.ts b/src/store/ChartAdapterStore.ts index 18b3ebf28..f21a2731e 100644 --- a/src/store/ChartAdapterStore.ts +++ b/src/store/ChartAdapterStore.ts @@ -1,4 +1,4 @@ -import { action, makeObservable, observable, when, runInAction } from 'mobx'; +import { action, makeObservable, observable, when, runInAction, computed } from 'mobx'; import moment from 'moment'; import debounce from 'lodash.debounce'; import { TFlutterChart, TLoadHistoryParams, TQuote } from 'src/types'; @@ -40,13 +40,21 @@ export default class ChartAdapterStore { yLocal: 0, bottomIndex: 0, }; + touchValues: { + x?: number; + y?: number; + yOnTouchEnd?: number; + } = {}; isOverFlutterCharts = false; + enableVerticalScrollTimer?: ReturnType; + scrollChartParentOnTouchTimer?: ReturnType; constructor(mainStore: MainStore) { makeObservable(this, { onMount: action.bound, onTickHistory: action.bound, + onTouch: action.bound, onChartLoad: action.bound, onTick: action.bound, loadHistory: action.bound, @@ -54,8 +62,12 @@ export default class ChartAdapterStore { onQuoteAreaChanged: action.bound, setMsPerPx: action.bound, newChart: action.bound, + enableVerticalScrollTimer: observable, scale: action.bound, + scrollableChartParent: computed, + scrollChartParentOnTouchTimer: observable, toggleDataFitMode: action.bound, + touchValues: observable, onCrosshairMove: action.bound, isDataFitModeEnabled: observable, isChartLoaded: observable, @@ -190,6 +202,9 @@ export default class ChartAdapterStore { } window.flutterChartElement?.addEventListener('wheel', this.onWheel, { capture: true }); + window.flutterChartElement?.addEventListener('touchstart', this.onTouch, { capture: true }); + window.flutterChartElement?.addEventListener('touchmove', this.onTouch, { capture: true }); + window.flutterChartElement?.addEventListener('touchend', this.onTouch, { capture: true }); window.flutterChartElement?.addEventListener('dblclick', this.onDoubleClick, { capture: true }); window.addEventListener('mousemove', this.onMouseMove, { capture: true }); } @@ -198,8 +213,13 @@ export default class ChartAdapterStore { window._flutter.initState.isMounted = false; window.flutterChartElement?.removeEventListener('wheel', this.onWheel, { capture: true }); + window.flutterChartElement?.removeEventListener('touchstart', this.onTouch, { capture: true }); + window.flutterChartElement?.removeEventListener('touchmove', this.onTouch, { capture: true }); + window.flutterChartElement?.removeEventListener('touchend', this.onTouch, { capture: true }); window.flutterChartElement?.removeEventListener('dblclick', this.onDoubleClick, { capture: true }); window.removeEventListener('mousemove', this.onMouseMove, { capture: true }); + clearTimeout(this.enableVerticalScrollTimer); + clearTimeout(this.scrollChartParentOnTouchTimer); } onChartLoad() { @@ -216,15 +236,70 @@ export default class ChartAdapterStore { } } + onTouch(e: TouchEvent) { + const chartNode = this.mainStore.chart.chartNode; + // Prevent vertical scroll on the chart for touch devices by forcing scroll on a scrollable parent of the chart: + if ( + chartNode && + this.scrollableChartParent && + !this.mainStore.state.isVerticalScrollEnabled && + e.touches.length === 1 + ) { + const { pageX, screenX, screenY } = e.touches[0]; + if (['touchstart', 'touchend'].includes(e.type)) { + this.touchValues = e.type === 'touchstart' ? { x: screenX, y: screenY } : { yOnTouchEnd: screenY }; + } else if (e.type === 'touchmove') { + const nonScrollableAreaWidth = chartNode.offsetWidth - this.mainStore.chart.yAxisWidth; + const { left } = chartNode.getBoundingClientRect(); + + if (this.touchValues.x && this.touchValues.y) { + const deltaX = Math.abs(screenX - this.touchValues.x); + const deltaY = Math.abs(screenY - this.touchValues.y); + const isVerticalScroll = deltaY > deltaX; + const x = pageX - left; + if (x < nonScrollableAreaWidth && isVerticalScroll && !this.scrollChartParentOnTouchTimer) { + this.touchValues.yOnTouchEnd = undefined; + this.scrollChartParentOnTouchTimer = setTimeout(() => { + this.scrollableChartParent?.scrollBy({ + top: screenY - Number(this.touchValues.yOnTouchEnd ?? this.touchValues.y), + behavior: 'smooth', + }); + this.scrollChartParentOnTouchTimer = undefined; + }, 300); + } + } + this.touchValues = { x: screenX, y: screenY }; + } + } + } + onWheel = (e: WheelEvent) => { e.preventDefault(); + + // Prevent vertical scroll on the chart on wheel devices by disabling pointer events to make chart parent scrollable: + const chartNode = this.mainStore.chart.chartNode; + if (chartNode && !this.mainStore.state.isVerticalScrollEnabled) { + const nonScrollableAreaWidth = chartNode.offsetWidth - this.mainStore.chart.yAxisWidth; + const { left } = chartNode.getBoundingClientRect(); + const isVerticalScroll = e.deltaY && e.deltaX === 0; + const x = e.pageX - left; + if (x < nonScrollableAreaWidth && isVerticalScroll) { + if (this.enableVerticalScrollTimer) return; + chartNode.style.pointerEvents = 'none'; + this.enableVerticalScrollTimer = setTimeout(() => { + chartNode.style.pointerEvents = 'auto'; + this.enableVerticalScrollTimer = undefined; + }, 300); + return; + } + } + if (e.deltaX === 0 && e.deltaZ === 0) { const value = (100 - Math.min(10, Math.max(-10, e.deltaY))) / 100; this.scale(value); } else { window.flutterChart?.app.scroll(e.deltaX); } - return false; }; @@ -439,24 +514,24 @@ export default class ChartAdapterStore { let delta_x, delta_y, ratio; // Here we interpolate the pixel distance between two adjacent ticks. - if (bar && bar.DT! < date) { + if (bar && (bar.DT as Date) < date) { const barNext = this.mainStore.chart.feed?.quotes[tickIdx + 1]; const barPrev = tickIdx > 0 ? this.mainStore.chart.feed?.quotes[tickIdx - 1] : null; - if (barNext && barNext.Close && barNext.DT! > date) { - delta_x = this.getXFromEpoch(barNext.DT!.getTime()) - x; + if (barNext && barNext.Close && (barNext.DT as Date) > date) { + delta_x = this.getXFromEpoch((barNext.DT as Date).getTime()) - x; ratio = - (((date as unknown) as number) - bar.DT!.getTime()) / - (barNext.DT!.getTime() - bar.DT!.getTime()); + ((date as unknown as number) - (bar.DT as Date).getTime()) / + ((barNext.DT as Date).getTime() - (bar.DT as Date).getTime()); if (price) delta_y = barNext.Close - price; } else if (barPrev && barPrev.Close) { - delta_x = x - this.getXFromEpoch(barPrev.DT!.getTime()); + delta_x = x - this.getXFromEpoch((barPrev.DT as Date).getTime()); ratio = - (((date as unknown) as number) - bar.DT!.getTime()) / - (bar.DT!.getTime() - barPrev.DT!.getTime()); + ((date as unknown as number) - (bar.DT as Date).getTime()) / + ((bar.DT as Date).getTime() - (barPrev.DT as Date).getTime()); if (price) delta_y = price - barPrev.Close; } @@ -499,4 +574,18 @@ export default class ChartAdapterStore { getQuoteFromY(y: number) { return this.flutterChart?.app.getQuoteFromY(y) ?? 0; } + + get scrollableChartParent() { + const chartNode = this.mainStore.chart.chartNode; + if (!chartNode) return undefined; + let parent = chartNode.parentElement; + while (parent) { + const { overflow } = window.getComputedStyle(parent); + if (overflow.split(' ').every(o => o === 'auto' || o === 'scroll')) { + return parent; + } + parent = parent.parentElement; + } + return document.documentElement; + } } diff --git a/src/store/ChartState.ts b/src/store/ChartState.ts index fccccfbe7..1e8bad72a 100644 --- a/src/store/ChartState.ts +++ b/src/store/ChartState.ts @@ -42,6 +42,7 @@ class ChartState { shouldDrawTicksFromContractInfo? = false; has_updated_settings = false; isAnimationEnabled?: boolean; + isVerticalScrollEnabled? = true; mainStore: MainStore; margin?: number; granularity: TGranularity; @@ -108,6 +109,7 @@ class ChartState { heightFactor: observable, isConnectionOpened: observable, isChartReady: observable, + isVerticalScrollEnabled: observable, chartStatusListener: observable, debouncedStateChange: action.bound, stateChangeListener: observable, @@ -171,6 +173,7 @@ class ChartState { isAnimationEnabled = true, isConnectionOpened, isStaticChart, + isVerticalScrollEnabled = true, granularity, margin = 0, refreshActiveSymbols, @@ -223,6 +226,7 @@ class ChartState { this.isAnimationEnabled = isAnimationEnabled; this.isConnectionOpened = isConnectionOpened; this.isStaticChart = isStaticChart; + this.isVerticalScrollEnabled = isVerticalScrollEnabled; this.margin = margin; this.has_updated_settings = !isDeepEqual(this.settings?.whitespace, settings?.whitespace); this.settings = settings; diff --git a/src/types/props.types.ts b/src/types/props.types.ts index 985139181..1d10756a6 100644 --- a/src/types/props.types.ts +++ b/src/types/props.types.ts @@ -211,6 +211,7 @@ export type TChartProps = { isConnectionOpened?: boolean; onMessage?: (message: TNotification) => void; isAnimationEnabled?: boolean; + isVerticalScrollEnabled?: boolean; showLastDigitStats?: boolean; scrollToEpoch?: number | null; clearChart?: () => void;