From e3bc23bd570b1c6eccfd67ac73c3abcb161e1dcc Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 11 Mar 2022 19:38:47 +0100 Subject: [PATCH] Management: Introduce Statistics for channels (#1703) --- .eslintignore | 2 + src/api/gramjs/apiBuilders/statistics.ts | 115 +++++ src/api/gramjs/methods/chats.ts | 4 + src/api/gramjs/methods/client.ts | 6 +- src/api/gramjs/methods/index.ts | 2 + src/api/gramjs/methods/statistics.ts | 43 ++ src/api/types/chats.ts | 2 + src/api/types/index.ts | 1 + src/api/types/statistics.ts | 56 +++ src/assets/fonts/icomoon.woff | Bin 42248 -> 42472 bytes src/assets/fonts/icomoon.woff2 | Bin 19848 -> 19972 bytes src/bundles/extra.ts | 1 + src/components/middle/HeaderActions.tsx | 5 + src/components/middle/HeaderMenuContainer.tsx | 16 + src/components/right/RightColumn.tsx | 11 +- src/components/right/RightHeader.tsx | 23 + .../right/statistics/Statistics.async.tsx | 16 + .../right/statistics/Statistics.scss | 103 ++++ .../right/statistics/Statistics.tsx | 171 +++++++ .../right/statistics/StatisticsOverview.scss | 40 ++ .../right/statistics/StatisticsOverview.tsx | 72 +++ .../statistics/StatisticsRecentMessage.scss | 59 +++ .../statistics/StatisticsRecentMessage.tsx | 68 +++ src/global/initial.ts | 4 + src/global/types.ts | 14 +- src/lib/gramjs/client/TelegramClient.d.ts | 2 +- src/lib/gramjs/client/TelegramClient.js | 8 +- src/lib/gramjs/tl/apiTl.js | 4 +- src/lib/gramjs/tl/static/api.json | 4 +- src/lib/lovely-chart/Axes.js | 173 +++++++ src/lib/lovely-chart/Header.js | 64 +++ src/lib/lovely-chart/LovelyChart.js | 215 ++++++++ src/lib/lovely-chart/Minimap.js | 276 +++++++++++ src/lib/lovely-chart/Projection.js | 79 +++ src/lib/lovely-chart/StateManager.js | 222 +++++++++ src/lib/lovely-chart/Tools.js | 100 ++++ src/lib/lovely-chart/Tooltip.js | 467 ++++++++++++++++++ src/lib/lovely-chart/TransitionManager.js | 138 ++++++ src/lib/lovely-chart/Zoomer.js | 168 +++++++ src/lib/lovely-chart/canvas.js | 22 + src/lib/lovely-chart/captureEvents.js | 79 +++ src/lib/lovely-chart/constants.js | 62 +++ src/lib/lovely-chart/data.js | 91 ++++ src/lib/lovely-chart/drawDatasets.js | 229 +++++++++ src/lib/lovely-chart/format.js | 90 ++++ src/lib/lovely-chart/formulas.js | 56 +++ src/lib/lovely-chart/hideOnScroll.js | 42 ++ src/lib/lovely-chart/minifiers.js | 11 + src/lib/lovely-chart/preparePoints.js | 79 +++ src/lib/lovely-chart/simplify.js | 201 ++++++++ src/lib/lovely-chart/skin.js | 90 ++++ src/lib/lovely-chart/styles/_animations.scss | 124 +++++ src/lib/lovely-chart/styles/_buttons.scss | 67 +++ src/lib/lovely-chart/styles/_common.scss | 152 ++++++ src/lib/lovely-chart/styles/_header.scss | 54 ++ src/lib/lovely-chart/styles/_minimap.scss | 142 ++++++ src/lib/lovely-chart/styles/_tools.scss | 14 + src/lib/lovely-chart/styles/_tooltip.scss | 100 ++++ src/lib/lovely-chart/styles/_variables.scss | 21 + src/lib/lovely-chart/styles/index.scss | 8 + src/lib/lovely-chart/toggleText.js | 38 ++ src/lib/lovely-chart/utils.js | 126 +++++ src/modules/actions/all.ts | 1 + src/modules/actions/api/statistics.ts | 53 ++ src/modules/actions/ui/misc.ts | 7 + src/modules/reducers/index.ts | 1 + src/modules/reducers/statistics.ts | 33 ++ src/modules/selectors/index.ts | 1 + src/modules/selectors/statistics.ts | 19 + src/modules/selectors/ui.ts | 5 +- src/styles/Telegram T.json | 317 ++++++------ src/styles/icons.scss | 3 + src/types/index.ts | 1 + 73 files changed, 4935 insertions(+), 158 deletions(-) create mode 100644 src/api/gramjs/apiBuilders/statistics.ts create mode 100644 src/api/gramjs/methods/statistics.ts create mode 100644 src/api/types/statistics.ts create mode 100644 src/components/right/statistics/Statistics.async.tsx create mode 100644 src/components/right/statistics/Statistics.scss create mode 100644 src/components/right/statistics/Statistics.tsx create mode 100644 src/components/right/statistics/StatisticsOverview.scss create mode 100644 src/components/right/statistics/StatisticsOverview.tsx create mode 100644 src/components/right/statistics/StatisticsRecentMessage.scss create mode 100644 src/components/right/statistics/StatisticsRecentMessage.tsx create mode 100644 src/lib/lovely-chart/Axes.js create mode 100644 src/lib/lovely-chart/Header.js create mode 100644 src/lib/lovely-chart/LovelyChart.js create mode 100644 src/lib/lovely-chart/Minimap.js create mode 100644 src/lib/lovely-chart/Projection.js create mode 100644 src/lib/lovely-chart/StateManager.js create mode 100644 src/lib/lovely-chart/Tools.js create mode 100644 src/lib/lovely-chart/Tooltip.js create mode 100644 src/lib/lovely-chart/TransitionManager.js create mode 100644 src/lib/lovely-chart/Zoomer.js create mode 100644 src/lib/lovely-chart/canvas.js create mode 100644 src/lib/lovely-chart/captureEvents.js create mode 100644 src/lib/lovely-chart/constants.js create mode 100644 src/lib/lovely-chart/data.js create mode 100644 src/lib/lovely-chart/drawDatasets.js create mode 100644 src/lib/lovely-chart/format.js create mode 100644 src/lib/lovely-chart/formulas.js create mode 100644 src/lib/lovely-chart/hideOnScroll.js create mode 100644 src/lib/lovely-chart/minifiers.js create mode 100644 src/lib/lovely-chart/preparePoints.js create mode 100644 src/lib/lovely-chart/simplify.js create mode 100644 src/lib/lovely-chart/skin.js create mode 100644 src/lib/lovely-chart/styles/_animations.scss create mode 100644 src/lib/lovely-chart/styles/_buttons.scss create mode 100644 src/lib/lovely-chart/styles/_common.scss create mode 100644 src/lib/lovely-chart/styles/_header.scss create mode 100644 src/lib/lovely-chart/styles/_minimap.scss create mode 100644 src/lib/lovely-chart/styles/_tools.scss create mode 100644 src/lib/lovely-chart/styles/_tooltip.scss create mode 100644 src/lib/lovely-chart/styles/_variables.scss create mode 100644 src/lib/lovely-chart/styles/index.scss create mode 100644 src/lib/lovely-chart/toggleText.js create mode 100644 src/lib/lovely-chart/utils.js create mode 100644 src/modules/actions/api/statistics.ts create mode 100644 src/modules/reducers/statistics.ts create mode 100644 src/modules/selectors/statistics.ts diff --git a/.eslintignore b/.eslintignore index b67974c35..27792b67b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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/ diff --git a/src/api/gramjs/apiBuilders/statistics.ts b/src/api/gramjs/apiBuilders/statistics.ts new file mode 100644 index 000000000..598b965e1 --- /dev/null +++ b/src/api/gramjs/apiBuilders/statistics.ts @@ -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, values: Array) { + 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), + }; +} diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index 36cd23660..d5fabe566 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -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 ? { diff --git a/src/api/gramjs/methods/client.ts b/src/api/gramjs/methods/client.ts index 6ce6cbd4f..abcad17cf 100644 --- a/src/api/gramjs/methods/client.ts +++ b/src/api/gramjs/methods/client.ts @@ -167,6 +167,8 @@ export async function invokeRequest( request: T, shouldReturnTrue: true, shouldThrow?: boolean, + shouldIgnoreUpdates?: undefined, + dcId?: number, ): Promise; export async function invokeRequest( @@ -174,6 +176,7 @@ export async function invokeRequest( shouldReturnTrue?: boolean, shouldThrow?: boolean, shouldIgnoreUpdates?: boolean, + dcId?: number, ): Promise; export async function invokeRequest( @@ -181,6 +184,7 @@ export async function invokeRequest( shouldReturnTrue = false, shouldThrow = false, shouldIgnoreUpdates = false, + dcId?: number, ) { if (!isConnected) { if (DEBUG) { @@ -197,7 +201,7 @@ export async function invokeRequest( 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 diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 6bf936d11..fbe3eab97 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -75,3 +75,5 @@ export { getAvailableReactions, sendReaction, sendEmojiInteraction, fetchMessageReactionsList, setDefaultReaction, fetchMessageReactions, sendWatchingEmojiInteraction, } from './reactions'; + +export { fetchStatistics, fetchStatisticsAsyncGraph } from './statistics'; diff --git a/src/api/gramjs/methods/statistics.ts b/src/api/gramjs/methods/statistics.ts new file mode 100644 index 000000000..8aa7af7b8 --- /dev/null +++ b/src/api/gramjs/methods/statistics.ts @@ -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 { + 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 { + 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); +} diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index f7dbc0734..4eaadfc06 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -91,8 +91,10 @@ export interface ApiChatFullInfo { botCommands?: ApiBotCommand[]; enabledReactions?: string[]; sendAsId?: string; + canViewStatistics?: boolean; recentRequesterIds?: string[]; requestsPending?: number; + statisticsDcId?: number; } export interface ApiChatMember { diff --git a/src/api/types/index.ts b/src/api/types/index.ts index d0059b8a2..af86b6a7d 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -8,3 +8,4 @@ export * from './settings'; export * from './bots'; export * from './misc'; export * from './calls'; +export * from './statistics'; diff --git a/src/api/types/statistics.ts b/src/api/types/statistics.ts new file mode 100644 index 000000000..641f7bbc9 --- /dev/null +++ b/src/api/types/statistics.ts @@ -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; +} + +export interface StatisticsGraph { + type: string; + zoomToken?: string; + labelFormatter: string; + tooltipFormatter: string; + labels: Array; + 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; +} diff --git a/src/assets/fonts/icomoon.woff b/src/assets/fonts/icomoon.woff index 45cae334b313a1e9c43afee259d89b007e515c8c..18e20bdbe823b9eca83f6b2021b991dc53090f7c 100644 GIT binary patch delta 550 zcmeA;#q{DdlUTXGn;Qco0}w2I!N3is=P)o%{UlbrlypgOxnFMx(xfbdGki)p!u6+nxZC;&A= z@Jhxzd5O8HKrsiPnl=!g>BafGAiuZ-=${i4|M)Xbo9x5r%{XoI492x{>zNrs|NmlM z!tB8y#$e0f&A`B_sHA2pXkun;Bqk~-B4(@zq(#`2Rh87#)lBRe&5Yz38SNOw zJj%QYjG~;9^}Ia%l0rg~FWCe*I0e`ervYiEv^v$wTX=cm7&W=rSXsq*UxU=v|6*fjWM_+;#?Hpr2r>_Zfqn-D3lJCWZY+)G zxB1Gz%>oo)V7TYD_yCkHYHd*8*Rsbzt z>;TlL0LCjA@8l)srUJzhfNI)6c%~QU?}Gf|5}-d`O#I`|ICZiQqc`K!%`+I+&Q)dv z>H^}q>-YA?^V@u7;AQ~|FfiP6>Np0YC%Y^#XPmXUW diff --git a/src/assets/fonts/icomoon.woff2 b/src/assets/fonts/icomoon.woff2 index 2050d29929f5027d59c593a5a9e469b730f9e20d..a1df7a845bc7333b47fc5a8f25c79c661a2a615b 100644 GIT binary patch literal 19972 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�S^pcf(D*Zi@3m}6r%N~F0SXTxS*c{L}fMrho)(3t51?r&dpJ+-5S zt<}<(up{;Qs_qgPF6oZ99EXZ|1H57Yo!Az%!Y3^ahJ&^ZL!f;v2%Oj~DM_{uFPc}1 zPNZ%a`Fo5vx>pSq_Qride>`8r^PWs+9IwbJ`F=5U9nRu3`9kn6U%Q%VQI2Iw$Rpff=(R%>e_W|(odgX?J z5?2E47fvKql1cOCk;zFF-(9GS+(@7mr#p_k8B9uw%)WCaGUCb|_nDX!arDhM4hQA6 zcZcNX^OqHe4~uSn9$pcvl9eqoKO~Zgw!^Gjy*~*1Eqeux+cwS)cFoGfxsf zeTsIoqEd}fX>IBv+Q?qR-Q^~En%rdSdxf0Mwg>L%InIs=l6UwVAJTY-a}E|J`h0KOy!S z+VDio_LXwl{|&eO@K`>=4?A;#kDQ^$dctnD$AV*E(LhH<~^guuY?7C~~jW{w~)4 zeg*l?lJDA&BpTV;x9Zr`**6}RJ4`(mk2hzhjwM9j{fx!o{QP(QSnmi~LeHbCSW_i> zjn&l;oO3DUmClkUH47Ee3o_e-+k-sXgW7`>-B|pTNIqcV2V0 z$nVGj5KreV;BC1{;km&pOlI>xkLv<5cyi5j3&jg~pDsd+U&>wD#qC-YLW3o`eF!Kt z-Q{%Y3H?q0+U@9ftsnv@0k_NUe#C$a9Sl*#0Jf&NVo>aiDv3196>`ZBD#4!aB|Cio z_T4cbdJJSx&w<;&x}QVux5mwUfUm*lYf+$0_fPTn*LHKel^TCPjS|)J`6j$9<7;&% z1MqU9r1A@-9NYn|*B&NtNDH>6ZC$df&ETmAn-gC7p9j+oYI4%@*a3GUT9cC_+U}66 zmc*~m&WiuE4Ou8A|Dbe39y=o?eYSrC5V*+-(SuDKIs{t4ywV`}WyG4*Ss`LbG7UVSrC~ z{nYl)XAllY;Ozg$_pFga-+T;>LPbx@+3fOJ!T-0>y*^+#{Z`xCWC4v9)JysAV7Es{=#e|F$wVeEv%yasyn7b zOmPu=tE>+hwo0CC;W|Q^J)`!z@YTyUdq2xiYB0jp!E^8yP3);UEUr=n^yTD$DOU? z#`u142_aC4APGWwh4L5-G00iQ5%I)QkJ{W1lp`w-;-eMi^Pl~W?(nrKKVrLc=|WLk zE1I==rIj~JAu~W=tyUC+2cd(=r2RHUU5zezTr@r*o>;d|`TZ$*6C>i`Q>T=N4;vGm zcl_{xs3dR|YXTk3YgLv|FEmV@{t36z=*4`@>bdiWSGrJMMMjo#&3Q z*|7IvZc?9pxIPWrG>Ybaqhu{Q$t1K6Lnr{e$kLorvhvnseYDX-N#F1Y5;8$c>O+Q- zam=#TKbd(~Q+3i1m7KiLPtAI!sT|j24E#>z{2do8a>)z0ZeG3|io z2Ln^cyW-c4j55aD6Ze-&0PnRu!9DA5C&2i_z=A+)llRBY+^}Snf6=Z!NP2d5qS_KJ zysvx>E|AenX^i@7*i2lZa7Y7bz(5c(5?dgIbIyRvj zAMT1K0jCpKiSFH^{{vKfGy6jk?B9ht~>=4q+o?8RACBN zgd!D%(HS_CMX)(s9!c>9LXlV^mB|%Km0F|K>CKt9V9}CgD^{&pw_($kZ98`D*#{6| zLKzoQX}V!rw&Qv}Isga)L!dA?0*OLnusA${NFr0HG&+OHVsp4WzCb7vOQbTnLa9<~ zv^u@PXfj)@HoL>=a&!0a^z!!c_4A+jAKw4%YyI)HYtz^+)PT_q(*m(5htuV@`^-1p zFf9;^ayVUXyU$}9000000FWd}k|aq=k|arzBq0L;000005D^g(5fKp)F*7qWGcz+Y Ti-?Gbh=_=YsMLaCS~vj!>C~P? literal 19848 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 diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 3c4aaf9fe..6056c275c 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -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'; diff --git a/src/components/middle/HeaderActions.tsx b/src/components/middle/HeaderActions.tsx index f96a8963c..c676e724b 100644 --- a/src/components/middle/HeaderActions.tsx +++ b/src/components/middle/HeaderActions.tsx @@ -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 = ({ canSearch, canCall, canMute, + canViewStatistics, canLeave, canEnterVoiceChat, canCreateVoiceChat, @@ -261,6 +263,7 @@ const HeaderActions: FC = ({ canSearch={canSearch} canCall={canCall} canMute={canMute} + canViewStatistics={canViewStatistics} canLeave={canLeave} canEnterVoiceChat={canEnterVoiceChat} canCreateVoiceChat={canCreateVoiceChat} @@ -303,6 +306,7 @@ export default memo(withGlobal( 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( canSearch, canCall, canMute, + canViewStatistics, canLeave, canEnterVoiceChat, canCreateVoiceChat, diff --git a/src/components/middle/HeaderMenuContainer.tsx b/src/components/middle/HeaderMenuContainer.tsx index c5224d1ad..5fb374950 100644 --- a/src/components/middle/HeaderMenuContainer.tsx +++ b/src/components/middle/HeaderMenuContainer.tsx @@ -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 = ({ canSearch, canCall, canMute, + canViewStatistics, canLeave, canEnterVoiceChat, canCreateVoiceChat, @@ -91,6 +93,7 @@ const HeaderMenuContainer: FC = ({ openLinkedChat, addContact, openCallFallbackConfirm, + toggleStatistics, } = getDispatch(); const [isMenuOpen, setIsMenuOpen] = useState(true); @@ -166,6 +169,11 @@ const HeaderMenuContainer: FC = ({ closeMenu(); }, [closeMenu, onSearchClick]); + const handleStatisticsClick = useCallback(() => { + toggleStatistics(); + closeMenu(); + }, [closeMenu, toggleStatistics]); + const handleSelectMessages = useCallback(() => { enterMessageSelectMode(); closeMenu(); @@ -266,6 +274,14 @@ const HeaderMenuContainer: FC = ({ > {lang('ReportSelectMessages')} + {canViewStatistics && ( + + {lang('Statistics')} + + )} {canLeave && ( = ({ addChatMembers, setNewChatMembersDialogState, setEditingExportedInvite, + toggleStatistics, setOpenedInviteInfo, requestNextManagementScreen, } = getDispatch(); @@ -84,6 +86,7 @@ const RightColumn: FC = ({ 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 = ({ break; } + case RightColumnContent.Statistics: + toggleStatistics(); + break; case RightColumnContent.Search: { blurSearchInput(); closeLocalTextSearch(); @@ -168,7 +174,7 @@ const RightColumn: FC = ({ }, [ 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 = ({ /> ); + case RightColumnContent.Statistics: + return ; case RightColumnContent.StickerSearch: return ; case RightColumnContent.GifSearch: @@ -284,6 +292,7 @@ const RightColumn: FC = ({ isProfile={isProfile} isSearch={isSearch} isManagement={isManagement} + isStatistics={isStatistics} isStickerSearch={isStickerSearch} isGifSearch={isGifSearch} isPollResults={isPollResults} diff --git a/src/components/right/RightHeader.tsx b/src/components/right/RightHeader.tsx index 1ef205d04..06ddcb2f1 100644 --- a/src/components/right/RightHeader.tsx +++ b/src/components/right/RightHeader.tsx @@ -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 = ({ isProfile, isSearch, isManagement, + isStatistics, isStickerSearch, isGifSearch, isPollResults, @@ -119,6 +123,7 @@ const RightHeader: FC = ({ gifSearchQuery, shouldSkipAnimation, isEditingInvite, + canViewStatistics, currentInviteInfo, }) => { const { @@ -129,6 +134,7 @@ const RightHeader: FC = ({ toggleManagement, openHistoryCalendar, addContact, + toggleStatistics, setEditingExportedInvite, deleteExportedChatInvite, } = getDispatch(); @@ -237,6 +243,8 @@ const RightHeader: FC = ({ ) : 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 = ({ onChange={handleGifSearchQueryChange} /> ); + case HeaderContent.Statistics: + return

{lang('Statistics')}

; case HeaderContent.SharedMedia: return

{lang('SharedMedia')}

; case HeaderContent.ManageChannelSubscribers: @@ -397,6 +407,17 @@ const RightHeader: FC = ({ )} + {canViewStatistics && ( + + )} ); @@ -459,11 +480,13 @@ export default memo(withGlobal( && (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, diff --git a/src/components/right/statistics/Statistics.async.tsx b/src/components/right/statistics/Statistics.async.tsx new file mode 100644 index 000000000..6ef36b682 --- /dev/null +++ b/src/components/right/statistics/Statistics.async.tsx @@ -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 = (props) => { + const Statistics = useModuleLoader(Bundles.Extra, 'Statistics'); + + // eslint-disable-next-line react/jsx-props-no-spreading + return Statistics ? : ; +}; + +export default StatisticsAsync; diff --git a/src/components/right/statistics/Statistics.scss b/src/components/right/statistics/Statistics.scss new file mode 100644 index 000000000..8201221c1 --- /dev/null +++ b/src/components/right/statistics/Statistics.scss @@ -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); + } +} diff --git a/src/components/right/statistics/Statistics.tsx b/src/components/right/statistics/Statistics.tsx new file mode 100644 index 000000000..deae31b8e --- /dev/null +++ b/src/components/right/statistics/Statistics.tsx @@ -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; +let LovelyChart: ILovelyChart; + +async function ensureLovelyChart() { + if (!lovelyChartPromise) { + lovelyChartPromise = import('../../../lib/lovely-chart/LovelyChart') as Promise; + 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 = ({ + chatId, isActive, statistics, dcId, +}) => { + const lang = useLang(); + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); + const [isReady, setIsReady] = useState(false); + const loadedCharts = useRef([]); + + 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)[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 ; + } + + return ( +
+ + + {!loadedCharts.current.length && } + +
+ {GRAPHS.map((graph) => ( +
+ ))} +
+ + {Boolean(statistics.recentTopMessages?.length) && +
+

{lang('ChannelStats.Recent.Header')}

+ + {statistics.recentTopMessages.map((message) => ( + + ))} +
+ } +
+ ); +}; + +export default memo(withGlobal( + (global, { chatId }): StateProps => { + const statistics = selectStatistics(global, chatId); + const dcId = selectChat(global, chatId)?.fullInfo?.statisticsDcId; + + return { statistics, dcId }; + }, +)(Statistics)); diff --git a/src/components/right/statistics/StatisticsOverview.scss b/src/components/right/statistics/StatisticsOverview.scss new file mode 100644 index 000000000..6c7083765 --- /dev/null +++ b/src/components/right/statistics/StatisticsOverview.scss @@ -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); + } + } +} diff --git a/src/components/right/statistics/StatisticsOverview.tsx b/src/components/right/statistics/StatisticsOverview.tsx new file mode 100644 index 000000000..743a06a47 --- /dev/null +++ b/src/components/right/statistics/StatisticsOverview.tsx @@ -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 = ({ statistics }) => { + const lang = useLang(); + + const renderOverviewItemValue = ({ change, percentage }: StatisticsOverviewItem) => { + const isChangeNegative = Number(change) < 0; + + return ( + + {isChangeNegative ? change : `+${change}`} + {percentage && ( + <> + {' '} + ({percentage}%) + + )} + + ); + }; + + const { + followers, viewsPerPost, sharesPerPost, enabledNotifications, + } = statistics; + + return ( +
+

{lang('ChannelStats.Overview')}

+ + + + + + + + + + + +
+ {followers.current} {renderOverviewItemValue(followers)} +

{lang('ChannelStats.Overview.Followers')}

+
+ {enabledNotifications.percentage}% +

{lang('ChannelStats.Overview.EnabledNotifications')}

+
+ {viewsPerPost.current} + {' '} + {renderOverviewItemValue(viewsPerPost)} +

{lang('ChannelStats.Overview.ViewsPerPost')}

+
+ {sharesPerPost.current} + {' '} + {renderOverviewItemValue(sharesPerPost)} +

{lang('ChannelStats.Overview.SharesPerPost')}

+
+
+ ); +}; + +export default memo(StatisticsOverview); diff --git a/src/components/right/statistics/StatisticsRecentMessage.scss b/src/components/right/statistics/StatisticsRecentMessage.scss new file mode 100644 index 000000000..143aba450 --- /dev/null +++ b/src/components/right/statistics/StatisticsRecentMessage.scss @@ -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; + } +} diff --git a/src/components/right/statistics/StatisticsRecentMessage.tsx b/src/components/right/statistics/StatisticsRecentMessage.tsx new file mode 100644 index 000000000..e12a1f629 --- /dev/null +++ b/src/components/right/statistics/StatisticsRecentMessage.tsx @@ -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 = ({ message }) => { + const lang = useLang(); + + const mediaThumbnail = getMessageMediaThumbDataUri(message); + const mediaBlobUrl = useMedia(getMessageMediaHash(message, 'micro')); + const isRoundVideo = Boolean(getMessageRoundVideo(message)); + + return ( +

+

+
+ {renderSummary(lang, message, mediaBlobUrl || mediaThumbnail, isRoundVideo)} +
+
+ {lang('ChannelStats.ViewsCount', message.views)} +
+
+ +
+
+ {formatDateTimeToString(message.date * 1000, lang.code)} +
+
+ {Boolean(message.forwards) ? lang('ChannelStats.SharesCount', message.forwards) : 'No shares'} +
+
+

+ ); +}; + +function renderSummary(lang: LangFn, message: ApiMessage, blobUrl?: string, isRoundVideo?: boolean) { + if (!blobUrl) { + return renderMessageSummary(lang, message); + } + + return ( + + + {getMessageVideo(message) && } + {renderMessageSummary(lang, message, true)} + + ); +} + +export default memo(StatisticsRecentMessage); diff --git a/src/global/initial.ts b/src/global/initial.ts index 9c6ce4357..8d0522c58 100644 --- a/src/global/initial.ts +++ b/src/global/initial.ts @@ -194,4 +194,8 @@ export const INITIAL_STATE: GlobalState = { }, serviceNotifications: [], + + statistics: { + byChatId: {}, + }, }; diff --git a/src/global/types.ts b/src/global/types.ts index bc27068ce..614053aab 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -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; + }; }; 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 { diff --git a/src/lib/gramjs/client/TelegramClient.d.ts b/src/lib/gramjs/client/TelegramClient.d.ts index 56335a84a..358ee9ca0 100644 --- a/src/lib/gramjs/client/TelegramClient.d.ts +++ b/src/lib/gramjs/client/TelegramClient.d.ts @@ -10,7 +10,7 @@ declare class TelegramClient { async start(authParams: UserAuthParams | BotAuthParams); - async invoke(request: R): Promise; + async invoke(request: R, dcId?: number): Promise; async uploadFile(uploadParams: UploadFileParams): ReturnType; diff --git a/src/lib/gramjs/client/TelegramClient.js b/src/lib/gramjs/client/TelegramClient.js index 104b0306c..4d3e5bfad 100644 --- a/src/lib/gramjs/client/TelegramClient.js +++ b/src/lib/gramjs/client/TelegramClient.js @@ -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; diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 4e033555f..0b8d081c6 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -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 = Vector; langpack.getLanguages#42c6978f lang_pack:string = Vector; -folders.editPeerFolders#6847d0ab folder_peers:Vector = Updates;`; \ No newline at end of file +folders.editPeerFolders#6847d0ab folder_peers:Vector = Updates; +stats.getBroadcastStats#ab42441a flags:# dark:flags.0?true channel:InputChannel = stats.BroadcastStats; +stats.loadAsyncGraph#621d5fa0 flags:# token:string x:flags.0?long = StatsGraph;`; \ No newline at end of file diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 5bf086f14..34a241c57 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -206,5 +206,7 @@ "messages.setChatAvailableReactions", "messages.getAvailableReactions", "messages.setDefaultReaction", - "help.getAppConfig" + "help.getAppConfig", + "stats.getBroadcastStats", + "stats.loadAsyncGraph" ] diff --git a/src/lib/lovely-chart/Axes.js b/src/lib/lovely-chart/Axes.js new file mode 100644 index 000000000..c0d354b41 --- /dev/null +++ b/src/lib/lovely-chart/Axes.js @@ -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 }; +} diff --git a/src/lib/lovely-chart/Header.js b/src/lib/lovely-chart/Header.js new file mode 100644 index 000000000..b2105d8b3 --- /dev/null +++ b/src/lib/lovely-chart/Header.js @@ -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, + }; +} diff --git a/src/lib/lovely-chart/LovelyChart.js b/src/lib/lovely-chart/LovelyChart.js new file mode 100644 index 000000000..707744705 --- /dev/null +++ b/src/lib/lovely-chart/LovelyChart.js @@ -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 }; diff --git a/src/lib/lovely-chart/Minimap.js b/src/lib/lovely-chart/Minimap.js new file mode 100644 index 000000000..aa3f82131 --- /dev/null +++ b/src/lib/lovely-chart/Minimap.js @@ -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 = + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + + _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 }; +} diff --git a/src/lib/lovely-chart/Projection.js b/src/lib/lovely-chart/Projection.js new file mode 100644 index 000000000..6ed8e0c09 --- /dev/null +++ b/src/lib/lovely-chart/Projection.js @@ -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), + ]; +} diff --git a/src/lib/lovely-chart/StateManager.js b/src/lib/lovely-chart/StateManager.js new file mode 100644 index 000000000..25701782f --- /dev/null +++ b/src/lib/lovely-chart/StateManager.js @@ -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); +} diff --git a/src/lib/lovely-chart/Tools.js b/src/lib/lovely-chart/Tools.js new file mode 100644 index 000000000..629ec0827 --- /dev/null +++ b/src/lib/lovely-chart/Tools.js @@ -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 = `${name}`; + + 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, + }; +} diff --git a/src/lib/lovely-chart/Tooltip.js b/src/lib/lovely-chart/Tooltip.js new file mode 100644 index 000000000..0df59877a --- /dev/null +++ b/src/lib/lovely-chart/Tooltip.js @@ -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 = '
'; + + 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 = `${title}`; + } 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 = `${name}${formatInteger(value)}`; + _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 = `All${totalValue}`; + 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 }; +} + diff --git a/src/lib/lovely-chart/TransitionManager.js b/src/lib/lovely-chart/TransitionManager.js new file mode 100644 index 000000000..cfbd07bb9 --- /dev/null +++ b/src/lib/lovely-chart/TransitionManager.js @@ -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 }; +} diff --git a/src/lib/lovely-chart/Zoomer.js b/src/lib/lovely-chart/Zoomer.js new file mode 100644 index 000000000..445dc7251 --- /dev/null +++ b/src/lib/lovely-chart/Zoomer.js @@ -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 }; +} diff --git a/src/lib/lovely-chart/canvas.js b/src/lib/lovely-chart/canvas.js new file mode 100644 index 000000000..2f53855bb --- /dev/null +++ b/src/lib/lovely-chart/canvas.js @@ -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); +} diff --git a/src/lib/lovely-chart/captureEvents.js b/src/lib/lovely-chart/captureEvents.js new file mode 100644 index 000000000..1eca9a6b5 --- /dev/null +++ b/src/lib/lovely-chart/captureEvents.js @@ -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); +} diff --git a/src/lib/lovely-chart/constants.js b/src/lib/lovely-chart/constants.js new file mode 100644 index 000000000..8a7205f18 --- /dev/null +++ b/src/lib/lovely-chart/constants.js @@ -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', +]; diff --git a/src/lib/lovely-chart/data.js b/src/lib/lovely-chart/data.js new file mode 100644 index 000000000..c735b1143 --- /dev/null +++ b/src/lib/lovely-chart/data.js @@ -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); +} diff --git a/src/lib/lovely-chart/drawDatasets.js b/src/lib/lovely-chart/drawDatasets.js new file mode 100644 index 000000000..891572a93 --- /dev/null +++ b/src/lib/lovely-chart/drawDatasets.js @@ -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(); +} diff --git a/src/lib/lovely-chart/format.js b/src/lib/lovely-chart/format.js new file mode 100644 index 000000000..e74a4d099 --- /dev/null +++ b/src/lib/lovely-chart/format.js @@ -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]; +} diff --git a/src/lib/lovely-chart/formulas.js b/src/lib/lovely-chart/formulas.js new file mode 100644 index 000000000..15684a72c --- /dev/null +++ b/src/lib/lovely-chart/formulas.js @@ -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; +} diff --git a/src/lib/lovely-chart/hideOnScroll.js b/src/lib/lovely-chart/hideOnScroll.js new file mode 100644 index 000000000..03b5c1220 --- /dev/null +++ b/src/lib/lovely-chart/hideOnScroll.js @@ -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; +})(); diff --git a/src/lib/lovely-chart/minifiers.js b/src/lib/lovely-chart/minifiers.js new file mode 100644 index 000000000..4bff89259 --- /dev/null +++ b/src/lib/lovely-chart/minifiers.js @@ -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); +} diff --git a/src/lib/lovely-chart/preparePoints.js b/src/lib/lovely-chart/preparePoints.js new file mode 100644 index 000000000..97d3487a1 --- /dev/null +++ b/src/lib/lovely-chart/preparePoints.js @@ -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)] + )); +} diff --git a/src/lib/lovely-chart/simplify.js b/src/lib/lovely-chart/simplify.js new file mode 100644 index 000000000..6b172dfbc --- /dev/null +++ b/src/lib/lovely-chart/simplify.js @@ -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; +})(); diff --git a/src/lib/lovely-chart/skin.js b/src/lib/lovely-chart/skin.js new file mode 100644 index 000000000..a8de5bd5d --- /dev/null +++ b/src/lib/lovely-chart/skin.js @@ -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); +} diff --git a/src/lib/lovely-chart/styles/_animations.scss b/src/lib/lovely-chart/styles/_animations.scss new file mode 100644 index 000000000..0c55b280e --- /dev/null +++ b/src/lib/lovely-chart/styles/_animations.scss @@ -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; + } +} diff --git a/src/lib/lovely-chart/styles/_buttons.scss b/src/lib/lovely-chart/styles/_buttons.scss new file mode 100644 index 000000000..345bb6279 --- /dev/null +++ b/src/lib/lovely-chart/styles/_buttons.scss @@ -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; +} diff --git a/src/lib/lovely-chart/styles/_common.scss b/src/lib/lovely-chart/styles/_common.scss new file mode 100644 index 000000000..37f43fd0e --- /dev/null +++ b/src/lib/lovely-chart/styles/_common.scss @@ -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; + } +} diff --git a/src/lib/lovely-chart/styles/_header.scss b/src/lib/lovely-chart/styles/_header.scss new file mode 100644 index 000000000..a1f21efee --- /dev/null +++ b/src/lib/lovely-chart/styles/_header.scss @@ -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%; + } + } +} diff --git a/src/lib/lovely-chart/styles/_minimap.scss b/src/lib/lovely-chart/styles/_minimap.scss new file mode 100644 index 000000000..87eb096bc --- /dev/null +++ b/src/lib/lovely-chart/styles/_minimap.scss @@ -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; + } + } + } +} diff --git a/src/lib/lovely-chart/styles/_tools.scss b/src/lib/lovely-chart/styles/_tools.scss new file mode 100644 index 000000000..a404adebe --- /dev/null +++ b/src/lib/lovely-chart/styles/_tools.scss @@ -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; +} diff --git a/src/lib/lovely-chart/styles/_tooltip.scss b/src/lib/lovely-chart/styles/_tooltip.scss new file mode 100644 index 000000000..22f0c1552 --- /dev/null +++ b/src/lib/lovely-chart/styles/_tooltip.scss @@ -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; +} diff --git a/src/lib/lovely-chart/styles/_variables.scss b/src/lib/lovely-chart/styles/_variables.scss new file mode 100644 index 000000000..093fda324 --- /dev/null +++ b/src/lib/lovely-chart/styles/_variables.scss @@ -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; +} diff --git a/src/lib/lovely-chart/styles/index.scss b/src/lib/lovely-chart/styles/index.scss new file mode 100644 index 000000000..17f08910a --- /dev/null +++ b/src/lib/lovely-chart/styles/index.scss @@ -0,0 +1,8 @@ +@import 'variables'; +@import 'animations'; +@import 'common'; +@import 'header'; +@import 'minimap'; +@import 'tooltip'; +@import 'tools'; +@import 'buttons'; diff --git a/src/lib/lovely-chart/toggleText.js b/src/lib/lovely-chart/toggleText.js new file mode 100644 index 000000000..4b4b5e029 --- /dev/null +++ b/src/lib/lovely-chart/toggleText.js @@ -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'); +} diff --git a/src/lib/lovely-chart/utils.js b/src/lib/lovely-chart/utils.js new file mode 100644 index 000000000..d13959c23 --- /dev/null +++ b/src/lib/lovely-chart/utils.js @@ -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); + }; +} diff --git a/src/modules/actions/all.ts b/src/modules/actions/all.ts index c204951e8..380749b86 100644 --- a/src/modules/actions/all.ts +++ b/src/modules/actions/all.ts @@ -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'; diff --git a/src/modules/actions/api/statistics.ts b/src/modules/actions/api/statistics.ts new file mode 100644 index 000000000..6194eb139 --- /dev/null +++ b/src/modules/actions/api/statistics.ts @@ -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)); + })(); +}); diff --git a/src/modules/actions/ui/misc.ts b/src/modules/actions/ui/misc.ts index 5121b5428..619a07504 100644 --- a/src/modules/actions/ui/misc.ts +++ b/src/modules/actions/ui/misc.ts @@ -109,6 +109,13 @@ addReducer('openChat', (global) => { }; }); +addReducer('toggleStatistics', (global) => { + return { + ...global, + isStatisticsShown: !global.isStatisticsShown, + }; +}); + addReducer('toggleLeftColumn', (global) => { return { ...global, diff --git a/src/modules/reducers/index.ts b/src/modules/reducers/index.ts index 1c445a173..e944c6ae7 100644 --- a/src/modules/reducers/index.ts +++ b/src/modules/reducers/index.ts @@ -8,3 +8,4 @@ export * from './management'; export * from './settings'; export * from './twoFaSettings'; export * from './payments'; +export * from './statistics'; diff --git a/src/modules/reducers/statistics.ts b/src/modules/reducers/statistics.ts new file mode 100644 index 000000000..c13dc3948 --- /dev/null +++ b/src/modules/reducers/statistics.ts @@ -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, + }, + }, + }, + }; +} diff --git a/src/modules/selectors/index.ts b/src/modules/selectors/index.ts index 43aaca36d..d34f6b86d 100644 --- a/src/modules/selectors/index.ts +++ b/src/modules/selectors/index.ts @@ -8,3 +8,4 @@ export * from './management'; export * from './symbols'; export * from './payments'; export * from './settings'; +export * from './statistics'; diff --git a/src/modules/selectors/statistics.ts b/src/modules/selectors/statistics.ts new file mode 100644 index 000000000..425b505ea --- /dev/null +++ b/src/modules/selectors/statistics.ts @@ -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; +} diff --git a/src/modules/selectors/ui.ts b/src/modules/selectors/ui.ts index 54e3b1b4e..bab69dded 100644 --- a/src/modules/selectors/ui.ts +++ b/src/modules/selectors/ui.ts @@ -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 ? ( diff --git a/src/styles/Telegram T.json b/src/styles/Telegram T.json index d41fc6783..4c08e7de6 100644 --- a/src/styles/Telegram T.json +++ b/src/styles/Telegram T.json @@ -2,7 +2,7 @@ "metadata": { "name": "Telegram T", "lastOpened": 0, - "created": 1646057396458 + "created": 1646326576385 }, "iconSets": [ { @@ -157,13 +157,21 @@ }, { "selection": [ + { + "order": 700, + "id": 50, + "name": "stats", + "prevSize": 32, + "code": 59798, + "tempChar": "" + }, { "order": 699, "id": 49, "name": "copy-media", "prevSize": 32, "code": 59797, - "tempChar": "" + "tempChar": "" }, { "order": 698, @@ -171,7 +179,7 @@ "name": "reaction-filled", "prevSize": 32, "code": 59796, - "tempChar": "" + "tempChar": "" }, { "order": 695, @@ -179,7 +187,7 @@ "name": "reactions", "prevSize": 32, "code": 59795, - "tempChar": "" + "tempChar": "" }, { "order": 693, @@ -187,7 +195,7 @@ "name": "sidebar", "prevSize": 32, "code": 59794, - "tempChar": "" + "tempChar": "" }, { "order": 690, @@ -195,7 +203,7 @@ "name": "video-stop", "prevSize": 32, "code": 59787, - "tempChar": "" + "tempChar": "" }, { "order": 678, @@ -203,7 +211,7 @@ "name": "speaker", "prevSize": 32, "code": 59777, - "tempChar": "" + "tempChar": "" }, { "order": 679, @@ -211,7 +219,7 @@ "name": "speaker-outline", "prevSize": 32, "code": 59778, - "tempChar": "" + "tempChar": "" }, { "order": 680, @@ -219,7 +227,7 @@ "name": "phone-discard-outline", "prevSize": 32, "code": 59779, - "tempChar": "" + "tempChar": "" }, { "order": 681, @@ -227,7 +235,7 @@ "name": "allow-speak", "prevSize": 32, "code": 59780, - "tempChar": "" + "tempChar": "" }, { "order": 682, @@ -235,7 +243,7 @@ "name": "stop-raising-hand", "prevSize": 32, "code": 59781, - "tempChar": "" + "tempChar": "" }, { "order": 683, @@ -243,7 +251,7 @@ "name": "share-screen", "prevSize": 32, "code": 59782, - "tempChar": "" + "tempChar": "" }, { "order": 684, @@ -251,7 +259,7 @@ "name": "voice-chat", "prevSize": 32, "code": 59783, - "tempChar": "" + "tempChar": "" }, { "order": 689, @@ -259,7 +267,7 @@ "name": "video", "prevSize": 32, "code": 59784, - "tempChar": "" + "tempChar": "" }, { "order": 686, @@ -267,7 +275,7 @@ "name": "noise-suppression", "prevSize": 32, "code": 59785, - "tempChar": "" + "tempChar": "" }, { "order": 688, @@ -275,7 +283,7 @@ "name": "phone-discard", "prevSize": 32, "code": 59786, - "tempChar": "" + "tempChar": "" }, { "order": 667, @@ -283,7 +291,7 @@ "name": "bot-commands-filled", "prevSize": 32, "code": 59775, - "tempChar": "" + "tempChar": "" }, { "order": 664, @@ -291,7 +299,7 @@ "name": "reply-filled", "prevSize": 32, "code": 59776, - "tempChar": "" + "tempChar": "" }, { "order": 656, @@ -299,7 +307,7 @@ "name": "bug", "prevSize": 32, "code": 59774, - "tempChar": "" + "tempChar": "" }, { "order": 619, @@ -307,7 +315,7 @@ "name": "data", "prevSize": 32, "code": 59773, - "tempChar": "" + "tempChar": "" }, { "order": 622, @@ -315,7 +323,7 @@ "name": "darkmode", "prevSize": 32, "code": 59769, - "tempChar": "" + "tempChar": "" }, { "order": 623, @@ -323,7 +331,7 @@ "name": "animations", "prevSize": 32, "code": 59770, - "tempChar": "" + "tempChar": "" }, { "order": 626, @@ -331,7 +339,7 @@ "name": "enter", "prevSize": 32, "code": 59771, - "tempChar": "" + "tempChar": "" }, { "order": 627, @@ -339,7 +347,7 @@ "name": "fontsize", "prevSize": 32, "code": 59772, - "tempChar": "" + "tempChar": "" }, { "order": 630, @@ -347,7 +355,7 @@ "name": "permissions", "prevSize": 32, "code": 59766, - "tempChar": "" + "tempChar": "" }, { "order": 631, @@ -355,7 +363,7 @@ "name": "card", "prevSize": 32, "code": 59767, - "tempChar": "" + "tempChar": "" }, { "order": 634, @@ -363,7 +371,7 @@ "name": "truck", "prevSize": 32, "code": 59768, - "tempChar": "" + "tempChar": "" }, { "order": 663, @@ -371,7 +379,7 @@ "name": "share-filled", "prevSize": 32, "code": 59738, - "tempChar": "" + "tempChar": "" }, { "order": 638, @@ -379,7 +387,7 @@ "name": "bold", "prevSize": 32, "code": 59745, - "tempChar": "" + "tempChar": "" }, { "order": 639, @@ -387,7 +395,7 @@ "name": "bot-command", "prevSize": 32, "code": 59746, - "tempChar": "" + "tempChar": "" }, { "order": 642, @@ -395,7 +403,7 @@ "name": "calendar-filter", "prevSize": 32, "code": 59747, - "tempChar": "" + "tempChar": "" }, { "order": 643, @@ -403,7 +411,7 @@ "name": "comments", "prevSize": 32, "code": 59748, - "tempChar": "" + "tempChar": "" }, { "order": 645, @@ -411,7 +419,7 @@ "name": "comments-sticker", "prevSize": 32, "code": 59749, - "tempChar": "" + "tempChar": "" }, { "order": 646, @@ -419,7 +427,7 @@ "name": "arrow-down", "prevSize": 32, "code": 59750, - "tempChar": "" + "tempChar": "" }, { "order": 668, @@ -427,7 +435,7 @@ "name": "email", "prevSize": 32, "code": 59751, - "tempChar": "" + "tempChar": "" }, { "order": 648, @@ -435,7 +443,7 @@ "name": "italic", "prevSize": 32, "code": 59752, - "tempChar": "" + "tempChar": "" }, { "order": 620, @@ -443,7 +451,7 @@ "name": "link", "prevSize": 32, "code": 59753, - "tempChar": "" + "tempChar": "" }, { "order": 621, @@ -451,7 +459,7 @@ "name": "mention", "prevSize": 32, "code": 59754, - "tempChar": "" + "tempChar": "" }, { "order": 624, @@ -459,7 +467,7 @@ "name": "monospace", "prevSize": 32, "code": 59755, - "tempChar": "" + "tempChar": "" }, { "order": 625, @@ -467,7 +475,7 @@ "name": "next", "prevSize": 32, "code": 59756, - "tempChar": "" + "tempChar": "" }, { "order": 628, @@ -475,7 +483,7 @@ "name": "password-off", "prevSize": 32, "code": 59757, - "tempChar": "" + "tempChar": "" }, { "order": 629, @@ -483,7 +491,7 @@ "name": "pin-list", "prevSize": 32, "code": 59758, - "tempChar": "" + "tempChar": "" }, { "order": 632, @@ -491,7 +499,7 @@ "name": "previous", "prevSize": 32, "code": 59759, - "tempChar": "" + "tempChar": "" }, { "order": 633, @@ -499,7 +507,7 @@ "name": "replace", "prevSize": 32, "code": 59760, - "tempChar": "" + "tempChar": "" }, { "order": 636, @@ -507,7 +515,7 @@ "name": "schedule", "prevSize": 32, "code": 59761, - "tempChar": "" + "tempChar": "" }, { "order": 691, @@ -515,7 +523,7 @@ "name": "strikethrough", "prevSize": 32, "code": 59762, - "tempChar": "" + "tempChar": "" }, { "order": 692, @@ -523,7 +531,7 @@ "name": "underlined", "prevSize": 32, "code": 59763, - "tempChar": "" + "tempChar": "" }, { "order": 641, @@ -531,7 +539,7 @@ "name": "zoom-in", "prevSize": 32, "code": 59764, - "tempChar": "" + "tempChar": "" }, { "order": 649, @@ -539,7 +547,7 @@ "name": "zoom-out", "prevSize": 32, "code": 59765, - "tempChar": "" + "tempChar": "" } ], "id": 2, @@ -553,6 +561,25 @@ "height": 1024, "prevSize": 32, "icons": [ + { + "id": 50, + "paths": [ + "M888.8 944.933h-646.133c-86.933 0-157.733-70.8-157.733-157.733v-646.267c0-23.6 19.067-42.667 42.667-42.667s42.667 19.067 42.667 42.667v646.133c0 39.867 32.533 72.4 72.4 72.4h646.133c23.6 0 42.667 19.067 42.667 42.667s-19.067 42.8-42.667 42.8z", + "M274.8 704.4c-10.933 0-21.867-4.133-30.133-12.533-16.667-16.667-16.667-43.733 0-60.4l180.267-180.267c16.533-16.533 43.333-16.667 60-0.267l95.6 93.467 277.2-296.933c12-12.8 30.533-17.067 46.8-10.533 16.267 6.4 27.067 22.133 27.067 39.733v235.333c0 23.6-19.067 42.667-42.667 42.667s-42.667-19.067-42.667-42.667v-127.067l-233.2 249.733c-7.867 8.4-18.8 13.333-30.267 13.6s-22.533-4.133-30.8-12.133l-96.667-94.533-150.4 150.267c-8.267 8.4-19.2 12.533-30.133 12.533z", + "M888.533 319.2h-223.867c-23.6 0-42.667-19.067-42.667-42.667s19.067-42.667 42.667-42.667h223.867c23.6 0 42.667 19.067 42.667 42.667s-19.067 42.667-42.667 42.667z" + ], + "attrs": [ + {}, + {}, + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "stats" + ] + }, { "id": 49, "paths": [ @@ -2892,7 +2919,7 @@ "name": "select", "prevSize": 32, "code": 59744, - "tempChar": "" + "tempChar": "" }, { "order": 480, @@ -2900,7 +2927,7 @@ "name": "folder", "prevSize": 32, "code": 59667, - "tempChar": "" + "tempChar": "" }, { "order": 481, @@ -2908,7 +2935,7 @@ "name": "bots", "prevSize": 32, "code": 59669, - "tempChar": "" + "tempChar": "" }, { "order": 482, @@ -2916,7 +2943,7 @@ "name": "calendar", "prevSize": 32, "code": 59670, - "tempChar": "" + "tempChar": "" }, { "order": 483, @@ -2924,7 +2951,7 @@ "name": "cloud-download", "prevSize": 32, "code": 59671, - "tempChar": "" + "tempChar": "" }, { "order": 484, @@ -2932,7 +2959,7 @@ "name": "colorize", "prevSize": 32, "code": 59672, - "tempChar": "" + "tempChar": "" }, { "order": 651, @@ -2940,7 +2967,7 @@ "name": "forward", "prevSize": 32, "code": 59687, - "tempChar": "" + "tempChar": "" }, { "order": 650, @@ -2948,7 +2975,7 @@ "name": "reply", "prevSize": 32, "code": 59719, - "tempChar": "" + "tempChar": "" }, { "order": 487, @@ -2956,7 +2983,7 @@ "name": "help", "prevSize": 32, "code": 59690, - "tempChar": "" + "tempChar": "" }, { "order": 488, @@ -2964,7 +2991,7 @@ "name": "info", "prevSize": 32, "code": 59691, - "tempChar": "" + "tempChar": "" }, { "order": 489, @@ -2972,7 +2999,7 @@ "name": "info-filled", "prevSize": 32, "code": 59675, - "tempChar": "" + "tempChar": "" }, { "order": 490, @@ -2980,7 +3007,7 @@ "name": "delete-filled", "prevSize": 32, "code": 59676, - "tempChar": "" + "tempChar": "" }, { "order": 491, @@ -2988,7 +3015,7 @@ "name": "delete", "prevSize": 32, "code": 59677, - "tempChar": "" + "tempChar": "" }, { "order": 492, @@ -2996,7 +3023,7 @@ "name": "edit", "prevSize": 32, "code": 59683, - "tempChar": "" + "tempChar": "" }, { "order": 493, @@ -3004,7 +3031,7 @@ "name": "new-chat-filled", "prevSize": 32, "code": 59705, - "tempChar": "" + "tempChar": "" }, { "order": 494, @@ -3012,7 +3039,7 @@ "name": "send", "prevSize": 32, "code": 59722, - "tempChar": "" + "tempChar": "" }, { "order": 495, @@ -3020,7 +3047,7 @@ "name": "send-outline", "prevSize": 32, "code": 59723, - "tempChar": "" + "tempChar": "" }, { "order": 496, @@ -3028,7 +3055,7 @@ "name": "add-user-filled", "prevSize": 32, "code": 59652, - "tempChar": "" + "tempChar": "" }, { "order": 497, @@ -3036,7 +3063,7 @@ "name": "add-user", "prevSize": 32, "code": 59653, - "tempChar": "" + "tempChar": "" }, { "order": 498, @@ -3044,7 +3071,7 @@ "name": "delete-user", "prevSize": 32, "code": 59678, - "tempChar": "" + "tempChar": "" }, { "order": 499, @@ -3052,7 +3079,7 @@ "name": "microphone", "prevSize": 32, "code": 59701, - "tempChar": "" + "tempChar": "" }, { "order": 500, @@ -3060,7 +3087,7 @@ "name": "microphone-alt", "prevSize": 32, "code": 59707, - "tempChar": "" + "tempChar": "" }, { "order": 501, @@ -3068,7 +3095,7 @@ "name": "poll", "prevSize": 32, "code": 59704, - "tempChar": "" + "tempChar": "" }, { "order": 502, @@ -3076,7 +3103,7 @@ "name": "revote", "prevSize": 32, "code": 59706, - "tempChar": "" + "tempChar": "" }, { "order": 503, @@ -3084,7 +3111,7 @@ "name": "photo", "prevSize": 32, "code": 59712, - "tempChar": "" + "tempChar": "" }, { "order": 504, @@ -3092,7 +3119,7 @@ "name": "document", "prevSize": 32, "code": 59679, - "tempChar": "" + "tempChar": "" }, { "order": 505, @@ -3100,7 +3127,7 @@ "name": "camera", "prevSize": 32, "code": 59662, - "tempChar": "" + "tempChar": "" }, { "order": 506, @@ -3108,7 +3135,7 @@ "name": "camera-add", "prevSize": 32, "code": 59663, - "tempChar": "" + "tempChar": "" }, { "order": 507, @@ -3116,7 +3143,7 @@ "name": "logout", "prevSize": 32, "code": 59698, - "tempChar": "" + "tempChar": "" }, { "order": 508, @@ -3124,7 +3151,7 @@ "name": "saved-messages", "prevSize": 32, "code": 59720, - "tempChar": "" + "tempChar": "" }, { "order": 509, @@ -3132,7 +3159,7 @@ "name": "settings", "prevSize": 32, "code": 59726, - "tempChar": "" + "tempChar": "" }, { "order": 652, @@ -3140,7 +3167,7 @@ "name": "phone", "prevSize": 32, "code": 59711, - "tempChar": "" + "tempChar": "" }, { "order": 653, @@ -3148,7 +3175,7 @@ "name": "attach", "prevSize": 32, "code": 59657, - "tempChar": "" + "tempChar": "" }, { "order": 512, @@ -3156,7 +3183,7 @@ "name": "copy", "prevSize": 32, "code": 59674, - "tempChar": "" + "tempChar": "" }, { "order": 513, @@ -3164,7 +3191,7 @@ "name": "channel", "prevSize": 32, "code": 59665, - "tempChar": "" + "tempChar": "" }, { "order": 514, @@ -3172,7 +3199,7 @@ "name": "group", "prevSize": 32, "code": 59689, - "tempChar": "" + "tempChar": "" }, { "order": 515, @@ -3180,7 +3207,7 @@ "name": "user", "prevSize": 32, "code": 59737, - "tempChar": "" + "tempChar": "" }, { "order": 516, @@ -3188,7 +3215,7 @@ "name": "non-contacts", "prevSize": 32, "code": 59688, - "tempChar": "" + "tempChar": "" }, { "order": 517, @@ -3196,7 +3223,7 @@ "name": "active-sessions", "prevSize": 32, "code": 59650, - "tempChar": "" + "tempChar": "" }, { "order": 518, @@ -3204,7 +3231,7 @@ "name": "admin", "prevSize": 32, "code": 59654, - "tempChar": "" + "tempChar": "" }, { "order": 519, @@ -3212,7 +3239,7 @@ "name": "download", "prevSize": 32, "code": 59681, - "tempChar": "" + "tempChar": "" }, { "order": 520, @@ -3220,7 +3247,7 @@ "name": "location", "prevSize": 32, "code": 59696, - "tempChar": "" + "tempChar": "" }, { "order": 521, @@ -3228,7 +3255,7 @@ "name": "stop", "prevSize": 32, "code": 59730, - "tempChar": "" + "tempChar": "" }, { "order": 523, @@ -3236,7 +3263,7 @@ "name": "archive", "prevSize": 32, "code": 59656, - "tempChar": "" + "tempChar": "" }, { "order": 524, @@ -3244,7 +3271,7 @@ "name": "unarchive", "prevSize": 32, "code": 59731, - "tempChar": "" + "tempChar": "" }, { "order": 525, @@ -3252,7 +3279,7 @@ "name": "readchats", "prevSize": 32, "code": 59699, - "tempChar": "" + "tempChar": "" }, { "order": 526, @@ -3260,7 +3287,7 @@ "name": "unread", "prevSize": 32, "code": 59735, - "tempChar": "" + "tempChar": "" }, { "order": 654, @@ -3268,7 +3295,7 @@ "name": "message", "prevSize": 32, "code": 59700, - "tempChar": "" + "tempChar": "" }, { "order": 659, @@ -3276,7 +3303,7 @@ "name": "lock", "prevSize": 32, "code": 59697, - "tempChar": "" + "tempChar": "" }, { "order": 529, @@ -3284,7 +3311,7 @@ "name": "unlock", "prevSize": 32, "code": 59732, - "tempChar": "" + "tempChar": "" }, { "order": 530, @@ -3292,7 +3319,7 @@ "name": "mute", "prevSize": 32, "code": 59703, - "tempChar": "" + "tempChar": "" }, { "order": 531, @@ -3300,7 +3327,7 @@ "name": "unmute", "prevSize": 32, "code": 59733, - "tempChar": "" + "tempChar": "" }, { "order": 532, @@ -3308,7 +3335,7 @@ "name": "pin", "prevSize": 32, "code": 59713, - "tempChar": "" + "tempChar": "" }, { "order": 533, @@ -3316,7 +3343,7 @@ "name": "unpin", "prevSize": 32, "code": 59734, - "tempChar": "" + "tempChar": "" }, { "order": 534, @@ -3324,7 +3351,7 @@ "name": "smallscreen", "prevSize": 32, "code": 59742, - "tempChar": "" + "tempChar": "" }, { "order": 535, @@ -3332,7 +3359,7 @@ "name": "fullscreen", "prevSize": 32, "code": 59743, - "tempChar": "" + "tempChar": "" }, { "order": 536, @@ -3340,7 +3367,7 @@ "name": "large-pause", "prevSize": 32, "code": 59694, - "tempChar": "" + "tempChar": "" }, { "order": 537, @@ -3348,7 +3375,7 @@ "name": "large-play", "prevSize": 32, "code": 59695, - "tempChar": "" + "tempChar": "" }, { "order": 538, @@ -3356,7 +3383,7 @@ "name": "pause", "prevSize": 32, "code": 59709, - "tempChar": "" + "tempChar": "" }, { "order": 539, @@ -3364,7 +3391,7 @@ "name": "play", "prevSize": 32, "code": 59715, - "tempChar": "" + "tempChar": "" }, { "order": 540, @@ -3372,7 +3399,7 @@ "name": "channelviews", "prevSize": 32, "code": 59666, - "tempChar": "" + "tempChar": "" }, { "order": 541, @@ -3380,7 +3407,7 @@ "name": "message-succeeded", "prevSize": 32, "code": 59648, - "tempChar": "" + "tempChar": "" }, { "order": 657, @@ -3388,7 +3415,7 @@ "name": "message-read", "prevSize": 32, "code": 59649, - "tempChar": "" + "tempChar": "" }, { "order": 543, @@ -3396,7 +3423,7 @@ "name": "message-pending", "prevSize": 32, "code": 59724, - "tempChar": "" + "tempChar": "" }, { "order": 544, @@ -3404,7 +3431,7 @@ "name": "message-failed", "prevSize": 32, "code": 59725, - "tempChar": "" + "tempChar": "" }, { "order": 545, @@ -3412,7 +3439,7 @@ "name": "favorite", "prevSize": 32, "code": 59710, - "tempChar": "" + "tempChar": "" }, { "order": 546, @@ -3420,7 +3447,7 @@ "name": "keyboard", "prevSize": 32, "code": 59716, - "tempChar": "" + "tempChar": "" }, { "order": 547, @@ -3428,7 +3455,7 @@ "name": "delete-left", "prevSize": 32, "code": 59717, - "tempChar": "" + "tempChar": "" }, { "order": 548, @@ -3436,7 +3463,7 @@ "name": "recent", "prevSize": 32, "code": 59718, - "tempChar": "" + "tempChar": "" }, { "order": 549, @@ -3444,7 +3471,7 @@ "name": "gifs", "prevSize": 32, "code": 59727, - "tempChar": "" + "tempChar": "" }, { "order": 550, @@ -3452,7 +3479,7 @@ "name": "stickers", "prevSize": 32, "code": 59739, - "tempChar": "" + "tempChar": "" }, { "order": 551, @@ -3460,7 +3487,7 @@ "name": "smile", "prevSize": 32, "code": 59728, - "tempChar": "" + "tempChar": "" }, { "order": 552, @@ -3468,7 +3495,7 @@ "name": "animals", "prevSize": 32, "code": 59655, - "tempChar": "" + "tempChar": "" }, { "order": 553, @@ -3476,7 +3503,7 @@ "name": "eats", "prevSize": 32, "code": 59682, - "tempChar": "" + "tempChar": "" }, { "order": 554, @@ -3484,7 +3511,7 @@ "name": "sport", "prevSize": 32, "code": 59729, - "tempChar": "" + "tempChar": "" }, { "order": 555, @@ -3492,7 +3519,7 @@ "name": "car", "prevSize": 32, "code": 59664, - "tempChar": "" + "tempChar": "" }, { "order": 556, @@ -3500,7 +3527,7 @@ "name": "lamp", "prevSize": 32, "code": 59692, - "tempChar": "" + "tempChar": "" }, { "order": 557, @@ -3508,7 +3535,7 @@ "name": "language", "prevSize": 32, "code": 59693, - "tempChar": "" + "tempChar": "" }, { "order": 558, @@ -3516,7 +3543,7 @@ "name": "flag", "prevSize": 32, "code": 59686, - "tempChar": "" + "tempChar": "" }, { "order": 559, @@ -3524,7 +3551,7 @@ "name": "more", "prevSize": 32, "code": 59702, - "tempChar": "" + "tempChar": "" }, { "order": 560, @@ -3532,7 +3559,7 @@ "name": "search", "prevSize": 32, "code": 59721, - "tempChar": "" + "tempChar": "" }, { "order": 561, @@ -3540,7 +3567,7 @@ "name": "remove", "prevSize": 32, "code": 59740, - "tempChar": "" + "tempChar": "" }, { "order": 562, @@ -3548,7 +3575,7 @@ "name": "add", "prevSize": 32, "code": 59651, - "tempChar": "" + "tempChar": "" }, { "order": 563, @@ -3556,7 +3583,7 @@ "name": "check", "prevSize": 32, "code": 59668, - "tempChar": "" + "tempChar": "" }, { "order": 564, @@ -3564,7 +3591,7 @@ "name": "close", "prevSize": 32, "code": 59673, - "tempChar": "" + "tempChar": "" }, { "order": 610, @@ -3572,7 +3599,7 @@ "name": "arrow-left", "prevSize": 32, "code": 59661, - "tempChar": "" + "tempChar": "" }, { "order": 566, @@ -3580,7 +3607,7 @@ "name": "arrow-right", "prevSize": 32, "code": 59708, - "tempChar": "" + "tempChar": "" }, { "order": 567, @@ -3588,7 +3615,7 @@ "name": "down", "prevSize": 32, "code": 59680, - "tempChar": "" + "tempChar": "" }, { "order": 568, @@ -3596,7 +3623,7 @@ "name": "up", "prevSize": 32, "code": 59736, - "tempChar": "" + "tempChar": "" }, { "order": 569, @@ -3604,7 +3631,7 @@ "name": "eye-closed", "prevSize": 32, "code": 59685, - "tempChar": "" + "tempChar": "" }, { "order": 570, @@ -3612,7 +3639,7 @@ "name": "eye", "prevSize": 32, "code": 59684, - "tempChar": "" + "tempChar": "" }, { "order": 571, @@ -3620,7 +3647,7 @@ "name": "muted", "prevSize": 32, "code": 59741, - "tempChar": "" + "tempChar": "" }, { "order": 572, @@ -3628,7 +3655,7 @@ "name": "avatar-archived-chats", "prevSize": 32, "code": 59658, - "tempChar": "" + "tempChar": "" }, { "order": 573, @@ -3636,7 +3663,7 @@ "name": "avatar-deleted-account", "prevSize": 32, "code": 59659, - "tempChar": "" + "tempChar": "" }, { "order": 574, @@ -3644,7 +3671,7 @@ "name": "avatar-saved-messages", "prevSize": 32, "code": 59660, - "tempChar": "" + "tempChar": "" }, { "order": 575, @@ -3652,7 +3679,7 @@ "name": "pinned-chat", "prevSize": 32, "code": 59714, - "tempChar": "" + "tempChar": "" } ], "prevSize": 32, diff --git a/src/styles/icons.scss b/src/styles/icons.scss index cf74e018f..536d6a7c6 100644 --- a/src/styles/icons.scss +++ b/src/styles/icons.scss @@ -51,6 +51,9 @@ .icon-volume-3:before { content: "\e991"; } +.icon-stats:before { + content: "\e996"; +} .icon-copy-media:before { content: "\e995"; } diff --git a/src/types/index.ts b/src/types/index.ts index 9b945aca7..f291fa832 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -236,6 +236,7 @@ export enum RightColumnContent { ChatInfo, Search, Management, + Statistics, StickerSearch, GifSearch, PollResults,