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 45cae334b..18e20bdbe 100644
Binary files a/src/assets/fonts/icomoon.woff and b/src/assets/fonts/icomoon.woff differ
diff --git a/src/assets/fonts/icomoon.woff2 b/src/assets/fonts/icomoon.woff2
index 2050d2992..a1df7a845 100644
Binary files a/src/assets/fonts/icomoon.woff2 and b/src/assets/fonts/icomoon.woff2 differ
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 && (
+
+ )}
{canLeave && (
+ );
+};
+
+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,