Management: Introduce Statistics for channels (#1703)

This commit is contained in:
Alexander Zinchuk 2022-03-11 19:38:47 +01:00
parent b1fbc1c4b6
commit e3bc23bd57
73 changed files with 4935 additions and 158 deletions

View File

@ -8,6 +8,8 @@ src/lib/gramjs/tl/api.d.ts
src/lib/gramjs/tl/apiTl.js
src/lib/gramjs/tl/schemaTl.js
src/lib/lovely-chart
webpack.config.js
jest.config.js
src/lib/secret-sauce/

View File

@ -0,0 +1,115 @@
import { Api as GramJs } from '../../../lib/gramjs';
import {
ApiStatistics,
StatisticsGraph,
StatisticsOverviewItem,
StatisticsOverviewPercentage,
} from '../../types';
export function buildStatistics(stats: GramJs.stats.BroadcastStats): ApiStatistics {
return {
// Graphs
growthGraph: buildGraph(stats.growthGraph),
followersGraph: buildGraph(stats.followersGraph),
muteGraph: buildGraph(stats.muteGraph),
topHoursGraph: buildGraph(stats.topHoursGraph),
// Async graphs
languagesGraph: (stats.languagesGraph as GramJs.StatsGraphAsync).token,
viewsBySourceGraph: (stats.viewsBySourceGraph as GramJs.StatsGraphAsync).token,
newFollowersBySourceGraph: (stats.newFollowersBySourceGraph as GramJs.StatsGraphAsync).token,
interactionsGraph: (stats.interactionsGraph as GramJs.StatsGraphAsync).token,
// Statistics overview
followers: buildStatisticsOverview(stats.followers),
viewsPerPost: buildStatisticsOverview(stats.viewsPerPost),
sharesPerPost: buildStatisticsOverview(stats.sharesPerPost),
enabledNotifications: buildStatisticsPercentage(stats.enabledNotifications),
// Recent posts
recentTopMessages: stats.recentMessageInteractions,
};
}
export function buildGraph(result: GramJs.TypeStatsGraph, isPercentage?: boolean): StatisticsGraph {
if ((result as GramJs.StatsGraphError).error) {
throw new Error((result as GramJs.StatsGraphError).error);
}
const data = JSON.parse((result as GramJs.StatsGraph).json.data);
const [x, ...y] = data.columns;
const hasSecondYAxis = data.y_scaled;
return {
type: getGraphType(data.types.y0, isPercentage),
zoomToken: (result as GramJs.StatsGraph).zoomToken,
labelFormatter: data.xTickFormatter,
tooltipFormatter: data.xTooltipFormatter,
labels: x.slice(1),
hideCaption: !data.subchart.show,
hasSecondYAxis,
isStacked: data.stacked && !hasSecondYAxis,
isPercentage,
datasets: y.map((item: any) => {
const key = item[0];
return {
name: data.names[key],
color: extractColor(data.colors[key]),
values: item.slice(1),
};
}),
...calculateMinimapRange(data.subchart.defaultZoom, x.slice(1)),
};
}
function getGraphType(apiType: string, isPercentage?: boolean): string {
switch (apiType) {
case 'step':
return 'bar';
default:
return isPercentage ? 'area' : apiType;
}
}
function extractColor(color: string): string {
return color.substring(color.indexOf('#'));
}
function calculateMinimapRange(range: Array<number>, values: Array<number>) {
const [min, max] = range;
let minIndex = 0;
let maxIndex = values.length - 1;
values.forEach((item, index) => {
if (!minIndex && item >= min) {
minIndex = index;
}
if (!maxIndex && item >= max) {
maxIndex = index;
}
});
const begin = Math.max(0, minIndex / (values.length - 1));
const end = Math.min(1, maxIndex / (values.length - 1));
return { minimapRange: { begin, end }, labelFromIndex: minIndex, labelToIndex: maxIndex };
}
function buildStatisticsOverview({ current, previous }: GramJs.StatsAbsValueAndPrev): StatisticsOverviewItem {
const change = current - previous;
return {
current,
change,
...(previous && { percentage: (change ? ((Math.abs(change) / previous) * 100) : 0).toFixed(2) }),
};
}
function buildStatisticsPercentage(data: GramJs.StatsPercentValue): StatisticsOverviewPercentage {
return {
percentage: ((data.part / data.total) * 100).toFixed(2),
};
}

View File

@ -405,6 +405,7 @@ async function getFullChannelInfo(
migratedFromChatId,
migratedFromMaxId,
canViewParticipants,
canViewStats,
linkedChatId,
hiddenPrehistory,
call,
@ -413,6 +414,7 @@ async function getFullChannelInfo(
defaultSendAs,
requestsPending,
recentRequesters,
statsDc,
} = result.fullChat;
const inviteLink = exportedInvite instanceof GramJs.ChatInviteExported
@ -456,6 +458,7 @@ async function getFullChannelInfo(
maxMessageId: migratedFromMaxId,
} : undefined,
canViewMembers: canViewParticipants,
canViewStatistics: canViewStats,
isPreHistoryHidden: hiddenPrehistory,
members,
kickedMembers,
@ -467,6 +470,7 @@ async function getFullChannelInfo(
sendAsId: defaultSendAs ? getApiChatIdFromMtpPeer(defaultSendAs) : undefined,
requestsPending,
recentRequesterIds: recentRequesters?.map((userId) => buildApiPeerId(userId, 'user')),
statisticsDcId: statsDc,
},
users: [...(users || []), ...(bannedUsers || []), ...(adminUsers || [])],
groupCall: call ? {

View File

@ -167,6 +167,8 @@ export async function invokeRequest<T extends GramJs.AnyRequest>(
request: T,
shouldReturnTrue: true,
shouldThrow?: boolean,
shouldIgnoreUpdates?: undefined,
dcId?: number,
): Promise<true | undefined>;
export async function invokeRequest<T extends GramJs.AnyRequest>(
@ -174,6 +176,7 @@ export async function invokeRequest<T extends GramJs.AnyRequest>(
shouldReturnTrue?: boolean,
shouldThrow?: boolean,
shouldIgnoreUpdates?: boolean,
dcId?: number,
): Promise<T['__response'] | undefined>;
export async function invokeRequest<T extends GramJs.AnyRequest>(
@ -181,6 +184,7 @@ export async function invokeRequest<T extends GramJs.AnyRequest>(
shouldReturnTrue = false,
shouldThrow = false,
shouldIgnoreUpdates = false,
dcId?: number,
) {
if (!isConnected) {
if (DEBUG) {
@ -197,7 +201,7 @@ export async function invokeRequest<T extends GramJs.AnyRequest>(
console.log(`[GramJs/client] INVOKE ${request.className}`);
}
const result = await client.invoke(request);
const result = await client.invoke(request, dcId);
if (DEBUG) {
// eslint-disable-next-line no-console

View File

@ -75,3 +75,5 @@ export {
getAvailableReactions, sendReaction, sendEmojiInteraction, fetchMessageReactionsList,
setDefaultReaction, fetchMessageReactions, sendWatchingEmojiInteraction,
} from './reactions';
export { fetchStatistics, fetchStatisticsAsyncGraph } from './statistics';

View File

@ -0,0 +1,43 @@
import BigInt from 'big-integer';
import { Api as GramJs } from '../../../lib/gramjs';
import { ApiChat, ApiStatistics, StatisticsGraph } from '../../types';
import { invokeRequest } from './client';
import { buildInputEntity } from '../gramjsBuilders';
import { buildStatistics, buildGraph } from '../apiBuilders/statistics';
export async function fetchStatistics({ chat }: { chat: ApiChat }): Promise<ApiStatistics | undefined> {
const result = await invokeRequest(new GramJs.stats.GetBroadcastStats({
channel: buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel,
}), undefined, undefined, undefined, chat.fullInfo!.statisticsDcId);
if (!result) {
return undefined;
}
return buildStatistics(result);
}
export async function fetchStatisticsAsyncGraph({
token,
x,
isPercentage,
dcId,
}: {
token: string;
x?: number;
isPercentage?: boolean;
dcId?: number;
}): Promise<StatisticsGraph | undefined> {
const result = await invokeRequest(new GramJs.stats.LoadAsyncGraph({
token,
...(x && { x: BigInt(x) }),
}), undefined, undefined, undefined, dcId);
if (!result) {
return undefined;
}
return buildGraph(result as GramJs.StatsGraph, isPercentage);
}

View File

@ -91,8 +91,10 @@ export interface ApiChatFullInfo {
botCommands?: ApiBotCommand[];
enabledReactions?: string[];
sendAsId?: string;
canViewStatistics?: boolean;
recentRequesterIds?: string[];
requestsPending?: number;
statisticsDcId?: number;
}
export interface ApiChatMember {

View File

@ -8,3 +8,4 @@ export * from './settings';
export * from './bots';
export * from './misc';
export * from './calls';
export * from './statistics';

View File

@ -0,0 +1,56 @@
import { ApiMessage } from './messages';
export interface ApiStatistics {
growthGraph: StatisticsGraph;
followersGraph: StatisticsGraph;
muteGraph: StatisticsGraph;
topHoursGraph: StatisticsGraph;
interactionsGraph: StatisticsGraph | string;
viewsBySourceGraph: StatisticsGraph | string;
newFollowersBySourceGraph: StatisticsGraph | string;
languagesGraph: StatisticsGraph | string;
followers: StatisticsOverviewItem;
viewsPerPost: StatisticsOverviewItem;
sharesPerPost: StatisticsOverviewItem;
enabledNotifications: StatisticsOverviewPercentage;
recentTopMessages: Array<StatisticsRecentMessage | StatisticsRecentMessage & ApiMessage>;
}
export interface StatisticsGraph {
type: string;
zoomToken?: string;
labelFormatter: string;
tooltipFormatter: string;
labels: Array<string | number>;
isStacked: boolean;
isPercentage?: boolean;
hideCaption: boolean;
hasSecondYAxis: boolean;
minimapRange: {
begin: number;
end: number;
};
labelFromIndex: number;
labelToIndex: number;
datasets: {
name: string;
color: string;
values: number[];
};
}
export interface StatisticsOverviewItem {
current?: number;
change?: number;
percentage: string;
}
export interface StatisticsOverviewPercentage {
percentage: string;
}
export interface StatisticsRecentMessage {
msgId: number;
forwards: number;
views: number;
}

Binary file not shown.

Binary file not shown.

View File

@ -46,6 +46,7 @@ export { default as SendAsMenu } from '../components/middle/composer/SendAsMenu'
export { default as RightSearch } from '../components/right/RightSearch';
export { default as StickerSearch } from '../components/right/StickerSearch';
export { default as GifSearch } from '../components/right/GifSearch';
export { default as Statistics } from '../components/right/statistics/Statistics';
export { default as PollResults } from '../components/right/PollResults';
export { default as Management } from '../components/right/management/Management';

View File

@ -50,6 +50,7 @@ interface StateProps {
canSearch?: boolean;
canCall?: boolean;
canMute?: boolean;
canViewStatistics?: boolean;
canLeave?: boolean;
canEnterVoiceChat?: boolean;
canCreateVoiceChat?: boolean;
@ -70,6 +71,7 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
canSearch,
canCall,
canMute,
canViewStatistics,
canLeave,
canEnterVoiceChat,
canCreateVoiceChat,
@ -261,6 +263,7 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
canSearch={canSearch}
canCall={canCall}
canMute={canMute}
canViewStatistics={canViewStatistics}
canLeave={canLeave}
canEnterVoiceChat={canEnterVoiceChat}
canCreateVoiceChat={canCreateVoiceChat}
@ -303,6 +306,7 @@ export default memo(withGlobal<OwnProps>(
const canEnterVoiceChat = ARE_CALLS_SUPPORTED && chat.isCallActive;
const canCreateVoiceChat = ARE_CALLS_SUPPORTED && !chat.isCallActive
&& (chat.adminRights?.manageCall || (chat.isCreator && isChatBasicGroup(chat)));
const canViewStatistics = chat.fullInfo?.canViewStatistics;
const pendingJoinRequests = chat.fullInfo?.requestsPending;
return {
@ -315,6 +319,7 @@ export default memo(withGlobal<OwnProps>(
canSearch,
canCall,
canMute,
canViewStatistics,
canLeave,
canEnterVoiceChat,
canCreateVoiceChat,

View File

@ -37,6 +37,7 @@ export type OwnProps = {
canSearch?: boolean;
canCall?: boolean;
canMute?: boolean;
canViewStatistics?: boolean;
canLeave?: boolean;
canEnterVoiceChat?: boolean;
canCreateVoiceChat?: boolean;
@ -67,6 +68,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
canSearch,
canCall,
canMute,
canViewStatistics,
canLeave,
canEnterVoiceChat,
canCreateVoiceChat,
@ -91,6 +93,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
openLinkedChat,
addContact,
openCallFallbackConfirm,
toggleStatistics,
} = getDispatch();
const [isMenuOpen, setIsMenuOpen] = useState(true);
@ -166,6 +169,11 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
closeMenu();
}, [closeMenu, onSearchClick]);
const handleStatisticsClick = useCallback(() => {
toggleStatistics();
closeMenu();
}, [closeMenu, toggleStatistics]);
const handleSelectMessages = useCallback(() => {
enterMessageSelectMode();
closeMenu();
@ -266,6 +274,14 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
>
{lang('ReportSelectMessages')}
</MenuItem>
{canViewStatistics && (
<MenuItem
icon="stats"
onClick={handleStatisticsClick}
>
{lang('Statistics')}
</MenuItem>
)}
{canLeave && (
<MenuItem
destructive

View File

@ -24,6 +24,7 @@ import Profile from './Profile';
import Transition from '../ui/Transition';
import RightSearch from './RightSearch.async';
import Management from './management/Management.async';
import Statistics from './statistics/Statistics.async';
import StickerSearch from './StickerSearch.async';
import GifSearch from './GifSearch.async';
import PollResults from './PollResults.async';
@ -69,6 +70,7 @@ const RightColumn: FC<StateProps> = ({
addChatMembers,
setNewChatMembersDialogState,
setEditingExportedInvite,
toggleStatistics,
setOpenedInviteInfo,
requestNextManagementScreen,
} = getDispatch();
@ -84,6 +86,7 @@ const RightColumn: FC<StateProps> = ({
const isProfile = contentKey === RightColumnContent.ChatInfo;
const isSearch = contentKey === RightColumnContent.Search;
const isManagement = contentKey === RightColumnContent.Management;
const isStatistics = contentKey === RightColumnContent.Statistics;
const isStickerSearch = contentKey === RightColumnContent.StickerSearch;
const isGifSearch = contentKey === RightColumnContent.GifSearch;
const isPollResults = contentKey === RightColumnContent.PollResults;
@ -147,6 +150,9 @@ const RightColumn: FC<StateProps> = ({
break;
}
case RightColumnContent.Statistics:
toggleStatistics();
break;
case RightColumnContent.Search: {
blurSearchInput();
closeLocalTextSearch();
@ -168,7 +174,7 @@ const RightColumn: FC<StateProps> = ({
}, [
contentKey, isScrolledDown, toggleChatInfo, closePollResults, setNewChatMembersDialogState,
managementScreen, toggleManagement, closeLocalTextSearch, setStickerSearchQuery, setGifSearchQuery,
setEditingExportedInvite, chatId, setOpenedInviteInfo,
setEditingExportedInvite, chatId, setOpenedInviteInfo, toggleStatistics,
]);
const handleSelectChatMember = useCallback((memberId, isPromoted) => {
@ -260,6 +266,8 @@ const RightColumn: FC<StateProps> = ({
/>
);
case RightColumnContent.Statistics:
return <Statistics chatId={chatId!} isActive={isOpen && isActive} />;
case RightColumnContent.StickerSearch:
return <StickerSearch onClose={close} isActive={isOpen && isActive} />;
case RightColumnContent.GifSearch:
@ -284,6 +292,7 @@ const RightColumn: FC<StateProps> = ({
isProfile={isProfile}
isSearch={isSearch}
isManagement={isManagement}
isStatistics={isStatistics}
isStickerSearch={isStickerSearch}
isGifSearch={isGifSearch}
isPollResults={isPollResults}

View File

@ -38,6 +38,7 @@ type OwnProps = {
isProfile?: boolean;
isSearch?: boolean;
isManagement?: boolean;
isStatistics?: boolean;
isStickerSearch?: boolean;
isGifSearch?: boolean;
isPollResults?: boolean;
@ -52,6 +53,7 @@ type OwnProps = {
type StateProps = {
canAddContact?: boolean;
canManage?: boolean;
canViewStatistics?: boolean;
isChannel?: boolean;
userId?: string;
messageSearchQuery?: string;
@ -69,6 +71,7 @@ enum HeaderContent {
MemberList,
SharedMedia,
Search,
Statistics,
Management,
ManageInitial,
ManageChannelSubscribers,
@ -102,6 +105,7 @@ const RightHeader: FC<OwnProps & StateProps> = ({
isProfile,
isSearch,
isManagement,
isStatistics,
isStickerSearch,
isGifSearch,
isPollResults,
@ -119,6 +123,7 @@ const RightHeader: FC<OwnProps & StateProps> = ({
gifSearchQuery,
shouldSkipAnimation,
isEditingInvite,
canViewStatistics,
currentInviteInfo,
}) => {
const {
@ -129,6 +134,7 @@ const RightHeader: FC<OwnProps & StateProps> = ({
toggleManagement,
openHistoryCalendar,
addContact,
toggleStatistics,
setEditingExportedInvite,
deleteExportedChatInvite,
} = getDispatch();
@ -237,6 +243,8 @@ const RightHeader: FC<OwnProps & StateProps> = ({
) : managementScreen === ManagementScreens.JoinRequests ? (
HeaderContent.ManageJoinRequests
) : undefined // Never reached
) : isStatistics ? (
HeaderContent.Statistics
) : undefined; // When column is closed
const renderingContentKey = useCurrentOrPrev(contentKey, true) ?? -1;
@ -361,6 +369,8 @@ const RightHeader: FC<OwnProps & StateProps> = ({
onChange={handleGifSearchQueryChange}
/>
);
case HeaderContent.Statistics:
return <h3>{lang('Statistics')}</h3>;
case HeaderContent.SharedMedia:
return <h3>{lang('SharedMedia')}</h3>;
case HeaderContent.ManageChannelSubscribers:
@ -397,6 +407,17 @@ const RightHeader: FC<OwnProps & StateProps> = ({
<i className="icon-edit" />
</Button>
)}
{canViewStatistics && (
<Button
round
color="translucent"
size="smaller"
ariaLabel={lang('Statistics')}
onClick={toggleStatistics}
>
<i className="icon-stats" />
</Button>
)}
</section>
</>
);
@ -459,11 +480,13 @@ export default memo(withGlobal<OwnProps>(
&& (isUserId(chat.id) || ((isChatAdmin(chat) || chat.isCreator) && !chat.isNotJoined)),
);
const isEditingInvite = Boolean(chatId && global.management.byChatId[chatId]?.editingInvite);
const canViewStatistics = chat?.fullInfo?.canViewStatistics;
const currentInviteInfo = chatId ? global.management.byChatId[chatId]?.inviteInfo?.invite : undefined;
return {
canManage,
canAddContact,
canViewStatistics,
isChannel,
userId: user?.id,
messageSearchQuery,

View File

@ -0,0 +1,16 @@
import React, { FC } from '../../../lib/teact/teact';
import { Bundles } from '../../../util/moduleLoader';
import { OwnProps } from './Statistics';
import useModuleLoader from '../../../hooks/useModuleLoader';
import Loading from '../../ui/Loading';
const StatisticsAsync: FC<OwnProps> = (props) => {
const Statistics = useModuleLoader(Bundles.Extra, 'Statistics');
// eslint-disable-next-line react/jsx-props-no-spreading
return Statistics ? <Statistics {...props} /> : <Loading />;
};
export default StatisticsAsync;

View File

@ -0,0 +1,103 @@
.Statistics {
height: 100%;
overflow-x: hidden;
overflow-y: hidden;
&--messages {
padding: 1rem 0.75rem;
&-title {
padding-left: 0.25rem;
font-size: 16px;
color: var(--text-color);
line-height: 30px;
text-transform: lowercase;
&:first-letter {
text-transform: uppercase;
}
}
}
&.ready {
overflow-y: scroll !important;
}
.chat-container {
margin-bottom: 1rem;
border-bottom: 1px solid var(--color-borders);
opacity: 1;
transition: opacity 0.3s ease;
&.hidden {
opacity: 0;
}
}
.lovely-chart--container {
font: inherit !important;
font-size: 13px !important;
}
.lovely-chart--header {
margin: 0 1rem;
}
.lovely-chart--header,
.lovely-chart--tooltip-title,
.lovely-chart--tooltip-dataset-value,
.lovely-chart--percentage-title {
font-weight: 500 !important;
}
.lovely-chart--container-type-pie {
&.lovely-chart--state-zoomed-in > canvas {
animation-name: pie-slim-in !important;
}
&:not(.lovely-chart--state-zoomed-in) > canvas {
animation-name: pie-slim-out !important;
}
}
}
@keyframes pie-slim-in {
0% {
clip-path: circle(80% at center calc(50% - 7.5px));
-webkit-clip-path: circle(80% at center calc(50% - 7.5px));
transform: rotate(-360deg);
}
25% {
clip-path: circle(40% at center calc(50% - 7.5px));
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
transform: rotate(-360deg);
}
75% {
clip-path: circle(40% at center calc(50% - 7.5px));
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
transform: rotate(0);
}
}
@keyframes pie-slim-out {
0% {
clip-path: circle(40% at center calc(50% - 7.5px));
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
transform: rotate(360deg);
}
50% {
clip-path: circle(40% at center calc(50% - 7.5px));
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
transform: rotate(0);
}
75% {
clip-path: circle(80% at center calc(50% - 7.5px));
-webkit-clip-path: circle(80% at center calc(50% - 7.5px));
transform: rotate(0);
}
}

View File

@ -0,0 +1,171 @@
import React, {
FC, memo, useState, useEffect, useRef,
} from '../../../lib/teact/teact';
import { getDispatch, withGlobal } from '../../../lib/teact/teactn';
import { callApi } from '../../../api/gramjs';
import {
ApiMessage, ApiStatistics, StatisticsRecentMessage as StatisticsRecentMessageType, StatisticsGraph,
} from '../../../api/types';
import { selectChat, selectStatistics } from '../../../modules/selectors';
import buildClassName from '../../../util/buildClassName';
import useLang from '../../../hooks/useLang';
import Loading from '../../ui/Loading';
import StatisticsOverview from './StatisticsOverview';
import StatisticsRecentMessage from './StatisticsRecentMessage';
import './Statistics.scss';
type ILovelyChart = { create: Function };
let lovelyChartPromise: Promise<ILovelyChart>;
let LovelyChart: ILovelyChart;
async function ensureLovelyChart() {
if (!lovelyChartPromise) {
lovelyChartPromise = import('../../../lib/lovely-chart/LovelyChart') as Promise<ILovelyChart>;
LovelyChart = await lovelyChartPromise;
}
return lovelyChartPromise;
}
const GRAPHS_TITLES = {
growthGraph: 'ChannelStats.Graph.Growth',
followersGraph: 'ChannelStats.Graph.Followers',
muteGraph: 'ChannelStats.Graph.Notifications',
topHoursGraph: 'ChannelStats.Graph.ViewsByHours',
viewsBySourceGraph: 'ChannelStats.Graph.ViewsBySource',
newFollowersBySourceGraph: 'ChannelStats.Graph.NewFollowersBySource',
languagesGraph: 'ChannelStats.Graph.Language',
interactionsGraph: 'ChannelStats.Graph.Interactions',
};
const GRAPHS = Object.keys(GRAPHS_TITLES) as (keyof ApiStatistics)[];
export type OwnProps = {
chatId: string;
isActive: boolean;
};
export type StateProps = {
statistics: ApiStatistics;
dcId?: number;
};
const Statistics: FC<OwnProps & StateProps> = ({
chatId, isActive, statistics, dcId,
}) => {
const lang = useLang();
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
const [isReady, setIsReady] = useState(false);
const loadedCharts = useRef<string[]>([]);
const { loadStatistics, loadStatisticsAsyncGraph } = getDispatch();
useEffect(() => {
loadStatistics({ chatId });
}, [chatId, loadStatistics]);
useEffect(() => {
if (!isActive) {
loadedCharts.current = [];
}
}, [isActive]);
// Load async graphs
useEffect(() => {
if (!statistics) {
return;
}
GRAPHS.forEach((graph) => {
const isAsync = typeof statistics?.[graph] === 'string';
if (isAsync) {
loadStatisticsAsyncGraph({
name: graph,
chatId,
token: statistics[graph],
// Hardcode percentage for languages graph, since API does not return `percentage` flag
isPercentage: graph === 'languagesGraph',
});
}
});
}, [chatId, statistics, loadStatisticsAsyncGraph]);
useEffect(() => {
(async () => {
await ensureLovelyChart();
if (!isReady) {
setIsReady(true);
return;
}
if (!statistics) {
return;
}
GRAPHS.forEach((graph, index: number) => {
const isAsync = typeof statistics?.[graph] === 'string';
if (isAsync || loadedCharts.current.includes(graph)) {
return;
}
const { zoomToken } = (statistics[graph] as StatisticsGraph);
LovelyChart.create(
containerRef.current!.children[index],
{
title: lang((GRAPHS_TITLES as Record<string, string>)[graph]),
...zoomToken && {
onZoom: (x: number) => callApi('fetchStatisticsAsyncGraph', { token: zoomToken, x, dcId }),
zoomOutLabel: lang('Graph.ZoomOut'),
},
...(statistics[graph] as StatisticsGraph),
},
);
loadedCharts.current.push(graph);
});
})();
}, [isReady, statistics, lang, chatId, loadStatisticsAsyncGraph, dcId]);
if (!isReady || !statistics) {
return <Loading />;
}
return (
<div className={buildClassName('Statistics custom-scroll', isReady && 'ready')}>
<StatisticsOverview statistics={statistics} />
{!loadedCharts.current.length && <Loading />}
<div ref={containerRef}>
{GRAPHS.map((graph) => (
<div className={buildClassName('chat-container', !loadedCharts.current.includes(graph) && 'hidden')} />
))}
</div>
{Boolean(statistics.recentTopMessages?.length) &&
<div className="Statistics--messages">
<h2 className="Statistics--messages-title">{lang('ChannelStats.Recent.Header')}</h2>
{statistics.recentTopMessages.map((message) => (
<StatisticsRecentMessage message={message as ApiMessage & StatisticsRecentMessageType} />
))}
</div>
}
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
const statistics = selectStatistics(global, chatId);
const dcId = selectChat(global, chatId)?.fullInfo?.statisticsDcId;
return { statistics, dcId };
},
)(Statistics));

View File

@ -0,0 +1,40 @@
.StatisticsOverview {
padding: 1rem 0.75rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--color-borders);
&--title {
padding-left: 0.25rem;
font-size: 16px;
color: var(--text-color);
line-height: 30px;
text-transform: lowercase;
&:first-letter {
text-transform: uppercase;
}
}
&--table {
width: 100%;
&-heading {
font-size: 0.9375rem;
color: var(--color-text-secondary);
}
&-value {
font-weight: 500;
font-size: 1rem;
}
}
&--value {
font-size: 0.6875rem;
color: var(--color-text-green);
&.negative {
color: var(--color-error);
}
}
}

View File

@ -0,0 +1,72 @@
import React, { FC, memo } from '../../../lib/teact/teact';
import { ApiStatistics, StatisticsOverviewItem } from '../../../api/types';
import buildClassName from '../../../util/buildClassName';
import useLang from '../../../hooks/useLang';
import './StatisticsOverview.scss';
export type OwnProps = {
statistics: ApiStatistics;
};
const StatisticsOverview: FC<OwnProps> = ({ statistics }) => {
const lang = useLang();
const renderOverviewItemValue = ({ change, percentage }: StatisticsOverviewItem) => {
const isChangeNegative = Number(change) < 0;
return (
<span className={buildClassName('StatisticsOverview--value', isChangeNegative && 'negative')}>
{isChangeNegative ? change : `+${change}`}
{percentage && (
<>
{' '}
({percentage}%)
</>
)}
</span>
);
};
const {
followers, viewsPerPost, sharesPerPost, enabledNotifications,
} = statistics;
return (
<div className="StatisticsOverview">
<h2 className="StatisticsOverview--title">{lang('ChannelStats.Overview')}</h2>
<table className="StatisticsOverview--table">
<tr>
<td>
<b className="StatisticsOverview--table-value">{followers.current}</b> {renderOverviewItemValue(followers)}
<h3 className="StatisticsOverview--table-heading">{lang('ChannelStats.Overview.Followers')}</h3>
</td>
<td>
<b className="StatisticsOverview--table-value">{enabledNotifications.percentage}%</b>
<h3 className="StatisticsOverview--table-heading">{lang('ChannelStats.Overview.EnabledNotifications')}</h3>
</td>
</tr>
<tr>
<td>
<b className="StatisticsOverview--table-value">{viewsPerPost.current}</b>
{' '}
{renderOverviewItemValue(viewsPerPost)}
<h3 className="StatisticsOverview--table-heading">{lang('ChannelStats.Overview.ViewsPerPost')}</h3>
</td>
<td>
<b className="StatisticsOverview--table-value">{sharesPerPost.current}</b>
{' '}
{renderOverviewItemValue(sharesPerPost)}
<h3 className="StatisticsOverview--table-heading">{lang('ChannelStats.Overview.SharesPerPost')}</h3>
</td>
</tr>
</table>
</div>
);
};
export default memo(StatisticsOverview);

View File

@ -0,0 +1,59 @@
.StatisticsRecentMessage {
position: relative;
padding-left: 3rem;
&--summary {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-right: 0.75rem;
.media-preview--image {
width: 2.5rem;
height: 2.5rem;
position: absolute;
left: 0;
top: 0;
object-fit: cover;
border-radius: 0.25rem;
margin-inline-end: 0.25rem;
&.round {
border-radius: 0.625rem;
}
}
.icon-play {
position: relative;
display: inline-block;
font-size: 0.75rem;
color: #fff;
margin-inline-start: -1.25rem;
margin-inline-end: 0.5rem;
bottom: 0.0625rem;
}
}
&--title {
display: flex;
align-items: center;
line-height: 1.25rem;
}
&--info {
display: flex;
align-items: center;
width: 100%;
color: var(--color-text-meta);
}
&--meta {
font-size: 0.75rem;
}
&--date {
flex: 1;
font-size: 0.8125rem;
}
}

View File

@ -0,0 +1,68 @@
import React, { FC, memo } from '../../../lib/teact/teact';
import useLang, { LangFn } from '../../../hooks/useLang';
import { ApiMessage, StatisticsRecentMessage as StatisticsRecentMessageType } from '../../../api/types';
import buildClassName from '../../../util/buildClassName';
import { formatDateTimeToString } from '../../../util/dateFormat';
import {
getMessageMediaHash,
getMessageMediaThumbDataUri,
getMessageVideo,
getMessageRoundVideo,
} from '../../../modules/helpers';
import { renderMessageSummary } from '../../common/helpers/renderMessageText';
import useMedia from '../../../hooks/useMedia';
import './StatisticsRecentMessage.scss';
export type OwnProps = {
message: ApiMessage & StatisticsRecentMessageType;
};
const StatisticsRecentMessage: FC<OwnProps> = ({ message }) => {
const lang = useLang();
const mediaThumbnail = getMessageMediaThumbDataUri(message);
const mediaBlobUrl = useMedia(getMessageMediaHash(message, 'micro'));
const isRoundVideo = Boolean(getMessageRoundVideo(message));
return (
<p className="StatisticsRecentMessage">
<div className="StatisticsRecentMessage--title">
<div className="StatisticsRecentMessage--summary">
{renderSummary(lang, message, mediaBlobUrl || mediaThumbnail, isRoundVideo)}
</div>
<div className="StatisticsRecentMessage--meta">
{lang('ChannelStats.ViewsCount', message.views)}
</div>
</div>
<div className="StatisticsRecentMessage--info">
<div className="StatisticsRecentMessage--date">
{formatDateTimeToString(message.date * 1000, lang.code)}
</div>
<div className="StatisticsRecentMessage--meta">
{Boolean(message.forwards) ? lang('ChannelStats.SharesCount', message.forwards) : 'No shares'}
</div>
</div>
</p>
);
};
function renderSummary(lang: LangFn, message: ApiMessage, blobUrl?: string, isRoundVideo?: boolean) {
if (!blobUrl) {
return renderMessageSummary(lang, message);
}
return (
<span className="media-preview">
<img src={blobUrl} alt="" className={buildClassName('media-preview--image', isRoundVideo && 'round')} />
{getMessageVideo(message) && <i className="icon-play" />}
{renderMessageSummary(lang, message, true)}
</span>
);
}
export default memo(StatisticsRecentMessage);

View File

@ -194,4 +194,8 @@ export const INITIAL_STATE: GlobalState = {
},
serviceNotifications: [],
statistics: {
byChatId: {},
},
};

View File

@ -26,6 +26,7 @@ import {
ApiAvailableReaction,
ApiAppConfig,
ApiSponsoredMessage,
ApiStatistics,
ApiPaymentFormNativeParams,
} from '../api/types';
import {
@ -107,6 +108,7 @@ export interface ServiceNotification {
export type GlobalState = {
appConfig?: ApiAppConfig;
isChatInfoShown: boolean;
isStatisticsShown?: boolean;
isLeftColumnShown: boolean;
isPollModalOpen?: boolean;
newChatMembersProgress?: NewChatMembersProgress;
@ -498,6 +500,10 @@ export type GlobalState = {
shouldShowContextMenuHint?: boolean;
serviceNotifications: ServiceNotification[];
statistics: {
byChatId: Record<string, ApiStatistics>;
};
};
export type ActionTypes = (
@ -508,8 +514,8 @@ export type ActionTypes = (
'toggleChatInfo' | 'setIsUiReady' | 'addRecentEmoji' | 'addRecentSticker' | 'toggleLeftColumn' |
'toggleSafeLinkModal' | 'openHistoryCalendar' | 'closeHistoryCalendar' | 'disableContextMenuHint' |
'setNewChatMembersDialogState' | 'disableHistoryAnimations' | 'setLeftColumnWidth' | 'resetLeftColumnWidth' |
'openSeenByModal' | 'closeSeenByModal' | 'closeReactorListModal' |
'openReactorListModal' |
'openSeenByModal' | 'closeSeenByModal' | 'closeReactorListModal' | 'openReactorListModal' |
'toggleStatistics' |
// auth
'setAuthPhoneNumber' | 'setAuthCode' | 'setAuthPassword' | 'signUp' | 'returnToAuthPhoneNumber' | 'signOut' |
'setAuthRememberMe' | 'clearAuthError' | 'uploadProfilePhoto' | 'goToAuthQrCode' | 'clearCache' |
@ -601,7 +607,9 @@ export type ActionTypes = (
'toggleGroupCallVideo' | 'requestToSpeak' | 'setGroupCallParticipantVolume' | 'toggleGroupCallPanel' |
'createGroupCall' | 'joinVoiceChatByLink' | 'subscribeToGroupCallUpdates' | 'createGroupCallInviteLink' |
'loadMoreGroupCallParticipants' | 'connectToActiveGroupCall' | 'playGroupCallSound' |
'openCallFallbackConfirm' | 'closeCallFallbackConfirm' | 'inviteToCallFallback'
'openCallFallbackConfirm' | 'closeCallFallbackConfirm' | 'inviteToCallFallback' |
// stats
'loadStatistics' | 'loadStatisticsAsyncGraph'
);
export interface DispatchOptions {

View File

@ -10,7 +10,7 @@ declare class TelegramClient {
async start(authParams: UserAuthParams | BotAuthParams);
async invoke<R extends Api.AnyRequest>(request: R): Promise<R['__response']>;
async invoke<R extends Api.AnyRequest>(request: R, dcId?: number): Promise<R['__response']>;
async uploadFile(uploadParams: UploadFileParams): ReturnType<typeof uploadFile>;

View File

@ -772,20 +772,20 @@ class TelegramClient {
/**
* Invokes a MTProtoRequest (sends and receives it) and returns its result
* @param request
* @param dcId Optional dcId to use when sending the request
* @returns {Promise}
*/
async invoke(request) {
async invoke(request, dcId) {
if (request.classType !== 'request') {
throw new Error('You can only invoke MTProtoRequests');
}
// This causes issues for now because not enough utils
// await request.resolve(this, utils)
const sender = dcId === undefined ? this._sender : await this.getSender(dcId);
this._lastRequest = new Date().getTime();
let attempt = 0;
for (attempt = 0; attempt < this._requestRetries; attempt++) {
const promise = this._sender.sendWithInvokeSupport(request);
const promise = sender.sendWithInvokeSupport(request);
try {
const result = await promise.promise;
return result;

View File

@ -1165,4 +1165,6 @@ phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates;
langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDifference;
langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector<string> = Vector<LangPackString>;
langpack.getLanguages#42c6978f lang_pack:string = Vector<LangPackLanguage>;
folders.editPeerFolders#6847d0ab folder_peers:Vector<InputFolderPeer> = Updates;`;
folders.editPeerFolders#6847d0ab folder_peers:Vector<InputFolderPeer> = Updates;
stats.getBroadcastStats#ab42441a flags:# dark:flags.0?true channel:InputChannel = stats.BroadcastStats;
stats.loadAsyncGraph#621d5fa0 flags:# token:string x:flags.0?long = StatsGraph;`;

View File

@ -206,5 +206,7 @@
"messages.setChatAvailableReactions",
"messages.getAvailableReactions",
"messages.setDefaultReaction",
"help.getAppConfig"
"help.getAppConfig",
"stats.getBroadcastStats",
"stats.loadAsyncGraph"
]

View File

@ -0,0 +1,173 @@
import { GUTTER, AXES_FONT, X_AXIS_HEIGHT, X_AXIS_SHIFT_START, PLOT_TOP_PADDING } from './constants';
import { humanize } from './format';
import { getCssColor } from './skin';
import { applyXEdgeOpacity, applyYEdgeOpacity, xScaleLevelToStep, yScaleLevelToStep } from './formulas';
import { toPixels } from './Projection';
export function createAxes(context, data, plotSize, colors) {
function drawXAxis(state, projection) {
context.clearRect(0, plotSize.height - X_AXIS_HEIGHT + 1, plotSize.width, X_AXIS_HEIGHT + 1);
const topOffset = plotSize.height - X_AXIS_HEIGHT / 2;
const scaleLevel = Math.floor(state.xAxisScale);
const step = xScaleLevelToStep(scaleLevel);
const opacityFactor = 1 - (state.xAxisScale - scaleLevel);
context.font = AXES_FONT;
context.textAlign = 'center';
context.textBaseline = 'middle';
for (let i = state.labelFromIndex; i <= state.labelToIndex; i++) {
const shiftedI = i - X_AXIS_SHIFT_START;
if (shiftedI % step !== 0) {
continue;
}
const label = data.xLabels[i];
const [xPx] = toPixels(projection, i, 0);
let opacity = shiftedI % (step * 2) === 0 ? 1 : opacityFactor;
opacity = applyYEdgeOpacity(opacity, xPx, plotSize.width);
context.fillStyle = getCssColor(colors, 'x-axis-text', opacity);
context.fillText(label.text, xPx, topOffset);
}
}
function drawYAxis(state, projection, secondaryProjection) {
const {
yAxisScale, yAxisScaleFrom, yAxisScaleTo, yAxisScaleProgress = 0,
yMinViewport, yMinViewportFrom, yMinViewportTo,
yMaxViewport, yMaxViewportFrom, yMaxViewportTo,
yMinViewportSecond, yMinViewportSecondFrom, yMinViewportSecondTo,
yMaxViewportSecond, yMaxViewportSecondFrom, yMaxViewportSecondTo,
} = state;
const colorKey = secondaryProjection && `dataset#${data.datasets[0].key}`;
const isYChanging = yMinViewportFrom !== undefined || yMaxViewportFrom !== undefined;
if (data.isPercentage) {
_drawYAxisPercents(projection);
} else {
_drawYAxisScaled(
state,
projection,
Math.round(yAxisScaleTo || yAxisScale),
yMinViewportTo !== undefined ? yMinViewportTo : yMinViewport,
yMaxViewportTo !== undefined ? yMaxViewportTo : yMaxViewport,
yAxisScaleFrom ? yAxisScaleProgress : 1,
colorKey,
);
}
if (yAxisScaleProgress > 0 && isYChanging) {
_drawYAxisScaled(
state,
projection,
Math.round(yAxisScaleFrom),
yMinViewportFrom !== undefined ? yMinViewportFrom : yMinViewport,
yMaxViewportFrom !== undefined ? yMaxViewportFrom : yMaxViewport,
1 - yAxisScaleProgress,
colorKey,
);
}
if (secondaryProjection) {
const { yAxisScaleSecond, yAxisScaleSecondFrom, yAxisScaleSecondTo, yAxisScaleSecondProgress = 0 } = state;
const secondaryColorKey = `dataset#${data.datasets[data.datasets.length - 1].key}`;
const isYChanging = yMinViewportSecondFrom !== undefined || yMaxViewportSecondFrom !== undefined;
_drawYAxisScaled(
state,
secondaryProjection,
Math.round(yAxisScaleSecondTo || yAxisScaleSecond),
yMinViewportSecondTo !== undefined ? yMinViewportSecondTo : yMinViewportSecond,
yMaxViewportSecondTo !== undefined ? yMaxViewportSecondTo : yMaxViewportSecond,
yAxisScaleSecondFrom ? yAxisScaleSecondProgress : 1,
secondaryColorKey,
true,
);
if (yAxisScaleSecondProgress > 0 && isYChanging) {
_drawYAxisScaled(
state,
secondaryProjection,
Math.round(yAxisScaleSecondFrom),
yMinViewportSecondFrom !== undefined ? yMinViewportSecondFrom : yMinViewportSecond,
yMaxViewportSecondFrom !== undefined ? yMaxViewportSecondFrom : yMaxViewportSecond,
1 - yAxisScaleSecondProgress,
secondaryColorKey,
true,
);
}
}
}
function _drawYAxisScaled(state, projection, scaleLevel, yMin, yMax, opacity = 1, colorKey = null, isSecondary = false) {
const step = yScaleLevelToStep(scaleLevel);
const firstVisibleValue = Math.ceil(yMin / step) * step;
const lastVisibleValue = Math.floor(yMax / step) * step;
context.font = AXES_FONT;
context.textAlign = isSecondary ? 'right' : 'left';
context.textBaseline = 'bottom';
context.lineWidth = 1;
context.beginPath();
for (let value = firstVisibleValue; value <= lastVisibleValue; value += step) {
const [, yPx] = toPixels(projection, 0, value);
const textOpacity = applyXEdgeOpacity(opacity, yPx);
context.fillStyle = colorKey
? getCssColor(colors, colorKey, textOpacity)
: getCssColor(colors, 'y-axis-text', textOpacity);
if (!isSecondary) {
context.fillText(humanize(value), GUTTER, yPx - GUTTER / 2);
} else {
context.fillText(humanize(value), plotSize.width - GUTTER, yPx - GUTTER / 2);
}
if (isSecondary) {
context.strokeStyle = getCssColor(colors, colorKey, opacity);
context.moveTo(plotSize.width - GUTTER, yPx);
context.lineTo(plotSize.width - GUTTER * 2, yPx);
} else {
context.moveTo(GUTTER, yPx);
context.strokeStyle = getCssColor(colors, 'grid-lines', opacity);
context.lineTo(plotSize.width - GUTTER, yPx);
}
}
context.stroke();
}
function _drawYAxisPercents(projection) {
const percentValues = [0, 0.25, 0.50, 0.75, 1];
const [, height] = projection.getSize();
context.font = AXES_FONT;
context.textAlign = 'left';
context.textBaseline = 'bottom';
context.lineWidth = 1;
context.beginPath();
percentValues.forEach((value) => {
const yPx = height - height * value + PLOT_TOP_PADDING;
context.fillStyle = getCssColor(colors, 'y-axis-text', 1);
context.fillText(`${value * 100}%`, GUTTER, yPx - GUTTER / 4);
context.moveTo(GUTTER, yPx);
context.strokeStyle = getCssColor(colors, 'grid-lines', 1);
context.lineTo(plotSize.width - GUTTER, yPx);
});
context.stroke();
}
return { drawXAxis, drawYAxis };
}

View File

@ -0,0 +1,64 @@
import { createElement, addEventListener } from './minifiers';
import { toggleText } from './toggleText';
import { throttle } from './utils';
export function createHeader(container, title, zoomOutLabel = 'Zoom out', zoomOutCallback) {
let _element;
let _titleElement;
let _zoomOutElement;
let _captionElement;
let _isZooming;
const setCaptionThrottled = throttle(setCaption, 100, false);
_setupLayout();
function setCaption(caption) {
if (_isZooming) {
return;
}
_captionElement.innerHTML = caption;
}
function zoom(caption) {
_zoomOutElement = toggleText(_titleElement, zoomOutLabel, 'lovely-chart--header-title lovely-chart--header-zoom-out-control');
setTimeout(() => {
addEventListener(_zoomOutElement, 'click', _onZoomOut);
}, 500);
setCaption(caption);
}
function toggleIsZooming(isZooming) {
_isZooming = isZooming;
}
function _setupLayout() {
_element = createElement();
_element.className = 'lovely-chart--header';
_titleElement = createElement();
_titleElement.className = 'lovely-chart--header-title';
_titleElement.innerHTML = title;
_element.appendChild(_titleElement);
_captionElement = createElement();
_captionElement.className = 'lovely-chart--header-caption lovely-chart--position-right';
_element.appendChild(_captionElement);
container.appendChild(_element);
}
function _onZoomOut() {
_titleElement = toggleText(_zoomOutElement, title, 'lovely-chart--header-title', true);
zoomOutCallback();
}
return {
setCaption: setCaptionThrottled,
zoom,
toggleIsZooming,
};
}

View File

@ -0,0 +1,215 @@
import { createStateManager } from './StateManager';
import { createHeader } from './Header';
import { createAxes } from './Axes';
import { createMinimap } from './Minimap';
import { createTooltip } from './Tooltip';
import { createTools } from './Tools';
import { createZoomer } from './Zoomer';
import { createColors } from './skin';
import { analyzeData } from './data';
import { setupCanvas, clearCanvas } from './canvas';
import { preparePoints } from './preparePoints';
import { createProjection } from './Projection';
import { drawDatasets } from './drawDatasets';
import { createElement } from './minifiers';
import { getFullLabelDate, getLabelDate } from './format';
import {
X_AXIS_HEIGHT,
GUTTER,
PLOT_TOP_PADDING,
PLOT_HEIGHT,
PLOT_LINE_WIDTH,
SIMPLIFIER_PLOT_FACTOR,
} from './constants';
import { getSimplificationDelta, isDataRange } from './formulas';
import { debounce } from './utils';
import './styles/index.scss';
function create(container, originalData) {
let _stateManager;
let _element;
let _plot;
let _context;
let _plotSize;
let _header;
let _axes;
let _minimap;
let _tooltip;
let _tools;
let _zoomer;
let _state;
let _windowWidth = window.innerWidth;
const _data = analyzeData(originalData);
const _colors = createColors(_data.colors);
const _redrawDebounced = debounce(_redraw, 500, false, true);
_setupComponents();
_setupGlobalListeners();
function _setupComponents() {
_setupContainer();
_header = createHeader(_element, _data.title, _data.zoomOutLabel, _onZoomOut);
_setupPlotCanvas();
_stateManager = createStateManager(_data, _plotSize, _onStateUpdate);
_axes = createAxes(_context, _data, _plotSize, _colors);
_minimap = createMinimap(_element, _data, _colors, _onRangeChange);
_tooltip = createTooltip(_element, _data, _plotSize, _colors, _onZoomIn, _onFocus);
_tools = createTools(_element, _data, _onFilterChange);
_zoomer = _data.isZoomable && createZoomer(_data, originalData, _colors, _stateManager, _element, _header, _minimap, _tooltip, _tools);
// hideOnScroll(_element);
}
function _setupContainer() {
_element = createElement();
_element.className = `lovely-chart--container${_data.shouldZoomToPie ? ' lovely-chart--container-type-pie' : ''}`;
container.appendChild(_element);
}
function _setupPlotCanvas() {
const { canvas, context } = setupCanvas(_element, {
width: _element.clientWidth,
height: PLOT_HEIGHT,
});
_plot = canvas;
_context = context;
_plotSize = {
width: _plot.offsetWidth,
height: _plot.offsetHeight,
};
}
function _onStateUpdate(state) {
_state = state;
const { datasets } = _data;
const range = {
from: state.labelFromIndex,
to: state.labelToIndex,
};
const boundsAndParams = {
begin: state.begin,
end: state.end,
totalXWidth: state.totalXWidth,
yMin: state.yMinViewport,
yMax: state.yMaxViewport,
availableWidth: _plotSize.width,
availableHeight: _plotSize.height - X_AXIS_HEIGHT,
xPadding: GUTTER,
yPadding: PLOT_TOP_PADDING,
};
const visibilities = datasets.map(({ key }) => state[`opacity#${key}`]);
const points = preparePoints(_data, datasets, range, visibilities, boundsAndParams);
const projection = createProjection(boundsAndParams);
let secondaryPoints = null;
let secondaryProjection = null;
if (_data.hasSecondYAxis) {
const secondaryDataset = datasets.find((d) => d.hasOwnYAxis);
const bounds = {
yMin: state.yMinViewportSecond,
yMax: state.yMaxViewportSecond,
};
secondaryPoints = preparePoints(_data, [secondaryDataset], range, visibilities, bounds)[0];
secondaryProjection = projection.copy(bounds);
}
if (!_data.hideCaption) {
_header.setCaption(_getCaption(state));
}
clearCanvas(_plot, _context);
const totalPoints = points.reduce((a, p) => a + p.length, 0);
const simplification = getSimplificationDelta(totalPoints) * SIMPLIFIER_PLOT_FACTOR;
drawDatasets(
_context, state, _data,
range, points, projection, secondaryPoints, secondaryProjection,
PLOT_LINE_WIDTH, visibilities, _colors, false, simplification,
);
if (!_data.isPie) {
_axes.drawYAxis(state, projection, secondaryProjection);
// TODO check isChanged
_axes.drawXAxis(state, projection);
}
_minimap.update(state);
_tooltip.update(state, points, projection, secondaryPoints, secondaryProjection);
}
function _onRangeChange(range) {
_stateManager.update({ range });
}
function _onFilterChange(filter) {
_stateManager.update({ filter });
}
function _onFocus(focusOn) {
if (_data.isBars || _data.isPie) {
// TODO animate
_stateManager.update({ focusOn });
}
}
function _onZoomIn(labelIndex) {
_zoomer.zoomIn(_state, labelIndex);
}
function _onZoomOut() {
_zoomer.zoomOut(_state);
}
function _setupGlobalListeners() {
document.documentElement.addEventListener('darkmode', () => {
_stateManager.update();
});
window.addEventListener('resize', () => {
if (window.innerWidth !== _windowWidth) {
_windowWidth = window.innerWidth;
_redrawDebounced();
}
});
window.addEventListener('orientationchange', () => {
_redrawDebounced();
});
}
function _redraw() {
Object.assign(_data, analyzeData(originalData));
_element.remove();
_setupComponents();
}
function _getCaption(state) {
let startIndex;
let endIndex;
if (_zoomer && _zoomer.isZoomed()) {
// TODO Fix label
startIndex = state.labelFromIndex === 0 ? 0 : state.labelFromIndex + 1;
endIndex = state.labelToIndex === state.totalXWidth - 1 ? state.labelToIndex : state.labelToIndex - 1;
} else {
startIndex = state.labelFromIndex;
endIndex = state.labelToIndex;
}
return isDataRange(_data.xLabels[startIndex], _data.xLabels[endIndex])
? (
`${getLabelDate(_data.xLabels[startIndex])}` +
' — ' +
`${getLabelDate(_data.xLabels[endIndex])}`
)
: getFullLabelDate(_data.xLabels[startIndex]);
}
}
export { create };

View File

@ -0,0 +1,276 @@
import { setupCanvas, clearCanvas } from './canvas';
import { preparePoints } from './preparePoints';
import { createProjection } from './Projection';
import { drawDatasets } from './drawDatasets';
import { captureEvents } from './captureEvents';
import {
DEFAULT_RANGE,
MINIMAP_HEIGHT,
MINIMAP_EAR_WIDTH,
MINIMAP_MARGIN,
MINIMAP_LINE_WIDTH,
MINIMAP_MAX_ANIMATED_DATASETS,
SIMPLIFIER_MINIMAP_FACTOR,
} from './constants';
import { proxyMerge, throttleWithRaf } from './utils';
import { createElement } from './minifiers';
import { getSimplificationDelta } from './formulas';
export function createMinimap(container, data, colors, rangeCallback) {
let _element;
let _canvas;
let _context;
let _canvasSize;
let _ruler;
let _slider;
let _capturedOffset;
let _range = {};
let _state;
const _updateRulerOnRaf = throttleWithRaf(_updateRuler);
_setupLayout();
_updateRange(data.minimapRange || DEFAULT_RANGE);
function update(newState) {
const { begin, end } = newState;
if (!_capturedOffset) {
_updateRange({ begin, end }, true);
}
if (data.datasets.length >= MINIMAP_MAX_ANIMATED_DATASETS) {
newState = newState.static;
}
if (!_isStateChanged(newState)) {
return;
}
_state = proxyMerge(newState, { focusOn: null });
clearCanvas(_canvas, _context);
_drawDatasets(_state);
}
function toggle(shouldShow) {
_element.classList.toggle('lovely-chart--state-hidden', !shouldShow);
requestAnimationFrame(() => {
_element.classList.toggle('lovely-chart--state-transparent', !shouldShow);
});
}
function _setupLayout() {
_element = createElement();
_element.className = 'lovely-chart--minimap';
_element.style.height = `${MINIMAP_HEIGHT}px`;
_setupCanvas();
_setupRuler();
container.appendChild(_element);
_canvasSize = {
width: _canvas.offsetWidth,
height: _canvas.offsetHeight,
};
}
function _getSize() {
return {
width: container.offsetWidth - MINIMAP_MARGIN * 2,
height: MINIMAP_HEIGHT,
};
}
function _setupCanvas() {
const { canvas, context } = setupCanvas(_element, _getSize());
_canvas = canvas;
_context = context;
}
function _setupRuler() {
_ruler = createElement();
_ruler.className = 'lovely-chart--minimap-ruler';
_ruler.innerHTML =
'<div class="lovely-chart--minimap-mask"></div>' +
'<div class="lovely-chart--minimap-slider">' +
'<div class="lovely-chart--minimap-slider-handle"><span class="lovely-chart--minimap-slider-handle-pin"></span></div>' +
'<div class="lovely-chart--minimap-slider-inner"></div>' +
'<div class="lovely-chart--minimap-slider-handle"><span class="lovely-chart--minimap-slider-handle-pin"></span></div>' +
'</div>' +
'<div class="lovely-chart--minimap-mask"></div>';
_slider = _ruler.children[1];
captureEvents(
_slider.children[1],
{
onCapture: _onDragCapture,
onDrag: _onSliderDrag,
onRelease: _onDragRelease,
draggingCursor: 'grabbing',
},
);
captureEvents(
_slider.children[0],
{
onCapture: _onDragCapture,
onDrag: _onLeftEarDrag,
onRelease: _onDragRelease,
draggingCursor: 'ew-resize',
},
);
captureEvents(
_slider.children[2],
{
onCapture: _onDragCapture,
onDrag: _onRightEarDrag,
onRelease: _onDragRelease,
draggingCursor: 'ew-resize',
},
);
_element.appendChild(_ruler);
}
function _isStateChanged(newState) {
if (!_state) {
return true;
}
const { datasets } = data;
if (datasets.some(({ key }) => _state[`opacity#${key}`] !== newState[`opacity#${key}`])) {
return true;
}
if (_state.yMaxMinimap !== newState.yMaxMinimap) {
return true;
}
return false;
}
function _drawDatasets(state = {}) {
const { datasets } = data;
const range = {
from: 0,
to: state.totalXWidth,
};
const boundsAndParams = {
begin: 0,
end: 1,
totalXWidth: state.totalXWidth,
yMin: state.yMinMinimap,
yMax: state.yMaxMinimap,
availableWidth: _canvasSize.width,
availableHeight: _canvasSize.height,
yPadding: 1,
};
const visibilities = datasets.map(({ key }) => _state[`opacity#${key}`]);
const points = preparePoints(data, datasets, range, visibilities, boundsAndParams, true);
const projection = createProjection(boundsAndParams);
let secondaryPoints = null;
let secondaryProjection = null;
if (data.hasSecondYAxis) {
const secondaryDataset = datasets.find((d) => d.hasOwnYAxis);
const bounds = { yMin: state.yMinMinimapSecond, yMax: state.yMaxMinimapSecond };
secondaryPoints = preparePoints(data, [secondaryDataset], range, visibilities, bounds)[0];
secondaryProjection = projection.copy(bounds);
}
const totalPoints = points.reduce((a, p) => a + p.length, 0);
const simplification = getSimplificationDelta(totalPoints) * SIMPLIFIER_MINIMAP_FACTOR;
drawDatasets(
_context, state, data,
range, points, projection, secondaryPoints, secondaryProjection,
MINIMAP_LINE_WIDTH, visibilities, colors, true, simplification,
);
}
function _onDragCapture(e) {
e.preventDefault();
_capturedOffset = e.target.offsetLeft;
}
function _onDragRelease() {
_capturedOffset = null;
}
function _onSliderDrag(moveEvent, captureEvent, { dragOffsetX }) {
const minX1 = 0;
const maxX1 = _canvasSize.width - _slider.offsetWidth;
const newX1 = Math.max(minX1, Math.min(_capturedOffset + dragOffsetX - MINIMAP_EAR_WIDTH, maxX1));
const newX2 = newX1 + _slider.offsetWidth;
const begin = newX1 / _canvasSize.width;
const end = newX2 / _canvasSize.width;
_updateRange({ begin, end });
}
function _onLeftEarDrag(moveEvent, captureEvent, { dragOffsetX }) {
const minX1 = 0;
const maxX1 = _slider.offsetLeft + _slider.offsetWidth - MINIMAP_EAR_WIDTH * 2;
const newX1 = Math.min(maxX1, Math.max(minX1, _capturedOffset + dragOffsetX));
const begin = newX1 / _canvasSize.width;
_updateRange({ begin });
}
function _onRightEarDrag(moveEvent, captureEvent, { dragOffsetX }) {
const minX2 = _slider.offsetLeft + MINIMAP_EAR_WIDTH * 2;
const maxX2 = _canvasSize.width;
const newX2 = Math.max(minX2, Math.min(_capturedOffset + MINIMAP_EAR_WIDTH + dragOffsetX, maxX2));
const end = newX2 / _canvasSize.width;
_updateRange({ end });
}
function _updateRange(range, isExternal) {
let nextRange = Object.assign({}, _range, range);
if (_state && _state.minimapDelta && !isExternal) {
nextRange = _adjustDiscreteRange(nextRange);
}
if (nextRange.begin === _range.begin && nextRange.end === _range.end) {
return;
}
_range = nextRange;
_updateRulerOnRaf();
if (!isExternal) {
rangeCallback(_range);
}
}
function _adjustDiscreteRange(nextRange) {
// TODO sometimes beginChange and endChange are different for slider drag because of pixels division
const begin = Math.round(nextRange.begin / _state.minimapDelta) * _state.minimapDelta;
const end = Math.round(nextRange.end / _state.minimapDelta) * _state.minimapDelta;
return { begin, end };
}
function _updateRuler() {
const { begin, end } = _range;
_ruler.children[0].style.width = `${begin * 100}%`;
_ruler.children[1].style.width = `${(end - begin) * 100}%`;
_ruler.children[2].style.width = `${(1 - end) * 100}%`;
}
return { update, toggle };
}

View File

@ -0,0 +1,79 @@
import { proxyMerge } from './utils';
export function createProjection(params) {
const {
begin,
end,
totalXWidth,
yMin,
yMax,
availableWidth,
availableHeight,
xPadding = 0,
yPadding = 0,
} = params;
let effectiveWidth = availableWidth;
// TODO bug get rid of padding jumps
if (begin === 0) {
effectiveWidth -= xPadding;
}
if (end === 1) {
effectiveWidth -= xPadding;
}
const xFactor = effectiveWidth / ((end - begin) * totalXWidth);
let xOffsetPx = (begin * totalXWidth) * xFactor;
if (begin === 0) {
xOffsetPx -= xPadding;
}
const effectiveHeight = availableHeight - yPadding;
const yFactor = effectiveHeight / (yMax - yMin);
const yOffsetPx = yMin * yFactor;
function getState() {
return { xFactor, xOffsetPx, availableHeight, yFactor, yOffsetPx };
}
function findClosestLabelIndex(xPx) {
return Math.round((xPx + xOffsetPx) / xFactor);
}
function copy(overrides, cons) {
return createProjection(proxyMerge(params, overrides), cons);
}
function getCenter() {
return [
availableWidth / 2,
availableHeight - effectiveHeight / 2,
];
}
function getSize() {
return [availableWidth, effectiveHeight];
}
function getParams() {
return params;
}
return {
findClosestLabelIndex,
copy,
getCenter,
getSize,
getParams,
getState,
};
}
export function toPixels(projection, labelIndex, value) {
const { xFactor, xOffsetPx, availableHeight, yFactor, yOffsetPx } = projection.getState();
return [
labelIndex * xFactor - xOffsetPx,
availableHeight - (value * yFactor - yOffsetPx),
];
}

View File

@ -0,0 +1,222 @@
import { createTransitionManager } from './TransitionManager';
import { throttleWithRaf, getMaxMin, mergeArrays, proxyMerge, sumArrays } from './utils';
import {
AXES_MAX_COLUMN_WIDTH,
AXES_MAX_ROW_HEIGHT,
X_AXIS_HEIGHT,
ANIMATE_PROPS,
Y_AXIS_ZERO_BASED_THRESHOLD,
} from './constants';
import { xStepToScaleLevel, yScaleLevelToStep, yStepToScaleLevel } from './formulas';
export function createStateManager(data, viewportSize, callback) {
const _range = { begin: 0, end: 1 };
const _filter = _buildDefaultFilter();
const _transitionConfig = _buildTransitionConfig();
const _transitions = createTransitionManager(_runCallback);
const _runCallbackOnRaf = throttleWithRaf(_runCallback);
let _state = {};
function update({ range = {}, filter = {}, focusOn, minimapDelta } = {}, noTransition) {
Object.assign(_range, range);
Object.assign(_filter, filter);
const prevState = _state;
_state = calculateState(data, viewportSize, _range, _filter, focusOn, minimapDelta, prevState);
if (!noTransition) {
_transitionConfig.forEach(({ prop, duration, options }) => {
const transition = _transitions.get(prop);
const currentTarget = transition ? transition.to : prevState[prop];
if (currentTarget !== undefined && currentTarget !== _state[prop]) {
const current = transition
? (options.includes('fast') ? prevState[prop] : transition.current)
: prevState[prop];
if (transition) {
_transitions.remove(prop);
}
_transitions.add(prop, current, _state[prop], duration, options);
}
});
}
if (!_transitions.isRunning() || !_transitions.isFast()) {
_runCallbackOnRaf();
}
}
function hasAnimations() {
return _transitions.isFast();
}
function _buildTransitionConfig() {
const transitionConfig = [];
const datasetVisibilities = data.datasets.map(({ key }) => `opacity#${key} 300`);
mergeArrays([
ANIMATE_PROPS,
datasetVisibilities,
]).forEach((transition) => {
const [prop, duration, ...options] = transition.split(' ');
transitionConfig.push({ prop, duration, options });
});
return transitionConfig;
}
function _buildDefaultFilter() {
const filter = {};
data.datasets.forEach(({ key }) => {
filter[key] = true;
});
return filter;
}
function _runCallback() {
const state = _transitions.isFast() ? proxyMerge(_state, _transitions.getState()) : _state;
state.static = _state;
callback(state);
}
return { update, hasAnimations };
}
function calculateState(data, viewportSize, range, filter, focusOn, minimapDelta, prevState) {
const { begin, end } = range;
const totalXWidth = data.xLabels.length - 1;
const labelFromIndex = Math.max(0, Math.ceil(totalXWidth * begin));
const labelToIndex = Math.min(Math.floor(totalXWidth * end), totalXWidth);
const xAxisScale = calculateXAxisScale(viewportSize.width, labelFromIndex, labelToIndex);
const yRanges = data.isStacked
? calculateYRangesStacked(data, filter, labelFromIndex, labelToIndex, prevState)
: calculateYRanges(data, filter, labelFromIndex, labelToIndex, prevState);
const yAxisScale = calculateYAxisScale(viewportSize.height, yRanges.yMinViewport, yRanges.yMaxViewport);
const yAxisScaleSecond = data.hasSecondYAxis &&
calculateYAxisScale(viewportSize.height, yRanges.yMinViewportSecond, yRanges.yMaxViewportSecond);
const yStep = yScaleLevelToStep(yAxisScale);
yRanges.yMinViewport -= yRanges.yMinViewport % yStep;
if (yAxisScaleSecond) {
const yStepSecond = yScaleLevelToStep(yAxisScaleSecond);
yRanges.yMinViewportSecond -= yRanges.yMinViewportSecond % yStepSecond;
}
const datasetsOpacity = {};
data.datasets.forEach(({ key }) => {
datasetsOpacity[`opacity#${key}`] = filter[key] ? 1 : 0;
});
// TODO perf
return Object.assign(
{
totalXWidth,
xAxisScale,
yAxisScale,
yAxisScaleSecond,
labelFromIndex: Math.max(0, labelFromIndex - 1),
labelToIndex: Math.min(labelToIndex + 1, totalXWidth),
filter: Object.assign({}, filter),
focusOn: focusOn !== undefined ? focusOn : prevState.focusOn,
minimapDelta: minimapDelta !== undefined ? minimapDelta : prevState.minimapDelta,
},
yRanges,
datasetsOpacity,
range,
);
}
function calculateYRanges(data, filter, labelFromIndex, labelToIndex, prevState) {
const secondaryYAxisDataset = data.hasSecondYAxis && data.datasets.slice(-1)[0];
const filteredDatasets = data.datasets.filter((d) => filter[d.key] && d !== secondaryYAxisDataset);
const yRanges = calculateYRangesForGroup(data, labelFromIndex, labelToIndex, prevState, filteredDatasets);
if (secondaryYAxisDataset) {
const group = filter[secondaryYAxisDataset.key] ? [secondaryYAxisDataset] : [];
const {
yMinViewport: yMinViewportSecond,
yMaxViewport: yMaxViewportSecond,
yMinMinimap: yMinMinimapSecond,
yMaxMinimap: yMaxMinimapSecond,
} = calculateYRangesForGroup(data, labelFromIndex, labelToIndex, prevState, [secondaryYAxisDataset]);
Object.assign(yRanges, {
yMinViewportSecond,
yMaxViewportSecond,
yMinMinimapSecond,
yMaxMinimapSecond,
});
}
return yRanges;
}
function calculateYRangesForGroup(data, labelFromIndex, labelToIndex, prevState, datasets) {
const { min: yMinMinimapReal = prevState.yMinMinimap, max: yMaxMinimap = prevState.yMaxMinimap }
= getMaxMin(mergeArrays(datasets.map(({ yMax, yMin }) => [yMax, yMin])));
const yMinMinimap = yMinMinimapReal / yMaxMinimap > Y_AXIS_ZERO_BASED_THRESHOLD ? yMinMinimapReal : 0;
let yMinViewport;
let yMaxViewport;
if (labelFromIndex === 0 && labelToIndex === data.xLabels.length - 1) {
yMinViewport = yMinMinimap;
yMaxViewport = yMaxMinimap;
} else {
const filteredValues = datasets.map(({ values }) => values);
const viewportValues = filteredValues.map((values) => values.slice(labelFromIndex, labelToIndex + 1));
const viewportMaxMin = getMaxMin(mergeArrays(viewportValues));
const yMinViewportReal = viewportMaxMin.min !== undefined ? viewportMaxMin.min : prevState.yMinViewport;
yMaxViewport = viewportMaxMin.max !== undefined ? viewportMaxMin.max : prevState.yMaxViewport;
yMinViewport = yMinViewportReal / yMaxViewport > Y_AXIS_ZERO_BASED_THRESHOLD ? yMinViewportReal : 0;
}
return {
yMinViewport,
yMaxViewport,
yMinMinimap,
yMaxMinimap,
};
}
function calculateYRangesStacked(data, filter, labelFromIndex, labelToIndex, prevState) {
const filteredDatasets = data.datasets.filter((d) => filter[d.key]);
const filteredValues = filteredDatasets.map(({ values }) => values);
const sums = filteredValues.length ? sumArrays(filteredValues) : [];
const { max: yMaxMinimap = prevState.yMaxMinimap } = getMaxMin(sums);
const { max: yMaxViewport = prevState.yMaxViewport } = getMaxMin(sums.slice(labelFromIndex, labelToIndex + 1));
return {
yMinViewport: 0,
yMaxViewport,
yMinMinimap: 0,
yMaxMinimap,
};
}
function calculateXAxisScale(plotWidth, labelFromIndex, labelToIndex) {
const viewportLabelsCount = labelToIndex - labelFromIndex;
const maxColumns = Math.floor(plotWidth / AXES_MAX_COLUMN_WIDTH);
return xStepToScaleLevel(viewportLabelsCount / maxColumns);
}
function calculateYAxisScale(plotHeight, yMin, yMax) {
const availableHeight = plotHeight - X_AXIS_HEIGHT;
const viewportLabelsCount = yMax - yMin;
const maxRows = Math.floor(availableHeight / AXES_MAX_ROW_HEIGHT);
return yStepToScaleLevel(viewportLabelsCount / maxRows);
}

View File

@ -0,0 +1,100 @@
import { createElement } from './minifiers';
import { captureEvents } from './captureEvents';
export function createTools(container, data, filterCallback) {
let _element;
_setupLayout();
_updateFilter();
function redraw() {
if (_element) {
const oldElement = _element;
oldElement.classList.add('lovely-chart--state-hidden');
setTimeout(() => {
oldElement.parentNode.removeChild(oldElement);
}, 500);
}
_setupLayout();
_element.classList.add('lovely-chart--state-transparent');
requestAnimationFrame(() => {
_element.classList.remove('lovely-chart--state-transparent');
});
}
function _setupLayout() {
_element = createElement();
_element.className = 'lovely-chart--tools';
if (data.datasets.length < 2) {
_element.className += ' lovely-chart--state-hidden';
}
data.datasets.forEach(({ key, name }) => {
const control = createElement('a');
control.href = '#';
control.dataset.key = key;
control.className = `lovely-chart--button lovely-chart--color-${data.colors[key].slice(1)} lovely-chart--state-checked`;
control.innerHTML = `<span class="lovely-chart--button-check"></span><span class="lovely-chart--button-label">${name}</span>`;
control.addEventListener('click', (e) => {
e.preventDefault();
if (!control.dataset.clickPrevented) {
_updateFilter(control);
}
delete control.dataset.clickPrevented;
});
captureEvents(control, {
onLongPress: () => {
control.dataset.clickPrevented = 'true';
_updateFilter(control, true);
},
});
_element.appendChild(control);
});
container.appendChild(_element);
}
function _updateFilter(button, isLongPress = false) {
const buttons = Array.from(_element.getElementsByTagName('a'));
const isSingleChecked = _element.querySelectorAll('.lovely-chart--state-checked').length === 1;
if (button) {
if (button.classList.contains('lovely-chart--state-checked') && isSingleChecked) {
if (isLongPress) {
buttons.forEach((b) => b.classList.add('lovely-chart--state-checked'));
button.classList.remove('lovely-chart--state-checked');
} else {
button.classList.remove('lovely-chart--state-shake');
requestAnimationFrame(() => {
button.classList.add('lovely-chart--state-shake');
});
}
} else if (isLongPress) {
buttons.forEach((b) => b.classList.remove('lovely-chart--state-checked'));
button.classList.add('lovely-chart--state-checked');
} else {
button.classList.toggle('lovely-chart--state-checked');
}
}
const filter = {};
buttons.forEach((input) => {
filter[input.dataset.key] = input.classList.contains('lovely-chart--state-checked');
});
filterCallback(filter);
}
return {
redraw,
};
}

View File

@ -0,0 +1,467 @@
import { setupCanvas, clearCanvas } from './canvas';
import { BALLOON_OFFSET, X_AXIS_HEIGHT } from './constants';
import { getPieRadius } from './formulas';
import { formatInteger, getLabelDate, getLabelTime, statsFormatDayHourFull } from './format';
import { getCssColor } from './skin';
import { throttle, throttleWithRaf } from './utils';
import { addEventListener, createElement } from './minifiers';
import { toPixels } from './Projection';
export function createTooltip(container, data, plotSize, colors, onZoom, onFocus) {
let _state;
let _points;
let _projection;
let _secondaryPoints;
let _secondaryProjection;
let _element;
let _canvas;
let _context;
let _balloon;
let _offsetX;
let _offsetY;
let _clickedOnLabel = null;
let _isZoomed = false;
let _isZooming = false;
const _selectLabelOnRaf = throttleWithRaf(_selectLabel);
const _throttledUpdateContent = throttle(_updateContent, 100, true, true);
_setupLayout();
function update(state, points, projection, secondaryPoints, secondaryProjection) {
_state = state;
_points = points;
_projection = projection;
_secondaryPoints = secondaryPoints;
_secondaryProjection = secondaryProjection;
_selectLabel(true);
}
function toggleLoading(isLoading) {
_balloon.classList.toggle('lovely-chart--state-loading', isLoading);
if (!isLoading) {
_clear();
}
}
function toggleIsZoomed(isZoomed) {
if (isZoomed !== _isZoomed) {
_isZooming = true;
}
_isZoomed = isZoomed;
_balloon.classList.toggle('lovely-chart--state-inactive', isZoomed);
}
function _setupLayout() {
_element = createElement();
_element.className = `lovely-chart--tooltip`;
_setupCanvas();
_setupBalloon();
if ('ontouchstart' in window) {
addEventListener(_element, 'touchmove', _onMouseMove);
addEventListener(_element, 'touchstart', _onMouseMove);
addEventListener(document, 'touchstart', _onDocumentMove);
} else {
addEventListener(_element, 'mousemove', _onMouseMove);
addEventListener(_element, 'click', _onClick);
addEventListener(document, 'mousemove', _onDocumentMove);
}
container.appendChild(_element);
}
function _setupCanvas() {
const { canvas, context } = setupCanvas(_element, plotSize);
_canvas = canvas;
_context = context;
}
function _setupBalloon() {
_balloon = createElement();
_balloon.className = `lovely-chart--tooltip-balloon${!data.isZoomable ? ' lovely-chart--state-inactive' : ''}`;
_balloon.innerHTML = '<div class="lovely-chart--tooltip-title"></div><div class="lovely-chart--tooltip-legend"></div><div class="lovely-chart--spinner"></div>';
if ('ontouchstart' in window && data.isZoomable) {
addEventListener(_balloon, 'click', _onBalloonClick);
}
_element.appendChild(_balloon);
}
function _onMouseMove(e) {
if (e.target === _balloon || _balloon.contains(e.target) || _clickedOnLabel) {
return;
}
_isZooming = false;
const pageOffset = _getPageOffset(_element);
_offsetX = (e.touches ? e.touches[0].clientX : e.clientX) - pageOffset.left;
_offsetY = (e.touches ? e.touches[0].clientY : e.clientY) - pageOffset.top;
_selectLabelOnRaf();
}
function _onDocumentMove(e) {
if (_offsetX !== null && e.target !== _element && !_element.contains(e.target)) {
_clear();
}
}
function _onClick(e) {
if (_isZooming) {
return;
}
const oldLabelIndex = _clickedOnLabel;
_clickedOnLabel = null;
_onMouseMove(e, true);
const newLabelIndex = _getLabelIndex();
if (newLabelIndex !== oldLabelIndex) {
_clickedOnLabel = newLabelIndex;
}
if (data.isZoomable) {
onZoom(newLabelIndex);
}
}
function _onBalloonClick() {
if (_balloon.classList.contains('lovely-chart--state-inactive')) {
return;
}
const labelIndex = _projection.findClosestLabelIndex(_offsetX);
onZoom(labelIndex);
}
function _clear(isExternal) {
_offsetX = null;
_clickedOnLabel = null;
clearCanvas(_canvas, _context);
_hideBalloon();
if (!isExternal && onFocus) {
onFocus(null);
}
}
function _getLabelIndex() {
const labelIndex = _projection.findClosestLabelIndex(_offsetX);
return labelIndex < _state.labelFromIndex || labelIndex > _state.labelToIndex ? null : labelIndex;
}
function _selectLabel(isExternal) {
if (!_offsetX || !_state || _isZooming) {
return;
}
const labelIndex = _getLabelIndex();
if (labelIndex === null) {
_clear(isExternal);
return;
}
const pointerVector = getPointerVector();
const shouldShowBalloon = data.isPie ? pointerVector.distance <= getPieRadius(_projection) : true;
if (!isExternal && onFocus) {
if (data.isPie) {
onFocus(pointerVector);
} else {
onFocus(labelIndex);
}
}
function getValue(values, labelIndex) {
if (data.isPie) {
return values.slice(_state.labelFromIndex, _state.labelToIndex + 1).reduce((a, x) => a + x, 0);
}
return values[labelIndex];
}
const [xPx] = toPixels(_projection, labelIndex, 0);
const statistics = data.datasets
.map(({ key, name, values, hasOwnYAxis }, i) => ({
key,
name,
value: getValue(values, labelIndex),
hasOwnYAxis,
originalIndex: i,
}))
.filter(({ key }) => _state.filter[key]);
if (statistics.length && shouldShowBalloon) {
_updateBalloon(statistics, labelIndex);
} else {
_hideBalloon();
}
clearCanvas(_canvas, _context);
if (data.isLines || data.isAreas) {
if (data.isLines) {
_drawCircles(statistics, labelIndex);
}
_drawTail(xPx, plotSize.height - X_AXIS_HEIGHT, getCssColor(colors, 'grid-lines'));
}
}
function _drawCircles(statistics, labelIndex) {
statistics.forEach(({ value, key, hasOwnYAxis, originalIndex }) => {
const pointIndex = labelIndex - _state.labelFromIndex;
const point = hasOwnYAxis ? _secondaryPoints[pointIndex] : _points[originalIndex][pointIndex];
if (!point) {
return;
}
const [x, y] = hasOwnYAxis
? toPixels(_secondaryProjection, labelIndex, point.stackValue)
: toPixels(_projection, labelIndex, point.stackValue);
// TODO animate
_drawCircle(
[x, y],
getCssColor(colors, `dataset#${key}`),
getCssColor(colors, 'background'),
);
});
}
function _drawCircle([xPx, yPx], strokeColor, fillColor) {
_context.strokeStyle = strokeColor;
_context.fillStyle = fillColor;
_context.lineWidth = 2;
_context.beginPath();
_context.arc(xPx, yPx, 4, 0, 2 * Math.PI);
_context.fill();
_context.stroke();
}
function _drawTail(xPx, height, color) {
_context.strokeStyle = color;
_context.lineWidth = 1;
_context.beginPath();
_context.moveTo(xPx, 0);
_context.lineTo(xPx, height);
_context.stroke();
}
function _getBalloonLeftOffset(labelIndex) {
const meanLabel = (_state.labelFromIndex + _state.labelToIndex) / 2;
const { angle } = getPointerVector();
const shouldPlaceRight = data.isPie ? angle > Math.PI / 2 : labelIndex < meanLabel;
return shouldPlaceRight
? _offsetX + BALLOON_OFFSET
: _offsetX - (_balloon.offsetWidth + BALLOON_OFFSET);
}
function _getBalloonTopOffset() {
return data.isPie ? `${_offsetY}px` : 0;
}
function _updateBalloon(statistics, labelIndex) {
_balloon.style.transform = `translate3D(${_getBalloonLeftOffset(labelIndex)}px, ${_getBalloonTopOffset()}, 0)`;
_balloon.classList.add('lovely-chart--state-shown');
if (data.isPie) {
_updateContent(null, statistics);
} else {
_throttledUpdateContent(_getTitle(data, labelIndex), statistics);
}
}
function _getTitle(data, labelIndex) {
switch (data.tooltipFormatter) {
case 'statsFormatDayHourFull':
return statsFormatDayHourFull(data.xLabels[labelIndex].value);
case 'statsTooltipFormat(\'day\')':
return getLabelDate(data.xLabels[labelIndex]);
case 'statsTooltipFormat(\'5min\')':
return getLabelTime(data.xLabels[labelIndex]);
default:
return data.xLabels[labelIndex].text;
}
}
function _isPieSectorSelected(statistics, value, totalValue, index, pointerVector) {
const offset = index > 0 ? statistics.slice(0, index).reduce((a, x) => a + x.value, 0) : 0;
const beginAngle = offset / totalValue * Math.PI * 2 - Math.PI / 2;
const endAngle = (offset + value) / totalValue * Math.PI * 2 - Math.PI / 2;
return pointerVector &&
beginAngle <= pointerVector.angle &&
pointerVector.angle < endAngle &&
pointerVector.distance <= getPieRadius(_projection);
}
function _updateTitle(title) {
const titleContainer = _balloon.children[0];
if (data.isPie) {
if (titleContainer) {
titleContainer.style.display = 'none';
}
} else {
if (titleContainer.style.display === 'none') {
titleContainer.style.display = '';
}
const currentTitle = titleContainer.querySelector(':not(.lovely-chart--state-hidden)');
if (!titleContainer.innerHTML || !currentTitle) {
titleContainer.innerHTML = `<span>${title}</span>`;
} else {
currentTitle.innerHTML = title;
}
}
}
function _insertNewDataSet(dataSetContainer, { name, key, value }, totalValue) {
const className = `lovely-chart--tooltip-dataset-value lovely-chart--position-right lovely-chart--color-${data.colors[key].slice(1)}`;
const newDataSet = createElement();
newDataSet.className = 'lovely-chart--tooltip-dataset';
newDataSet.setAttribute('data-present', 'true');
newDataSet.setAttribute('data-name', name);
newDataSet.innerHTML = `<span class="lovely-chart--dataset-title">${name}</span><span class="${className}">${formatInteger(value)}</span>`;
_renderPercentageValue(newDataSet, value, totalValue);
const totalText = dataSetContainer.querySelector(`[data-total="true"]`);
if (totalText) {
dataSetContainer.insertBefore(newDataSet, totalText);
} else {
dataSetContainer.appendChild(newDataSet);
}
}
function _updateDataSet(currentDataSet, { key, value } = {}, totalValue) {
currentDataSet.setAttribute('data-present', 'true');
const valueElement = currentDataSet.querySelector(`.lovely-chart--tooltip-dataset-value.lovely-chart--color-${data.colors[key].slice(1)}:not(.lovely-chart--state-hidden)`);
valueElement.innerHTML = formatInteger(value);
_renderPercentageValue(currentDataSet, value, totalValue);
}
function _renderPercentageValue(dataSet, value, totalValue) {
if (!data.isPercentage) {
return;
}
if (data.isPie) {
Array.from(dataSet.querySelectorAll(`.lovely-chart--percentage-title`)).forEach(e => e.remove());
return;
}
const percentageValue = totalValue ? Math.round(value / totalValue * 100) : 0;
const percentageElement = dataSet.querySelector(`.lovely-chart--percentage-title:not(.lovely-chart--state-hidden)`);
if (!percentageElement) {
const newPercentageTitle = createElement('span');
newPercentageTitle.className = 'lovely-chart--percentage-title lovely-chart--position-left';
newPercentageTitle.innerHTML = `${percentageValue}%`;
dataSet.prepend(newPercentageTitle);
} else {
percentageElement.innerHTML = `${percentageValue}%`;
}
}
function _updateDataSets(statistics) {
const dataSetContainer = _balloon.children[1];
if (data.isPie) {
dataSetContainer.classList.add('lovely-chart--tooltip-legend-pie');
}
Array.from(dataSetContainer.children).forEach((dataSet) => {
if (!data.isPie && dataSetContainer.classList.contains('lovely-chart--tooltip-legend-pie')) {
dataSet.remove();
} else {
dataSet.setAttribute('data-present', 'false');
}
});
const totalValue = statistics.reduce((a, x) => a + x.value, 0);
const pointerVector = getPointerVector();
const finalStatistics = data.isPie ? statistics.filter(({ value }, index) => _isPieSectorSelected(statistics, value, totalValue, index, pointerVector)) : statistics;
finalStatistics.forEach((statItem) => {
const currentDataSet = dataSetContainer.querySelector(`[data-name="${statItem.name}"]`);
if (!currentDataSet) {
_insertNewDataSet(dataSetContainer, statItem, totalValue);
} else {
_updateDataSet(currentDataSet, statItem, totalValue);
}
});
if (data.isBars && data.isStacked) {
_renderTotal(dataSetContainer, formatInteger(totalValue));
}
Array.from(dataSetContainer.querySelectorAll('[data-present="false"]'))
.forEach((dataSet) => {
dataSet.remove();
});
}
function _updateContent(title, statistics) {
_updateTitle(title);
_updateDataSets(statistics);
}
function _renderTotal(dataSetContainer, totalValue) {
const totalText = dataSetContainer.querySelector(`[data-total="true"]`);
const className = `lovely-chart--tooltip-dataset-value lovely-chart--position-right`;
if (!totalText) {
const newTotalText = createElement();
newTotalText.className = 'lovely-chart--tooltip-dataset';
newTotalText.setAttribute('data-present', 'true');
newTotalText.setAttribute('data-total', 'true');
newTotalText.innerHTML = `<span>All</span><span class="${className}">${totalValue}</span>`;
dataSetContainer.appendChild(newTotalText);
} else {
totalText.setAttribute('data-present', 'true');
const valueElement = totalText.querySelector(`.lovely-chart--tooltip-dataset-value:not(.lovely-chart--state-hidden)`);
valueElement.innerHTML = totalValue;
}
}
function _hideBalloon() {
_balloon.classList.remove('lovely-chart--state-shown');
}
function getPointerVector() {
const { width, height } = _element.getBoundingClientRect();
const center = [width / 2, height / 2];
const angle = Math.atan2(_offsetY - center[1], _offsetX - center[0]);
const distance = Math.sqrt((_offsetX - center[0]) ** 2 + (_offsetY - center[1]) ** 2);
return {
angle: angle >= -Math.PI / 2 ? angle : 2 * Math.PI + angle,
distance,
};
}
function _getPageOffset(el) {
return el.getBoundingClientRect();
}
return { update, toggleLoading, toggleIsZoomed };
}

View File

@ -0,0 +1,138 @@
import { SPEED_TEST_FAST_FPS, SPEED_TEST_INTERVAL, TRANSITION_DEFAULT_DURATION } from './constants';
function transition(t) {
// faster
// return -t * (t - 2);
// easeOut
return 1 - Math.pow(1 - t, 1.675);
}
export function createTransitionManager(onTick) {
const _transitions = {};
let _nextFrame = null;
let _testStartedAt = null;
let _fps = null;
let _testingFps = null;
let _slowDetectedAt = null;
let _startedAsSlow = null;
function add(prop, from, to, duration, options) {
_transitions[prop] = {
from,
to,
duration,
options,
current: from,
startedAt: Date.now(),
progress: 0,
};
if (!_nextFrame) {
_resetSpeedTest();
_nextFrame = requestAnimationFrame(_tick);
}
}
function remove(prop) {
delete _transitions[prop];
if (!isRunning()) {
cancelAnimationFrame(_nextFrame);
_nextFrame = null;
}
}
function get(prop) {
return _transitions[prop];
}
function getState() {
const state = {};
Object.keys(_transitions).forEach((prop) => {
const { current, from, to, progress } = _transitions[prop];
state[prop] = current;
// TODO perf lazy
state[`${prop}From`] = from;
state[`${prop}To`] = to;
state[`${prop}Progress`] = progress;
});
return state;
}
function isRunning() {
return Boolean(Object.keys(_transitions).length);
}
function isFast(forceCheck) {
if (!forceCheck && (_startedAsSlow || _slowDetectedAt)) {
return false;
}
return _fps === null || _fps >= SPEED_TEST_FAST_FPS;
}
function _tick() {
const isSlow = !isFast();
_speedTest();
const state = {};
Object.keys(_transitions).forEach((prop) => {
const { startedAt, from, to, duration = TRANSITION_DEFAULT_DURATION, options } = _transitions[prop];
const progress = Math.min(1, (Date.now() - startedAt) / duration);
let current = from + (to - from) * transition(progress);
if (options.includes('ceil')) {
current = Math.ceil(current);
} else if (options.includes('floor')) {
current = Math.floor(current);
}
_transitions[prop].current = current;
_transitions[prop].progress = progress;
state[prop] = current;
if (progress === 1) {
remove(prop);
}
});
if (!isSlow) {
onTick(state);
}
if (isRunning()) {
_nextFrame = requestAnimationFrame(_tick);
}
}
function _resetSpeedTest() {
_testStartedAt = null;
_testingFps = null;
if (_slowDetectedAt && Date.now() - _slowDetectedAt > 5000) {
_slowDetectedAt = null;
}
_startedAsSlow = Boolean(_slowDetectedAt) || !isFast(true);
}
function _speedTest() {
if (!_testStartedAt || (Date.now() - _testStartedAt) >= SPEED_TEST_INTERVAL) {
if (_testingFps) {
_fps = _testingFps;
if (!_slowDetectedAt && !isFast(true)) {
_slowDetectedAt = Date.now();
}
}
_testStartedAt = Date.now();
_testingFps = 0;
} else {
_testingFps++;
}
}
return { add, remove, get, getState, isRunning, isFast };
}

View File

@ -0,0 +1,168 @@
import { analyzeData } from './data';
import { getFullLabelDate } from './format';
import { ZOOM_RANGE_DELTA, ZOOM_RANGE_MIDDLE, ZOOM_TIMEOUT } from './constants';
import { createColors } from './skin';
export function createZoomer(data, overviewData, colors, stateManager, container, header, minimap, tooltip, tools) {
let _isZoomed = false;
let _stateBeforeZoomIn;
let _stateBeforeZoomOut;
function zoomIn(state, labelIndex) {
if (_isZoomed) {
return;
}
const label = data.xLabels[labelIndex];
_stateBeforeZoomIn = state;
header.toggleIsZooming(true);
tooltip.toggleLoading(true);
tooltip.toggleIsZoomed(true);
if (data.shouldZoomToPie) {
container.classList.add('lovely-chart--state-zoomed-in');
container.classList.add('lovely-chart--state-animating');
}
const { value } = label;
const dataPromise = data.shouldZoomToPie ? Promise.resolve(_generatePieData(labelIndex)) : data.onZoom(value);
dataPromise
.then((newData) => _replaceData(newData, labelIndex, label))
.catch(() => {
tooltip.toggleLoading(false);
tooltip.toggleIsZoomed(false);
header.toggleIsZooming(false);
});
}
function zoomOut(state) {
if (!_isZoomed) {
return;
}
_stateBeforeZoomOut = state;
header.toggleIsZooming(true);
tooltip.toggleLoading(true);
tooltip.toggleIsZoomed(false);
if (data.shouldZoomToPie) {
container.classList.remove('lovely-chart--state-zoomed-in');
container.classList.add('lovely-chart--state-animating');
}
const labelIndex = Math.round((state.labelFromIndex + state.labelToIndex) / 2);
_replaceData(overviewData, labelIndex);
}
function isZoomed() {
return _isZoomed;
}
function _replaceData(newRawData, labelIndex, zoomInLabel) {
tooltip.toggleLoading(false);
const labelWidth = 1 / data.xLabels.length;
const labelMiddle = labelIndex / (data.xLabels.length - 1);
const filter = {};
data.datasets.forEach(({ key }) => filter[key] = false);
const newData = analyzeData(newRawData, _isZoomed || data.shouldZoomToPie ? 'day' : 'hour');
const shouldZoomToLines = Object.keys(data.datasets).length !== Object.keys(newData.datasets).length;
stateManager.update({
range: {
begin: labelMiddle - labelWidth / 2,
end: labelMiddle + labelWidth / 2,
},
filter,
});
setTimeout(() => {
Object.assign(data, newData);
if (shouldZoomToLines && newRawData.colors) {
Object.assign(colors, createColors(newRawData.colors));
}
if (shouldZoomToLines) {
minimap.toggle(_isZoomed);
tools.redraw();
container.style.width = `${container.scrollWidth}px`;
container.style.height = `${container.scrollHeight}px`;
}
stateManager.update({
range: {
begin: ZOOM_RANGE_MIDDLE - ZOOM_RANGE_DELTA,
end: ZOOM_RANGE_MIDDLE + ZOOM_RANGE_DELTA,
},
focusOn: null,
}, true);
const daysCount = _isZoomed || data.shouldZoomToPie ? data.xLabels.length : data.xLabels.length / 24;
const halfDayWidth = (1 / daysCount) / 2;
let range;
let filter;
if (_isZoomed) {
range = {
begin: _stateBeforeZoomIn.begin,
end: _stateBeforeZoomIn.end,
};
filter = shouldZoomToLines ? _stateBeforeZoomIn.filter : _stateBeforeZoomOut.filter;
} else {
if (shouldZoomToLines) {
range = {
begin: 0,
end: 1,
};
filter = {};
data.datasets.forEach(({ key }) => filter[key] = true);
} else {
range = data.shouldZoomToPie ? {
begin: ZOOM_RANGE_MIDDLE - halfDayWidth,
end: ZOOM_RANGE_MIDDLE + halfDayWidth,
} : newData.minimapRange;
filter = _stateBeforeZoomIn.filter;
}
}
stateManager.update({
range,
filter,
minimapDelta: _isZoomed ? null : range.end - range.begin,
});
if (zoomInLabel) {
header.zoom(getFullLabelDate(zoomInLabel));
}
_isZoomed = !_isZoomed;
header.toggleIsZooming(false);
}, stateManager.hasAnimations() ? ZOOM_TIMEOUT : 0);
setTimeout(() => {
if (data.shouldZoomToPie) {
container.classList.remove('lovely-chart--state-animating');
}
}, stateManager.hasAnimations() ? 1000 : 0);
}
function _generatePieData(labelIndex) {
return Object.assign(
{},
overviewData,
{
type: 'pie',
labels: overviewData.labels.slice(labelIndex - 3, labelIndex + 4),
datasets: overviewData.datasets.map((dataset) => {
return {
...dataset,
values: dataset.values.slice(labelIndex - 3, labelIndex + 4),
};
}),
},
);
}
return { zoomIn, zoomOut, isZoomed };
}

View File

@ -0,0 +1,22 @@
import { DPR } from './constants';
import { createElement } from './minifiers';
export function setupCanvas(container, { width, height }) {
const canvas = createElement('canvas');
canvas.width = width * DPR;
canvas.height = height * DPR;
canvas.style.width = '100%';
canvas.style.height = `${height}px`;
const context = canvas.getContext('2d');
context.scale(DPR, DPR);
container.appendChild(canvas);
return { canvas, context };
}
export function clearCanvas(canvas, context) {
context.clearRect(0, 0, canvas.width, canvas.height);
}

View File

@ -0,0 +1,79 @@
import { addEventListener, removeEventListener } from './minifiers';
import { LONG_PRESS_TIMEOUT } from './constants';
export function captureEvents(element, options) {
let captureEvent = null;
let longPressTimeout = null;
function onCapture(e) {
captureEvent = e;
if (e.type === 'mousedown') {
addEventListener(document, 'mousemove', onMove);
addEventListener(document, 'mouseup', onRelease);
} else if (e.type === 'touchstart') {
addEventListener(document, 'touchmove', onMove);
addEventListener(document, 'touchend', onRelease);
addEventListener(document, 'touchcancel', onRelease);
// https://stackoverflow.com/questions/11287877/how-can-i-get-e-offsetx-on-mobile-ipad
// Android does not have this value, and iOS has it but as read-only.
if (e.pageX === undefined) {
e.pageX = e.touches[0].pageX;
}
}
if (options.draggingCursor) {
document.documentElement.classList.add(`cursor-${options.draggingCursor}`);
}
options.onCapture && options.onCapture(e);
if (options.onLongPress) {
longPressTimeout = setTimeout(() => options.onLongPress(), LONG_PRESS_TIMEOUT);
}
}
function onRelease(e) {
if (captureEvent) {
if (longPressTimeout) {
clearTimeout(longPressTimeout);
longPressTimeout = null;
}
if (options.draggingCursor) {
document.documentElement.classList.remove(`cursor-${options.draggingCursor}`);
}
removeEventListener(document, 'mouseup', onRelease);
removeEventListener(document, 'mousemove', onMove);
removeEventListener(document, 'touchcancel', onRelease);
removeEventListener(document, 'touchend', onRelease);
removeEventListener(document, 'touchmove', onMove);
captureEvent = null;
options.onRelease && options.onRelease(e);
}
}
function onMove(e) {
if (captureEvent) {
if (longPressTimeout) {
clearTimeout(longPressTimeout);
longPressTimeout = null;
}
if (e.type === 'touchmove' && e.pageX === undefined) {
e.pageX = e.touches[0].pageX;
}
options.onDrag && options.onDrag(e, captureEvent, {
dragOffsetX: e.pageX - captureEvent.pageX,
});
}
}
addEventListener(element, 'mousedown', onCapture);
addEventListener(element, 'touchstart', onCapture);
}

View File

@ -0,0 +1,62 @@
export const DPR = window.devicePixelRatio || 1;
export const DEFAULT_RANGE = { begin: 0.8, end: 1 };
export const TRANSITION_DEFAULT_DURATION = 300;
export const LONG_PRESS_TIMEOUT = 500;
export const GUTTER = 10;
export const PLOT_HEIGHT = 320;
export const PLOT_TOP_PADDING = 15;
export const PLOT_LINE_WIDTH = 2;
export const PLOT_PIE_RADIUS_FACTOR = 0.9 / 2;
export const PLOT_PIE_SHIFT = 10;
export const PLOT_BARS_WIDTH_SHIFT = 0.5;
export const BALLOON_OFFSET = 20;
export const AXES_FONT = '300 10px Helvetica, Arial, sans-serif';
export const AXES_MAX_COLUMN_WIDTH = 45;
export const AXES_MAX_ROW_HEIGHT = 50;
export const X_AXIS_HEIGHT = 30;
export const X_AXIS_SHIFT_START = 1;
export const Y_AXIS_ZERO_BASED_THRESHOLD = 0.1;
export const MINIMAP_HEIGHT = 40;
export const MINIMAP_MARGIN = 10;
export const MINIMAP_LINE_WIDTH = 1;
export const MINIMAP_EAR_WIDTH = 8;
export const MINIMAP_MAX_ANIMATED_DATASETS = 4;
export const ZOOM_TIMEOUT = TRANSITION_DEFAULT_DURATION;
export const ZOOM_RANGE_DELTA = 0.1;
export const ZOOM_RANGE_MIDDLE = .5;
export const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export const WEEK_DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
export const WEEK_DAYS_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export const MILISECONDS_IN_DAY = 24 * 60 * 60 * 1000;
export const SPEED_TEST_INTERVAL = 200;
export const SPEED_TEST_FAST_FPS = 4;
export const SIMPLIFIER_MIN_POINTS = 1000;
export const SIMPLIFIER_PLOT_FACTOR = 1;
export const SIMPLIFIER_MINIMAP_FACTOR = 0.5;
export const ANIMATE_PROPS = [
// Viewport X-axis
'begin 200 fast', 'end 200 fast', 'labelFromIndex 200 fast floor', 'labelToIndex 200 fast ceil',
// X-axis labels
'xAxisScale 400',
// Viewport Y-axis
'yMinViewport', 'yMaxViewport', 'yMinViewportSecond', 'yMaxViewportSecond',
// Minimap Y-axis
'yMinMinimap', 'yMaxMinimap', 'yMinMinimapSecond', 'yMaxMinimapSecond',
// Y-axis labels
'yAxisScale', 'yAxisScaleSecond',
];

View File

@ -0,0 +1,91 @@
import { getMaxMin } from './utils';
import { statsFormatDay, statsFormatDayHour, statsFormatText, statsFormatMin } from './format';
export function analyzeData(data) {
const { title, labelFormatter, tooltipFormatter, isStacked, isPercentage, hasSecondYAxis, onZoom, minimapRange, hideCaption, zoomOutLabel } = data;
const { datasets, labels } = prepareDatasets(data);
const colors = {};
let totalYMin = Infinity;
let totalYMax = -Infinity;
datasets.forEach(({ key, color, yMin, yMax }) => {
colors[key] = color;
if (yMin < totalYMin) {
totalYMin = yMin;
}
if (yMax > totalYMax) {
totalYMax = yMax;
}
});
let xLabels;
switch (labelFormatter) {
case 'statsFormatDayHour':
xLabels = statsFormatDayHour(labels);
break;
case 'statsFormat(\'day\')':
xLabels = statsFormatDay(labels);
break;
case 'statsFormat(\'5min\')':
xLabels = statsFormatMin(labels);
break;
default:
xLabels = statsFormatText(labels);
break;
}
const analyzed = {
title,
labelFormatter,
tooltipFormatter,
xLabels,
datasets,
isStacked,
isPercentage,
hasSecondYAxis,
onZoom,
isLines: data.type === 'line',
isBars: data.type === 'bar',
isAreas: data.type === 'area',
isPie: data.type === 'pie',
yMin: totalYMin,
yMax: totalYMax,
colors,
minimapRange,
hideCaption,
zoomOutLabel,
};
analyzed.shouldZoomToPie = !analyzed.onZoom && analyzed.isPercentage;
analyzed.isZoomable = analyzed.onZoom || analyzed.shouldZoomToPie;
return analyzed;
}
function prepareDatasets(data) {
const { type, labels, datasets, hasSecondYAxis } = data;
return {
labels: cloneArray(labels),
datasets: datasets.map(({ name, color, values }, i) => {
const { min: yMin, max: yMax } = getMaxMin(values);
return {
type,
key: `y${i}`,
name,
color,
values: cloneArray(values),
hasOwnYAxis: hasSecondYAxis && i === datasets.length - 1,
yMin,
yMax,
};
}),
};
}
function cloneArray(array) {
return array.slice(0);
}

View File

@ -0,0 +1,229 @@
import { getCssColor } from './skin';
import { mergeArrays } from './utils';
import { getPieRadius, getPieTextShift, getPieTextSize } from './formulas';
import { PLOT_BARS_WIDTH_SHIFT, PLOT_PIE_SHIFT } from './constants';
import { simplify } from './simplify';
import { toPixels } from './Projection';
export function drawDatasets(
context, state, data,
range, points, projection, secondaryPoints, secondaryProjection,
lineWidth, visibilities, colors, pieToBar, simplification,
) {
data.datasets.forEach(({ key, type, hasOwnYAxis }, i) => {
if (!visibilities[i]) {
return;
}
const options = {
color: getCssColor(colors, `dataset#${key}`),
lineWidth,
opacity: data.isStacked ? 1 : visibilities[i],
simplification,
};
const datasetType = type === 'pie' && pieToBar ? 'bar' : type;
let datasetPoints = hasOwnYAxis ? secondaryPoints : points[i];
let datasetProjection = hasOwnYAxis ? secondaryProjection : projection;
if (datasetType === 'area') {
const { yMin, yMax } = projection.getParams();
const yHeight = yMax - yMin;
const bottomLine = [
{ labelIndex: range.from, stackValue: 0 },
{ labelIndex: range.to, stackValue: 0 },
];
const topLine = [
{ labelIndex: range.to, stackValue: yHeight },
{ labelIndex: range.from, stackValue: yHeight },
];
datasetPoints = mergeArrays([points[i - 1] || bottomLine, topLine]);
}
if (datasetType === 'pie') {
options.center = projection.getCenter();
options.radius = getPieRadius(projection);
options.pointerVector = state.focusOn;
}
if (datasetType === 'bar') {
const [x0] = toPixels(projection, 0, 0);
const [x1] = toPixels(projection, 1, 0);
options.lineWidth = x1 - x0;
options.focusOn = state.focusOn;
}
drawDataset(datasetType, context, datasetPoints, datasetProjection, options);
});
if (state.focusOn && data.isBars) {
const [x0] = toPixels(projection, 0, 0);
const [x1] = toPixels(projection, 1, 0);
drawBarsMask(context, projection, {
focusOn: state.focusOn,
color: getCssColor(colors, 'mask'),
lineWidth: x1 - x0,
});
}
}
function drawDataset(type, ...args) {
switch (type) {
case 'line':
return drawDatasetLine(...args);
case 'bar':
return drawDatasetBars(...args);
case 'area':
return drawDatasetArea(...args);
case 'pie':
return drawDatasetPie(...args);
}
}
function drawDatasetLine(context, points, projection, options) {
context.beginPath();
let pixels = [];
for (let j = 0, l = points.length; j < l; j++) {
const { labelIndex, stackValue } = points[j];
pixels.push(toPixels(projection, labelIndex, stackValue));
}
if (options.simplification) {
const simplifierFn = simplify(pixels);
pixels = simplifierFn(options.simplification).points;
}
pixels.forEach(([x, y]) => {
context.lineTo(x, y);
});
context.save();
context.strokeStyle = options.color;
context.lineWidth = options.lineWidth;
context.globalAlpha = options.opacity;
context.lineJoin = 'bevel';
context.lineCap = 'butt';
context.stroke();
context.restore();
}
// TODO try areas
function drawDatasetBars(context, points, projection, options) {
const { yMin } = projection.getParams();
context.save();
context.globalAlpha = options.opacity;
context.fillStyle = options.color;
for (let j = 0, l = points.length; j < l; j++) {
const { labelIndex, stackValue, stackOffset = 0 } = points[j];
const [, yFrom] = toPixels(projection, labelIndex, Math.max(stackOffset, yMin));
const [x, yTo] = toPixels(projection, labelIndex, stackValue);
const rectX = x - options.lineWidth / 2;
const rectY = yTo;
const rectW = options.opacity === 1 ?
options.lineWidth + PLOT_BARS_WIDTH_SHIFT :
options.lineWidth + PLOT_BARS_WIDTH_SHIFT * options.opacity;
const rectH = yFrom - yTo;
context.fillRect(rectX, rectY, rectW, rectH);
}
context.restore();
}
function drawBarsMask(context, projection, options) {
const [xCenter, yCenter] = projection.getCenter();
const [width, height] = projection.getSize();
const [x] = toPixels(projection, options.focusOn, 0);
context.fillStyle = options.color;
context.fillRect(xCenter - width / 2, yCenter - height / 2, x - options.lineWidth / 2 + PLOT_BARS_WIDTH_SHIFT, height);
context.fillRect(x + options.lineWidth / 2, yCenter - height / 2, width - (x + options.lineWidth / 2), height);
}
function drawDatasetArea(context, points, projection, options) {
context.beginPath();
let pixels = [];
for (let j = 0, l = points.length; j < l; j++) {
const { labelIndex, stackValue } = points[j];
pixels.push(toPixels(projection, labelIndex, stackValue));
}
if (options.simplification) {
const simplifierFn = simplify(pixels);
pixels = simplifierFn(options.simplification).points;
}
pixels.forEach(([x, y]) => {
context.lineTo(x, y);
});
context.save();
context.fillStyle = options.color;
context.lineWidth = options.lineWidth;
context.globalAlpha = options.opacity;
context.lineJoin = 'bevel';
context.lineCap = 'butt';
context.fill();
context.restore();
}
function drawDatasetPie(context, points, projection, options) {
const { visibleValue, stackValue, stackOffset = 0 } = points[0];
if (!visibleValue) {
return;
}
const { yMin, yMax } = projection.getParams();
const percentFactor = 1 / (yMax - yMin);
const percent = visibleValue * percentFactor;
const beginAngle = stackOffset * percentFactor * Math.PI * 2 - Math.PI / 2;
const endAngle = stackValue * percentFactor * Math.PI * 2 - Math.PI / 2;
const { radius = 120, center: [x, y], pointerVector } = options;
const shift = (
pointerVector &&
beginAngle <= pointerVector.angle &&
pointerVector.angle < endAngle &&
pointerVector.distance <= radius
) ? PLOT_PIE_SHIFT : 0;
const shiftAngle = (beginAngle + endAngle) / 2;
const directionX = Math.cos(shiftAngle);
const directionY = Math.sin(shiftAngle);
const shiftX = directionX * shift;
const shiftY = directionY * shift;
context.save();
context.beginPath();
context.fillStyle = options.color;
context.moveTo(x + shiftX, y + shiftY);
context.arc(x + shiftX, y + shiftY, radius, beginAngle, endAngle);
context.lineTo(x + shiftX, y + shiftY);
context.fill();
context.font = `700 ${getPieTextSize(percent, radius)}px Helvetica, Arial, sans-serif`;
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillStyle = 'white';
const textShift = getPieTextShift(percent, radius);
context.fillText(
`${Math.round(percent * 100)}%`, x + directionX * textShift + shiftX, y + directionY * textShift + shiftY,
);
context.restore();
}

View File

@ -0,0 +1,90 @@
import { MONTHS, WEEK_DAYS, WEEK_DAYS_SHORT } from './constants';
export function statsFormatDayHour(labels) {
return labels.map((value) => ({
value,
text: `${value}:00`,
}));
}
export function statsFormatDayHourFull(value) {
return `${value}:00`;
}
export function statsFormatDay(labels) {
return labels.map((value) => {
const date = new Date(value);
const day = date.getDate();
const month = MONTHS[date.getMonth()];
return ({
value,
text: `${day} ${month}`,
});
});
}
export function statsFormatMin(labels) {
return labels.map((value) => ({
value,
text: new Date(value).toString().match(/(\d+:\d+):/)[1],
}));
}
export function statsFormatText(labels) {
return labels.map((value, i) => {
return ({
value: i,
text: value,
});
});
}
export function humanize(value, decimals = 1) {
if (value >= 1e6) {
return keepThreeDigits(value / 1e6, decimals) + 'M';
} else if (value >= 1e3) {
return keepThreeDigits(value / 1e3, decimals) + 'K';
}
return value;
}
// TODO perf
function keepThreeDigits(value, decimals) {
return value
.toFixed(decimals)
.replace(/(\d{3,})\.\d+/, '$1')
.replace(/\.0+$/, '');
}
export function formatInteger(n) {
return String(n).replace(/\d(?=(\d{3})+$)/g, '$& ');
}
export function getFullLabelDate(label, { isShort = false } = {}) {
return getLabelDate(label, { isShort, displayWeekDay: true });
}
export function getLabelDate(label, { isShort = false, displayWeekDay = false, displayYear = true, displayHours = false } = {}) {
const { value } = label;
const date = new Date(value);
const weekDaysArray = isShort ? WEEK_DAYS_SHORT : WEEK_DAYS;
let string = `${date.getUTCDate()} ${MONTHS[date.getUTCMonth()]}`;
if (displayWeekDay) {
string = `${weekDaysArray[date.getUTCDay()]}, ` + string;
}
if (displayYear) {
string += ` ${date.getUTCFullYear()}`;
}
if (displayHours) {
string += `, ${('0' + date.getUTCHours()).slice(-2)}:${('0' + date.getUTCMinutes()).slice(-2)}`
}
return string;
}
export function getLabelTime(label) {
return new Date(label.value).toString().match(/(\d+:\d+):/)[1];
}

View File

@ -0,0 +1,56 @@
import { GUTTER, PLOT_PIE_RADIUS_FACTOR, MILISECONDS_IN_DAY, SIMPLIFIER_MIN_POINTS } from './constants';
export function xScaleLevelToStep(scaleLevel) {
return Math.pow(2, scaleLevel);
}
export function xStepToScaleLevel(step) {
return Math.ceil(Math.log2(step || 1));
}
const SCALE_LEVELS = [
1, 2, 8, 18, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 25000, 50000, 100000,
250000, 500000, 1000000, 2500000, 5000000, 10000000, 25000000, 50000000, 100000000,
];
export function yScaleLevelToStep(scaleLevel) {
return SCALE_LEVELS[scaleLevel] || SCALE_LEVELS[SCALE_LEVELS.length - 1];
}
export function yStepToScaleLevel(neededStep) {
return SCALE_LEVELS.findIndex((step) => step >= neededStep) || SCALE_LEVELS.length - 1;
}
export function applyYEdgeOpacity(opacity, xPx, plotWidth) {
const edgeOffset = Math.min(xPx + GUTTER, plotWidth - xPx);
if (edgeOffset <= GUTTER * 4) {
opacity = Math.min(1, opacity, edgeOffset / (GUTTER * 4));
}
return opacity;
}
export function applyXEdgeOpacity(opacity, yPx) {
return (yPx - GUTTER <= GUTTER * 2)
? Math.min(1, opacity, (yPx - GUTTER) / (GUTTER * 2))
: opacity;
}
export function getPieRadius(projection) {
return Math.min(...projection.getSize()) * PLOT_PIE_RADIUS_FACTOR;
}
export function getPieTextSize(percent, radius) {
return (radius + percent * 200) / 10;
}
export function getPieTextShift(percent, radius, shift) {
return percent >= 0.99 ? 0 : Math.min(1 - Math.log(percent * 30) / 5, 4 / 5) * radius;
}
export function isDataRange(labelFrom, labelTo) {
return Math.abs(labelTo.value - labelFrom.value) > MILISECONDS_IN_DAY;
}
export function getSimplificationDelta(pointsLength) {
return pointsLength >= SIMPLIFIER_MIN_POINTS ? Math.min((pointsLength / 1000), 1) : 0;
}

View File

@ -0,0 +1,42 @@
import { debounce } from './utils';
export const hideOnScroll = (() => {
const chartEls = [];
const showAllDebounced = debounce(showAll, 500, true, false);
const hideScrolledDebounced = debounce(hideScrolled, 500, false, true);
function setup(chartEl) {
chartEls.push(chartEl);
if (chartEls.length === 1) {
window.onscroll = () => {
showAllDebounced();
hideScrolledDebounced();
};
} else {
hideScrolledDebounced();
}
}
function showAll() {
chartEls.forEach((chartEl) => {
chartEl.classList.remove('lovely-chart--state-invisible');
});
}
function hideScrolled() {
chartEls.forEach((chartEl) => {
const { top, bottom } = chartEl.getBoundingClientRect();
const shouldHide = bottom < 0 || top > window.innerHeight;
if (!chartEl.classList.contains('lovely-chart--state-invisible')) {
chartEl.style.width = `${chartEl.scrollWidth}px`;
chartEl.style.height = `${chartEl.scrollHeight}px`;
}
chartEl.classList.toggle('lovely-chart--state-invisible', shouldHide);
});
}
return setup;
})();

View File

@ -0,0 +1,11 @@
export const createElement = (tagName = 'div') => {
return document.createElement(tagName);
};
export function addEventListener(element, event, cb) {
element.addEventListener(event, cb);
}
export function removeEventListener(element, event, cb) {
element.removeEventListener(event, cb);
}

View File

@ -0,0 +1,79 @@
import { sumArrays } from './utils';
export function preparePoints(data, datasets, range, visibilities, bounds, pieToArea) {
let values = datasets.map(({ values }) => (
values.slice(range.from, range.to + 1)
));
if (data.isPie && !pieToArea) {
values = prepareSumsByX(values);
}
const points = values.map((datasetValues, i) => (
datasetValues.map((value, j) => {
let visibleValue = value;
if (data.isStacked) {
visibleValue *= visibilities[i];
}
return {
labelIndex: range.from + j,
value,
visibleValue,
stackOffset: 0,
stackValue: visibleValue,
};
})
));
if (data.isPercentage) {
preparePercentage(points, bounds);
}
if (data.isStacked) {
prepareStacked(points);
}
return points;
}
function getSumsByY(points) {
return sumArrays(points.map((datasetPoints) => (
datasetPoints.map(({ visibleValue }) => visibleValue)
)));
}
// TODO perf cache for [0..1], use in state
function preparePercentage(points, bounds) {
const sumsByY = getSumsByY(points);
points.forEach((datasetPoints) => {
datasetPoints.forEach((point, j) => {
point.percent = point.visibleValue / sumsByY[j];
point.visibleValue = point.percent * bounds.yMax;
});
});
}
function prepareStacked(points) {
const accum = [];
points.forEach((datasetPoints) => {
datasetPoints.forEach((point, j) => {
if (accum[j] === undefined) {
accum[j] = 0;
}
point.stackOffset = accum[j];
accum[j] += point.visibleValue;
point.stackValue = accum[j];
});
});
}
function prepareSumsByX(values) {
return values.map((datasetValues) => (
[datasetValues.reduce((sum, value) => sum + value, 0)]
));
}

View File

@ -0,0 +1,201 @@
export const simplify = (() => {
function simplify(points, indexes, fixedPoints) {
if (points.length < 6) {
return function () {
return {
points: points,
indexes: indexes,
removed: [],
};
};
}
let worker = precalculate(points, fixedPoints);
return function (delta) {
let result = [],
resultIndexes = [],
removed = [];
let delta2 = delta * delta,
markers = worker(delta2);
for (let i = 0, l = points.length; i < l; i++) {
if (markers[i] >= delta2 || i == 0 || i == l - 1) {
result.push(points[i]);
resultIndexes.push(indexes ? indexes[i] : i);
} else {
removed.push(i);
}
}
return {
points: result,
indexes: resultIndexes,
removed: removed,
};
};
}
let E1 = 1.0 / Math.pow(2, 22), // максимальная дельта
MAXLIMIT = 100000;
function precalculate(points, fixedPoints) {
let len = points.length,
distances = [],
queue = [],
maximumDelta;
for (let i = 0, l = points.length; i < l; ++i) {
distances[i] = 0;
}
if (!fixedPoints) {
fixedPoints = [];
}
//инициализируем дерево срединным значением
//чтобы не попадает в ситуации когда начало линии близко к концу(те полигон)
//и правильные расчеты сложны
let subdivisionTree = 0;
for (let i = 0, l = fixedPoints.length; i < l; ++i) {
distances[fixedPoints[i]] = MAXLIMIT;
}
function worker(params) {
let start = params.start,
end = params.end,
record = params.record,
currentLimit = params.currentLimit,
usedDistance = 0;
if (!record) {
//let deltaShifts = getDeltaShifts(points);
let usedIndex = -1,
vector = [
points[end][0] - points[start][0],
points[end][1] - points[start][1],
];
for (let i = 0, l = fixedPoints.length; i < l; ++i) {
let fixId = fixedPoints[i];
if (fixId > start) {
if (fixId < end) {
usedIndex = fixId;
usedDistance = MAXLIMIT;
break;
} else {
break;
}
}
}
if (usedIndex < 0) {
if (Math.abs(vector[0]) > E1 || Math.abs(vector[1]) > E1) {
let vectorLength = vector[0] * vector[0] + vector[1] * vector[1],
vectorLength_1 = +1.0 / vectorLength;
for (let i = start + 1; i < end; ++i) {
let segmentDistance = pointToSegmentDistanceSquare(points[i], points[start], points[end], vector, vectorLength_1);
if (segmentDistance > usedDistance) {
usedIndex = i;
usedDistance = segmentDistance;
}
}
} else {
//фиксируем на среднинной точке
usedIndex = Math.round((start + end) * 0.5);
usedDistance = currentLimit;
}
distances[usedIndex] = usedDistance;
}
record = {
start: start,
end: end,
index: usedIndex,
distance: usedDistance,
};
}
if (record.index && record.distance > maximumDelta) {
if (record.index - start >= 2) {
queue.push({
start: start,
end: record.index,
record: record.left,
currentLimit: record.distance,
parent: record,
parentProperty: 'left',
});
}
if (end - record.index >= 2) {
queue.push({
start: record.index,
end: end,
record: record.right,
currentLimit: record.distance,
parent: record,
parentProperty: 'right',
});
}
}
return record;
}
function tick() {
let request = queue.pop(),
result = worker(request);
if (request.parent && request.parentProperty) {
request.parent[request.parentProperty] = result;
}
return result;
}
return function (delta) {
maximumDelta = delta;
queue.push({
start: 0,
end: len - 1,
record: subdivisionTree,
currentLimit: MAXLIMIT,
});
subdivisionTree = tick();
while (queue.length) {
tick();
}
return distances;
};
}
function pointToSegmentDistanceSquare(p, v1, v2, dv, dvlen_1) {
let t;
let vx = +v1[0],
vy = +v1[1];
t = +((p[0] - vx) * dv[0] + (p[1] - vy) * dv[1]) * (dvlen_1);
if (t > 1) {
vx = +v2[0];
vy = +v2[1];
} else if (t > 0) {
vx += +dv[0] * t;
vy += +dv[1] * t;
}
let a = +p[0] - vx,
b = +p[1] - vy;
return +a * a + b * b;
}
return simplify;
})();

View File

@ -0,0 +1,90 @@
function detectSkin() {
return document.documentElement.classList.contains('theme-dark') ? 'skin-night' : 'skin-day';
}
let skin = detectSkin();
const COLORS = {
'skin-day': {
'background': '#FFFFFF',
'text-color': '#222222',
'minimap-mask': '#E2EEF9/0.6',
'minimap-slider': '#C0D1E1',
'grid-lines': '#182D3B/0.1',
'zoom-out-text': '#108BE3',
'tooltip-background': '#FFFFFF',
'tooltip-arrow': '#D2D5D7',
'mask': '#FFFFFF/0.5',
'x-axis-text': '#252529/0.6',
'y-axis-text': '#252529/0.6',
},
'skin-night': {
'background': '#242F3E',
'text-color': '#FFFFFF',
'minimap-mask': '#304259/0.6',
'minimap-slider': '#56626D',
'grid-lines': '#FFFFFF/0.1',
'zoom-out-text': '#48AAF0',
'tooltip-background': '#1c2533',
'tooltip-arrow': '#D2D5D7',
'mask': '#242F3E/0.5',
'x-axis-text': '#A3B1C2/0.6',
'y-axis-text': '#A3B1C2/0.6',
},
};
const styleElement = document.createElement('style');
styleElement.type = 'text/css';
styleElement.appendChild(document.createTextNode(''));
document.head.appendChild(styleElement);
const styleSheet = styleElement.sheet;
document.documentElement.addEventListener('darkmode', () => {
skin = detectSkin();
});
export function createColors(datasetColors) {
const colors = {};
const baseClass = `.lovely-chart--color`;
['skin-day', 'skin-night'].forEach((skin) => {
colors[skin] = {};
Object.keys(COLORS[skin]).forEach((prop) => {
colors[skin][prop] = hexToChannels(COLORS[skin][prop]);
});
Object.keys(datasetColors).forEach((key) => {
colors[skin][`dataset#${key}`] = hexToChannels(datasetColors[key]);
addCssRule(styleSheet, `.lovely-chart--tooltip-dataset-value${baseClass}-${datasetColors[key].slice(1)}`, `color: ${datasetColors[key]}`);
addCssRule(styleSheet, `.lovely-chart--button${baseClass}-${datasetColors[key].slice(1)}`, `border-color: ${datasetColors[key]}; color: ${datasetColors[key]}`);
addCssRule(styleSheet, `.lovely-chart--button.lovely-chart--state-checked${baseClass}-${datasetColors[key].slice(1)}`, `background-color: ${datasetColors[key]}`);
});
});
return colors;
}
export function getCssColor(colors, key, opacity) {
return buildCssColor(colors[skin][key], opacity);
}
function hexToChannels(hexWithAlpha) {
const [hex, alpha] = hexWithAlpha.replace('#', '').split('/');
return [
parseInt(hex.slice(0, 2), 16),
parseInt(hex.slice(2, 4), 16),
parseInt(hex.slice(4, 6), 16),
alpha ? parseFloat(alpha) : 1,
];
}
function buildCssColor([r, g, b, a = 1], opacity = 1) {
return `rgba(${r}, ${g}, ${b}, ${a * opacity})`;
}
function addCssRule(sheet, selector, rule) {
sheet.insertRule(`${selector} { ${rule} }`, sheet.cssRules.length);
}

View File

@ -0,0 +1,124 @@
@keyframes lovely-chart--animation-shake {
0% {
transform: translateX(-3px);
}
16.66% {
transform: translateX(3px);
}
33.33% {
transform: translateX(-2px);
}
50% {
transform: translateX(2px);
}
66.66% {
transform: translateX(-1px);
}
83.33% {
transform: translateX(1px);
}
100% {
transform: translateX(0);
}
}
@keyframes lovely-chart--animation-fadeIn {
from {
opacity: 0;
transform: scale3d(.5, .5, 1);
}
40% {
transform: scale3d(.75, .75, 1);
}
to {
opacity: 1;
transform: scale3d(1, 1, 1);
}
}
@keyframes lovely-chart--animation-fadeOut {
from {
opacity: 1;
transform: scale3d(1, 1, 1);
}
60% {
transform: scale3d(.75, .75, 1);
}
to {
opacity: 0;
transform: scale3d(.5, .5, 1);
}
}
@keyframes lovely-chart--animation-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg)
}
}
.lovely-chart--transition-container {
position: relative;
}
.lovely-chart--transition {
position: absolute;
top: 0;
bottom: 0;
&.lovely-chart--position-top {
transform-origin: top left;
&.lovely-chart--position-right {
transform-origin: top right;
}
}
&.lovely-chart--position-bottom {
transform-origin: bottom left;
&.lovely-chart--position-right {
transform-origin: bottom right;
}
}
&.lovely-chart--position-left {
left: 0;
}
&.lovely-chart--position-right {
right: 0;
}
&.lovely-chart--state-hidden {
opacity: 0;
}
}
.lovely-chart--state-animated {
animation-fill-mode: forwards;
animation-timing-function: ease-out;
animation-duration: 200ms;
&.lovely-chart--state-hidden {
animation-name: lovely-chart--animation-fadeOut;
animation-duration: 300ms;
}
&:not(.lovely-chart--state-hidden) {
animation-name: lovely-chart--animation-fadeIn;
}
}

View File

@ -0,0 +1,67 @@
.lovely-chart--button {
border: 1px solid #E6ECF0;
background-color: transparent;
padding: 7px 7px;
display: inline-block;
border-radius: 18px;
text-align: center;
text-decoration: none;
margin: 0 6px 8px 0;
// transition: background-color 300ms ease-out, border-color 300ms ease-out;
position: relative;
border-color: var(--text-color);
background: transparent;
color: var(--text-color);
&.lovely-chart--state-checked {
background-color: var(--text-color);
animation-duration: 500ms;
.lovely-chart--button-check {
opacity: 1;
}
.lovely-chart--button-label {
color: #ffffff;
transform: translateX(6px);
}
}
&.lovely-chart--state-shake {
animation-name: lovely-chart--animation-shake;
}
}
.lovely-chart--button-check {
display: inline-block;
position: absolute;
top: 7px;
left: 7px;
width: 20px;
height: 20px;
opacity: 0;
transition: opacity 300ms ease-out;
&::after {
position: absolute;
top: 50%;
left: 50%;
content: '';
width: 12px;
height: 12px;
margin-left: -6px;
margin-top: -6px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -256 1792 1792' version='1.1'%0A%3E%3Cg transform='matrix(1,0,0,-1,7.5932203,1217.0847)' id='g3003'%3E%3Cpath d='m 1671,970 q 0,-40 -28,-68 L 919,178 783,42 Q 755,14 715,14 675,14 647,42 L 511,178 149,540 q -28,28 -28,68 0,40 28,68 l 136,136 q 28,28 68,28 40,0 68,-28 l 294,-295 656,657 q 28,28 68,28 40,0 68,-28 l 136,-136 q 28,-28 28,-68 z' style='fill:white'/%3E%3C/g%3E%3C/svg%3E");
background-size: 100%;
}
}
.lovely-chart--button-label {
display: inline-block;
font-weight: 300;
line-height: 20px;
color: inherit;
padding: 0 13px;
transition: transform 300ms ease-out, color 300ms ease-out;
}

View File

@ -0,0 +1,152 @@
.lovely-chart--container {
font: 300 13px '-apple-system', 'HelveticaNeue', Helvetica, Arial, sans-serif;
color: #222222;
position: relative;
text-align: left;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
&.lovely-chart--state-invisible > * {
display: none;
}
> canvas,
.lovely-chart--tooltip canvas {
margin-top: 10px;
}
}
.lovely-chart--container-type-pie {
&.lovely-chart--state-animating {
> canvas {
transform-origin: center calc(50% - 7.5px);
}
&.lovely-chart--state-zoomed-in > canvas {
animation: lovely-chart--animation-pie-transition-in 0.6s;
animation-timing-function: ease-out;
}
&:not(.lovely-chart--state-zoomed-in) > canvas {
animation: lovely-chart--animation-pie-transition-out 0.55s;
animation-timing-function: ease-in;
}
}
}
@keyframes lovely-chart--animation-pie-transition-in {
0% {
clip-path: circle(80% at center calc(50% - 7.5px));
-webkit-clip-path: circle(80% at center calc(50% - 7.5px));
transform: rotate(-360deg);
}
25% {
clip-path: circle(32% at center calc(50% - 7.5px));
-webkit-clip-path: circle(32% at center calc(50% - 7.5px));
transform: rotate(-360deg);
}
75% {
clip-path: circle(32% at center calc(50% - 7.5px));
-webkit-clip-path: circle(32% at center calc(50% - 7.5px));
transform: rotate(0);
}
}
@media (max-width: 480px) {
@keyframes lovely-chart--animation-pie-transition-in {
0% {
clip-path: circle(80% at center calc(50% - 7.5px));
-webkit-clip-path: circle(80% at center calc(50% - 7.5px));
transform: rotate(-360deg);
}
25% {
clip-path: circle(40% at center calc(50% - 7.5px));
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
transform: rotate(-360deg);
}
75% {
clip-path: circle(40% at center calc(50% - 7.5px));
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
transform: rotate(0);
}
}
}
@keyframes lovely-chart--animation-pie-transition-out {
0% {
clip-path: circle(32% at center calc(50% - 7.5px));
-webkit-clip-path: circle(32% at center calc(50% - 7.5px));
transform: rotate(360deg);
}
50% {
clip-path: circle(32% at center calc(50% - 7.5px));
-webkit-clip-path: circle(32% at center calc(50% - 7.5px));
transform: rotate(0);
}
75% {
clip-path: circle(80% at center calc(50% - 7.5px));
-webkit-clip-path: circle(80% at center calc(50% - 7.5px));
transform: rotate(0);
}
}
@media (max-width: 480px) {
@keyframes lovely-chart--animation-pie-transition-out {
0% {
clip-path: circle(40% at center calc(50% - 7.5px));
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
transform: rotate(360deg);
}
50% {
clip-path: circle(40% at center calc(50% - 7.5px));
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
transform: rotate(0);
}
75% {
clip-path: circle(80% at center calc(50% - 7.5px));
-webkit-clip-path: circle(80% at center calc(50% - 7.5px));
transform: rotate(0);
}
}
}
html.cursor-ew-resize, html.cursor-ew-resize * {
cursor: ew-resize !important;
}
html.cursor-grabbing, html.cursor-grabbing * {
cursor: grabbing !important;
}
.lovely-chart--spinner {
width: 16px;
height: 16px;
position: relative;
&.lovely-chart--size-big {
width: 64px;
height: 64px;
}
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' version='1.1' width='512px' height='512px' viewBox='0 0 16 16'%3E%3Cg%3E%3Cpath fill='%23D2D5D7' d='M9.9 0.2l-0.2 1c3 0.8 5.3 3.5 5.3 6.8 0 3.9-3.1 7-7 7s-7-3.1-7-7c0-3.3 2.3-6 5.3-6.8l-0.2-1c-3.5 0.9-6.1 4.1-6.1 7.8 0 4.4 3.6 8 8 8s8-3.6 8-8c0-3.7-2.6-6.9-6.1-7.8z' data-original='%23444444' class='active-path' data-old_color='%23444444'/%3E%3C/g%3E%3C/svg%3E%0A");
background-size: 100%;
animation: lovely-chart--animation-spin 1s infinite linear;
}
}

View File

@ -0,0 +1,54 @@
.lovely-chart--header {
height: 30px;
font-weight: bold;
margin: 0 10px;
&-title, &-caption {
color: var(--text-color);
line-height: 30px;
// transition: color 300ms ease;
&.lovely-chart--state-hidden {
opacity: 0;
pointer-events: none;
}
}
&-title {
font-size: 16px;
float: left;
margin-right: 1em;
text-transform: lowercase;
&:first-letter {
text-transform: uppercase;
}
}
&-caption {
font-size: 12px;
text-align: right;
float: right;
}
&-zoom-out-control {
display: flex;
align-items: center;
color: var(--zoom-out-text);
cursor: pointer;
text-transform: none;
&::before {
content: '';
display: inline-block;
margin-right: 6px;
width: 16px;
height: 16px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' version='1.1' id='Capa_1' x='0px' y='0px' viewBox='0 0 512 512' style='enable-background:new 0 0 512 512;' xml:space='preserve' width='512px' height='512px'%3E%3Cg%3E%3Cg%3E%3Cpath d='M497.938,430.063l-112-112c-0.367-0.367-0.805-0.613-1.18-0.965C404.438,285.332,416,248.035,416,208 C416,93.313,322.695,0,208,0S0,93.313,0,208s93.305,208,208,208c40.035,0,77.332-11.563,109.098-31.242 c0.354,0.375,0.598,0.813,0.965,1.18l112,112C439.43,507.313,451.719,512,464,512c12.281,0,24.57-4.688,33.938-14.063 C516.688,479.203,516.688,448.797,497.938,430.063z M64,208c0-79.406,64.602-144,144-144s144,64.594,144,144 c0,79.406-64.602,144-144,144S64,287.406,64,208z' data-original='%23000000' class='active-path' data-old_color='%232d98e6' fill='%232d98e6'/%3E%3Cpath d='M272,176H144c-17.672,0-32,14.328-32,32s14.328,32,32,32h128c17.672,0,32-14.328,32-32S289.672,176,272,176z' data-original='%23000000' class='active-path' data-old_color='%232d98e6' fill='%232d98e6'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center;
background-size: 100%;
}
}
}

View File

@ -0,0 +1,142 @@
.lovely-chart--minimap {
position: relative;
margin: 0 10px 16px 10px;
border-radius: 6px;
overflow: hidden;
opacity: 1;
transition: opacity 400ms ease;
&.lovely-chart--state-hidden {
display: none;
}
&.lovely-chart--state-transparent {
opacity: 0;
}
}
.lovely-chart--minimap-ruler {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
white-space: nowrap;
user-select: none;
-webkit-user-select: none;
}
.lovely-chart--minimap-mask {
display: inline-block;
width: 33.3%;
height: 100%;
background: var(--minimap-mask);
// transition: background-color 300ms ease-out;
&:last-child {
position: relative;
&::after {
content: '';
display: block;
position: absolute;
top: 0;
bottom: 0;
right: -5px;
width: 5px;
background: var(--minimap-mask);
// transition: background-color 300ms ease-out;
}
}
}
.lovely-chart--minimap-slider {
display: inline-block;
box-sizing: border-box;
width: 33.3%;
height: 100%;
min-width: 16px;
&-inner {
box-sizing: border-box;
border-top: 1px solid var(--minimap-slider);
border-bottom: 1px solid var(--minimap-slider);
float: left;
width: calc(100% - 16px);
height: 100%;
background: transparent !important;
cursor: grab;
// transition: border-color 300ms ease-out;
}
&-handle {
width: 8px;
height: 100%;
position: relative;
background: var(--minimap-mask);
cursor: ew-resize;
// transition: background-color 300ms ease-out;
&::before, &::after {
content: '';
display: block;
position: absolute;
}
&::before {
background: var(--minimap-slider);
top: 0;
bottom: 0;
right: 0;
left: 0;
// transition: background-color 300ms ease-out;
}
&-pin {
display: block;
position: absolute;
background: #ffffff;
width: 2px;
height: 8px;
top: calc(50% - 4px);
left: calc(50% - 1px);
pointer-events: none;
}
&:first-child {
float: left;
&::before {
border-top-left-radius: 6px;
border-bottom-left-radius: 6px;
}
}
&:last-child {
float: right;
&::before {
border-top-right-radius: 6px;
border-bottom-right-radius: 6px;
}
}
@media (pointer: coarse) {
&:after {
top: -10px;
bottom: -10px;
z-index: 1;
}
&:first-child:after {
left: -26px;
right: 0;
}
&:last-child:after {
left: 0;
right: -26px;
}
}
}
}

View File

@ -0,0 +1,14 @@
.lovely-chart--tools {
opacity: 1;
transition: opacity 400ms ease;
&.lovely-chart--state-hidden {
display: none;
}
&.lovely-chart--state-transparent {
opacity: 0;
}
padding: 0 10px 16px 10px;
}

View File

@ -0,0 +1,100 @@
.lovely-chart--tooltip {
position: absolute;
top: 30px;
left: 0;
}
.lovely-chart--tooltip-balloon {
position: absolute;
top: 5px;
left: 0;
min-width: 130px;
max-height: 320px;
overflow: auto;
-webkit-overflow-scrolling: touch;
border-radius: 8px;
padding: 8px 10px;
white-space: nowrap;
background: var(--tooltip-background);
color: var(--text-color);
box-shadow: 0 1px 2px 1px rgba(211, 211, 211, 0.8);
opacity: 0;
transition: opacity 200ms ease-out;
cursor: pointer;
pointer-events: none;
&.lovely-chart--state-shown {
opacity: 1;
pointer-events: auto;
}
&.lovely-chart--state-inactive {
cursor: default;
.lovely-chart--tooltip-title::after {
display: none;
}
}
&.lovely-chart--state-loading {
.lovely-chart--spinner {
display: block;
}
}
.lovely-chart--spinner {
position: absolute;
top: 7px;
right: 8px;
display: none;
}
}
.lovely-chart--tooltip-title {
font-size: 12px;
font-weight: bold;
position: relative;
padding-bottom: 5px;
&::after {
content: '';
display: block;
position: absolute;
right: 0;
top: 0;
width: 12px;
height: 12px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' version='1.1' id='Capa_1' x='0px' y='0px' width='512px' height='512px' viewBox='0 0 451.846 451.847' style='enable-background:new 0 0 451.846 451.847;' xml:space='preserve' class=''%3E%3Cg%3E%3Cg%3E%3Cpath d='M345.441,248.292L151.154,442.573c-12.359,12.365-32.397,12.365-44.75,0c-12.354-12.354-12.354-32.391,0-44.744 L278.318,225.92L106.409,54.017c-12.354-12.359-12.354-32.394,0-44.748c12.354-12.359,32.391-12.359,44.75,0l194.287,194.284 c6.177,6.18,9.262,14.271,9.262,22.366C354.708,234.018,351.617,242.115,345.441,248.292z' data-original='%23000000' class='active-path' data-old_color='%23757B84' fill='%23D2D5D7'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
background-position: center;
background-repeat: no-repeat;
background-size: 75%;
}
}
.lovely-chart--tooltip-dataset {
height: 20px;
line-height: 20px;
color: var(--text-color);
.lovely-chart--tooltip-dataset-value {
float: right;
font-weight: bold;
margin-left: 5px;
}
}
.lovely-chart--percentage-title {
display: inline-block;
font-weight: bold;
width: 35px;
margin-right: 5px;
text-align: right;
&.lovely-chart--transition ~ .lovely-chart--dataset-title {
padding-left: 30px;
}
}
html.theme-dark .lovely-chart--tooltip-balloon {
box-shadow: none;
}

View File

@ -0,0 +1,21 @@
.lovely-chart--container {
--background-color: #ffffff;
--text-color: #222222;
--minimap-mask: #{rgba(#E2EEF9, 0.6)};
--minimap-slider: #C0D1E1;
--grid-lines: #{rgba(#182D3B, 0.1)};
--zoom-out-text: #108BE3;
--tooltip-background: #ffffff;
--tooltip-arrow: #D2D5D7;
}
html.theme-dark .lovely-chart--container {
--background-color: #242F3E;
--text-color: #ffffff;
--minimap-mask: #{rgba(#304259, 0.6)};
--minimap-slider: #56626D;
--grid-lines: #{rgba(#FFFFFF, 0.1)};
--zoom-out-text: #48AAF0;
--tooltip-background: #1c2533;
--tooltip-arrow: #D2D5D7;
}

View File

@ -0,0 +1,8 @@
@import 'variables';
@import 'animations';
@import 'common';
@import 'header';
@import 'minimap';
@import 'tooltip';
@import 'tools';
@import 'buttons';

View File

@ -0,0 +1,38 @@
import { createElement } from './minifiers';
export function toggleText(element, newText, className = '', inverse = false) {
const container = element.parentNode;
container.classList.add('lovely-chart--transition-container');
const newElement = createElement(element.tagName);
newElement.className = `${className} lovely-chart--transition lovely-chart--position-${inverse ? 'top' : 'bottom'} lovely-chart--state-hidden`;
newElement.innerHTML = newText;
const selector = className.length ? `.${className.split(' ').join('.')}` : '';
const oldElements = container.querySelectorAll(`${selector}.lovely-chart--state-hidden`);
oldElements.forEach(e => e.remove());
element.classList.add('lovely-chart--transition');
element.classList.remove('lovely-chart--position-bottom', 'lovely-chart--position-top');
element.classList.add(inverse ? 'lovely-chart--position-bottom' : 'lovely-chart--position-top');
container.insertBefore(newElement, element.nextSibling);
toggleElementIn(newElement);
toggleElementOut(element);
return newElement;
}
function toggleElementIn(element) {
// Remove and add `animated` class to re-trigger animation
element.classList.remove('lovely-chart--state-animated');
element.classList.add('lovely-chart--state-animated');
element.classList.remove('lovely-chart--state-hidden');
}
function toggleElementOut(element) {
// Remove and add `animated` class to re-trigger animation
element.classList.remove('lovely-chart--state-animated');
element.classList.add('lovely-chart--state-animated');
element.classList.add('lovely-chart--state-hidden');
}

View File

@ -0,0 +1,126 @@
// https://jsperf.com/finding-maximum-element-in-an-array
export function getMaxMin(array) {
const length = array.length;
let max = array[0];
let min = array[0];
for (let i = 0; i < length; i++) {
const value = array[i];
if (value > max) {
max = value;
} else if (value < min) {
min = value;
}
}
return { max, min };
}
// https://jsperf.com/multi-array-concat/24
export function mergeArrays(arrays) {
return [].concat.apply([], arrays);
}
export function sumArrays(arrays) {
const sums = [];
const n = arrays.length;
for (let i = 0, l = arrays[0].length; i < l; i++) {
sums[i] = 0;
for (let j = 0; j < n; j++) {
sums[i] += arrays[j][i];
}
}
return sums;
}
export function proxyMerge(obj1, obj2) {
return new Proxy({}, {
get: (obj, prop) => {
if (obj[prop] !== undefined) {
return obj[prop];
} else if (obj2[prop] !== undefined) {
return obj2[prop];
} else {
return obj1[prop];
}
},
});
}
export function throttle(
fn,
ms,
shouldRunFirst = true,
) {
let interval = null;
let isPending;
let args;
return (..._args) => {
isPending = true;
args = _args;
if (!interval) {
if (shouldRunFirst) {
isPending = false;
// @ts-ignore
fn(...args);
}
interval = window.setInterval(() => {
if (!isPending) {
window.clearInterval(interval);
interval = null;
return;
}
isPending = false;
// @ts-ignore
fn(...args);
}, ms);
}
};
}
export function throttleWithRaf(fn) {
let waiting = false;
let args;
return function (..._args) {
args = _args;
if (!waiting) {
waiting = true;
requestAnimationFrame(() => {
waiting = false;
fn(...args);
});
}
};
}
export function debounce(fn, ms, shouldRunFirst = true, shouldRunLast = true) {
let waitingTimeout = null;
return function () {
if (waitingTimeout) {
clearTimeout(waitingTimeout);
waitingTimeout = null;
} else if (shouldRunFirst) {
fn();
}
waitingTimeout = setTimeout(() => {
if (shouldRunLast) {
fn();
}
waitingTimeout = null;
}, ms);
};
}

View File

@ -24,6 +24,7 @@ import './api/settings';
import './api/twoFaSettings';
import './api/payments';
import './api/reactions';
import './api/statistics';
import './apiUpdaters/initial';
import './apiUpdaters/chats';

View File

@ -0,0 +1,53 @@
import { addReducer, getGlobal, setGlobal } from '../../../lib/teact/teactn';
import { callApi } from '../../../api/gramjs';
import { updateStatistics, updateStatisticsGraph } from '../../reducers';
import { selectChatMessages, selectChat } from '../../selectors';
addReducer('loadStatistics', (global, actions, payload) => {
const { chatId } = payload;
const chat = selectChat(global, chatId);
if (!chat?.fullInfo) {
return;
}
(async () => {
const result = await callApi('fetchStatistics', { chat });
if (!result) {
return;
}
global = getGlobal();
if (result?.recentTopMessages.length) {
const messages = selectChatMessages(global, chatId);
result.recentTopMessages = result.recentTopMessages
.map((message) => ({ ...message, ...messages[message.msgId] }));
}
global = updateStatistics(global, chatId, result);
setGlobal(global);
})();
});
addReducer('loadStatisticsAsyncGraph', (global, actions, payload) => {
const { chatId, token, name, isPercentage } = payload;
const chat = selectChat(global, chatId);
if (!chat?.fullInfo) {
return;
}
(async () => {
const dcId = chat.fullInfo!.statisticsDcId;
const result = await callApi('fetchStatisticsAsyncGraph', { token, dcId, isPercentage });
if (!result) {
return;
}
setGlobal(updateStatisticsGraph(getGlobal(), chatId, name, result));
})();
});

View File

@ -109,6 +109,13 @@ addReducer('openChat', (global) => {
};
});
addReducer('toggleStatistics', (global) => {
return {
...global,
isStatisticsShown: !global.isStatisticsShown,
};
});
addReducer('toggleLeftColumn', (global) => {
return {
...global,

View File

@ -8,3 +8,4 @@ export * from './management';
export * from './settings';
export * from './twoFaSettings';
export * from './payments';
export * from './statistics';

View File

@ -0,0 +1,33 @@
import { GlobalState } from '../../global/types';
import { ApiStatistics, StatisticsGraph } from '../../api/types';
export function updateStatistics(
global: GlobalState, chatId: string, statistics: ApiStatistics,
): GlobalState {
return {
...global,
statistics: {
byChatId: {
...global.statistics.byChatId,
[chatId]: statistics,
},
},
};
}
export function updateStatisticsGraph(
global: GlobalState, chatId: string, name: string, update: StatisticsGraph,
): GlobalState {
return {
...global,
statistics: {
byChatId: {
...global.statistics.byChatId,
[chatId]: {
...(global.statistics.byChatId[chatId] || {}),
[name]: update,
},
},
},
};
}

View File

@ -8,3 +8,4 @@ export * from './management';
export * from './symbols';
export * from './payments';
export * from './settings';
export * from './statistics';

View File

@ -0,0 +1,19 @@
import { GlobalState } from '../../global/types';
import { selectCurrentMessageList } from './messages';
import { selectChat } from './chats';
export function selectStatistics(global: GlobalState, chatId: string) {
return global.statistics.byChatId[chatId];
}
export function selectIsStatisticsShown(global: GlobalState) {
if (!global.isStatisticsShown) {
return false;
}
const { chatId: currentChatId } = selectCurrentMessageList(global) || {};
const chat = currentChatId ? selectChat(global, currentChatId) : undefined;
return chat?.fullInfo?.canViewStatistics;
}

View File

@ -4,7 +4,8 @@ import { NewChatMembersProgress, RightColumnContent } from '../../types';
import { getSystemTheme, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import { selectCurrentMessageList, selectIsPollResultsOpen } from './messages';
import { selectCurrentTextSearch } from './localSearch';
import { selectCurrentGifSearch, selectCurrentStickerSearch } from './symbols';
import { selectCurrentStickerSearch, selectCurrentGifSearch } from './symbols';
import { selectIsStatisticsShown } from './statistics';
import { selectCurrentManagement } from './management';
export function selectIsMediaViewerOpen(global: GlobalState) {
@ -19,6 +20,8 @@ export function selectRightColumnContentKey(global: GlobalState) {
RightColumnContent.Search
) : selectCurrentManagement(global) ? (
RightColumnContent.Management
) : selectIsStatisticsShown(global) ? (
RightColumnContent.Statistics
) : selectCurrentStickerSearch(global).query !== undefined ? (
RightColumnContent.StickerSearch
) : selectCurrentGifSearch(global).query !== undefined ? (

File diff suppressed because it is too large Load Diff

View File

@ -51,6 +51,9 @@
.icon-volume-3:before {
content: "\e991";
}
.icon-stats:before {
content: "\e996";
}
.icon-copy-media:before {
content: "\e995";
}

View File

@ -236,6 +236,7 @@ export enum RightColumnContent {
ChatInfo,
Search,
Management,
Statistics,
StickerSearch,
GifSearch,
PollResults,