Management: Introduce Statistics for channels (#1703)
This commit is contained in:
parent
b1fbc1c4b6
commit
e3bc23bd57
@ -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/
|
||||
|
||||
115
src/api/gramjs/apiBuilders/statistics.ts
Normal file
115
src/api/gramjs/apiBuilders/statistics.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { Api as GramJs } from '../../../lib/gramjs';
|
||||
import {
|
||||
ApiStatistics,
|
||||
StatisticsGraph,
|
||||
StatisticsOverviewItem,
|
||||
StatisticsOverviewPercentage,
|
||||
} from '../../types';
|
||||
|
||||
export function buildStatistics(stats: GramJs.stats.BroadcastStats): ApiStatistics {
|
||||
return {
|
||||
// Graphs
|
||||
growthGraph: buildGraph(stats.growthGraph),
|
||||
followersGraph: buildGraph(stats.followersGraph),
|
||||
muteGraph: buildGraph(stats.muteGraph),
|
||||
topHoursGraph: buildGraph(stats.topHoursGraph),
|
||||
|
||||
// Async graphs
|
||||
languagesGraph: (stats.languagesGraph as GramJs.StatsGraphAsync).token,
|
||||
viewsBySourceGraph: (stats.viewsBySourceGraph as GramJs.StatsGraphAsync).token,
|
||||
newFollowersBySourceGraph: (stats.newFollowersBySourceGraph as GramJs.StatsGraphAsync).token,
|
||||
interactionsGraph: (stats.interactionsGraph as GramJs.StatsGraphAsync).token,
|
||||
|
||||
// Statistics overview
|
||||
followers: buildStatisticsOverview(stats.followers),
|
||||
viewsPerPost: buildStatisticsOverview(stats.viewsPerPost),
|
||||
sharesPerPost: buildStatisticsOverview(stats.sharesPerPost),
|
||||
enabledNotifications: buildStatisticsPercentage(stats.enabledNotifications),
|
||||
|
||||
// Recent posts
|
||||
recentTopMessages: stats.recentMessageInteractions,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGraph(result: GramJs.TypeStatsGraph, isPercentage?: boolean): StatisticsGraph {
|
||||
if ((result as GramJs.StatsGraphError).error) {
|
||||
throw new Error((result as GramJs.StatsGraphError).error);
|
||||
}
|
||||
|
||||
const data = JSON.parse((result as GramJs.StatsGraph).json.data);
|
||||
const [x, ...y] = data.columns;
|
||||
const hasSecondYAxis = data.y_scaled;
|
||||
|
||||
return {
|
||||
type: getGraphType(data.types.y0, isPercentage),
|
||||
zoomToken: (result as GramJs.StatsGraph).zoomToken,
|
||||
labelFormatter: data.xTickFormatter,
|
||||
tooltipFormatter: data.xTooltipFormatter,
|
||||
labels: x.slice(1),
|
||||
hideCaption: !data.subchart.show,
|
||||
hasSecondYAxis,
|
||||
isStacked: data.stacked && !hasSecondYAxis,
|
||||
isPercentage,
|
||||
datasets: y.map((item: any) => {
|
||||
const key = item[0];
|
||||
|
||||
return {
|
||||
name: data.names[key],
|
||||
color: extractColor(data.colors[key]),
|
||||
values: item.slice(1),
|
||||
};
|
||||
}),
|
||||
...calculateMinimapRange(data.subchart.defaultZoom, x.slice(1)),
|
||||
};
|
||||
}
|
||||
|
||||
function getGraphType(apiType: string, isPercentage?: boolean): string {
|
||||
switch (apiType) {
|
||||
case 'step':
|
||||
return 'bar';
|
||||
default:
|
||||
return isPercentage ? 'area' : apiType;
|
||||
}
|
||||
}
|
||||
|
||||
function extractColor(color: string): string {
|
||||
return color.substring(color.indexOf('#'));
|
||||
}
|
||||
|
||||
function calculateMinimapRange(range: Array<number>, values: Array<number>) {
|
||||
const [min, max] = range;
|
||||
|
||||
let minIndex = 0;
|
||||
let maxIndex = values.length - 1;
|
||||
|
||||
values.forEach((item, index) => {
|
||||
if (!minIndex && item >= min) {
|
||||
minIndex = index;
|
||||
}
|
||||
|
||||
if (!maxIndex && item >= max) {
|
||||
maxIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
const begin = Math.max(0, minIndex / (values.length - 1));
|
||||
const end = Math.min(1, maxIndex / (values.length - 1));
|
||||
|
||||
return { minimapRange: { begin, end }, labelFromIndex: minIndex, labelToIndex: maxIndex };
|
||||
}
|
||||
|
||||
function buildStatisticsOverview({ current, previous }: GramJs.StatsAbsValueAndPrev): StatisticsOverviewItem {
|
||||
const change = current - previous;
|
||||
|
||||
return {
|
||||
current,
|
||||
change,
|
||||
...(previous && { percentage: (change ? ((Math.abs(change) / previous) * 100) : 0).toFixed(2) }),
|
||||
};
|
||||
}
|
||||
|
||||
function buildStatisticsPercentage(data: GramJs.StatsPercentValue): StatisticsOverviewPercentage {
|
||||
return {
|
||||
percentage: ((data.part / data.total) * 100).toFixed(2),
|
||||
};
|
||||
}
|
||||
@ -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 ? {
|
||||
|
||||
@ -167,6 +167,8 @@ export async function invokeRequest<T extends GramJs.AnyRequest>(
|
||||
request: T,
|
||||
shouldReturnTrue: true,
|
||||
shouldThrow?: boolean,
|
||||
shouldIgnoreUpdates?: undefined,
|
||||
dcId?: number,
|
||||
): Promise<true | undefined>;
|
||||
|
||||
export async function invokeRequest<T extends GramJs.AnyRequest>(
|
||||
@ -174,6 +176,7 @@ export async function invokeRequest<T extends GramJs.AnyRequest>(
|
||||
shouldReturnTrue?: boolean,
|
||||
shouldThrow?: boolean,
|
||||
shouldIgnoreUpdates?: boolean,
|
||||
dcId?: number,
|
||||
): Promise<T['__response'] | undefined>;
|
||||
|
||||
export async function invokeRequest<T extends GramJs.AnyRequest>(
|
||||
@ -181,6 +184,7 @@ export async function invokeRequest<T extends GramJs.AnyRequest>(
|
||||
shouldReturnTrue = false,
|
||||
shouldThrow = false,
|
||||
shouldIgnoreUpdates = false,
|
||||
dcId?: number,
|
||||
) {
|
||||
if (!isConnected) {
|
||||
if (DEBUG) {
|
||||
@ -197,7 +201,7 @@ export async function invokeRequest<T extends GramJs.AnyRequest>(
|
||||
console.log(`[GramJs/client] INVOKE ${request.className}`);
|
||||
}
|
||||
|
||||
const result = await client.invoke(request);
|
||||
const result = await client.invoke(request, dcId);
|
||||
|
||||
if (DEBUG) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
@ -75,3 +75,5 @@ export {
|
||||
getAvailableReactions, sendReaction, sendEmojiInteraction, fetchMessageReactionsList,
|
||||
setDefaultReaction, fetchMessageReactions, sendWatchingEmojiInteraction,
|
||||
} from './reactions';
|
||||
|
||||
export { fetchStatistics, fetchStatisticsAsyncGraph } from './statistics';
|
||||
|
||||
43
src/api/gramjs/methods/statistics.ts
Normal file
43
src/api/gramjs/methods/statistics.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import BigInt from 'big-integer';
|
||||
import { Api as GramJs } from '../../../lib/gramjs';
|
||||
|
||||
import { ApiChat, ApiStatistics, StatisticsGraph } from '../../types';
|
||||
|
||||
import { invokeRequest } from './client';
|
||||
import { buildInputEntity } from '../gramjsBuilders';
|
||||
import { buildStatistics, buildGraph } from '../apiBuilders/statistics';
|
||||
|
||||
export async function fetchStatistics({ chat }: { chat: ApiChat }): Promise<ApiStatistics | undefined> {
|
||||
const result = await invokeRequest(new GramJs.stats.GetBroadcastStats({
|
||||
channel: buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel,
|
||||
}), undefined, undefined, undefined, chat.fullInfo!.statisticsDcId);
|
||||
|
||||
if (!result) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return buildStatistics(result);
|
||||
}
|
||||
|
||||
export async function fetchStatisticsAsyncGraph({
|
||||
token,
|
||||
x,
|
||||
isPercentage,
|
||||
dcId,
|
||||
}: {
|
||||
token: string;
|
||||
x?: number;
|
||||
isPercentage?: boolean;
|
||||
dcId?: number;
|
||||
}): Promise<StatisticsGraph | undefined> {
|
||||
const result = await invokeRequest(new GramJs.stats.LoadAsyncGraph({
|
||||
token,
|
||||
...(x && { x: BigInt(x) }),
|
||||
}), undefined, undefined, undefined, dcId);
|
||||
|
||||
if (!result) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return buildGraph(result as GramJs.StatsGraph, isPercentage);
|
||||
}
|
||||
@ -91,8 +91,10 @@ export interface ApiChatFullInfo {
|
||||
botCommands?: ApiBotCommand[];
|
||||
enabledReactions?: string[];
|
||||
sendAsId?: string;
|
||||
canViewStatistics?: boolean;
|
||||
recentRequesterIds?: string[];
|
||||
requestsPending?: number;
|
||||
statisticsDcId?: number;
|
||||
}
|
||||
|
||||
export interface ApiChatMember {
|
||||
|
||||
@ -8,3 +8,4 @@ export * from './settings';
|
||||
export * from './bots';
|
||||
export * from './misc';
|
||||
export * from './calls';
|
||||
export * from './statistics';
|
||||
|
||||
56
src/api/types/statistics.ts
Normal file
56
src/api/types/statistics.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { ApiMessage } from './messages';
|
||||
|
||||
export interface ApiStatistics {
|
||||
growthGraph: StatisticsGraph;
|
||||
followersGraph: StatisticsGraph;
|
||||
muteGraph: StatisticsGraph;
|
||||
topHoursGraph: StatisticsGraph;
|
||||
interactionsGraph: StatisticsGraph | string;
|
||||
viewsBySourceGraph: StatisticsGraph | string;
|
||||
newFollowersBySourceGraph: StatisticsGraph | string;
|
||||
languagesGraph: StatisticsGraph | string;
|
||||
followers: StatisticsOverviewItem;
|
||||
viewsPerPost: StatisticsOverviewItem;
|
||||
sharesPerPost: StatisticsOverviewItem;
|
||||
enabledNotifications: StatisticsOverviewPercentage;
|
||||
recentTopMessages: Array<StatisticsRecentMessage | StatisticsRecentMessage & ApiMessage>;
|
||||
}
|
||||
|
||||
export interface StatisticsGraph {
|
||||
type: string;
|
||||
zoomToken?: string;
|
||||
labelFormatter: string;
|
||||
tooltipFormatter: string;
|
||||
labels: Array<string | number>;
|
||||
isStacked: boolean;
|
||||
isPercentage?: boolean;
|
||||
hideCaption: boolean;
|
||||
hasSecondYAxis: boolean;
|
||||
minimapRange: {
|
||||
begin: number;
|
||||
end: number;
|
||||
};
|
||||
labelFromIndex: number;
|
||||
labelToIndex: number;
|
||||
datasets: {
|
||||
name: string;
|
||||
color: string;
|
||||
values: number[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface StatisticsOverviewItem {
|
||||
current?: number;
|
||||
change?: number;
|
||||
percentage: string;
|
||||
}
|
||||
|
||||
export interface StatisticsOverviewPercentage {
|
||||
percentage: string;
|
||||
}
|
||||
|
||||
export interface StatisticsRecentMessage {
|
||||
msgId: number;
|
||||
forwards: number;
|
||||
views: number;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@ -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';
|
||||
|
||||
@ -50,6 +50,7 @@ interface StateProps {
|
||||
canSearch?: boolean;
|
||||
canCall?: boolean;
|
||||
canMute?: boolean;
|
||||
canViewStatistics?: boolean;
|
||||
canLeave?: boolean;
|
||||
canEnterVoiceChat?: boolean;
|
||||
canCreateVoiceChat?: boolean;
|
||||
@ -70,6 +71,7 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
|
||||
canSearch,
|
||||
canCall,
|
||||
canMute,
|
||||
canViewStatistics,
|
||||
canLeave,
|
||||
canEnterVoiceChat,
|
||||
canCreateVoiceChat,
|
||||
@ -261,6 +263,7 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
|
||||
canSearch={canSearch}
|
||||
canCall={canCall}
|
||||
canMute={canMute}
|
||||
canViewStatistics={canViewStatistics}
|
||||
canLeave={canLeave}
|
||||
canEnterVoiceChat={canEnterVoiceChat}
|
||||
canCreateVoiceChat={canCreateVoiceChat}
|
||||
@ -303,6 +306,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
const canEnterVoiceChat = ARE_CALLS_SUPPORTED && chat.isCallActive;
|
||||
const canCreateVoiceChat = ARE_CALLS_SUPPORTED && !chat.isCallActive
|
||||
&& (chat.adminRights?.manageCall || (chat.isCreator && isChatBasicGroup(chat)));
|
||||
const canViewStatistics = chat.fullInfo?.canViewStatistics;
|
||||
const pendingJoinRequests = chat.fullInfo?.requestsPending;
|
||||
|
||||
return {
|
||||
@ -315,6 +319,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
canSearch,
|
||||
canCall,
|
||||
canMute,
|
||||
canViewStatistics,
|
||||
canLeave,
|
||||
canEnterVoiceChat,
|
||||
canCreateVoiceChat,
|
||||
|
||||
@ -37,6 +37,7 @@ export type OwnProps = {
|
||||
canSearch?: boolean;
|
||||
canCall?: boolean;
|
||||
canMute?: boolean;
|
||||
canViewStatistics?: boolean;
|
||||
canLeave?: boolean;
|
||||
canEnterVoiceChat?: boolean;
|
||||
canCreateVoiceChat?: boolean;
|
||||
@ -67,6 +68,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
canSearch,
|
||||
canCall,
|
||||
canMute,
|
||||
canViewStatistics,
|
||||
canLeave,
|
||||
canEnterVoiceChat,
|
||||
canCreateVoiceChat,
|
||||
@ -91,6 +93,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
openLinkedChat,
|
||||
addContact,
|
||||
openCallFallbackConfirm,
|
||||
toggleStatistics,
|
||||
} = getDispatch();
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(true);
|
||||
@ -166,6 +169,11 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
closeMenu();
|
||||
}, [closeMenu, onSearchClick]);
|
||||
|
||||
const handleStatisticsClick = useCallback(() => {
|
||||
toggleStatistics();
|
||||
closeMenu();
|
||||
}, [closeMenu, toggleStatistics]);
|
||||
|
||||
const handleSelectMessages = useCallback(() => {
|
||||
enterMessageSelectMode();
|
||||
closeMenu();
|
||||
@ -266,6 +274,14 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
>
|
||||
{lang('ReportSelectMessages')}
|
||||
</MenuItem>
|
||||
{canViewStatistics && (
|
||||
<MenuItem
|
||||
icon="stats"
|
||||
onClick={handleStatisticsClick}
|
||||
>
|
||||
{lang('Statistics')}
|
||||
</MenuItem>
|
||||
)}
|
||||
{canLeave && (
|
||||
<MenuItem
|
||||
destructive
|
||||
|
||||
@ -24,6 +24,7 @@ import Profile from './Profile';
|
||||
import Transition from '../ui/Transition';
|
||||
import RightSearch from './RightSearch.async';
|
||||
import Management from './management/Management.async';
|
||||
import Statistics from './statistics/Statistics.async';
|
||||
import StickerSearch from './StickerSearch.async';
|
||||
import GifSearch from './GifSearch.async';
|
||||
import PollResults from './PollResults.async';
|
||||
@ -69,6 +70,7 @@ const RightColumn: FC<StateProps> = ({
|
||||
addChatMembers,
|
||||
setNewChatMembersDialogState,
|
||||
setEditingExportedInvite,
|
||||
toggleStatistics,
|
||||
setOpenedInviteInfo,
|
||||
requestNextManagementScreen,
|
||||
} = getDispatch();
|
||||
@ -84,6 +86,7 @@ const RightColumn: FC<StateProps> = ({
|
||||
const isProfile = contentKey === RightColumnContent.ChatInfo;
|
||||
const isSearch = contentKey === RightColumnContent.Search;
|
||||
const isManagement = contentKey === RightColumnContent.Management;
|
||||
const isStatistics = contentKey === RightColumnContent.Statistics;
|
||||
const isStickerSearch = contentKey === RightColumnContent.StickerSearch;
|
||||
const isGifSearch = contentKey === RightColumnContent.GifSearch;
|
||||
const isPollResults = contentKey === RightColumnContent.PollResults;
|
||||
@ -147,6 +150,9 @@ const RightColumn: FC<StateProps> = ({
|
||||
|
||||
break;
|
||||
}
|
||||
case RightColumnContent.Statistics:
|
||||
toggleStatistics();
|
||||
break;
|
||||
case RightColumnContent.Search: {
|
||||
blurSearchInput();
|
||||
closeLocalTextSearch();
|
||||
@ -168,7 +174,7 @@ const RightColumn: FC<StateProps> = ({
|
||||
}, [
|
||||
contentKey, isScrolledDown, toggleChatInfo, closePollResults, setNewChatMembersDialogState,
|
||||
managementScreen, toggleManagement, closeLocalTextSearch, setStickerSearchQuery, setGifSearchQuery,
|
||||
setEditingExportedInvite, chatId, setOpenedInviteInfo,
|
||||
setEditingExportedInvite, chatId, setOpenedInviteInfo, toggleStatistics,
|
||||
]);
|
||||
|
||||
const handleSelectChatMember = useCallback((memberId, isPromoted) => {
|
||||
@ -260,6 +266,8 @@ const RightColumn: FC<StateProps> = ({
|
||||
/>
|
||||
);
|
||||
|
||||
case RightColumnContent.Statistics:
|
||||
return <Statistics chatId={chatId!} isActive={isOpen && isActive} />;
|
||||
case RightColumnContent.StickerSearch:
|
||||
return <StickerSearch onClose={close} isActive={isOpen && isActive} />;
|
||||
case RightColumnContent.GifSearch:
|
||||
@ -284,6 +292,7 @@ const RightColumn: FC<StateProps> = ({
|
||||
isProfile={isProfile}
|
||||
isSearch={isSearch}
|
||||
isManagement={isManagement}
|
||||
isStatistics={isStatistics}
|
||||
isStickerSearch={isStickerSearch}
|
||||
isGifSearch={isGifSearch}
|
||||
isPollResults={isPollResults}
|
||||
|
||||
@ -38,6 +38,7 @@ type OwnProps = {
|
||||
isProfile?: boolean;
|
||||
isSearch?: boolean;
|
||||
isManagement?: boolean;
|
||||
isStatistics?: boolean;
|
||||
isStickerSearch?: boolean;
|
||||
isGifSearch?: boolean;
|
||||
isPollResults?: boolean;
|
||||
@ -52,6 +53,7 @@ type OwnProps = {
|
||||
type StateProps = {
|
||||
canAddContact?: boolean;
|
||||
canManage?: boolean;
|
||||
canViewStatistics?: boolean;
|
||||
isChannel?: boolean;
|
||||
userId?: string;
|
||||
messageSearchQuery?: string;
|
||||
@ -69,6 +71,7 @@ enum HeaderContent {
|
||||
MemberList,
|
||||
SharedMedia,
|
||||
Search,
|
||||
Statistics,
|
||||
Management,
|
||||
ManageInitial,
|
||||
ManageChannelSubscribers,
|
||||
@ -102,6 +105,7 @@ const RightHeader: FC<OwnProps & StateProps> = ({
|
||||
isProfile,
|
||||
isSearch,
|
||||
isManagement,
|
||||
isStatistics,
|
||||
isStickerSearch,
|
||||
isGifSearch,
|
||||
isPollResults,
|
||||
@ -119,6 +123,7 @@ const RightHeader: FC<OwnProps & StateProps> = ({
|
||||
gifSearchQuery,
|
||||
shouldSkipAnimation,
|
||||
isEditingInvite,
|
||||
canViewStatistics,
|
||||
currentInviteInfo,
|
||||
}) => {
|
||||
const {
|
||||
@ -129,6 +134,7 @@ const RightHeader: FC<OwnProps & StateProps> = ({
|
||||
toggleManagement,
|
||||
openHistoryCalendar,
|
||||
addContact,
|
||||
toggleStatistics,
|
||||
setEditingExportedInvite,
|
||||
deleteExportedChatInvite,
|
||||
} = getDispatch();
|
||||
@ -237,6 +243,8 @@ const RightHeader: FC<OwnProps & StateProps> = ({
|
||||
) : managementScreen === ManagementScreens.JoinRequests ? (
|
||||
HeaderContent.ManageJoinRequests
|
||||
) : undefined // Never reached
|
||||
) : isStatistics ? (
|
||||
HeaderContent.Statistics
|
||||
) : undefined; // When column is closed
|
||||
|
||||
const renderingContentKey = useCurrentOrPrev(contentKey, true) ?? -1;
|
||||
@ -361,6 +369,8 @@ const RightHeader: FC<OwnProps & StateProps> = ({
|
||||
onChange={handleGifSearchQueryChange}
|
||||
/>
|
||||
);
|
||||
case HeaderContent.Statistics:
|
||||
return <h3>{lang('Statistics')}</h3>;
|
||||
case HeaderContent.SharedMedia:
|
||||
return <h3>{lang('SharedMedia')}</h3>;
|
||||
case HeaderContent.ManageChannelSubscribers:
|
||||
@ -397,6 +407,17 @@ const RightHeader: FC<OwnProps & StateProps> = ({
|
||||
<i className="icon-edit" />
|
||||
</Button>
|
||||
)}
|
||||
{canViewStatistics && (
|
||||
<Button
|
||||
round
|
||||
color="translucent"
|
||||
size="smaller"
|
||||
ariaLabel={lang('Statistics')}
|
||||
onClick={toggleStatistics}
|
||||
>
|
||||
<i className="icon-stats" />
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
@ -459,11 +480,13 @@ export default memo(withGlobal<OwnProps>(
|
||||
&& (isUserId(chat.id) || ((isChatAdmin(chat) || chat.isCreator) && !chat.isNotJoined)),
|
||||
);
|
||||
const isEditingInvite = Boolean(chatId && global.management.byChatId[chatId]?.editingInvite);
|
||||
const canViewStatistics = chat?.fullInfo?.canViewStatistics;
|
||||
const currentInviteInfo = chatId ? global.management.byChatId[chatId]?.inviteInfo?.invite : undefined;
|
||||
|
||||
return {
|
||||
canManage,
|
||||
canAddContact,
|
||||
canViewStatistics,
|
||||
isChannel,
|
||||
userId: user?.id,
|
||||
messageSearchQuery,
|
||||
|
||||
16
src/components/right/statistics/Statistics.async.tsx
Normal file
16
src/components/right/statistics/Statistics.async.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import React, { FC } from '../../../lib/teact/teact';
|
||||
import { Bundles } from '../../../util/moduleLoader';
|
||||
|
||||
import { OwnProps } from './Statistics';
|
||||
|
||||
import useModuleLoader from '../../../hooks/useModuleLoader';
|
||||
import Loading from '../../ui/Loading';
|
||||
|
||||
const StatisticsAsync: FC<OwnProps> = (props) => {
|
||||
const Statistics = useModuleLoader(Bundles.Extra, 'Statistics');
|
||||
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
return Statistics ? <Statistics {...props} /> : <Loading />;
|
||||
};
|
||||
|
||||
export default StatisticsAsync;
|
||||
103
src/components/right/statistics/Statistics.scss
Normal file
103
src/components/right/statistics/Statistics.scss
Normal file
@ -0,0 +1,103 @@
|
||||
.Statistics {
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
|
||||
&--messages {
|
||||
padding: 1rem 0.75rem;
|
||||
|
||||
&-title {
|
||||
padding-left: 0.25rem;
|
||||
font-size: 16px;
|
||||
color: var(--text-color);
|
||||
line-height: 30px;
|
||||
text-transform: lowercase;
|
||||
|
||||
&:first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.ready {
|
||||
overflow-y: scroll !important;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.lovely-chart--container {
|
||||
font: inherit !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.lovely-chart--header {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.lovely-chart--header,
|
||||
.lovely-chart--tooltip-title,
|
||||
.lovely-chart--tooltip-dataset-value,
|
||||
.lovely-chart--percentage-title {
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.lovely-chart--container-type-pie {
|
||||
&.lovely-chart--state-zoomed-in > canvas {
|
||||
animation-name: pie-slim-in !important;
|
||||
}
|
||||
|
||||
&:not(.lovely-chart--state-zoomed-in) > canvas {
|
||||
animation-name: pie-slim-out !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pie-slim-in {
|
||||
0% {
|
||||
clip-path: circle(80% at center calc(50% - 7.5px));
|
||||
-webkit-clip-path: circle(80% at center calc(50% - 7.5px));
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
|
||||
25% {
|
||||
clip-path: circle(40% at center calc(50% - 7.5px));
|
||||
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
|
||||
75% {
|
||||
clip-path: circle(40% at center calc(50% - 7.5px));
|
||||
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
|
||||
transform: rotate(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pie-slim-out {
|
||||
0% {
|
||||
clip-path: circle(40% at center calc(50% - 7.5px));
|
||||
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
clip-path: circle(40% at center calc(50% - 7.5px));
|
||||
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
75% {
|
||||
clip-path: circle(80% at center calc(50% - 7.5px));
|
||||
-webkit-clip-path: circle(80% at center calc(50% - 7.5px));
|
||||
transform: rotate(0);
|
||||
}
|
||||
}
|
||||
171
src/components/right/statistics/Statistics.tsx
Normal file
171
src/components/right/statistics/Statistics.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import React, {
|
||||
FC, memo, useState, useEffect, useRef,
|
||||
} from '../../../lib/teact/teact';
|
||||
import { getDispatch, withGlobal } from '../../../lib/teact/teactn';
|
||||
|
||||
import { callApi } from '../../../api/gramjs';
|
||||
import {
|
||||
ApiMessage, ApiStatistics, StatisticsRecentMessage as StatisticsRecentMessageType, StatisticsGraph,
|
||||
} from '../../../api/types';
|
||||
import { selectChat, selectStatistics } from '../../../modules/selectors';
|
||||
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import useLang from '../../../hooks/useLang';
|
||||
|
||||
import Loading from '../../ui/Loading';
|
||||
import StatisticsOverview from './StatisticsOverview';
|
||||
import StatisticsRecentMessage from './StatisticsRecentMessage';
|
||||
|
||||
import './Statistics.scss';
|
||||
|
||||
type ILovelyChart = { create: Function };
|
||||
let lovelyChartPromise: Promise<ILovelyChart>;
|
||||
let LovelyChart: ILovelyChart;
|
||||
|
||||
async function ensureLovelyChart() {
|
||||
if (!lovelyChartPromise) {
|
||||
lovelyChartPromise = import('../../../lib/lovely-chart/LovelyChart') as Promise<ILovelyChart>;
|
||||
LovelyChart = await lovelyChartPromise;
|
||||
}
|
||||
|
||||
return lovelyChartPromise;
|
||||
}
|
||||
|
||||
const GRAPHS_TITLES = {
|
||||
growthGraph: 'ChannelStats.Graph.Growth',
|
||||
followersGraph: 'ChannelStats.Graph.Followers',
|
||||
muteGraph: 'ChannelStats.Graph.Notifications',
|
||||
topHoursGraph: 'ChannelStats.Graph.ViewsByHours',
|
||||
viewsBySourceGraph: 'ChannelStats.Graph.ViewsBySource',
|
||||
newFollowersBySourceGraph: 'ChannelStats.Graph.NewFollowersBySource',
|
||||
languagesGraph: 'ChannelStats.Graph.Language',
|
||||
interactionsGraph: 'ChannelStats.Graph.Interactions',
|
||||
};
|
||||
const GRAPHS = Object.keys(GRAPHS_TITLES) as (keyof ApiStatistics)[];
|
||||
|
||||
export type OwnProps = {
|
||||
chatId: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type StateProps = {
|
||||
statistics: ApiStatistics;
|
||||
dcId?: number;
|
||||
};
|
||||
|
||||
const Statistics: FC<OwnProps & StateProps> = ({
|
||||
chatId, isActive, statistics, dcId,
|
||||
}) => {
|
||||
const lang = useLang();
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const loadedCharts = useRef<string[]>([]);
|
||||
|
||||
const { loadStatistics, loadStatisticsAsyncGraph } = getDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
loadStatistics({ chatId });
|
||||
}, [chatId, loadStatistics]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
loadedCharts.current = [];
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
// Load async graphs
|
||||
useEffect(() => {
|
||||
if (!statistics) {
|
||||
return;
|
||||
}
|
||||
|
||||
GRAPHS.forEach((graph) => {
|
||||
const isAsync = typeof statistics?.[graph] === 'string';
|
||||
if (isAsync) {
|
||||
loadStatisticsAsyncGraph({
|
||||
name: graph,
|
||||
chatId,
|
||||
token: statistics[graph],
|
||||
// Hardcode percentage for languages graph, since API does not return `percentage` flag
|
||||
isPercentage: graph === 'languagesGraph',
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [chatId, statistics, loadStatisticsAsyncGraph]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await ensureLovelyChart();
|
||||
|
||||
if (!isReady) {
|
||||
setIsReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!statistics) {
|
||||
return;
|
||||
}
|
||||
|
||||
GRAPHS.forEach((graph, index: number) => {
|
||||
const isAsync = typeof statistics?.[graph] === 'string';
|
||||
if (isAsync || loadedCharts.current.includes(graph)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { zoomToken } = (statistics[graph] as StatisticsGraph);
|
||||
|
||||
LovelyChart.create(
|
||||
containerRef.current!.children[index],
|
||||
{
|
||||
title: lang((GRAPHS_TITLES as Record<string, string>)[graph]),
|
||||
...zoomToken && {
|
||||
onZoom: (x: number) => callApi('fetchStatisticsAsyncGraph', { token: zoomToken, x, dcId }),
|
||||
zoomOutLabel: lang('Graph.ZoomOut'),
|
||||
},
|
||||
...(statistics[graph] as StatisticsGraph),
|
||||
},
|
||||
);
|
||||
|
||||
loadedCharts.current.push(graph);
|
||||
});
|
||||
})();
|
||||
}, [isReady, statistics, lang, chatId, loadStatisticsAsyncGraph, dcId]);
|
||||
|
||||
if (!isReady || !statistics) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={buildClassName('Statistics custom-scroll', isReady && 'ready')}>
|
||||
<StatisticsOverview statistics={statistics} />
|
||||
|
||||
{!loadedCharts.current.length && <Loading />}
|
||||
|
||||
<div ref={containerRef}>
|
||||
{GRAPHS.map((graph) => (
|
||||
<div className={buildClassName('chat-container', !loadedCharts.current.includes(graph) && 'hidden')} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{Boolean(statistics.recentTopMessages?.length) &&
|
||||
<div className="Statistics--messages">
|
||||
<h2 className="Statistics--messages-title">{lang('ChannelStats.Recent.Header')}</h2>
|
||||
|
||||
{statistics.recentTopMessages.map((message) => (
|
||||
<StatisticsRecentMessage message={message as ApiMessage & StatisticsRecentMessageType} />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(withGlobal<OwnProps>(
|
||||
(global, { chatId }): StateProps => {
|
||||
const statistics = selectStatistics(global, chatId);
|
||||
const dcId = selectChat(global, chatId)?.fullInfo?.statisticsDcId;
|
||||
|
||||
return { statistics, dcId };
|
||||
},
|
||||
)(Statistics));
|
||||
40
src/components/right/statistics/StatisticsOverview.scss
Normal file
40
src/components/right/statistics/StatisticsOverview.scss
Normal file
@ -0,0 +1,40 @@
|
||||
.StatisticsOverview {
|
||||
padding: 1rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
|
||||
&--title {
|
||||
padding-left: 0.25rem;
|
||||
font-size: 16px;
|
||||
color: var(--text-color);
|
||||
line-height: 30px;
|
||||
text-transform: lowercase;
|
||||
|
||||
&:first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
&--table {
|
||||
width: 100%;
|
||||
|
||||
&-heading {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&-value {
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
&--value {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-green);
|
||||
|
||||
&.negative {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
}
|
||||
72
src/components/right/statistics/StatisticsOverview.tsx
Normal file
72
src/components/right/statistics/StatisticsOverview.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import React, { FC, memo } from '../../../lib/teact/teact';
|
||||
|
||||
import { ApiStatistics, StatisticsOverviewItem } from '../../../api/types';
|
||||
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import useLang from '../../../hooks/useLang';
|
||||
|
||||
import './StatisticsOverview.scss';
|
||||
|
||||
export type OwnProps = {
|
||||
statistics: ApiStatistics;
|
||||
};
|
||||
|
||||
const StatisticsOverview: FC<OwnProps> = ({ statistics }) => {
|
||||
const lang = useLang();
|
||||
|
||||
const renderOverviewItemValue = ({ change, percentage }: StatisticsOverviewItem) => {
|
||||
const isChangeNegative = Number(change) < 0;
|
||||
|
||||
return (
|
||||
<span className={buildClassName('StatisticsOverview--value', isChangeNegative && 'negative')}>
|
||||
{isChangeNegative ? change : `+${change}`}
|
||||
{percentage && (
|
||||
<>
|
||||
{' '}
|
||||
({percentage}%)
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const {
|
||||
followers, viewsPerPost, sharesPerPost, enabledNotifications,
|
||||
} = statistics;
|
||||
|
||||
return (
|
||||
<div className="StatisticsOverview">
|
||||
<h2 className="StatisticsOverview--title">{lang('ChannelStats.Overview')}</h2>
|
||||
|
||||
<table className="StatisticsOverview--table">
|
||||
<tr>
|
||||
<td>
|
||||
<b className="StatisticsOverview--table-value">{followers.current}</b> {renderOverviewItemValue(followers)}
|
||||
<h3 className="StatisticsOverview--table-heading">{lang('ChannelStats.Overview.Followers')}</h3>
|
||||
</td>
|
||||
<td>
|
||||
<b className="StatisticsOverview--table-value">{enabledNotifications.percentage}%</b>
|
||||
<h3 className="StatisticsOverview--table-heading">{lang('ChannelStats.Overview.EnabledNotifications')}</h3>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<b className="StatisticsOverview--table-value">{viewsPerPost.current}</b>
|
||||
{' '}
|
||||
{renderOverviewItemValue(viewsPerPost)}
|
||||
<h3 className="StatisticsOverview--table-heading">{lang('ChannelStats.Overview.ViewsPerPost')}</h3>
|
||||
</td>
|
||||
<td>
|
||||
<b className="StatisticsOverview--table-value">{sharesPerPost.current}</b>
|
||||
{' '}
|
||||
{renderOverviewItemValue(sharesPerPost)}
|
||||
<h3 className="StatisticsOverview--table-heading">{lang('ChannelStats.Overview.SharesPerPost')}</h3>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(StatisticsOverview);
|
||||
59
src/components/right/statistics/StatisticsRecentMessage.scss
Normal file
59
src/components/right/statistics/StatisticsRecentMessage.scss
Normal file
@ -0,0 +1,59 @@
|
||||
.StatisticsRecentMessage {
|
||||
position: relative;
|
||||
padding-left: 3rem;
|
||||
|
||||
&--summary {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 0.75rem;
|
||||
|
||||
.media-preview--image {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
object-fit: cover;
|
||||
border-radius: 0.25rem;
|
||||
margin-inline-end: 0.25rem;
|
||||
|
||||
&.round {
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-play {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
color: #fff;
|
||||
margin-inline-start: -1.25rem;
|
||||
margin-inline-end: 0.5rem;
|
||||
bottom: 0.0625rem;
|
||||
}
|
||||
}
|
||||
|
||||
&--title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
&--info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
color: var(--color-text-meta);
|
||||
}
|
||||
|
||||
&--meta {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
&--date {
|
||||
flex: 1;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
68
src/components/right/statistics/StatisticsRecentMessage.tsx
Normal file
68
src/components/right/statistics/StatisticsRecentMessage.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import React, { FC, memo } from '../../../lib/teact/teact';
|
||||
|
||||
import useLang, { LangFn } from '../../../hooks/useLang';
|
||||
|
||||
import { ApiMessage, StatisticsRecentMessage as StatisticsRecentMessageType } from '../../../api/types';
|
||||
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import { formatDateTimeToString } from '../../../util/dateFormat';
|
||||
import {
|
||||
getMessageMediaHash,
|
||||
getMessageMediaThumbDataUri,
|
||||
getMessageVideo,
|
||||
getMessageRoundVideo,
|
||||
} from '../../../modules/helpers';
|
||||
import { renderMessageSummary } from '../../common/helpers/renderMessageText';
|
||||
import useMedia from '../../../hooks/useMedia';
|
||||
|
||||
import './StatisticsRecentMessage.scss';
|
||||
|
||||
export type OwnProps = {
|
||||
message: ApiMessage & StatisticsRecentMessageType;
|
||||
};
|
||||
|
||||
const StatisticsRecentMessage: FC<OwnProps> = ({ message }) => {
|
||||
const lang = useLang();
|
||||
|
||||
const mediaThumbnail = getMessageMediaThumbDataUri(message);
|
||||
const mediaBlobUrl = useMedia(getMessageMediaHash(message, 'micro'));
|
||||
const isRoundVideo = Boolean(getMessageRoundVideo(message));
|
||||
|
||||
return (
|
||||
<p className="StatisticsRecentMessage">
|
||||
<div className="StatisticsRecentMessage--title">
|
||||
<div className="StatisticsRecentMessage--summary">
|
||||
{renderSummary(lang, message, mediaBlobUrl || mediaThumbnail, isRoundVideo)}
|
||||
</div>
|
||||
<div className="StatisticsRecentMessage--meta">
|
||||
{lang('ChannelStats.ViewsCount', message.views)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="StatisticsRecentMessage--info">
|
||||
<div className="StatisticsRecentMessage--date">
|
||||
{formatDateTimeToString(message.date * 1000, lang.code)}
|
||||
</div>
|
||||
<div className="StatisticsRecentMessage--meta">
|
||||
{Boolean(message.forwards) ? lang('ChannelStats.SharesCount', message.forwards) : 'No shares'}
|
||||
</div>
|
||||
</div>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
function renderSummary(lang: LangFn, message: ApiMessage, blobUrl?: string, isRoundVideo?: boolean) {
|
||||
if (!blobUrl) {
|
||||
return renderMessageSummary(lang, message);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="media-preview">
|
||||
<img src={blobUrl} alt="" className={buildClassName('media-preview--image', isRoundVideo && 'round')} />
|
||||
{getMessageVideo(message) && <i className="icon-play" />}
|
||||
{renderMessageSummary(lang, message, true)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(StatisticsRecentMessage);
|
||||
@ -194,4 +194,8 @@ export const INITIAL_STATE: GlobalState = {
|
||||
},
|
||||
|
||||
serviceNotifications: [],
|
||||
|
||||
statistics: {
|
||||
byChatId: {},
|
||||
},
|
||||
};
|
||||
|
||||
@ -26,6 +26,7 @@ import {
|
||||
ApiAvailableReaction,
|
||||
ApiAppConfig,
|
||||
ApiSponsoredMessage,
|
||||
ApiStatistics,
|
||||
ApiPaymentFormNativeParams,
|
||||
} from '../api/types';
|
||||
import {
|
||||
@ -107,6 +108,7 @@ export interface ServiceNotification {
|
||||
export type GlobalState = {
|
||||
appConfig?: ApiAppConfig;
|
||||
isChatInfoShown: boolean;
|
||||
isStatisticsShown?: boolean;
|
||||
isLeftColumnShown: boolean;
|
||||
isPollModalOpen?: boolean;
|
||||
newChatMembersProgress?: NewChatMembersProgress;
|
||||
@ -498,6 +500,10 @@ export type GlobalState = {
|
||||
shouldShowContextMenuHint?: boolean;
|
||||
|
||||
serviceNotifications: ServiceNotification[];
|
||||
|
||||
statistics: {
|
||||
byChatId: Record<string, ApiStatistics>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ActionTypes = (
|
||||
@ -508,8 +514,8 @@ export type ActionTypes = (
|
||||
'toggleChatInfo' | 'setIsUiReady' | 'addRecentEmoji' | 'addRecentSticker' | 'toggleLeftColumn' |
|
||||
'toggleSafeLinkModal' | 'openHistoryCalendar' | 'closeHistoryCalendar' | 'disableContextMenuHint' |
|
||||
'setNewChatMembersDialogState' | 'disableHistoryAnimations' | 'setLeftColumnWidth' | 'resetLeftColumnWidth' |
|
||||
'openSeenByModal' | 'closeSeenByModal' | 'closeReactorListModal' |
|
||||
'openReactorListModal' |
|
||||
'openSeenByModal' | 'closeSeenByModal' | 'closeReactorListModal' | 'openReactorListModal' |
|
||||
'toggleStatistics' |
|
||||
// auth
|
||||
'setAuthPhoneNumber' | 'setAuthCode' | 'setAuthPassword' | 'signUp' | 'returnToAuthPhoneNumber' | 'signOut' |
|
||||
'setAuthRememberMe' | 'clearAuthError' | 'uploadProfilePhoto' | 'goToAuthQrCode' | 'clearCache' |
|
||||
@ -601,7 +607,9 @@ export type ActionTypes = (
|
||||
'toggleGroupCallVideo' | 'requestToSpeak' | 'setGroupCallParticipantVolume' | 'toggleGroupCallPanel' |
|
||||
'createGroupCall' | 'joinVoiceChatByLink' | 'subscribeToGroupCallUpdates' | 'createGroupCallInviteLink' |
|
||||
'loadMoreGroupCallParticipants' | 'connectToActiveGroupCall' | 'playGroupCallSound' |
|
||||
'openCallFallbackConfirm' | 'closeCallFallbackConfirm' | 'inviteToCallFallback'
|
||||
'openCallFallbackConfirm' | 'closeCallFallbackConfirm' | 'inviteToCallFallback' |
|
||||
// stats
|
||||
'loadStatistics' | 'loadStatisticsAsyncGraph'
|
||||
);
|
||||
|
||||
export interface DispatchOptions {
|
||||
|
||||
2
src/lib/gramjs/client/TelegramClient.d.ts
vendored
2
src/lib/gramjs/client/TelegramClient.d.ts
vendored
@ -10,7 +10,7 @@ declare class TelegramClient {
|
||||
|
||||
async start(authParams: UserAuthParams | BotAuthParams);
|
||||
|
||||
async invoke<R extends Api.AnyRequest>(request: R): Promise<R['__response']>;
|
||||
async invoke<R extends Api.AnyRequest>(request: R, dcId?: number): Promise<R['__response']>;
|
||||
|
||||
async uploadFile(uploadParams: UploadFileParams): ReturnType<typeof uploadFile>;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1165,4 +1165,6 @@ phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates;
|
||||
langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDifference;
|
||||
langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector<string> = Vector<LangPackString>;
|
||||
langpack.getLanguages#42c6978f lang_pack:string = Vector<LangPackLanguage>;
|
||||
folders.editPeerFolders#6847d0ab folder_peers:Vector<InputFolderPeer> = Updates;`;
|
||||
folders.editPeerFolders#6847d0ab folder_peers:Vector<InputFolderPeer> = Updates;
|
||||
stats.getBroadcastStats#ab42441a flags:# dark:flags.0?true channel:InputChannel = stats.BroadcastStats;
|
||||
stats.loadAsyncGraph#621d5fa0 flags:# token:string x:flags.0?long = StatsGraph;`;
|
||||
@ -206,5 +206,7 @@
|
||||
"messages.setChatAvailableReactions",
|
||||
"messages.getAvailableReactions",
|
||||
"messages.setDefaultReaction",
|
||||
"help.getAppConfig"
|
||||
"help.getAppConfig",
|
||||
"stats.getBroadcastStats",
|
||||
"stats.loadAsyncGraph"
|
||||
]
|
||||
|
||||
173
src/lib/lovely-chart/Axes.js
Normal file
173
src/lib/lovely-chart/Axes.js
Normal file
@ -0,0 +1,173 @@
|
||||
import { GUTTER, AXES_FONT, X_AXIS_HEIGHT, X_AXIS_SHIFT_START, PLOT_TOP_PADDING } from './constants';
|
||||
import { humanize } from './format';
|
||||
import { getCssColor } from './skin';
|
||||
import { applyXEdgeOpacity, applyYEdgeOpacity, xScaleLevelToStep, yScaleLevelToStep } from './formulas';
|
||||
import { toPixels } from './Projection';
|
||||
|
||||
export function createAxes(context, data, plotSize, colors) {
|
||||
function drawXAxis(state, projection) {
|
||||
context.clearRect(0, plotSize.height - X_AXIS_HEIGHT + 1, plotSize.width, X_AXIS_HEIGHT + 1);
|
||||
|
||||
const topOffset = plotSize.height - X_AXIS_HEIGHT / 2;
|
||||
const scaleLevel = Math.floor(state.xAxisScale);
|
||||
const step = xScaleLevelToStep(scaleLevel);
|
||||
const opacityFactor = 1 - (state.xAxisScale - scaleLevel);
|
||||
|
||||
context.font = AXES_FONT;
|
||||
context.textAlign = 'center';
|
||||
context.textBaseline = 'middle';
|
||||
|
||||
for (let i = state.labelFromIndex; i <= state.labelToIndex; i++) {
|
||||
const shiftedI = i - X_AXIS_SHIFT_START;
|
||||
|
||||
if (shiftedI % step !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const label = data.xLabels[i];
|
||||
const [xPx] = toPixels(projection, i, 0);
|
||||
let opacity = shiftedI % (step * 2) === 0 ? 1 : opacityFactor;
|
||||
opacity = applyYEdgeOpacity(opacity, xPx, plotSize.width);
|
||||
|
||||
context.fillStyle = getCssColor(colors, 'x-axis-text', opacity);
|
||||
context.fillText(label.text, xPx, topOffset);
|
||||
}
|
||||
}
|
||||
|
||||
function drawYAxis(state, projection, secondaryProjection) {
|
||||
const {
|
||||
yAxisScale, yAxisScaleFrom, yAxisScaleTo, yAxisScaleProgress = 0,
|
||||
yMinViewport, yMinViewportFrom, yMinViewportTo,
|
||||
yMaxViewport, yMaxViewportFrom, yMaxViewportTo,
|
||||
yMinViewportSecond, yMinViewportSecondFrom, yMinViewportSecondTo,
|
||||
yMaxViewportSecond, yMaxViewportSecondFrom, yMaxViewportSecondTo,
|
||||
} = state;
|
||||
const colorKey = secondaryProjection && `dataset#${data.datasets[0].key}`;
|
||||
const isYChanging = yMinViewportFrom !== undefined || yMaxViewportFrom !== undefined;
|
||||
|
||||
if (data.isPercentage) {
|
||||
_drawYAxisPercents(projection);
|
||||
} else {
|
||||
_drawYAxisScaled(
|
||||
state,
|
||||
projection,
|
||||
Math.round(yAxisScaleTo || yAxisScale),
|
||||
yMinViewportTo !== undefined ? yMinViewportTo : yMinViewport,
|
||||
yMaxViewportTo !== undefined ? yMaxViewportTo : yMaxViewport,
|
||||
yAxisScaleFrom ? yAxisScaleProgress : 1,
|
||||
colorKey,
|
||||
);
|
||||
}
|
||||
|
||||
if (yAxisScaleProgress > 0 && isYChanging) {
|
||||
_drawYAxisScaled(
|
||||
state,
|
||||
projection,
|
||||
Math.round(yAxisScaleFrom),
|
||||
yMinViewportFrom !== undefined ? yMinViewportFrom : yMinViewport,
|
||||
yMaxViewportFrom !== undefined ? yMaxViewportFrom : yMaxViewport,
|
||||
1 - yAxisScaleProgress,
|
||||
colorKey,
|
||||
);
|
||||
}
|
||||
|
||||
if (secondaryProjection) {
|
||||
const { yAxisScaleSecond, yAxisScaleSecondFrom, yAxisScaleSecondTo, yAxisScaleSecondProgress = 0 } = state;
|
||||
const secondaryColorKey = `dataset#${data.datasets[data.datasets.length - 1].key}`;
|
||||
const isYChanging = yMinViewportSecondFrom !== undefined || yMaxViewportSecondFrom !== undefined;
|
||||
|
||||
_drawYAxisScaled(
|
||||
state,
|
||||
secondaryProjection,
|
||||
Math.round(yAxisScaleSecondTo || yAxisScaleSecond),
|
||||
yMinViewportSecondTo !== undefined ? yMinViewportSecondTo : yMinViewportSecond,
|
||||
yMaxViewportSecondTo !== undefined ? yMaxViewportSecondTo : yMaxViewportSecond,
|
||||
yAxisScaleSecondFrom ? yAxisScaleSecondProgress : 1,
|
||||
secondaryColorKey,
|
||||
true,
|
||||
);
|
||||
|
||||
if (yAxisScaleSecondProgress > 0 && isYChanging) {
|
||||
_drawYAxisScaled(
|
||||
state,
|
||||
secondaryProjection,
|
||||
Math.round(yAxisScaleSecondFrom),
|
||||
yMinViewportSecondFrom !== undefined ? yMinViewportSecondFrom : yMinViewportSecond,
|
||||
yMaxViewportSecondFrom !== undefined ? yMaxViewportSecondFrom : yMaxViewportSecond,
|
||||
1 - yAxisScaleSecondProgress,
|
||||
secondaryColorKey,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _drawYAxisScaled(state, projection, scaleLevel, yMin, yMax, opacity = 1, colorKey = null, isSecondary = false) {
|
||||
const step = yScaleLevelToStep(scaleLevel);
|
||||
const firstVisibleValue = Math.ceil(yMin / step) * step;
|
||||
const lastVisibleValue = Math.floor(yMax / step) * step;
|
||||
|
||||
context.font = AXES_FONT;
|
||||
context.textAlign = isSecondary ? 'right' : 'left';
|
||||
context.textBaseline = 'bottom';
|
||||
|
||||
context.lineWidth = 1;
|
||||
|
||||
context.beginPath();
|
||||
|
||||
for (let value = firstVisibleValue; value <= lastVisibleValue; value += step) {
|
||||
const [, yPx] = toPixels(projection, 0, value);
|
||||
const textOpacity = applyXEdgeOpacity(opacity, yPx);
|
||||
|
||||
context.fillStyle = colorKey
|
||||
? getCssColor(colors, colorKey, textOpacity)
|
||||
: getCssColor(colors, 'y-axis-text', textOpacity);
|
||||
|
||||
if (!isSecondary) {
|
||||
context.fillText(humanize(value), GUTTER, yPx - GUTTER / 2);
|
||||
} else {
|
||||
context.fillText(humanize(value), plotSize.width - GUTTER, yPx - GUTTER / 2);
|
||||
}
|
||||
|
||||
if (isSecondary) {
|
||||
context.strokeStyle = getCssColor(colors, colorKey, opacity);
|
||||
|
||||
context.moveTo(plotSize.width - GUTTER, yPx);
|
||||
context.lineTo(plotSize.width - GUTTER * 2, yPx);
|
||||
} else {
|
||||
context.moveTo(GUTTER, yPx);
|
||||
context.strokeStyle = getCssColor(colors, 'grid-lines', opacity);
|
||||
context.lineTo(plotSize.width - GUTTER, yPx);
|
||||
}
|
||||
}
|
||||
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
function _drawYAxisPercents(projection) {
|
||||
const percentValues = [0, 0.25, 0.50, 0.75, 1];
|
||||
const [, height] = projection.getSize();
|
||||
|
||||
context.font = AXES_FONT;
|
||||
context.textAlign = 'left';
|
||||
context.textBaseline = 'bottom';
|
||||
context.lineWidth = 1;
|
||||
|
||||
context.beginPath();
|
||||
|
||||
percentValues.forEach((value) => {
|
||||
const yPx = height - height * value + PLOT_TOP_PADDING;
|
||||
|
||||
context.fillStyle = getCssColor(colors, 'y-axis-text', 1);
|
||||
context.fillText(`${value * 100}%`, GUTTER, yPx - GUTTER / 4);
|
||||
|
||||
context.moveTo(GUTTER, yPx);
|
||||
context.strokeStyle = getCssColor(colors, 'grid-lines', 1);
|
||||
context.lineTo(plotSize.width - GUTTER, yPx);
|
||||
});
|
||||
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
return { drawXAxis, drawYAxis };
|
||||
}
|
||||
64
src/lib/lovely-chart/Header.js
Normal file
64
src/lib/lovely-chart/Header.js
Normal file
@ -0,0 +1,64 @@
|
||||
import { createElement, addEventListener } from './minifiers';
|
||||
import { toggleText } from './toggleText';
|
||||
import { throttle } from './utils';
|
||||
|
||||
export function createHeader(container, title, zoomOutLabel = 'Zoom out', zoomOutCallback) {
|
||||
let _element;
|
||||
let _titleElement;
|
||||
let _zoomOutElement;
|
||||
let _captionElement;
|
||||
let _isZooming;
|
||||
|
||||
const setCaptionThrottled = throttle(setCaption, 100, false);
|
||||
|
||||
_setupLayout();
|
||||
|
||||
function setCaption(caption) {
|
||||
if (_isZooming) {
|
||||
return;
|
||||
}
|
||||
|
||||
_captionElement.innerHTML = caption;
|
||||
}
|
||||
|
||||
function zoom(caption) {
|
||||
_zoomOutElement = toggleText(_titleElement, zoomOutLabel, 'lovely-chart--header-title lovely-chart--header-zoom-out-control');
|
||||
setTimeout(() => {
|
||||
addEventListener(_zoomOutElement, 'click', _onZoomOut);
|
||||
}, 500);
|
||||
|
||||
setCaption(caption);
|
||||
}
|
||||
|
||||
function toggleIsZooming(isZooming) {
|
||||
_isZooming = isZooming;
|
||||
}
|
||||
|
||||
function _setupLayout() {
|
||||
_element = createElement();
|
||||
_element.className = 'lovely-chart--header';
|
||||
|
||||
_titleElement = createElement();
|
||||
_titleElement.className = 'lovely-chart--header-title';
|
||||
_titleElement.innerHTML = title;
|
||||
_element.appendChild(_titleElement);
|
||||
|
||||
_captionElement = createElement();
|
||||
_captionElement.className = 'lovely-chart--header-caption lovely-chart--position-right';
|
||||
_element.appendChild(_captionElement);
|
||||
|
||||
container.appendChild(_element);
|
||||
}
|
||||
|
||||
function _onZoomOut() {
|
||||
_titleElement = toggleText(_zoomOutElement, title, 'lovely-chart--header-title', true);
|
||||
|
||||
zoomOutCallback();
|
||||
}
|
||||
|
||||
return {
|
||||
setCaption: setCaptionThrottled,
|
||||
zoom,
|
||||
toggleIsZooming,
|
||||
};
|
||||
}
|
||||
215
src/lib/lovely-chart/LovelyChart.js
Normal file
215
src/lib/lovely-chart/LovelyChart.js
Normal file
@ -0,0 +1,215 @@
|
||||
import { createStateManager } from './StateManager';
|
||||
import { createHeader } from './Header';
|
||||
import { createAxes } from './Axes';
|
||||
import { createMinimap } from './Minimap';
|
||||
import { createTooltip } from './Tooltip';
|
||||
import { createTools } from './Tools';
|
||||
import { createZoomer } from './Zoomer';
|
||||
import { createColors } from './skin';
|
||||
import { analyzeData } from './data';
|
||||
import { setupCanvas, clearCanvas } from './canvas';
|
||||
import { preparePoints } from './preparePoints';
|
||||
import { createProjection } from './Projection';
|
||||
import { drawDatasets } from './drawDatasets';
|
||||
import { createElement } from './minifiers';
|
||||
import { getFullLabelDate, getLabelDate } from './format';
|
||||
import {
|
||||
X_AXIS_HEIGHT,
|
||||
GUTTER,
|
||||
PLOT_TOP_PADDING,
|
||||
PLOT_HEIGHT,
|
||||
PLOT_LINE_WIDTH,
|
||||
SIMPLIFIER_PLOT_FACTOR,
|
||||
} from './constants';
|
||||
import { getSimplificationDelta, isDataRange } from './formulas';
|
||||
import { debounce } from './utils';
|
||||
import './styles/index.scss';
|
||||
|
||||
function create(container, originalData) {
|
||||
let _stateManager;
|
||||
|
||||
let _element;
|
||||
let _plot;
|
||||
let _context;
|
||||
let _plotSize;
|
||||
|
||||
let _header;
|
||||
let _axes;
|
||||
let _minimap;
|
||||
let _tooltip;
|
||||
let _tools;
|
||||
let _zoomer;
|
||||
|
||||
let _state;
|
||||
let _windowWidth = window.innerWidth;
|
||||
|
||||
const _data = analyzeData(originalData);
|
||||
const _colors = createColors(_data.colors);
|
||||
const _redrawDebounced = debounce(_redraw, 500, false, true);
|
||||
|
||||
_setupComponents();
|
||||
_setupGlobalListeners();
|
||||
|
||||
function _setupComponents() {
|
||||
_setupContainer();
|
||||
_header = createHeader(_element, _data.title, _data.zoomOutLabel, _onZoomOut);
|
||||
_setupPlotCanvas();
|
||||
_stateManager = createStateManager(_data, _plotSize, _onStateUpdate);
|
||||
_axes = createAxes(_context, _data, _plotSize, _colors);
|
||||
_minimap = createMinimap(_element, _data, _colors, _onRangeChange);
|
||||
_tooltip = createTooltip(_element, _data, _plotSize, _colors, _onZoomIn, _onFocus);
|
||||
_tools = createTools(_element, _data, _onFilterChange);
|
||||
_zoomer = _data.isZoomable && createZoomer(_data, originalData, _colors, _stateManager, _element, _header, _minimap, _tooltip, _tools);
|
||||
// hideOnScroll(_element);
|
||||
}
|
||||
|
||||
function _setupContainer() {
|
||||
_element = createElement();
|
||||
_element.className = `lovely-chart--container${_data.shouldZoomToPie ? ' lovely-chart--container-type-pie' : ''}`;
|
||||
|
||||
container.appendChild(_element);
|
||||
}
|
||||
|
||||
function _setupPlotCanvas() {
|
||||
const { canvas, context } = setupCanvas(_element, {
|
||||
width: _element.clientWidth,
|
||||
height: PLOT_HEIGHT,
|
||||
});
|
||||
|
||||
_plot = canvas;
|
||||
_context = context;
|
||||
|
||||
_plotSize = {
|
||||
width: _plot.offsetWidth,
|
||||
height: _plot.offsetHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function _onStateUpdate(state) {
|
||||
_state = state;
|
||||
|
||||
const { datasets } = _data;
|
||||
const range = {
|
||||
from: state.labelFromIndex,
|
||||
to: state.labelToIndex,
|
||||
};
|
||||
const boundsAndParams = {
|
||||
begin: state.begin,
|
||||
end: state.end,
|
||||
totalXWidth: state.totalXWidth,
|
||||
yMin: state.yMinViewport,
|
||||
yMax: state.yMaxViewport,
|
||||
availableWidth: _plotSize.width,
|
||||
availableHeight: _plotSize.height - X_AXIS_HEIGHT,
|
||||
xPadding: GUTTER,
|
||||
yPadding: PLOT_TOP_PADDING,
|
||||
};
|
||||
const visibilities = datasets.map(({ key }) => state[`opacity#${key}`]);
|
||||
const points = preparePoints(_data, datasets, range, visibilities, boundsAndParams);
|
||||
const projection = createProjection(boundsAndParams);
|
||||
|
||||
let secondaryPoints = null;
|
||||
let secondaryProjection = null;
|
||||
if (_data.hasSecondYAxis) {
|
||||
const secondaryDataset = datasets.find((d) => d.hasOwnYAxis);
|
||||
const bounds = {
|
||||
yMin: state.yMinViewportSecond,
|
||||
yMax: state.yMaxViewportSecond,
|
||||
};
|
||||
secondaryPoints = preparePoints(_data, [secondaryDataset], range, visibilities, bounds)[0];
|
||||
secondaryProjection = projection.copy(bounds);
|
||||
}
|
||||
|
||||
if (!_data.hideCaption) {
|
||||
_header.setCaption(_getCaption(state));
|
||||
}
|
||||
|
||||
clearCanvas(_plot, _context);
|
||||
|
||||
const totalPoints = points.reduce((a, p) => a + p.length, 0);
|
||||
const simplification = getSimplificationDelta(totalPoints) * SIMPLIFIER_PLOT_FACTOR;
|
||||
|
||||
drawDatasets(
|
||||
_context, state, _data,
|
||||
range, points, projection, secondaryPoints, secondaryProjection,
|
||||
PLOT_LINE_WIDTH, visibilities, _colors, false, simplification,
|
||||
);
|
||||
if (!_data.isPie) {
|
||||
_axes.drawYAxis(state, projection, secondaryProjection);
|
||||
// TODO check isChanged
|
||||
_axes.drawXAxis(state, projection);
|
||||
}
|
||||
_minimap.update(state);
|
||||
_tooltip.update(state, points, projection, secondaryPoints, secondaryProjection);
|
||||
}
|
||||
|
||||
function _onRangeChange(range) {
|
||||
_stateManager.update({ range });
|
||||
}
|
||||
|
||||
function _onFilterChange(filter) {
|
||||
_stateManager.update({ filter });
|
||||
}
|
||||
|
||||
function _onFocus(focusOn) {
|
||||
if (_data.isBars || _data.isPie) {
|
||||
// TODO animate
|
||||
_stateManager.update({ focusOn });
|
||||
}
|
||||
}
|
||||
|
||||
function _onZoomIn(labelIndex) {
|
||||
_zoomer.zoomIn(_state, labelIndex);
|
||||
}
|
||||
|
||||
function _onZoomOut() {
|
||||
_zoomer.zoomOut(_state);
|
||||
}
|
||||
|
||||
function _setupGlobalListeners() {
|
||||
document.documentElement.addEventListener('darkmode', () => {
|
||||
_stateManager.update();
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth !== _windowWidth) {
|
||||
_windowWidth = window.innerWidth;
|
||||
_redrawDebounced();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('orientationchange', () => {
|
||||
_redrawDebounced();
|
||||
});
|
||||
}
|
||||
|
||||
function _redraw() {
|
||||
Object.assign(_data, analyzeData(originalData));
|
||||
_element.remove();
|
||||
_setupComponents();
|
||||
}
|
||||
|
||||
function _getCaption(state) {
|
||||
let startIndex;
|
||||
let endIndex;
|
||||
|
||||
if (_zoomer && _zoomer.isZoomed()) {
|
||||
// TODO Fix label
|
||||
startIndex = state.labelFromIndex === 0 ? 0 : state.labelFromIndex + 1;
|
||||
endIndex = state.labelToIndex === state.totalXWidth - 1 ? state.labelToIndex : state.labelToIndex - 1;
|
||||
} else {
|
||||
startIndex = state.labelFromIndex;
|
||||
endIndex = state.labelToIndex;
|
||||
}
|
||||
|
||||
return isDataRange(_data.xLabels[startIndex], _data.xLabels[endIndex])
|
||||
? (
|
||||
`${getLabelDate(_data.xLabels[startIndex])}` +
|
||||
' — ' +
|
||||
`${getLabelDate(_data.xLabels[endIndex])}`
|
||||
)
|
||||
: getFullLabelDate(_data.xLabels[startIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
export { create };
|
||||
276
src/lib/lovely-chart/Minimap.js
Normal file
276
src/lib/lovely-chart/Minimap.js
Normal file
@ -0,0 +1,276 @@
|
||||
import { setupCanvas, clearCanvas } from './canvas';
|
||||
import { preparePoints } from './preparePoints';
|
||||
import { createProjection } from './Projection';
|
||||
import { drawDatasets } from './drawDatasets';
|
||||
import { captureEvents } from './captureEvents';
|
||||
import {
|
||||
DEFAULT_RANGE,
|
||||
MINIMAP_HEIGHT,
|
||||
MINIMAP_EAR_WIDTH,
|
||||
MINIMAP_MARGIN,
|
||||
MINIMAP_LINE_WIDTH,
|
||||
MINIMAP_MAX_ANIMATED_DATASETS,
|
||||
SIMPLIFIER_MINIMAP_FACTOR,
|
||||
} from './constants';
|
||||
import { proxyMerge, throttleWithRaf } from './utils';
|
||||
import { createElement } from './minifiers';
|
||||
import { getSimplificationDelta } from './formulas';
|
||||
|
||||
export function createMinimap(container, data, colors, rangeCallback) {
|
||||
let _element;
|
||||
let _canvas;
|
||||
let _context;
|
||||
let _canvasSize;
|
||||
let _ruler;
|
||||
let _slider;
|
||||
|
||||
let _capturedOffset;
|
||||
let _range = {};
|
||||
let _state;
|
||||
|
||||
const _updateRulerOnRaf = throttleWithRaf(_updateRuler);
|
||||
|
||||
_setupLayout();
|
||||
_updateRange(data.minimapRange || DEFAULT_RANGE);
|
||||
|
||||
function update(newState) {
|
||||
const { begin, end } = newState;
|
||||
if (!_capturedOffset) {
|
||||
_updateRange({ begin, end }, true);
|
||||
}
|
||||
|
||||
if (data.datasets.length >= MINIMAP_MAX_ANIMATED_DATASETS) {
|
||||
newState = newState.static;
|
||||
}
|
||||
|
||||
if (!_isStateChanged(newState)) {
|
||||
return;
|
||||
}
|
||||
|
||||
_state = proxyMerge(newState, { focusOn: null });
|
||||
clearCanvas(_canvas, _context);
|
||||
|
||||
_drawDatasets(_state);
|
||||
}
|
||||
|
||||
function toggle(shouldShow) {
|
||||
_element.classList.toggle('lovely-chart--state-hidden', !shouldShow);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
_element.classList.toggle('lovely-chart--state-transparent', !shouldShow);
|
||||
});
|
||||
}
|
||||
|
||||
function _setupLayout() {
|
||||
_element = createElement();
|
||||
|
||||
_element.className = 'lovely-chart--minimap';
|
||||
_element.style.height = `${MINIMAP_HEIGHT}px`;
|
||||
|
||||
_setupCanvas();
|
||||
_setupRuler();
|
||||
|
||||
container.appendChild(_element);
|
||||
|
||||
_canvasSize = {
|
||||
width: _canvas.offsetWidth,
|
||||
height: _canvas.offsetHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function _getSize() {
|
||||
return {
|
||||
width: container.offsetWidth - MINIMAP_MARGIN * 2,
|
||||
height: MINIMAP_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
function _setupCanvas() {
|
||||
const { canvas, context } = setupCanvas(_element, _getSize());
|
||||
|
||||
_canvas = canvas;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
function _setupRuler() {
|
||||
_ruler = createElement();
|
||||
_ruler.className = 'lovely-chart--minimap-ruler';
|
||||
_ruler.innerHTML =
|
||||
'<div class="lovely-chart--minimap-mask"></div>' +
|
||||
'<div class="lovely-chart--minimap-slider">' +
|
||||
'<div class="lovely-chart--minimap-slider-handle"><span class="lovely-chart--minimap-slider-handle-pin"></span></div>' +
|
||||
'<div class="lovely-chart--minimap-slider-inner"></div>' +
|
||||
'<div class="lovely-chart--minimap-slider-handle"><span class="lovely-chart--minimap-slider-handle-pin"></span></div>' +
|
||||
'</div>' +
|
||||
'<div class="lovely-chart--minimap-mask"></div>';
|
||||
|
||||
_slider = _ruler.children[1];
|
||||
|
||||
captureEvents(
|
||||
_slider.children[1],
|
||||
{
|
||||
onCapture: _onDragCapture,
|
||||
onDrag: _onSliderDrag,
|
||||
onRelease: _onDragRelease,
|
||||
draggingCursor: 'grabbing',
|
||||
},
|
||||
);
|
||||
|
||||
captureEvents(
|
||||
_slider.children[0],
|
||||
{
|
||||
onCapture: _onDragCapture,
|
||||
onDrag: _onLeftEarDrag,
|
||||
onRelease: _onDragRelease,
|
||||
draggingCursor: 'ew-resize',
|
||||
},
|
||||
);
|
||||
|
||||
captureEvents(
|
||||
_slider.children[2],
|
||||
{
|
||||
onCapture: _onDragCapture,
|
||||
onDrag: _onRightEarDrag,
|
||||
onRelease: _onDragRelease,
|
||||
draggingCursor: 'ew-resize',
|
||||
},
|
||||
);
|
||||
|
||||
_element.appendChild(_ruler);
|
||||
}
|
||||
|
||||
function _isStateChanged(newState) {
|
||||
if (!_state) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { datasets } = data;
|
||||
|
||||
if (datasets.some(({ key }) => _state[`opacity#${key}`] !== newState[`opacity#${key}`])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_state.yMaxMinimap !== newState.yMaxMinimap) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function _drawDatasets(state = {}) {
|
||||
const { datasets } = data;
|
||||
const range = {
|
||||
from: 0,
|
||||
to: state.totalXWidth,
|
||||
};
|
||||
const boundsAndParams = {
|
||||
begin: 0,
|
||||
end: 1,
|
||||
totalXWidth: state.totalXWidth,
|
||||
yMin: state.yMinMinimap,
|
||||
yMax: state.yMaxMinimap,
|
||||
availableWidth: _canvasSize.width,
|
||||
availableHeight: _canvasSize.height,
|
||||
yPadding: 1,
|
||||
};
|
||||
const visibilities = datasets.map(({ key }) => _state[`opacity#${key}`]);
|
||||
const points = preparePoints(data, datasets, range, visibilities, boundsAndParams, true);
|
||||
const projection = createProjection(boundsAndParams);
|
||||
|
||||
let secondaryPoints = null;
|
||||
let secondaryProjection = null;
|
||||
if (data.hasSecondYAxis) {
|
||||
const secondaryDataset = datasets.find((d) => d.hasOwnYAxis);
|
||||
const bounds = { yMin: state.yMinMinimapSecond, yMax: state.yMaxMinimapSecond };
|
||||
secondaryPoints = preparePoints(data, [secondaryDataset], range, visibilities, bounds)[0];
|
||||
secondaryProjection = projection.copy(bounds);
|
||||
}
|
||||
|
||||
const totalPoints = points.reduce((a, p) => a + p.length, 0);
|
||||
const simplification = getSimplificationDelta(totalPoints) * SIMPLIFIER_MINIMAP_FACTOR;
|
||||
|
||||
drawDatasets(
|
||||
_context, state, data,
|
||||
range, points, projection, secondaryPoints, secondaryProjection,
|
||||
MINIMAP_LINE_WIDTH, visibilities, colors, true, simplification,
|
||||
);
|
||||
}
|
||||
|
||||
function _onDragCapture(e) {
|
||||
e.preventDefault();
|
||||
_capturedOffset = e.target.offsetLeft;
|
||||
}
|
||||
|
||||
function _onDragRelease() {
|
||||
_capturedOffset = null;
|
||||
}
|
||||
|
||||
function _onSliderDrag(moveEvent, captureEvent, { dragOffsetX }) {
|
||||
const minX1 = 0;
|
||||
const maxX1 = _canvasSize.width - _slider.offsetWidth;
|
||||
|
||||
const newX1 = Math.max(minX1, Math.min(_capturedOffset + dragOffsetX - MINIMAP_EAR_WIDTH, maxX1));
|
||||
const newX2 = newX1 + _slider.offsetWidth;
|
||||
const begin = newX1 / _canvasSize.width;
|
||||
const end = newX2 / _canvasSize.width;
|
||||
|
||||
_updateRange({ begin, end });
|
||||
}
|
||||
|
||||
function _onLeftEarDrag(moveEvent, captureEvent, { dragOffsetX }) {
|
||||
const minX1 = 0;
|
||||
const maxX1 = _slider.offsetLeft + _slider.offsetWidth - MINIMAP_EAR_WIDTH * 2;
|
||||
|
||||
const newX1 = Math.min(maxX1, Math.max(minX1, _capturedOffset + dragOffsetX));
|
||||
const begin = newX1 / _canvasSize.width;
|
||||
|
||||
_updateRange({ begin });
|
||||
}
|
||||
|
||||
function _onRightEarDrag(moveEvent, captureEvent, { dragOffsetX }) {
|
||||
const minX2 = _slider.offsetLeft + MINIMAP_EAR_WIDTH * 2;
|
||||
const maxX2 = _canvasSize.width;
|
||||
|
||||
const newX2 = Math.max(minX2, Math.min(_capturedOffset + MINIMAP_EAR_WIDTH + dragOffsetX, maxX2));
|
||||
const end = newX2 / _canvasSize.width;
|
||||
|
||||
_updateRange({ end });
|
||||
}
|
||||
|
||||
function _updateRange(range, isExternal) {
|
||||
let nextRange = Object.assign({}, _range, range);
|
||||
|
||||
if (_state && _state.minimapDelta && !isExternal) {
|
||||
nextRange = _adjustDiscreteRange(nextRange);
|
||||
}
|
||||
|
||||
if (nextRange.begin === _range.begin && nextRange.end === _range.end) {
|
||||
return;
|
||||
}
|
||||
|
||||
_range = nextRange;
|
||||
_updateRulerOnRaf();
|
||||
|
||||
if (!isExternal) {
|
||||
rangeCallback(_range);
|
||||
}
|
||||
}
|
||||
|
||||
function _adjustDiscreteRange(nextRange) {
|
||||
// TODO sometimes beginChange and endChange are different for slider drag because of pixels division
|
||||
const begin = Math.round(nextRange.begin / _state.minimapDelta) * _state.minimapDelta;
|
||||
const end = Math.round(nextRange.end / _state.minimapDelta) * _state.minimapDelta;
|
||||
|
||||
return { begin, end };
|
||||
}
|
||||
|
||||
function _updateRuler() {
|
||||
const { begin, end } = _range;
|
||||
|
||||
_ruler.children[0].style.width = `${begin * 100}%`;
|
||||
_ruler.children[1].style.width = `${(end - begin) * 100}%`;
|
||||
_ruler.children[2].style.width = `${(1 - end) * 100}%`;
|
||||
}
|
||||
|
||||
return { update, toggle };
|
||||
}
|
||||
79
src/lib/lovely-chart/Projection.js
Normal file
79
src/lib/lovely-chart/Projection.js
Normal file
@ -0,0 +1,79 @@
|
||||
import { proxyMerge } from './utils';
|
||||
|
||||
export function createProjection(params) {
|
||||
const {
|
||||
begin,
|
||||
end,
|
||||
totalXWidth,
|
||||
yMin,
|
||||
yMax,
|
||||
availableWidth,
|
||||
availableHeight,
|
||||
xPadding = 0,
|
||||
yPadding = 0,
|
||||
} = params;
|
||||
|
||||
let effectiveWidth = availableWidth;
|
||||
|
||||
// TODO bug get rid of padding jumps
|
||||
if (begin === 0) {
|
||||
effectiveWidth -= xPadding;
|
||||
}
|
||||
if (end === 1) {
|
||||
effectiveWidth -= xPadding;
|
||||
}
|
||||
const xFactor = effectiveWidth / ((end - begin) * totalXWidth);
|
||||
let xOffsetPx = (begin * totalXWidth) * xFactor;
|
||||
if (begin === 0) {
|
||||
xOffsetPx -= xPadding;
|
||||
}
|
||||
|
||||
const effectiveHeight = availableHeight - yPadding;
|
||||
const yFactor = effectiveHeight / (yMax - yMin);
|
||||
const yOffsetPx = yMin * yFactor;
|
||||
|
||||
function getState() {
|
||||
return { xFactor, xOffsetPx, availableHeight, yFactor, yOffsetPx };
|
||||
}
|
||||
|
||||
function findClosestLabelIndex(xPx) {
|
||||
return Math.round((xPx + xOffsetPx) / xFactor);
|
||||
}
|
||||
|
||||
function copy(overrides, cons) {
|
||||
return createProjection(proxyMerge(params, overrides), cons);
|
||||
}
|
||||
|
||||
function getCenter() {
|
||||
return [
|
||||
availableWidth / 2,
|
||||
availableHeight - effectiveHeight / 2,
|
||||
];
|
||||
}
|
||||
|
||||
function getSize() {
|
||||
return [availableWidth, effectiveHeight];
|
||||
}
|
||||
|
||||
function getParams() {
|
||||
return params;
|
||||
}
|
||||
|
||||
return {
|
||||
findClosestLabelIndex,
|
||||
copy,
|
||||
getCenter,
|
||||
getSize,
|
||||
getParams,
|
||||
getState,
|
||||
};
|
||||
}
|
||||
|
||||
export function toPixels(projection, labelIndex, value) {
|
||||
const { xFactor, xOffsetPx, availableHeight, yFactor, yOffsetPx } = projection.getState();
|
||||
|
||||
return [
|
||||
labelIndex * xFactor - xOffsetPx,
|
||||
availableHeight - (value * yFactor - yOffsetPx),
|
||||
];
|
||||
}
|
||||
222
src/lib/lovely-chart/StateManager.js
Normal file
222
src/lib/lovely-chart/StateManager.js
Normal file
@ -0,0 +1,222 @@
|
||||
import { createTransitionManager } from './TransitionManager';
|
||||
import { throttleWithRaf, getMaxMin, mergeArrays, proxyMerge, sumArrays } from './utils';
|
||||
import {
|
||||
AXES_MAX_COLUMN_WIDTH,
|
||||
AXES_MAX_ROW_HEIGHT,
|
||||
X_AXIS_HEIGHT,
|
||||
ANIMATE_PROPS,
|
||||
Y_AXIS_ZERO_BASED_THRESHOLD,
|
||||
} from './constants';
|
||||
import { xStepToScaleLevel, yScaleLevelToStep, yStepToScaleLevel } from './formulas';
|
||||
|
||||
export function createStateManager(data, viewportSize, callback) {
|
||||
const _range = { begin: 0, end: 1 };
|
||||
const _filter = _buildDefaultFilter();
|
||||
const _transitionConfig = _buildTransitionConfig();
|
||||
const _transitions = createTransitionManager(_runCallback);
|
||||
const _runCallbackOnRaf = throttleWithRaf(_runCallback);
|
||||
|
||||
let _state = {};
|
||||
|
||||
function update({ range = {}, filter = {}, focusOn, minimapDelta } = {}, noTransition) {
|
||||
Object.assign(_range, range);
|
||||
Object.assign(_filter, filter);
|
||||
|
||||
const prevState = _state;
|
||||
_state = calculateState(data, viewportSize, _range, _filter, focusOn, minimapDelta, prevState);
|
||||
|
||||
if (!noTransition) {
|
||||
_transitionConfig.forEach(({ prop, duration, options }) => {
|
||||
const transition = _transitions.get(prop);
|
||||
const currentTarget = transition ? transition.to : prevState[prop];
|
||||
|
||||
if (currentTarget !== undefined && currentTarget !== _state[prop]) {
|
||||
const current = transition
|
||||
? (options.includes('fast') ? prevState[prop] : transition.current)
|
||||
: prevState[prop];
|
||||
|
||||
if (transition) {
|
||||
_transitions.remove(prop);
|
||||
}
|
||||
|
||||
_transitions.add(prop, current, _state[prop], duration, options);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!_transitions.isRunning() || !_transitions.isFast()) {
|
||||
_runCallbackOnRaf();
|
||||
}
|
||||
}
|
||||
|
||||
function hasAnimations() {
|
||||
return _transitions.isFast();
|
||||
}
|
||||
|
||||
function _buildTransitionConfig() {
|
||||
const transitionConfig = [];
|
||||
const datasetVisibilities = data.datasets.map(({ key }) => `opacity#${key} 300`);
|
||||
|
||||
mergeArrays([
|
||||
ANIMATE_PROPS,
|
||||
datasetVisibilities,
|
||||
]).forEach((transition) => {
|
||||
const [prop, duration, ...options] = transition.split(' ');
|
||||
transitionConfig.push({ prop, duration, options });
|
||||
});
|
||||
|
||||
return transitionConfig;
|
||||
}
|
||||
|
||||
function _buildDefaultFilter() {
|
||||
const filter = {};
|
||||
|
||||
data.datasets.forEach(({ key }) => {
|
||||
filter[key] = true;
|
||||
});
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
function _runCallback() {
|
||||
const state = _transitions.isFast() ? proxyMerge(_state, _transitions.getState()) : _state;
|
||||
state.static = _state;
|
||||
callback(state);
|
||||
}
|
||||
|
||||
return { update, hasAnimations };
|
||||
}
|
||||
|
||||
function calculateState(data, viewportSize, range, filter, focusOn, minimapDelta, prevState) {
|
||||
const { begin, end } = range;
|
||||
const totalXWidth = data.xLabels.length - 1;
|
||||
|
||||
const labelFromIndex = Math.max(0, Math.ceil(totalXWidth * begin));
|
||||
const labelToIndex = Math.min(Math.floor(totalXWidth * end), totalXWidth);
|
||||
|
||||
const xAxisScale = calculateXAxisScale(viewportSize.width, labelFromIndex, labelToIndex);
|
||||
|
||||
const yRanges = data.isStacked
|
||||
? calculateYRangesStacked(data, filter, labelFromIndex, labelToIndex, prevState)
|
||||
: calculateYRanges(data, filter, labelFromIndex, labelToIndex, prevState);
|
||||
|
||||
const yAxisScale = calculateYAxisScale(viewportSize.height, yRanges.yMinViewport, yRanges.yMaxViewport);
|
||||
const yAxisScaleSecond = data.hasSecondYAxis &&
|
||||
calculateYAxisScale(viewportSize.height, yRanges.yMinViewportSecond, yRanges.yMaxViewportSecond);
|
||||
|
||||
const yStep = yScaleLevelToStep(yAxisScale);
|
||||
yRanges.yMinViewport -= yRanges.yMinViewport % yStep;
|
||||
|
||||
if (yAxisScaleSecond) {
|
||||
const yStepSecond = yScaleLevelToStep(yAxisScaleSecond);
|
||||
yRanges.yMinViewportSecond -= yRanges.yMinViewportSecond % yStepSecond;
|
||||
}
|
||||
|
||||
const datasetsOpacity = {};
|
||||
data.datasets.forEach(({ key }) => {
|
||||
datasetsOpacity[`opacity#${key}`] = filter[key] ? 1 : 0;
|
||||
});
|
||||
|
||||
// TODO perf
|
||||
return Object.assign(
|
||||
{
|
||||
totalXWidth,
|
||||
xAxisScale,
|
||||
yAxisScale,
|
||||
yAxisScaleSecond,
|
||||
labelFromIndex: Math.max(0, labelFromIndex - 1),
|
||||
labelToIndex: Math.min(labelToIndex + 1, totalXWidth),
|
||||
filter: Object.assign({}, filter),
|
||||
focusOn: focusOn !== undefined ? focusOn : prevState.focusOn,
|
||||
minimapDelta: minimapDelta !== undefined ? minimapDelta : prevState.minimapDelta,
|
||||
},
|
||||
yRanges,
|
||||
datasetsOpacity,
|
||||
range,
|
||||
);
|
||||
}
|
||||
|
||||
function calculateYRanges(data, filter, labelFromIndex, labelToIndex, prevState) {
|
||||
const secondaryYAxisDataset = data.hasSecondYAxis && data.datasets.slice(-1)[0];
|
||||
const filteredDatasets = data.datasets.filter((d) => filter[d.key] && d !== secondaryYAxisDataset);
|
||||
|
||||
const yRanges = calculateYRangesForGroup(data, labelFromIndex, labelToIndex, prevState, filteredDatasets);
|
||||
|
||||
if (secondaryYAxisDataset) {
|
||||
const group = filter[secondaryYAxisDataset.key] ? [secondaryYAxisDataset] : [];
|
||||
const {
|
||||
yMinViewport: yMinViewportSecond,
|
||||
yMaxViewport: yMaxViewportSecond,
|
||||
yMinMinimap: yMinMinimapSecond,
|
||||
yMaxMinimap: yMaxMinimapSecond,
|
||||
} = calculateYRangesForGroup(data, labelFromIndex, labelToIndex, prevState, [secondaryYAxisDataset]);
|
||||
|
||||
Object.assign(yRanges, {
|
||||
yMinViewportSecond,
|
||||
yMaxViewportSecond,
|
||||
yMinMinimapSecond,
|
||||
yMaxMinimapSecond,
|
||||
});
|
||||
}
|
||||
|
||||
return yRanges;
|
||||
}
|
||||
|
||||
function calculateYRangesForGroup(data, labelFromIndex, labelToIndex, prevState, datasets) {
|
||||
const { min: yMinMinimapReal = prevState.yMinMinimap, max: yMaxMinimap = prevState.yMaxMinimap }
|
||||
= getMaxMin(mergeArrays(datasets.map(({ yMax, yMin }) => [yMax, yMin])));
|
||||
const yMinMinimap = yMinMinimapReal / yMaxMinimap > Y_AXIS_ZERO_BASED_THRESHOLD ? yMinMinimapReal : 0;
|
||||
|
||||
let yMinViewport;
|
||||
let yMaxViewport;
|
||||
|
||||
if (labelFromIndex === 0 && labelToIndex === data.xLabels.length - 1) {
|
||||
yMinViewport = yMinMinimap;
|
||||
yMaxViewport = yMaxMinimap;
|
||||
} else {
|
||||
const filteredValues = datasets.map(({ values }) => values);
|
||||
const viewportValues = filteredValues.map((values) => values.slice(labelFromIndex, labelToIndex + 1));
|
||||
const viewportMaxMin = getMaxMin(mergeArrays(viewportValues));
|
||||
const yMinViewportReal = viewportMaxMin.min !== undefined ? viewportMaxMin.min : prevState.yMinViewport;
|
||||
yMaxViewport = viewportMaxMin.max !== undefined ? viewportMaxMin.max : prevState.yMaxViewport;
|
||||
yMinViewport = yMinViewportReal / yMaxViewport > Y_AXIS_ZERO_BASED_THRESHOLD ? yMinViewportReal : 0;
|
||||
}
|
||||
|
||||
return {
|
||||
yMinViewport,
|
||||
yMaxViewport,
|
||||
yMinMinimap,
|
||||
yMaxMinimap,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateYRangesStacked(data, filter, labelFromIndex, labelToIndex, prevState) {
|
||||
const filteredDatasets = data.datasets.filter((d) => filter[d.key]);
|
||||
const filteredValues = filteredDatasets.map(({ values }) => values);
|
||||
|
||||
const sums = filteredValues.length ? sumArrays(filteredValues) : [];
|
||||
const { max: yMaxMinimap = prevState.yMaxMinimap } = getMaxMin(sums);
|
||||
const { max: yMaxViewport = prevState.yMaxViewport } = getMaxMin(sums.slice(labelFromIndex, labelToIndex + 1));
|
||||
|
||||
return {
|
||||
yMinViewport: 0,
|
||||
yMaxViewport,
|
||||
yMinMinimap: 0,
|
||||
yMaxMinimap,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateXAxisScale(plotWidth, labelFromIndex, labelToIndex) {
|
||||
const viewportLabelsCount = labelToIndex - labelFromIndex;
|
||||
const maxColumns = Math.floor(plotWidth / AXES_MAX_COLUMN_WIDTH);
|
||||
|
||||
return xStepToScaleLevel(viewportLabelsCount / maxColumns);
|
||||
}
|
||||
|
||||
function calculateYAxisScale(plotHeight, yMin, yMax) {
|
||||
const availableHeight = plotHeight - X_AXIS_HEIGHT;
|
||||
const viewportLabelsCount = yMax - yMin;
|
||||
const maxRows = Math.floor(availableHeight / AXES_MAX_ROW_HEIGHT);
|
||||
|
||||
return yStepToScaleLevel(viewportLabelsCount / maxRows);
|
||||
}
|
||||
100
src/lib/lovely-chart/Tools.js
Normal file
100
src/lib/lovely-chart/Tools.js
Normal file
@ -0,0 +1,100 @@
|
||||
import { createElement } from './minifiers';
|
||||
import { captureEvents } from './captureEvents';
|
||||
|
||||
export function createTools(container, data, filterCallback) {
|
||||
let _element;
|
||||
|
||||
_setupLayout();
|
||||
_updateFilter();
|
||||
|
||||
function redraw() {
|
||||
if (_element) {
|
||||
const oldElement = _element;
|
||||
oldElement.classList.add('lovely-chart--state-hidden');
|
||||
setTimeout(() => {
|
||||
oldElement.parentNode.removeChild(oldElement);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
_setupLayout();
|
||||
_element.classList.add('lovely-chart--state-transparent');
|
||||
requestAnimationFrame(() => {
|
||||
_element.classList.remove('lovely-chart--state-transparent');
|
||||
});
|
||||
}
|
||||
|
||||
function _setupLayout() {
|
||||
_element = createElement();
|
||||
_element.className = 'lovely-chart--tools';
|
||||
|
||||
if (data.datasets.length < 2) {
|
||||
_element.className += ' lovely-chart--state-hidden';
|
||||
}
|
||||
|
||||
data.datasets.forEach(({ key, name }) => {
|
||||
const control = createElement('a');
|
||||
control.href = '#';
|
||||
control.dataset.key = key;
|
||||
control.className = `lovely-chart--button lovely-chart--color-${data.colors[key].slice(1)} lovely-chart--state-checked`;
|
||||
control.innerHTML = `<span class="lovely-chart--button-check"></span><span class="lovely-chart--button-label">${name}</span>`;
|
||||
|
||||
control.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!control.dataset.clickPrevented) {
|
||||
_updateFilter(control);
|
||||
}
|
||||
|
||||
delete control.dataset.clickPrevented;
|
||||
});
|
||||
|
||||
captureEvents(control, {
|
||||
onLongPress: () => {
|
||||
control.dataset.clickPrevented = 'true';
|
||||
|
||||
_updateFilter(control, true);
|
||||
},
|
||||
});
|
||||
|
||||
_element.appendChild(control);
|
||||
});
|
||||
|
||||
container.appendChild(_element);
|
||||
}
|
||||
|
||||
function _updateFilter(button, isLongPress = false) {
|
||||
const buttons = Array.from(_element.getElementsByTagName('a'));
|
||||
const isSingleChecked = _element.querySelectorAll('.lovely-chart--state-checked').length === 1;
|
||||
|
||||
if (button) {
|
||||
if (button.classList.contains('lovely-chart--state-checked') && isSingleChecked) {
|
||||
if (isLongPress) {
|
||||
buttons.forEach((b) => b.classList.add('lovely-chart--state-checked'));
|
||||
button.classList.remove('lovely-chart--state-checked');
|
||||
} else {
|
||||
button.classList.remove('lovely-chart--state-shake');
|
||||
requestAnimationFrame(() => {
|
||||
button.classList.add('lovely-chart--state-shake');
|
||||
});
|
||||
}
|
||||
} else if (isLongPress) {
|
||||
buttons.forEach((b) => b.classList.remove('lovely-chart--state-checked'));
|
||||
button.classList.add('lovely-chart--state-checked');
|
||||
} else {
|
||||
button.classList.toggle('lovely-chart--state-checked');
|
||||
}
|
||||
}
|
||||
|
||||
const filter = {};
|
||||
|
||||
buttons.forEach((input) => {
|
||||
filter[input.dataset.key] = input.classList.contains('lovely-chart--state-checked');
|
||||
});
|
||||
|
||||
filterCallback(filter);
|
||||
}
|
||||
|
||||
return {
|
||||
redraw,
|
||||
};
|
||||
}
|
||||
467
src/lib/lovely-chart/Tooltip.js
Normal file
467
src/lib/lovely-chart/Tooltip.js
Normal file
@ -0,0 +1,467 @@
|
||||
import { setupCanvas, clearCanvas } from './canvas';
|
||||
import { BALLOON_OFFSET, X_AXIS_HEIGHT } from './constants';
|
||||
import { getPieRadius } from './formulas';
|
||||
import { formatInteger, getLabelDate, getLabelTime, statsFormatDayHourFull } from './format';
|
||||
import { getCssColor } from './skin';
|
||||
import { throttle, throttleWithRaf } from './utils';
|
||||
import { addEventListener, createElement } from './minifiers';
|
||||
import { toPixels } from './Projection';
|
||||
|
||||
export function createTooltip(container, data, plotSize, colors, onZoom, onFocus) {
|
||||
let _state;
|
||||
let _points;
|
||||
let _projection;
|
||||
let _secondaryPoints;
|
||||
let _secondaryProjection;
|
||||
|
||||
let _element;
|
||||
let _canvas;
|
||||
let _context;
|
||||
let _balloon;
|
||||
|
||||
let _offsetX;
|
||||
let _offsetY;
|
||||
let _clickedOnLabel = null;
|
||||
|
||||
let _isZoomed = false;
|
||||
let _isZooming = false;
|
||||
|
||||
const _selectLabelOnRaf = throttleWithRaf(_selectLabel);
|
||||
const _throttledUpdateContent = throttle(_updateContent, 100, true, true);
|
||||
|
||||
_setupLayout();
|
||||
|
||||
function update(state, points, projection, secondaryPoints, secondaryProjection) {
|
||||
_state = state;
|
||||
_points = points;
|
||||
_projection = projection;
|
||||
_secondaryPoints = secondaryPoints;
|
||||
_secondaryProjection = secondaryProjection;
|
||||
_selectLabel(true);
|
||||
}
|
||||
|
||||
function toggleLoading(isLoading) {
|
||||
_balloon.classList.toggle('lovely-chart--state-loading', isLoading);
|
||||
|
||||
if (!isLoading) {
|
||||
_clear();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleIsZoomed(isZoomed) {
|
||||
if (isZoomed !== _isZoomed) {
|
||||
_isZooming = true;
|
||||
}
|
||||
_isZoomed = isZoomed;
|
||||
_balloon.classList.toggle('lovely-chart--state-inactive', isZoomed);
|
||||
}
|
||||
|
||||
function _setupLayout() {
|
||||
_element = createElement();
|
||||
_element.className = `lovely-chart--tooltip`;
|
||||
|
||||
_setupCanvas();
|
||||
_setupBalloon();
|
||||
|
||||
if ('ontouchstart' in window) {
|
||||
addEventListener(_element, 'touchmove', _onMouseMove);
|
||||
addEventListener(_element, 'touchstart', _onMouseMove);
|
||||
addEventListener(document, 'touchstart', _onDocumentMove);
|
||||
} else {
|
||||
addEventListener(_element, 'mousemove', _onMouseMove);
|
||||
addEventListener(_element, 'click', _onClick);
|
||||
addEventListener(document, 'mousemove', _onDocumentMove);
|
||||
}
|
||||
|
||||
container.appendChild(_element);
|
||||
}
|
||||
|
||||
function _setupCanvas() {
|
||||
const { canvas, context } = setupCanvas(_element, plotSize);
|
||||
|
||||
_canvas = canvas;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
function _setupBalloon() {
|
||||
_balloon = createElement();
|
||||
_balloon.className = `lovely-chart--tooltip-balloon${!data.isZoomable ? ' lovely-chart--state-inactive' : ''}`;
|
||||
_balloon.innerHTML = '<div class="lovely-chart--tooltip-title"></div><div class="lovely-chart--tooltip-legend"></div><div class="lovely-chart--spinner"></div>';
|
||||
|
||||
if ('ontouchstart' in window && data.isZoomable) {
|
||||
addEventListener(_balloon, 'click', _onBalloonClick);
|
||||
}
|
||||
|
||||
_element.appendChild(_balloon);
|
||||
}
|
||||
|
||||
function _onMouseMove(e) {
|
||||
if (e.target === _balloon || _balloon.contains(e.target) || _clickedOnLabel) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isZooming = false;
|
||||
|
||||
const pageOffset = _getPageOffset(_element);
|
||||
_offsetX = (e.touches ? e.touches[0].clientX : e.clientX) - pageOffset.left;
|
||||
_offsetY = (e.touches ? e.touches[0].clientY : e.clientY) - pageOffset.top;
|
||||
|
||||
_selectLabelOnRaf();
|
||||
}
|
||||
|
||||
function _onDocumentMove(e) {
|
||||
if (_offsetX !== null && e.target !== _element && !_element.contains(e.target)) {
|
||||
_clear();
|
||||
}
|
||||
}
|
||||
|
||||
function _onClick(e) {
|
||||
if (_isZooming) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldLabelIndex = _clickedOnLabel;
|
||||
|
||||
_clickedOnLabel = null;
|
||||
_onMouseMove(e, true);
|
||||
|
||||
const newLabelIndex = _getLabelIndex();
|
||||
if (newLabelIndex !== oldLabelIndex) {
|
||||
_clickedOnLabel = newLabelIndex;
|
||||
}
|
||||
|
||||
if (data.isZoomable) {
|
||||
onZoom(newLabelIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function _onBalloonClick() {
|
||||
if (_balloon.classList.contains('lovely-chart--state-inactive')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const labelIndex = _projection.findClosestLabelIndex(_offsetX);
|
||||
onZoom(labelIndex);
|
||||
}
|
||||
|
||||
function _clear(isExternal) {
|
||||
_offsetX = null;
|
||||
_clickedOnLabel = null;
|
||||
clearCanvas(_canvas, _context);
|
||||
_hideBalloon();
|
||||
|
||||
if (!isExternal && onFocus) {
|
||||
onFocus(null);
|
||||
}
|
||||
}
|
||||
|
||||
function _getLabelIndex() {
|
||||
const labelIndex = _projection.findClosestLabelIndex(_offsetX);
|
||||
return labelIndex < _state.labelFromIndex || labelIndex > _state.labelToIndex ? null : labelIndex;
|
||||
}
|
||||
|
||||
function _selectLabel(isExternal) {
|
||||
if (!_offsetX || !_state || _isZooming) {
|
||||
return;
|
||||
}
|
||||
|
||||
const labelIndex = _getLabelIndex();
|
||||
if (labelIndex === null) {
|
||||
_clear(isExternal);
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerVector = getPointerVector();
|
||||
const shouldShowBalloon = data.isPie ? pointerVector.distance <= getPieRadius(_projection) : true;
|
||||
|
||||
if (!isExternal && onFocus) {
|
||||
if (data.isPie) {
|
||||
onFocus(pointerVector);
|
||||
} else {
|
||||
onFocus(labelIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function getValue(values, labelIndex) {
|
||||
if (data.isPie) {
|
||||
return values.slice(_state.labelFromIndex, _state.labelToIndex + 1).reduce((a, x) => a + x, 0);
|
||||
}
|
||||
|
||||
return values[labelIndex];
|
||||
}
|
||||
|
||||
const [xPx] = toPixels(_projection, labelIndex, 0);
|
||||
const statistics = data.datasets
|
||||
.map(({ key, name, values, hasOwnYAxis }, i) => ({
|
||||
key,
|
||||
name,
|
||||
value: getValue(values, labelIndex),
|
||||
hasOwnYAxis,
|
||||
originalIndex: i,
|
||||
}))
|
||||
.filter(({ key }) => _state.filter[key]);
|
||||
|
||||
if (statistics.length && shouldShowBalloon) {
|
||||
_updateBalloon(statistics, labelIndex);
|
||||
} else {
|
||||
_hideBalloon();
|
||||
}
|
||||
|
||||
clearCanvas(_canvas, _context);
|
||||
if (data.isLines || data.isAreas) {
|
||||
if (data.isLines) {
|
||||
_drawCircles(statistics, labelIndex);
|
||||
}
|
||||
|
||||
_drawTail(xPx, plotSize.height - X_AXIS_HEIGHT, getCssColor(colors, 'grid-lines'));
|
||||
}
|
||||
}
|
||||
|
||||
function _drawCircles(statistics, labelIndex) {
|
||||
statistics.forEach(({ value, key, hasOwnYAxis, originalIndex }) => {
|
||||
const pointIndex = labelIndex - _state.labelFromIndex;
|
||||
const point = hasOwnYAxis ? _secondaryPoints[pointIndex] : _points[originalIndex][pointIndex];
|
||||
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [x, y] = hasOwnYAxis
|
||||
? toPixels(_secondaryProjection, labelIndex, point.stackValue)
|
||||
: toPixels(_projection, labelIndex, point.stackValue);
|
||||
|
||||
// TODO animate
|
||||
_drawCircle(
|
||||
[x, y],
|
||||
getCssColor(colors, `dataset#${key}`),
|
||||
getCssColor(colors, 'background'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function _drawCircle([xPx, yPx], strokeColor, fillColor) {
|
||||
_context.strokeStyle = strokeColor;
|
||||
_context.fillStyle = fillColor;
|
||||
_context.lineWidth = 2;
|
||||
|
||||
_context.beginPath();
|
||||
_context.arc(xPx, yPx, 4, 0, 2 * Math.PI);
|
||||
_context.fill();
|
||||
_context.stroke();
|
||||
}
|
||||
|
||||
function _drawTail(xPx, height, color) {
|
||||
_context.strokeStyle = color;
|
||||
_context.lineWidth = 1;
|
||||
|
||||
_context.beginPath();
|
||||
_context.moveTo(xPx, 0);
|
||||
_context.lineTo(xPx, height);
|
||||
_context.stroke();
|
||||
}
|
||||
|
||||
function _getBalloonLeftOffset(labelIndex) {
|
||||
const meanLabel = (_state.labelFromIndex + _state.labelToIndex) / 2;
|
||||
const { angle } = getPointerVector();
|
||||
|
||||
const shouldPlaceRight = data.isPie ? angle > Math.PI / 2 : labelIndex < meanLabel;
|
||||
|
||||
return shouldPlaceRight
|
||||
? _offsetX + BALLOON_OFFSET
|
||||
: _offsetX - (_balloon.offsetWidth + BALLOON_OFFSET);
|
||||
}
|
||||
|
||||
function _getBalloonTopOffset() {
|
||||
return data.isPie ? `${_offsetY}px` : 0;
|
||||
}
|
||||
|
||||
function _updateBalloon(statistics, labelIndex) {
|
||||
_balloon.style.transform = `translate3D(${_getBalloonLeftOffset(labelIndex)}px, ${_getBalloonTopOffset()}, 0)`;
|
||||
_balloon.classList.add('lovely-chart--state-shown');
|
||||
|
||||
if (data.isPie) {
|
||||
_updateContent(null, statistics);
|
||||
} else {
|
||||
_throttledUpdateContent(_getTitle(data, labelIndex), statistics);
|
||||
}
|
||||
}
|
||||
|
||||
function _getTitle(data, labelIndex) {
|
||||
switch (data.tooltipFormatter) {
|
||||
case 'statsFormatDayHourFull':
|
||||
return statsFormatDayHourFull(data.xLabels[labelIndex].value);
|
||||
case 'statsTooltipFormat(\'day\')':
|
||||
return getLabelDate(data.xLabels[labelIndex]);
|
||||
case 'statsTooltipFormat(\'5min\')':
|
||||
return getLabelTime(data.xLabels[labelIndex]);
|
||||
default:
|
||||
return data.xLabels[labelIndex].text;
|
||||
}
|
||||
}
|
||||
|
||||
function _isPieSectorSelected(statistics, value, totalValue, index, pointerVector) {
|
||||
const offset = index > 0 ? statistics.slice(0, index).reduce((a, x) => a + x.value, 0) : 0;
|
||||
const beginAngle = offset / totalValue * Math.PI * 2 - Math.PI / 2;
|
||||
const endAngle = (offset + value) / totalValue * Math.PI * 2 - Math.PI / 2;
|
||||
|
||||
return pointerVector &&
|
||||
beginAngle <= pointerVector.angle &&
|
||||
pointerVector.angle < endAngle &&
|
||||
pointerVector.distance <= getPieRadius(_projection);
|
||||
}
|
||||
|
||||
function _updateTitle(title) {
|
||||
const titleContainer = _balloon.children[0];
|
||||
|
||||
if (data.isPie) {
|
||||
if (titleContainer) {
|
||||
titleContainer.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
if (titleContainer.style.display === 'none') {
|
||||
titleContainer.style.display = '';
|
||||
}
|
||||
const currentTitle = titleContainer.querySelector(':not(.lovely-chart--state-hidden)');
|
||||
|
||||
if (!titleContainer.innerHTML || !currentTitle) {
|
||||
titleContainer.innerHTML = `<span>${title}</span>`;
|
||||
} else {
|
||||
currentTitle.innerHTML = title;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _insertNewDataSet(dataSetContainer, { name, key, value }, totalValue) {
|
||||
const className = `lovely-chart--tooltip-dataset-value lovely-chart--position-right lovely-chart--color-${data.colors[key].slice(1)}`;
|
||||
const newDataSet = createElement();
|
||||
newDataSet.className = 'lovely-chart--tooltip-dataset';
|
||||
newDataSet.setAttribute('data-present', 'true');
|
||||
newDataSet.setAttribute('data-name', name);
|
||||
newDataSet.innerHTML = `<span class="lovely-chart--dataset-title">${name}</span><span class="${className}">${formatInteger(value)}</span>`;
|
||||
_renderPercentageValue(newDataSet, value, totalValue);
|
||||
|
||||
const totalText = dataSetContainer.querySelector(`[data-total="true"]`);
|
||||
if (totalText) {
|
||||
dataSetContainer.insertBefore(newDataSet, totalText);
|
||||
} else {
|
||||
dataSetContainer.appendChild(newDataSet);
|
||||
}
|
||||
}
|
||||
|
||||
function _updateDataSet(currentDataSet, { key, value } = {}, totalValue) {
|
||||
currentDataSet.setAttribute('data-present', 'true');
|
||||
|
||||
const valueElement = currentDataSet.querySelector(`.lovely-chart--tooltip-dataset-value.lovely-chart--color-${data.colors[key].slice(1)}:not(.lovely-chart--state-hidden)`);
|
||||
valueElement.innerHTML = formatInteger(value);
|
||||
|
||||
_renderPercentageValue(currentDataSet, value, totalValue);
|
||||
}
|
||||
|
||||
function _renderPercentageValue(dataSet, value, totalValue) {
|
||||
if (!data.isPercentage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.isPie) {
|
||||
Array.from(dataSet.querySelectorAll(`.lovely-chart--percentage-title`)).forEach(e => e.remove());
|
||||
return;
|
||||
}
|
||||
|
||||
const percentageValue = totalValue ? Math.round(value / totalValue * 100) : 0;
|
||||
const percentageElement = dataSet.querySelector(`.lovely-chart--percentage-title:not(.lovely-chart--state-hidden)`);
|
||||
|
||||
if (!percentageElement) {
|
||||
const newPercentageTitle = createElement('span');
|
||||
newPercentageTitle.className = 'lovely-chart--percentage-title lovely-chart--position-left';
|
||||
newPercentageTitle.innerHTML = `${percentageValue}%`;
|
||||
dataSet.prepend(newPercentageTitle);
|
||||
} else {
|
||||
percentageElement.innerHTML = `${percentageValue}%`;
|
||||
}
|
||||
}
|
||||
|
||||
function _updateDataSets(statistics) {
|
||||
const dataSetContainer = _balloon.children[1];
|
||||
if (data.isPie) {
|
||||
dataSetContainer.classList.add('lovely-chart--tooltip-legend-pie');
|
||||
}
|
||||
|
||||
Array.from(dataSetContainer.children).forEach((dataSet) => {
|
||||
if (!data.isPie && dataSetContainer.classList.contains('lovely-chart--tooltip-legend-pie')) {
|
||||
dataSet.remove();
|
||||
} else {
|
||||
dataSet.setAttribute('data-present', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
const totalValue = statistics.reduce((a, x) => a + x.value, 0);
|
||||
const pointerVector = getPointerVector();
|
||||
const finalStatistics = data.isPie ? statistics.filter(({ value }, index) => _isPieSectorSelected(statistics, value, totalValue, index, pointerVector)) : statistics;
|
||||
|
||||
finalStatistics.forEach((statItem) => {
|
||||
const currentDataSet = dataSetContainer.querySelector(`[data-name="${statItem.name}"]`);
|
||||
|
||||
if (!currentDataSet) {
|
||||
_insertNewDataSet(dataSetContainer, statItem, totalValue);
|
||||
} else {
|
||||
_updateDataSet(currentDataSet, statItem, totalValue);
|
||||
}
|
||||
});
|
||||
|
||||
if (data.isBars && data.isStacked) {
|
||||
_renderTotal(dataSetContainer, formatInteger(totalValue));
|
||||
}
|
||||
|
||||
Array.from(dataSetContainer.querySelectorAll('[data-present="false"]'))
|
||||
.forEach((dataSet) => {
|
||||
dataSet.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function _updateContent(title, statistics) {
|
||||
_updateTitle(title);
|
||||
_updateDataSets(statistics);
|
||||
}
|
||||
|
||||
function _renderTotal(dataSetContainer, totalValue) {
|
||||
const totalText = dataSetContainer.querySelector(`[data-total="true"]`);
|
||||
const className = `lovely-chart--tooltip-dataset-value lovely-chart--position-right`;
|
||||
if (!totalText) {
|
||||
const newTotalText = createElement();
|
||||
newTotalText.className = 'lovely-chart--tooltip-dataset';
|
||||
newTotalText.setAttribute('data-present', 'true');
|
||||
newTotalText.setAttribute('data-total', 'true');
|
||||
newTotalText.innerHTML = `<span>All</span><span class="${className}">${totalValue}</span>`;
|
||||
dataSetContainer.appendChild(newTotalText);
|
||||
} else {
|
||||
totalText.setAttribute('data-present', 'true');
|
||||
|
||||
const valueElement = totalText.querySelector(`.lovely-chart--tooltip-dataset-value:not(.lovely-chart--state-hidden)`);
|
||||
valueElement.innerHTML = totalValue;
|
||||
}
|
||||
}
|
||||
|
||||
function _hideBalloon() {
|
||||
_balloon.classList.remove('lovely-chart--state-shown');
|
||||
}
|
||||
|
||||
function getPointerVector() {
|
||||
const { width, height } = _element.getBoundingClientRect();
|
||||
|
||||
const center = [width / 2, height / 2];
|
||||
const angle = Math.atan2(_offsetY - center[1], _offsetX - center[0]);
|
||||
const distance = Math.sqrt((_offsetX - center[0]) ** 2 + (_offsetY - center[1]) ** 2);
|
||||
|
||||
return {
|
||||
angle: angle >= -Math.PI / 2 ? angle : 2 * Math.PI + angle,
|
||||
distance,
|
||||
};
|
||||
}
|
||||
|
||||
function _getPageOffset(el) {
|
||||
return el.getBoundingClientRect();
|
||||
}
|
||||
|
||||
return { update, toggleLoading, toggleIsZoomed };
|
||||
}
|
||||
|
||||
138
src/lib/lovely-chart/TransitionManager.js
Normal file
138
src/lib/lovely-chart/TransitionManager.js
Normal file
@ -0,0 +1,138 @@
|
||||
import { SPEED_TEST_FAST_FPS, SPEED_TEST_INTERVAL, TRANSITION_DEFAULT_DURATION } from './constants';
|
||||
|
||||
function transition(t) {
|
||||
// faster
|
||||
// return -t * (t - 2);
|
||||
// easeOut
|
||||
return 1 - Math.pow(1 - t, 1.675);
|
||||
}
|
||||
|
||||
export function createTransitionManager(onTick) {
|
||||
const _transitions = {};
|
||||
|
||||
let _nextFrame = null;
|
||||
|
||||
let _testStartedAt = null;
|
||||
let _fps = null;
|
||||
let _testingFps = null;
|
||||
let _slowDetectedAt = null;
|
||||
let _startedAsSlow = null;
|
||||
|
||||
function add(prop, from, to, duration, options) {
|
||||
_transitions[prop] = {
|
||||
from,
|
||||
to,
|
||||
duration,
|
||||
options,
|
||||
current: from,
|
||||
startedAt: Date.now(),
|
||||
progress: 0,
|
||||
};
|
||||
|
||||
if (!_nextFrame) {
|
||||
_resetSpeedTest();
|
||||
_nextFrame = requestAnimationFrame(_tick);
|
||||
}
|
||||
}
|
||||
|
||||
function remove(prop) {
|
||||
delete _transitions[prop];
|
||||
|
||||
if (!isRunning()) {
|
||||
cancelAnimationFrame(_nextFrame);
|
||||
_nextFrame = null;
|
||||
}
|
||||
}
|
||||
|
||||
function get(prop) {
|
||||
return _transitions[prop];
|
||||
}
|
||||
|
||||
function getState() {
|
||||
const state = {};
|
||||
|
||||
Object.keys(_transitions).forEach((prop) => {
|
||||
const { current, from, to, progress } = _transitions[prop];
|
||||
state[prop] = current;
|
||||
// TODO perf lazy
|
||||
state[`${prop}From`] = from;
|
||||
state[`${prop}To`] = to;
|
||||
state[`${prop}Progress`] = progress;
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function isRunning() {
|
||||
return Boolean(Object.keys(_transitions).length);
|
||||
}
|
||||
|
||||
function isFast(forceCheck) {
|
||||
if (!forceCheck && (_startedAsSlow || _slowDetectedAt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return _fps === null || _fps >= SPEED_TEST_FAST_FPS;
|
||||
}
|
||||
|
||||
function _tick() {
|
||||
const isSlow = !isFast();
|
||||
_speedTest();
|
||||
|
||||
const state = {};
|
||||
|
||||
Object.keys(_transitions).forEach((prop) => {
|
||||
const { startedAt, from, to, duration = TRANSITION_DEFAULT_DURATION, options } = _transitions[prop];
|
||||
const progress = Math.min(1, (Date.now() - startedAt) / duration);
|
||||
let current = from + (to - from) * transition(progress);
|
||||
|
||||
if (options.includes('ceil')) {
|
||||
current = Math.ceil(current);
|
||||
} else if (options.includes('floor')) {
|
||||
current = Math.floor(current);
|
||||
}
|
||||
|
||||
_transitions[prop].current = current;
|
||||
_transitions[prop].progress = progress;
|
||||
state[prop] = current;
|
||||
|
||||
if (progress === 1) {
|
||||
remove(prop);
|
||||
}
|
||||
});
|
||||
|
||||
if (!isSlow) {
|
||||
onTick(state);
|
||||
}
|
||||
|
||||
if (isRunning()) {
|
||||
_nextFrame = requestAnimationFrame(_tick);
|
||||
}
|
||||
}
|
||||
|
||||
function _resetSpeedTest() {
|
||||
_testStartedAt = null;
|
||||
_testingFps = null;
|
||||
if (_slowDetectedAt && Date.now() - _slowDetectedAt > 5000) {
|
||||
_slowDetectedAt = null;
|
||||
}
|
||||
_startedAsSlow = Boolean(_slowDetectedAt) || !isFast(true);
|
||||
}
|
||||
|
||||
function _speedTest() {
|
||||
if (!_testStartedAt || (Date.now() - _testStartedAt) >= SPEED_TEST_INTERVAL) {
|
||||
if (_testingFps) {
|
||||
_fps = _testingFps;
|
||||
if (!_slowDetectedAt && !isFast(true)) {
|
||||
_slowDetectedAt = Date.now();
|
||||
}
|
||||
}
|
||||
_testStartedAt = Date.now();
|
||||
_testingFps = 0;
|
||||
} else {
|
||||
_testingFps++;
|
||||
}
|
||||
}
|
||||
|
||||
return { add, remove, get, getState, isRunning, isFast };
|
||||
}
|
||||
168
src/lib/lovely-chart/Zoomer.js
Normal file
168
src/lib/lovely-chart/Zoomer.js
Normal file
@ -0,0 +1,168 @@
|
||||
import { analyzeData } from './data';
|
||||
import { getFullLabelDate } from './format';
|
||||
import { ZOOM_RANGE_DELTA, ZOOM_RANGE_MIDDLE, ZOOM_TIMEOUT } from './constants';
|
||||
import { createColors } from './skin';
|
||||
|
||||
export function createZoomer(data, overviewData, colors, stateManager, container, header, minimap, tooltip, tools) {
|
||||
let _isZoomed = false;
|
||||
let _stateBeforeZoomIn;
|
||||
let _stateBeforeZoomOut;
|
||||
|
||||
function zoomIn(state, labelIndex) {
|
||||
if (_isZoomed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = data.xLabels[labelIndex];
|
||||
|
||||
_stateBeforeZoomIn = state;
|
||||
header.toggleIsZooming(true);
|
||||
tooltip.toggleLoading(true);
|
||||
tooltip.toggleIsZoomed(true);
|
||||
if (data.shouldZoomToPie) {
|
||||
container.classList.add('lovely-chart--state-zoomed-in');
|
||||
container.classList.add('lovely-chart--state-animating');
|
||||
}
|
||||
|
||||
const { value } = label;
|
||||
const dataPromise = data.shouldZoomToPie ? Promise.resolve(_generatePieData(labelIndex)) : data.onZoom(value);
|
||||
dataPromise
|
||||
.then((newData) => _replaceData(newData, labelIndex, label))
|
||||
.catch(() => {
|
||||
tooltip.toggleLoading(false);
|
||||
tooltip.toggleIsZoomed(false);
|
||||
header.toggleIsZooming(false);
|
||||
});
|
||||
}
|
||||
|
||||
function zoomOut(state) {
|
||||
if (!_isZoomed) {
|
||||
return;
|
||||
}
|
||||
|
||||
_stateBeforeZoomOut = state;
|
||||
header.toggleIsZooming(true);
|
||||
tooltip.toggleLoading(true);
|
||||
tooltip.toggleIsZoomed(false);
|
||||
if (data.shouldZoomToPie) {
|
||||
container.classList.remove('lovely-chart--state-zoomed-in');
|
||||
container.classList.add('lovely-chart--state-animating');
|
||||
}
|
||||
|
||||
const labelIndex = Math.round((state.labelFromIndex + state.labelToIndex) / 2);
|
||||
_replaceData(overviewData, labelIndex);
|
||||
}
|
||||
|
||||
function isZoomed() {
|
||||
return _isZoomed;
|
||||
}
|
||||
|
||||
function _replaceData(newRawData, labelIndex, zoomInLabel) {
|
||||
tooltip.toggleLoading(false);
|
||||
|
||||
const labelWidth = 1 / data.xLabels.length;
|
||||
const labelMiddle = labelIndex / (data.xLabels.length - 1);
|
||||
const filter = {};
|
||||
data.datasets.forEach(({ key }) => filter[key] = false);
|
||||
const newData = analyzeData(newRawData, _isZoomed || data.shouldZoomToPie ? 'day' : 'hour');
|
||||
const shouldZoomToLines = Object.keys(data.datasets).length !== Object.keys(newData.datasets).length;
|
||||
|
||||
stateManager.update({
|
||||
range: {
|
||||
begin: labelMiddle - labelWidth / 2,
|
||||
end: labelMiddle + labelWidth / 2,
|
||||
},
|
||||
filter,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
Object.assign(data, newData);
|
||||
|
||||
if (shouldZoomToLines && newRawData.colors) {
|
||||
Object.assign(colors, createColors(newRawData.colors));
|
||||
}
|
||||
|
||||
if (shouldZoomToLines) {
|
||||
minimap.toggle(_isZoomed);
|
||||
tools.redraw();
|
||||
container.style.width = `${container.scrollWidth}px`;
|
||||
container.style.height = `${container.scrollHeight}px`;
|
||||
}
|
||||
|
||||
stateManager.update({
|
||||
range: {
|
||||
begin: ZOOM_RANGE_MIDDLE - ZOOM_RANGE_DELTA,
|
||||
end: ZOOM_RANGE_MIDDLE + ZOOM_RANGE_DELTA,
|
||||
},
|
||||
focusOn: null,
|
||||
}, true);
|
||||
|
||||
const daysCount = _isZoomed || data.shouldZoomToPie ? data.xLabels.length : data.xLabels.length / 24;
|
||||
const halfDayWidth = (1 / daysCount) / 2;
|
||||
|
||||
let range;
|
||||
let filter;
|
||||
|
||||
if (_isZoomed) {
|
||||
range = {
|
||||
begin: _stateBeforeZoomIn.begin,
|
||||
end: _stateBeforeZoomIn.end,
|
||||
};
|
||||
filter = shouldZoomToLines ? _stateBeforeZoomIn.filter : _stateBeforeZoomOut.filter;
|
||||
} else {
|
||||
if (shouldZoomToLines) {
|
||||
range = {
|
||||
begin: 0,
|
||||
end: 1,
|
||||
};
|
||||
filter = {};
|
||||
data.datasets.forEach(({ key }) => filter[key] = true);
|
||||
} else {
|
||||
range = data.shouldZoomToPie ? {
|
||||
begin: ZOOM_RANGE_MIDDLE - halfDayWidth,
|
||||
end: ZOOM_RANGE_MIDDLE + halfDayWidth,
|
||||
} : newData.minimapRange;
|
||||
filter = _stateBeforeZoomIn.filter;
|
||||
}
|
||||
}
|
||||
|
||||
stateManager.update({
|
||||
range,
|
||||
filter,
|
||||
minimapDelta: _isZoomed ? null : range.end - range.begin,
|
||||
});
|
||||
|
||||
if (zoomInLabel) {
|
||||
header.zoom(getFullLabelDate(zoomInLabel));
|
||||
}
|
||||
|
||||
_isZoomed = !_isZoomed;
|
||||
header.toggleIsZooming(false);
|
||||
}, stateManager.hasAnimations() ? ZOOM_TIMEOUT : 0);
|
||||
|
||||
setTimeout(() => {
|
||||
if (data.shouldZoomToPie) {
|
||||
container.classList.remove('lovely-chart--state-animating');
|
||||
}
|
||||
}, stateManager.hasAnimations() ? 1000 : 0);
|
||||
}
|
||||
|
||||
function _generatePieData(labelIndex) {
|
||||
return Object.assign(
|
||||
{},
|
||||
overviewData,
|
||||
{
|
||||
type: 'pie',
|
||||
labels: overviewData.labels.slice(labelIndex - 3, labelIndex + 4),
|
||||
datasets: overviewData.datasets.map((dataset) => {
|
||||
return {
|
||||
...dataset,
|
||||
values: dataset.values.slice(labelIndex - 3, labelIndex + 4),
|
||||
};
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return { zoomIn, zoomOut, isZoomed };
|
||||
}
|
||||
22
src/lib/lovely-chart/canvas.js
Normal file
22
src/lib/lovely-chart/canvas.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { DPR } from './constants';
|
||||
import { createElement } from './minifiers';
|
||||
|
||||
export function setupCanvas(container, { width, height }) {
|
||||
const canvas = createElement('canvas');
|
||||
|
||||
canvas.width = width * DPR;
|
||||
canvas.height = height * DPR;
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = `${height}px`;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
context.scale(DPR, DPR);
|
||||
|
||||
container.appendChild(canvas);
|
||||
|
||||
return { canvas, context };
|
||||
}
|
||||
|
||||
export function clearCanvas(canvas, context) {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
79
src/lib/lovely-chart/captureEvents.js
Normal file
79
src/lib/lovely-chart/captureEvents.js
Normal file
@ -0,0 +1,79 @@
|
||||
import { addEventListener, removeEventListener } from './minifiers';
|
||||
import { LONG_PRESS_TIMEOUT } from './constants';
|
||||
|
||||
export function captureEvents(element, options) {
|
||||
let captureEvent = null;
|
||||
let longPressTimeout = null;
|
||||
|
||||
function onCapture(e) {
|
||||
captureEvent = e;
|
||||
|
||||
if (e.type === 'mousedown') {
|
||||
addEventListener(document, 'mousemove', onMove);
|
||||
addEventListener(document, 'mouseup', onRelease);
|
||||
} else if (e.type === 'touchstart') {
|
||||
addEventListener(document, 'touchmove', onMove);
|
||||
addEventListener(document, 'touchend', onRelease);
|
||||
addEventListener(document, 'touchcancel', onRelease);
|
||||
|
||||
// https://stackoverflow.com/questions/11287877/how-can-i-get-e-offsetx-on-mobile-ipad
|
||||
// Android does not have this value, and iOS has it but as read-only.
|
||||
if (e.pageX === undefined) {
|
||||
e.pageX = e.touches[0].pageX;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.draggingCursor) {
|
||||
document.documentElement.classList.add(`cursor-${options.draggingCursor}`);
|
||||
}
|
||||
|
||||
options.onCapture && options.onCapture(e);
|
||||
|
||||
if (options.onLongPress) {
|
||||
longPressTimeout = setTimeout(() => options.onLongPress(), LONG_PRESS_TIMEOUT);
|
||||
}
|
||||
}
|
||||
|
||||
function onRelease(e) {
|
||||
if (captureEvent) {
|
||||
if (longPressTimeout) {
|
||||
clearTimeout(longPressTimeout);
|
||||
longPressTimeout = null;
|
||||
}
|
||||
|
||||
if (options.draggingCursor) {
|
||||
document.documentElement.classList.remove(`cursor-${options.draggingCursor}`);
|
||||
}
|
||||
|
||||
removeEventListener(document, 'mouseup', onRelease);
|
||||
removeEventListener(document, 'mousemove', onMove);
|
||||
removeEventListener(document, 'touchcancel', onRelease);
|
||||
removeEventListener(document, 'touchend', onRelease);
|
||||
removeEventListener(document, 'touchmove', onMove);
|
||||
|
||||
captureEvent = null;
|
||||
|
||||
options.onRelease && options.onRelease(e);
|
||||
}
|
||||
}
|
||||
|
||||
function onMove(e) {
|
||||
if (captureEvent) {
|
||||
if (longPressTimeout) {
|
||||
clearTimeout(longPressTimeout);
|
||||
longPressTimeout = null;
|
||||
}
|
||||
|
||||
if (e.type === 'touchmove' && e.pageX === undefined) {
|
||||
e.pageX = e.touches[0].pageX;
|
||||
}
|
||||
|
||||
options.onDrag && options.onDrag(e, captureEvent, {
|
||||
dragOffsetX: e.pageX - captureEvent.pageX,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener(element, 'mousedown', onCapture);
|
||||
addEventListener(element, 'touchstart', onCapture);
|
||||
}
|
||||
62
src/lib/lovely-chart/constants.js
Normal file
62
src/lib/lovely-chart/constants.js
Normal file
@ -0,0 +1,62 @@
|
||||
export const DPR = window.devicePixelRatio || 1;
|
||||
|
||||
export const DEFAULT_RANGE = { begin: 0.8, end: 1 };
|
||||
export const TRANSITION_DEFAULT_DURATION = 300;
|
||||
export const LONG_PRESS_TIMEOUT = 500;
|
||||
|
||||
export const GUTTER = 10;
|
||||
export const PLOT_HEIGHT = 320;
|
||||
export const PLOT_TOP_PADDING = 15;
|
||||
export const PLOT_LINE_WIDTH = 2;
|
||||
export const PLOT_PIE_RADIUS_FACTOR = 0.9 / 2;
|
||||
export const PLOT_PIE_SHIFT = 10;
|
||||
export const PLOT_BARS_WIDTH_SHIFT = 0.5;
|
||||
|
||||
export const BALLOON_OFFSET = 20;
|
||||
|
||||
export const AXES_FONT = '300 10px Helvetica, Arial, sans-serif';
|
||||
export const AXES_MAX_COLUMN_WIDTH = 45;
|
||||
export const AXES_MAX_ROW_HEIGHT = 50;
|
||||
export const X_AXIS_HEIGHT = 30;
|
||||
export const X_AXIS_SHIFT_START = 1;
|
||||
export const Y_AXIS_ZERO_BASED_THRESHOLD = 0.1;
|
||||
|
||||
export const MINIMAP_HEIGHT = 40;
|
||||
export const MINIMAP_MARGIN = 10;
|
||||
export const MINIMAP_LINE_WIDTH = 1;
|
||||
export const MINIMAP_EAR_WIDTH = 8;
|
||||
export const MINIMAP_MAX_ANIMATED_DATASETS = 4;
|
||||
|
||||
export const ZOOM_TIMEOUT = TRANSITION_DEFAULT_DURATION;
|
||||
export const ZOOM_RANGE_DELTA = 0.1;
|
||||
export const ZOOM_RANGE_MIDDLE = .5;
|
||||
|
||||
export const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
export const WEEK_DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
export const WEEK_DAYS_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
export const MILISECONDS_IN_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const SPEED_TEST_INTERVAL = 200;
|
||||
export const SPEED_TEST_FAST_FPS = 4;
|
||||
|
||||
export const SIMPLIFIER_MIN_POINTS = 1000;
|
||||
export const SIMPLIFIER_PLOT_FACTOR = 1;
|
||||
export const SIMPLIFIER_MINIMAP_FACTOR = 0.5;
|
||||
|
||||
export const ANIMATE_PROPS = [
|
||||
// Viewport X-axis
|
||||
'begin 200 fast', 'end 200 fast', 'labelFromIndex 200 fast floor', 'labelToIndex 200 fast ceil',
|
||||
|
||||
// X-axis labels
|
||||
'xAxisScale 400',
|
||||
|
||||
// Viewport Y-axis
|
||||
'yMinViewport', 'yMaxViewport', 'yMinViewportSecond', 'yMaxViewportSecond',
|
||||
|
||||
// Minimap Y-axis
|
||||
'yMinMinimap', 'yMaxMinimap', 'yMinMinimapSecond', 'yMaxMinimapSecond',
|
||||
|
||||
// Y-axis labels
|
||||
'yAxisScale', 'yAxisScaleSecond',
|
||||
];
|
||||
91
src/lib/lovely-chart/data.js
Normal file
91
src/lib/lovely-chart/data.js
Normal file
@ -0,0 +1,91 @@
|
||||
import { getMaxMin } from './utils';
|
||||
import { statsFormatDay, statsFormatDayHour, statsFormatText, statsFormatMin } from './format';
|
||||
|
||||
export function analyzeData(data) {
|
||||
const { title, labelFormatter, tooltipFormatter, isStacked, isPercentage, hasSecondYAxis, onZoom, minimapRange, hideCaption, zoomOutLabel } = data;
|
||||
const { datasets, labels } = prepareDatasets(data);
|
||||
|
||||
const colors = {};
|
||||
let totalYMin = Infinity;
|
||||
let totalYMax = -Infinity;
|
||||
datasets.forEach(({ key, color, yMin, yMax }) => {
|
||||
colors[key] = color;
|
||||
|
||||
if (yMin < totalYMin) {
|
||||
totalYMin = yMin;
|
||||
}
|
||||
|
||||
if (yMax > totalYMax) {
|
||||
totalYMax = yMax;
|
||||
}
|
||||
});
|
||||
|
||||
let xLabels;
|
||||
switch (labelFormatter) {
|
||||
case 'statsFormatDayHour':
|
||||
xLabels = statsFormatDayHour(labels);
|
||||
break;
|
||||
case 'statsFormat(\'day\')':
|
||||
xLabels = statsFormatDay(labels);
|
||||
break;
|
||||
case 'statsFormat(\'5min\')':
|
||||
xLabels = statsFormatMin(labels);
|
||||
break;
|
||||
default:
|
||||
xLabels = statsFormatText(labels);
|
||||
break;
|
||||
}
|
||||
|
||||
const analyzed = {
|
||||
title,
|
||||
labelFormatter,
|
||||
tooltipFormatter,
|
||||
xLabels,
|
||||
datasets,
|
||||
isStacked,
|
||||
isPercentage,
|
||||
hasSecondYAxis,
|
||||
onZoom,
|
||||
isLines: data.type === 'line',
|
||||
isBars: data.type === 'bar',
|
||||
isAreas: data.type === 'area',
|
||||
isPie: data.type === 'pie',
|
||||
yMin: totalYMin,
|
||||
yMax: totalYMax,
|
||||
colors,
|
||||
minimapRange,
|
||||
hideCaption,
|
||||
zoomOutLabel,
|
||||
};
|
||||
|
||||
analyzed.shouldZoomToPie = !analyzed.onZoom && analyzed.isPercentage;
|
||||
analyzed.isZoomable = analyzed.onZoom || analyzed.shouldZoomToPie;
|
||||
|
||||
return analyzed;
|
||||
}
|
||||
|
||||
function prepareDatasets(data) {
|
||||
const { type, labels, datasets, hasSecondYAxis } = data;
|
||||
|
||||
return {
|
||||
labels: cloneArray(labels),
|
||||
datasets: datasets.map(({ name, color, values }, i) => {
|
||||
const { min: yMin, max: yMax } = getMaxMin(values);
|
||||
|
||||
return {
|
||||
type,
|
||||
key: `y${i}`,
|
||||
name,
|
||||
color,
|
||||
values: cloneArray(values),
|
||||
hasOwnYAxis: hasSecondYAxis && i === datasets.length - 1,
|
||||
yMin,
|
||||
yMax,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneArray(array) {
|
||||
return array.slice(0);
|
||||
}
|
||||
229
src/lib/lovely-chart/drawDatasets.js
Normal file
229
src/lib/lovely-chart/drawDatasets.js
Normal file
@ -0,0 +1,229 @@
|
||||
import { getCssColor } from './skin';
|
||||
import { mergeArrays } from './utils';
|
||||
import { getPieRadius, getPieTextShift, getPieTextSize } from './formulas';
|
||||
import { PLOT_BARS_WIDTH_SHIFT, PLOT_PIE_SHIFT } from './constants';
|
||||
import { simplify } from './simplify';
|
||||
import { toPixels } from './Projection';
|
||||
|
||||
export function drawDatasets(
|
||||
context, state, data,
|
||||
range, points, projection, secondaryPoints, secondaryProjection,
|
||||
lineWidth, visibilities, colors, pieToBar, simplification,
|
||||
) {
|
||||
data.datasets.forEach(({ key, type, hasOwnYAxis }, i) => {
|
||||
if (!visibilities[i]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
color: getCssColor(colors, `dataset#${key}`),
|
||||
lineWidth,
|
||||
opacity: data.isStacked ? 1 : visibilities[i],
|
||||
simplification,
|
||||
};
|
||||
|
||||
const datasetType = type === 'pie' && pieToBar ? 'bar' : type;
|
||||
let datasetPoints = hasOwnYAxis ? secondaryPoints : points[i];
|
||||
let datasetProjection = hasOwnYAxis ? secondaryProjection : projection;
|
||||
|
||||
if (datasetType === 'area') {
|
||||
const { yMin, yMax } = projection.getParams();
|
||||
const yHeight = yMax - yMin;
|
||||
const bottomLine = [
|
||||
{ labelIndex: range.from, stackValue: 0 },
|
||||
{ labelIndex: range.to, stackValue: 0 },
|
||||
];
|
||||
const topLine = [
|
||||
{ labelIndex: range.to, stackValue: yHeight },
|
||||
{ labelIndex: range.from, stackValue: yHeight },
|
||||
];
|
||||
|
||||
datasetPoints = mergeArrays([points[i - 1] || bottomLine, topLine]);
|
||||
}
|
||||
|
||||
if (datasetType === 'pie') {
|
||||
options.center = projection.getCenter();
|
||||
options.radius = getPieRadius(projection);
|
||||
options.pointerVector = state.focusOn;
|
||||
}
|
||||
|
||||
if (datasetType === 'bar') {
|
||||
const [x0] = toPixels(projection, 0, 0);
|
||||
const [x1] = toPixels(projection, 1, 0);
|
||||
|
||||
options.lineWidth = x1 - x0;
|
||||
options.focusOn = state.focusOn;
|
||||
}
|
||||
|
||||
drawDataset(datasetType, context, datasetPoints, datasetProjection, options);
|
||||
});
|
||||
|
||||
if (state.focusOn && data.isBars) {
|
||||
const [x0] = toPixels(projection, 0, 0);
|
||||
const [x1] = toPixels(projection, 1, 0);
|
||||
|
||||
drawBarsMask(context, projection, {
|
||||
focusOn: state.focusOn,
|
||||
color: getCssColor(colors, 'mask'),
|
||||
lineWidth: x1 - x0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function drawDataset(type, ...args) {
|
||||
switch (type) {
|
||||
case 'line':
|
||||
return drawDatasetLine(...args);
|
||||
case 'bar':
|
||||
return drawDatasetBars(...args);
|
||||
case 'area':
|
||||
return drawDatasetArea(...args);
|
||||
case 'pie':
|
||||
return drawDatasetPie(...args);
|
||||
}
|
||||
}
|
||||
|
||||
function drawDatasetLine(context, points, projection, options) {
|
||||
context.beginPath();
|
||||
|
||||
let pixels = [];
|
||||
|
||||
for (let j = 0, l = points.length; j < l; j++) {
|
||||
const { labelIndex, stackValue } = points[j];
|
||||
pixels.push(toPixels(projection, labelIndex, stackValue));
|
||||
}
|
||||
|
||||
if (options.simplification) {
|
||||
const simplifierFn = simplify(pixels);
|
||||
pixels = simplifierFn(options.simplification).points;
|
||||
}
|
||||
|
||||
pixels.forEach(([x, y]) => {
|
||||
context.lineTo(x, y);
|
||||
});
|
||||
|
||||
context.save();
|
||||
context.strokeStyle = options.color;
|
||||
context.lineWidth = options.lineWidth;
|
||||
context.globalAlpha = options.opacity;
|
||||
context.lineJoin = 'bevel';
|
||||
context.lineCap = 'butt';
|
||||
context.stroke();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
// TODO try areas
|
||||
function drawDatasetBars(context, points, projection, options) {
|
||||
const { yMin } = projection.getParams();
|
||||
|
||||
context.save();
|
||||
context.globalAlpha = options.opacity;
|
||||
context.fillStyle = options.color;
|
||||
|
||||
for (let j = 0, l = points.length; j < l; j++) {
|
||||
const { labelIndex, stackValue, stackOffset = 0 } = points[j];
|
||||
|
||||
const [, yFrom] = toPixels(projection, labelIndex, Math.max(stackOffset, yMin));
|
||||
const [x, yTo] = toPixels(projection, labelIndex, stackValue);
|
||||
const rectX = x - options.lineWidth / 2;
|
||||
const rectY = yTo;
|
||||
const rectW = options.opacity === 1 ?
|
||||
options.lineWidth + PLOT_BARS_WIDTH_SHIFT :
|
||||
options.lineWidth + PLOT_BARS_WIDTH_SHIFT * options.opacity;
|
||||
const rectH = yFrom - yTo;
|
||||
|
||||
context.fillRect(rectX, rectY, rectW, rectH);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function drawBarsMask(context, projection, options) {
|
||||
const [xCenter, yCenter] = projection.getCenter();
|
||||
const [width, height] = projection.getSize();
|
||||
|
||||
const [x] = toPixels(projection, options.focusOn, 0);
|
||||
|
||||
context.fillStyle = options.color;
|
||||
context.fillRect(xCenter - width / 2, yCenter - height / 2, x - options.lineWidth / 2 + PLOT_BARS_WIDTH_SHIFT, height);
|
||||
context.fillRect(x + options.lineWidth / 2, yCenter - height / 2, width - (x + options.lineWidth / 2), height);
|
||||
}
|
||||
|
||||
function drawDatasetArea(context, points, projection, options) {
|
||||
context.beginPath();
|
||||
|
||||
let pixels = [];
|
||||
|
||||
for (let j = 0, l = points.length; j < l; j++) {
|
||||
const { labelIndex, stackValue } = points[j];
|
||||
pixels.push(toPixels(projection, labelIndex, stackValue));
|
||||
}
|
||||
|
||||
if (options.simplification) {
|
||||
const simplifierFn = simplify(pixels);
|
||||
pixels = simplifierFn(options.simplification).points;
|
||||
}
|
||||
|
||||
pixels.forEach(([x, y]) => {
|
||||
context.lineTo(x, y);
|
||||
});
|
||||
|
||||
context.save();
|
||||
context.fillStyle = options.color;
|
||||
context.lineWidth = options.lineWidth;
|
||||
context.globalAlpha = options.opacity;
|
||||
context.lineJoin = 'bevel';
|
||||
context.lineCap = 'butt';
|
||||
context.fill();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function drawDatasetPie(context, points, projection, options) {
|
||||
const { visibleValue, stackValue, stackOffset = 0 } = points[0];
|
||||
|
||||
if (!visibleValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { yMin, yMax } = projection.getParams();
|
||||
const percentFactor = 1 / (yMax - yMin);
|
||||
const percent = visibleValue * percentFactor;
|
||||
|
||||
const beginAngle = stackOffset * percentFactor * Math.PI * 2 - Math.PI / 2;
|
||||
const endAngle = stackValue * percentFactor * Math.PI * 2 - Math.PI / 2;
|
||||
|
||||
const { radius = 120, center: [x, y], pointerVector } = options;
|
||||
|
||||
const shift = (
|
||||
pointerVector &&
|
||||
beginAngle <= pointerVector.angle &&
|
||||
pointerVector.angle < endAngle &&
|
||||
pointerVector.distance <= radius
|
||||
) ? PLOT_PIE_SHIFT : 0;
|
||||
|
||||
const shiftAngle = (beginAngle + endAngle) / 2;
|
||||
const directionX = Math.cos(shiftAngle);
|
||||
const directionY = Math.sin(shiftAngle);
|
||||
const shiftX = directionX * shift;
|
||||
const shiftY = directionY * shift;
|
||||
|
||||
context.save();
|
||||
|
||||
context.beginPath();
|
||||
context.fillStyle = options.color;
|
||||
context.moveTo(x + shiftX, y + shiftY);
|
||||
context.arc(x + shiftX, y + shiftY, radius, beginAngle, endAngle);
|
||||
context.lineTo(x + shiftX, y + shiftY);
|
||||
context.fill();
|
||||
|
||||
context.font = `700 ${getPieTextSize(percent, radius)}px Helvetica, Arial, sans-serif`;
|
||||
context.textAlign = 'center';
|
||||
context.textBaseline = 'middle';
|
||||
context.fillStyle = 'white';
|
||||
const textShift = getPieTextShift(percent, radius);
|
||||
context.fillText(
|
||||
`${Math.round(percent * 100)}%`, x + directionX * textShift + shiftX, y + directionY * textShift + shiftY,
|
||||
);
|
||||
|
||||
context.restore();
|
||||
}
|
||||
90
src/lib/lovely-chart/format.js
Normal file
90
src/lib/lovely-chart/format.js
Normal file
@ -0,0 +1,90 @@
|
||||
import { MONTHS, WEEK_DAYS, WEEK_DAYS_SHORT } from './constants';
|
||||
|
||||
export function statsFormatDayHour(labels) {
|
||||
return labels.map((value) => ({
|
||||
value,
|
||||
text: `${value}:00`,
|
||||
}));
|
||||
}
|
||||
|
||||
export function statsFormatDayHourFull(value) {
|
||||
return `${value}:00`;
|
||||
}
|
||||
|
||||
export function statsFormatDay(labels) {
|
||||
return labels.map((value) => {
|
||||
const date = new Date(value);
|
||||
const day = date.getDate();
|
||||
const month = MONTHS[date.getMonth()];
|
||||
|
||||
return ({
|
||||
value,
|
||||
text: `${day} ${month}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function statsFormatMin(labels) {
|
||||
return labels.map((value) => ({
|
||||
value,
|
||||
text: new Date(value).toString().match(/(\d+:\d+):/)[1],
|
||||
}));
|
||||
}
|
||||
|
||||
export function statsFormatText(labels) {
|
||||
return labels.map((value, i) => {
|
||||
return ({
|
||||
value: i,
|
||||
text: value,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function humanize(value, decimals = 1) {
|
||||
if (value >= 1e6) {
|
||||
return keepThreeDigits(value / 1e6, decimals) + 'M';
|
||||
} else if (value >= 1e3) {
|
||||
return keepThreeDigits(value / 1e3, decimals) + 'K';
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// TODO perf
|
||||
function keepThreeDigits(value, decimals) {
|
||||
return value
|
||||
.toFixed(decimals)
|
||||
.replace(/(\d{3,})\.\d+/, '$1')
|
||||
.replace(/\.0+$/, '');
|
||||
}
|
||||
|
||||
export function formatInteger(n) {
|
||||
return String(n).replace(/\d(?=(\d{3})+$)/g, '$& ');
|
||||
}
|
||||
|
||||
export function getFullLabelDate(label, { isShort = false } = {}) {
|
||||
return getLabelDate(label, { isShort, displayWeekDay: true });
|
||||
}
|
||||
|
||||
export function getLabelDate(label, { isShort = false, displayWeekDay = false, displayYear = true, displayHours = false } = {}) {
|
||||
const { value } = label;
|
||||
const date = new Date(value);
|
||||
const weekDaysArray = isShort ? WEEK_DAYS_SHORT : WEEK_DAYS;
|
||||
|
||||
let string = `${date.getUTCDate()} ${MONTHS[date.getUTCMonth()]}`;
|
||||
if (displayWeekDay) {
|
||||
string = `${weekDaysArray[date.getUTCDay()]}, ` + string;
|
||||
}
|
||||
if (displayYear) {
|
||||
string += ` ${date.getUTCFullYear()}`;
|
||||
}
|
||||
if (displayHours) {
|
||||
string += `, ${('0' + date.getUTCHours()).slice(-2)}:${('0' + date.getUTCMinutes()).slice(-2)}`
|
||||
}
|
||||
|
||||
return string;
|
||||
}
|
||||
|
||||
export function getLabelTime(label) {
|
||||
return new Date(label.value).toString().match(/(\d+:\d+):/)[1];
|
||||
}
|
||||
56
src/lib/lovely-chart/formulas.js
Normal file
56
src/lib/lovely-chart/formulas.js
Normal file
@ -0,0 +1,56 @@
|
||||
import { GUTTER, PLOT_PIE_RADIUS_FACTOR, MILISECONDS_IN_DAY, SIMPLIFIER_MIN_POINTS } from './constants';
|
||||
|
||||
export function xScaleLevelToStep(scaleLevel) {
|
||||
return Math.pow(2, scaleLevel);
|
||||
}
|
||||
|
||||
export function xStepToScaleLevel(step) {
|
||||
return Math.ceil(Math.log2(step || 1));
|
||||
}
|
||||
|
||||
const SCALE_LEVELS = [
|
||||
1, 2, 8, 18, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 25000, 50000, 100000,
|
||||
250000, 500000, 1000000, 2500000, 5000000, 10000000, 25000000, 50000000, 100000000,
|
||||
];
|
||||
|
||||
export function yScaleLevelToStep(scaleLevel) {
|
||||
return SCALE_LEVELS[scaleLevel] || SCALE_LEVELS[SCALE_LEVELS.length - 1];
|
||||
}
|
||||
|
||||
export function yStepToScaleLevel(neededStep) {
|
||||
return SCALE_LEVELS.findIndex((step) => step >= neededStep) || SCALE_LEVELS.length - 1;
|
||||
}
|
||||
|
||||
export function applyYEdgeOpacity(opacity, xPx, plotWidth) {
|
||||
const edgeOffset = Math.min(xPx + GUTTER, plotWidth - xPx);
|
||||
if (edgeOffset <= GUTTER * 4) {
|
||||
opacity = Math.min(1, opacity, edgeOffset / (GUTTER * 4));
|
||||
}
|
||||
return opacity;
|
||||
}
|
||||
|
||||
export function applyXEdgeOpacity(opacity, yPx) {
|
||||
return (yPx - GUTTER <= GUTTER * 2)
|
||||
? Math.min(1, opacity, (yPx - GUTTER) / (GUTTER * 2))
|
||||
: opacity;
|
||||
}
|
||||
|
||||
export function getPieRadius(projection) {
|
||||
return Math.min(...projection.getSize()) * PLOT_PIE_RADIUS_FACTOR;
|
||||
}
|
||||
|
||||
export function getPieTextSize(percent, radius) {
|
||||
return (radius + percent * 200) / 10;
|
||||
}
|
||||
|
||||
export function getPieTextShift(percent, radius, shift) {
|
||||
return percent >= 0.99 ? 0 : Math.min(1 - Math.log(percent * 30) / 5, 4 / 5) * radius;
|
||||
}
|
||||
|
||||
export function isDataRange(labelFrom, labelTo) {
|
||||
return Math.abs(labelTo.value - labelFrom.value) > MILISECONDS_IN_DAY;
|
||||
}
|
||||
|
||||
export function getSimplificationDelta(pointsLength) {
|
||||
return pointsLength >= SIMPLIFIER_MIN_POINTS ? Math.min((pointsLength / 1000), 1) : 0;
|
||||
}
|
||||
42
src/lib/lovely-chart/hideOnScroll.js
Normal file
42
src/lib/lovely-chart/hideOnScroll.js
Normal file
@ -0,0 +1,42 @@
|
||||
import { debounce } from './utils';
|
||||
|
||||
export const hideOnScroll = (() => {
|
||||
const chartEls = [];
|
||||
const showAllDebounced = debounce(showAll, 500, true, false);
|
||||
const hideScrolledDebounced = debounce(hideScrolled, 500, false, true);
|
||||
|
||||
function setup(chartEl) {
|
||||
chartEls.push(chartEl);
|
||||
|
||||
if (chartEls.length === 1) {
|
||||
window.onscroll = () => {
|
||||
showAllDebounced();
|
||||
hideScrolledDebounced();
|
||||
};
|
||||
} else {
|
||||
hideScrolledDebounced();
|
||||
}
|
||||
}
|
||||
|
||||
function showAll() {
|
||||
chartEls.forEach((chartEl) => {
|
||||
chartEl.classList.remove('lovely-chart--state-invisible');
|
||||
});
|
||||
}
|
||||
|
||||
function hideScrolled() {
|
||||
chartEls.forEach((chartEl) => {
|
||||
const { top, bottom } = chartEl.getBoundingClientRect();
|
||||
const shouldHide = bottom < 0 || top > window.innerHeight;
|
||||
|
||||
if (!chartEl.classList.contains('lovely-chart--state-invisible')) {
|
||||
chartEl.style.width = `${chartEl.scrollWidth}px`;
|
||||
chartEl.style.height = `${chartEl.scrollHeight}px`;
|
||||
}
|
||||
|
||||
chartEl.classList.toggle('lovely-chart--state-invisible', shouldHide);
|
||||
});
|
||||
}
|
||||
|
||||
return setup;
|
||||
})();
|
||||
11
src/lib/lovely-chart/minifiers.js
Normal file
11
src/lib/lovely-chart/minifiers.js
Normal file
@ -0,0 +1,11 @@
|
||||
export const createElement = (tagName = 'div') => {
|
||||
return document.createElement(tagName);
|
||||
};
|
||||
|
||||
export function addEventListener(element, event, cb) {
|
||||
element.addEventListener(event, cb);
|
||||
}
|
||||
|
||||
export function removeEventListener(element, event, cb) {
|
||||
element.removeEventListener(event, cb);
|
||||
}
|
||||
79
src/lib/lovely-chart/preparePoints.js
Normal file
79
src/lib/lovely-chart/preparePoints.js
Normal file
@ -0,0 +1,79 @@
|
||||
import { sumArrays } from './utils';
|
||||
|
||||
export function preparePoints(data, datasets, range, visibilities, bounds, pieToArea) {
|
||||
let values = datasets.map(({ values }) => (
|
||||
values.slice(range.from, range.to + 1)
|
||||
));
|
||||
|
||||
if (data.isPie && !pieToArea) {
|
||||
values = prepareSumsByX(values);
|
||||
}
|
||||
|
||||
const points = values.map((datasetValues, i) => (
|
||||
datasetValues.map((value, j) => {
|
||||
let visibleValue = value;
|
||||
|
||||
if (data.isStacked) {
|
||||
visibleValue *= visibilities[i];
|
||||
}
|
||||
|
||||
return {
|
||||
labelIndex: range.from + j,
|
||||
value,
|
||||
visibleValue,
|
||||
stackOffset: 0,
|
||||
stackValue: visibleValue,
|
||||
};
|
||||
})
|
||||
));
|
||||
|
||||
if (data.isPercentage) {
|
||||
preparePercentage(points, bounds);
|
||||
}
|
||||
|
||||
if (data.isStacked) {
|
||||
prepareStacked(points);
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
function getSumsByY(points) {
|
||||
return sumArrays(points.map((datasetPoints) => (
|
||||
datasetPoints.map(({ visibleValue }) => visibleValue)
|
||||
)));
|
||||
}
|
||||
|
||||
// TODO perf cache for [0..1], use in state
|
||||
function preparePercentage(points, bounds) {
|
||||
const sumsByY = getSumsByY(points);
|
||||
|
||||
points.forEach((datasetPoints) => {
|
||||
datasetPoints.forEach((point, j) => {
|
||||
point.percent = point.visibleValue / sumsByY[j];
|
||||
point.visibleValue = point.percent * bounds.yMax;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function prepareStacked(points) {
|
||||
const accum = [];
|
||||
|
||||
points.forEach((datasetPoints) => {
|
||||
datasetPoints.forEach((point, j) => {
|
||||
if (accum[j] === undefined) {
|
||||
accum[j] = 0;
|
||||
}
|
||||
|
||||
point.stackOffset = accum[j];
|
||||
accum[j] += point.visibleValue;
|
||||
point.stackValue = accum[j];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function prepareSumsByX(values) {
|
||||
return values.map((datasetValues) => (
|
||||
[datasetValues.reduce((sum, value) => sum + value, 0)]
|
||||
));
|
||||
}
|
||||
201
src/lib/lovely-chart/simplify.js
Normal file
201
src/lib/lovely-chart/simplify.js
Normal file
@ -0,0 +1,201 @@
|
||||
export const simplify = (() => {
|
||||
function simplify(points, indexes, fixedPoints) {
|
||||
if (points.length < 6) {
|
||||
return function () {
|
||||
return {
|
||||
points: points,
|
||||
indexes: indexes,
|
||||
removed: [],
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let worker = precalculate(points, fixedPoints);
|
||||
|
||||
return function (delta) {
|
||||
let result = [],
|
||||
resultIndexes = [],
|
||||
removed = [];
|
||||
|
||||
let delta2 = delta * delta,
|
||||
markers = worker(delta2);
|
||||
|
||||
for (let i = 0, l = points.length; i < l; i++) {
|
||||
if (markers[i] >= delta2 || i == 0 || i == l - 1) {
|
||||
result.push(points[i]);
|
||||
resultIndexes.push(indexes ? indexes[i] : i);
|
||||
} else {
|
||||
removed.push(i);
|
||||
}
|
||||
}
|
||||
return {
|
||||
points: result,
|
||||
indexes: resultIndexes,
|
||||
removed: removed,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let E1 = 1.0 / Math.pow(2, 22), // максимальная дельта
|
||||
MAXLIMIT = 100000;
|
||||
|
||||
function precalculate(points, fixedPoints) {
|
||||
|
||||
let len = points.length,
|
||||
distances = [],
|
||||
queue = [],
|
||||
maximumDelta;
|
||||
for (let i = 0, l = points.length; i < l; ++i) {
|
||||
distances[i] = 0;
|
||||
}
|
||||
|
||||
if (!fixedPoints) {
|
||||
fixedPoints = [];
|
||||
}
|
||||
|
||||
//инициализируем дерево срединным значением
|
||||
//чтобы не попадает в ситуации когда начало линии близко к концу(те полигон)
|
||||
//и правильные расчеты сложны
|
||||
let subdivisionTree = 0;
|
||||
|
||||
for (let i = 0, l = fixedPoints.length; i < l; ++i) {
|
||||
distances[fixedPoints[i]] = MAXLIMIT;
|
||||
}
|
||||
|
||||
|
||||
function worker(params) {
|
||||
|
||||
let start = params.start,
|
||||
end = params.end,
|
||||
record = params.record,
|
||||
currentLimit = params.currentLimit,
|
||||
usedDistance = 0;
|
||||
|
||||
if (!record) {
|
||||
//let deltaShifts = getDeltaShifts(points);
|
||||
let usedIndex = -1,
|
||||
vector = [
|
||||
points[end][0] - points[start][0],
|
||||
points[end][1] - points[start][1],
|
||||
];
|
||||
for (let i = 0, l = fixedPoints.length; i < l; ++i) {
|
||||
let fixId = fixedPoints[i];
|
||||
if (fixId > start) {
|
||||
if (fixId < end) {
|
||||
usedIndex = fixId;
|
||||
usedDistance = MAXLIMIT;
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (usedIndex < 0) {
|
||||
if (Math.abs(vector[0]) > E1 || Math.abs(vector[1]) > E1) {
|
||||
let vectorLength = vector[0] * vector[0] + vector[1] * vector[1],
|
||||
vectorLength_1 = +1.0 / vectorLength;
|
||||
|
||||
for (let i = start + 1; i < end; ++i) {
|
||||
let segmentDistance = pointToSegmentDistanceSquare(points[i], points[start], points[end], vector, vectorLength_1);
|
||||
|
||||
if (segmentDistance > usedDistance) {
|
||||
usedIndex = i;
|
||||
usedDistance = segmentDistance;
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
//фиксируем на среднинной точке
|
||||
usedIndex = Math.round((start + end) * 0.5);
|
||||
usedDistance = currentLimit;
|
||||
}
|
||||
distances[usedIndex] = usedDistance;
|
||||
}
|
||||
record = {
|
||||
start: start,
|
||||
end: end,
|
||||
index: usedIndex,
|
||||
distance: usedDistance,
|
||||
};
|
||||
}
|
||||
|
||||
if (record.index && record.distance > maximumDelta) {
|
||||
if (record.index - start >= 2) {
|
||||
queue.push({
|
||||
start: start,
|
||||
end: record.index,
|
||||
record: record.left,
|
||||
currentLimit: record.distance,
|
||||
parent: record,
|
||||
parentProperty: 'left',
|
||||
});
|
||||
}
|
||||
if (end - record.index >= 2) {
|
||||
queue.push({
|
||||
start: record.index,
|
||||
end: end,
|
||||
record: record.right,
|
||||
currentLimit: record.distance,
|
||||
parent: record,
|
||||
parentProperty: 'right',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
let request = queue.pop(),
|
||||
result = worker(request);
|
||||
|
||||
if (request.parent && request.parentProperty) {
|
||||
request.parent[request.parentProperty] = result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return function (delta) {
|
||||
maximumDelta = delta;
|
||||
queue.push({
|
||||
start: 0,
|
||||
end: len - 1,
|
||||
record: subdivisionTree,
|
||||
currentLimit: MAXLIMIT,
|
||||
});
|
||||
subdivisionTree = tick();
|
||||
|
||||
while (queue.length) {
|
||||
tick();
|
||||
}
|
||||
|
||||
return distances;
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
function pointToSegmentDistanceSquare(p, v1, v2, dv, dvlen_1) {
|
||||
|
||||
let t;
|
||||
let vx = +v1[0],
|
||||
vy = +v1[1];
|
||||
|
||||
t = +((p[0] - vx) * dv[0] + (p[1] - vy) * dv[1]) * (dvlen_1);
|
||||
|
||||
if (t > 1) {
|
||||
vx = +v2[0];
|
||||
vy = +v2[1];
|
||||
} else if (t > 0) {
|
||||
vx += +dv[0] * t;
|
||||
vy += +dv[1] * t;
|
||||
}
|
||||
|
||||
let a = +p[0] - vx,
|
||||
b = +p[1] - vy;
|
||||
|
||||
return +a * a + b * b;
|
||||
}
|
||||
|
||||
return simplify;
|
||||
})();
|
||||
90
src/lib/lovely-chart/skin.js
Normal file
90
src/lib/lovely-chart/skin.js
Normal file
@ -0,0 +1,90 @@
|
||||
function detectSkin() {
|
||||
return document.documentElement.classList.contains('theme-dark') ? 'skin-night' : 'skin-day';
|
||||
}
|
||||
|
||||
let skin = detectSkin();
|
||||
|
||||
const COLORS = {
|
||||
'skin-day': {
|
||||
'background': '#FFFFFF',
|
||||
'text-color': '#222222',
|
||||
'minimap-mask': '#E2EEF9/0.6',
|
||||
'minimap-slider': '#C0D1E1',
|
||||
'grid-lines': '#182D3B/0.1',
|
||||
'zoom-out-text': '#108BE3',
|
||||
'tooltip-background': '#FFFFFF',
|
||||
'tooltip-arrow': '#D2D5D7',
|
||||
'mask': '#FFFFFF/0.5',
|
||||
'x-axis-text': '#252529/0.6',
|
||||
'y-axis-text': '#252529/0.6',
|
||||
},
|
||||
'skin-night': {
|
||||
'background': '#242F3E',
|
||||
'text-color': '#FFFFFF',
|
||||
'minimap-mask': '#304259/0.6',
|
||||
'minimap-slider': '#56626D',
|
||||
'grid-lines': '#FFFFFF/0.1',
|
||||
'zoom-out-text': '#48AAF0',
|
||||
'tooltip-background': '#1c2533',
|
||||
'tooltip-arrow': '#D2D5D7',
|
||||
'mask': '#242F3E/0.5',
|
||||
'x-axis-text': '#A3B1C2/0.6',
|
||||
'y-axis-text': '#A3B1C2/0.6',
|
||||
},
|
||||
};
|
||||
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.type = 'text/css';
|
||||
styleElement.appendChild(document.createTextNode(''));
|
||||
document.head.appendChild(styleElement);
|
||||
const styleSheet = styleElement.sheet;
|
||||
|
||||
document.documentElement.addEventListener('darkmode', () => {
|
||||
skin = detectSkin();
|
||||
});
|
||||
|
||||
export function createColors(datasetColors) {
|
||||
const colors = {};
|
||||
const baseClass = `.lovely-chart--color`;
|
||||
|
||||
['skin-day', 'skin-night'].forEach((skin) => {
|
||||
colors[skin] = {};
|
||||
|
||||
Object.keys(COLORS[skin]).forEach((prop) => {
|
||||
colors[skin][prop] = hexToChannels(COLORS[skin][prop]);
|
||||
});
|
||||
|
||||
Object.keys(datasetColors).forEach((key) => {
|
||||
colors[skin][`dataset#${key}`] = hexToChannels(datasetColors[key]);
|
||||
|
||||
addCssRule(styleSheet, `.lovely-chart--tooltip-dataset-value${baseClass}-${datasetColors[key].slice(1)}`, `color: ${datasetColors[key]}`);
|
||||
addCssRule(styleSheet, `.lovely-chart--button${baseClass}-${datasetColors[key].slice(1)}`, `border-color: ${datasetColors[key]}; color: ${datasetColors[key]}`);
|
||||
addCssRule(styleSheet, `.lovely-chart--button.lovely-chart--state-checked${baseClass}-${datasetColors[key].slice(1)}`, `background-color: ${datasetColors[key]}`);
|
||||
});
|
||||
});
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
export function getCssColor(colors, key, opacity) {
|
||||
return buildCssColor(colors[skin][key], opacity);
|
||||
}
|
||||
|
||||
function hexToChannels(hexWithAlpha) {
|
||||
const [hex, alpha] = hexWithAlpha.replace('#', '').split('/');
|
||||
|
||||
return [
|
||||
parseInt(hex.slice(0, 2), 16),
|
||||
parseInt(hex.slice(2, 4), 16),
|
||||
parseInt(hex.slice(4, 6), 16),
|
||||
alpha ? parseFloat(alpha) : 1,
|
||||
];
|
||||
}
|
||||
|
||||
function buildCssColor([r, g, b, a = 1], opacity = 1) {
|
||||
return `rgba(${r}, ${g}, ${b}, ${a * opacity})`;
|
||||
}
|
||||
|
||||
function addCssRule(sheet, selector, rule) {
|
||||
sheet.insertRule(`${selector} { ${rule} }`, sheet.cssRules.length);
|
||||
}
|
||||
124
src/lib/lovely-chart/styles/_animations.scss
Normal file
124
src/lib/lovely-chart/styles/_animations.scss
Normal file
@ -0,0 +1,124 @@
|
||||
@keyframes lovely-chart--animation-shake {
|
||||
0% {
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
|
||||
16.66% {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
33.33% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
66.66% {
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
83.33% {
|
||||
transform: translateX(1px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lovely-chart--animation-fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale3d(.5, .5, 1);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: scale3d(.75, .75, 1);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lovely-chart--animation-fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: scale3d(.75, .75, 1);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale3d(.5, .5, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lovely-chart--animation-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg)
|
||||
}
|
||||
}
|
||||
|
||||
.lovely-chart--transition-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lovely-chart--transition {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
|
||||
&.lovely-chart--position-top {
|
||||
transform-origin: top left;
|
||||
|
||||
&.lovely-chart--position-right {
|
||||
transform-origin: top right;
|
||||
}
|
||||
}
|
||||
|
||||
&.lovely-chart--position-bottom {
|
||||
transform-origin: bottom left;
|
||||
|
||||
&.lovely-chart--position-right {
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
}
|
||||
|
||||
&.lovely-chart--position-left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&.lovely-chart--position-right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&.lovely-chart--state-hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.lovely-chart--state-animated {
|
||||
animation-fill-mode: forwards;
|
||||
animation-timing-function: ease-out;
|
||||
animation-duration: 200ms;
|
||||
|
||||
&.lovely-chart--state-hidden {
|
||||
animation-name: lovely-chart--animation-fadeOut;
|
||||
animation-duration: 300ms;
|
||||
}
|
||||
|
||||
&:not(.lovely-chart--state-hidden) {
|
||||
animation-name: lovely-chart--animation-fadeIn;
|
||||
}
|
||||
}
|
||||
67
src/lib/lovely-chart/styles/_buttons.scss
Normal file
67
src/lib/lovely-chart/styles/_buttons.scss
Normal file
@ -0,0 +1,67 @@
|
||||
.lovely-chart--button {
|
||||
border: 1px solid #E6ECF0;
|
||||
background-color: transparent;
|
||||
padding: 7px 7px;
|
||||
display: inline-block;
|
||||
border-radius: 18px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
margin: 0 6px 8px 0;
|
||||
// transition: background-color 300ms ease-out, border-color 300ms ease-out;
|
||||
position: relative;
|
||||
|
||||
border-color: var(--text-color);
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
|
||||
&.lovely-chart--state-checked {
|
||||
background-color: var(--text-color);
|
||||
animation-duration: 500ms;
|
||||
|
||||
.lovely-chart--button-check {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lovely-chart--button-label {
|
||||
color: #ffffff;
|
||||
transform: translateX(6px);
|
||||
}
|
||||
}
|
||||
|
||||
&.lovely-chart--state-shake {
|
||||
animation-name: lovely-chart--animation-shake;
|
||||
}
|
||||
}
|
||||
|
||||
.lovely-chart--button-check {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
left: 7px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
opacity: 0;
|
||||
transition: opacity 300ms ease-out;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
content: '';
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: -6px;
|
||||
margin-top: -6px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -256 1792 1792' version='1.1'%0A%3E%3Cg transform='matrix(1,0,0,-1,7.5932203,1217.0847)' id='g3003'%3E%3Cpath d='m 1671,970 q 0,-40 -28,-68 L 919,178 783,42 Q 755,14 715,14 675,14 647,42 L 511,178 149,540 q -28,28 -28,68 0,40 28,68 l 136,136 q 28,28 68,28 40,0 68,-28 l 294,-295 656,657 q 28,28 68,28 40,0 68,-28 l 136,-136 q 28,-28 28,-68 z' style='fill:white'/%3E%3C/g%3E%3C/svg%3E");
|
||||
background-size: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.lovely-chart--button-label {
|
||||
display: inline-block;
|
||||
font-weight: 300;
|
||||
line-height: 20px;
|
||||
color: inherit;
|
||||
padding: 0 13px;
|
||||
transition: transform 300ms ease-out, color 300ms ease-out;
|
||||
}
|
||||
152
src/lib/lovely-chart/styles/_common.scss
Normal file
152
src/lib/lovely-chart/styles/_common.scss
Normal file
@ -0,0 +1,152 @@
|
||||
.lovely-chart--container {
|
||||
font: 300 13px '-apple-system', 'HelveticaNeue', Helvetica, Arial, sans-serif;
|
||||
color: #222222;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
|
||||
&.lovely-chart--state-invisible > * {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> canvas,
|
||||
.lovely-chart--tooltip canvas {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.lovely-chart--container-type-pie {
|
||||
&.lovely-chart--state-animating {
|
||||
> canvas {
|
||||
transform-origin: center calc(50% - 7.5px);
|
||||
}
|
||||
|
||||
&.lovely-chart--state-zoomed-in > canvas {
|
||||
animation: lovely-chart--animation-pie-transition-in 0.6s;
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
&:not(.lovely-chart--state-zoomed-in) > canvas {
|
||||
animation: lovely-chart--animation-pie-transition-out 0.55s;
|
||||
animation-timing-function: ease-in;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lovely-chart--animation-pie-transition-in {
|
||||
0% {
|
||||
clip-path: circle(80% at center calc(50% - 7.5px));
|
||||
-webkit-clip-path: circle(80% at center calc(50% - 7.5px));
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
|
||||
25% {
|
||||
clip-path: circle(32% at center calc(50% - 7.5px));
|
||||
-webkit-clip-path: circle(32% at center calc(50% - 7.5px));
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
|
||||
75% {
|
||||
clip-path: circle(32% at center calc(50% - 7.5px));
|
||||
-webkit-clip-path: circle(32% at center calc(50% - 7.5px));
|
||||
transform: rotate(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@keyframes lovely-chart--animation-pie-transition-in {
|
||||
0% {
|
||||
clip-path: circle(80% at center calc(50% - 7.5px));
|
||||
-webkit-clip-path: circle(80% at center calc(50% - 7.5px));
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
|
||||
25% {
|
||||
clip-path: circle(40% at center calc(50% - 7.5px));
|
||||
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
|
||||
75% {
|
||||
clip-path: circle(40% at center calc(50% - 7.5px));
|
||||
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
|
||||
transform: rotate(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lovely-chart--animation-pie-transition-out {
|
||||
0% {
|
||||
clip-path: circle(32% at center calc(50% - 7.5px));
|
||||
-webkit-clip-path: circle(32% at center calc(50% - 7.5px));
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
clip-path: circle(32% at center calc(50% - 7.5px));
|
||||
-webkit-clip-path: circle(32% at center calc(50% - 7.5px));
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
75% {
|
||||
clip-path: circle(80% at center calc(50% - 7.5px));
|
||||
-webkit-clip-path: circle(80% at center calc(50% - 7.5px));
|
||||
transform: rotate(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@keyframes lovely-chart--animation-pie-transition-out {
|
||||
0% {
|
||||
clip-path: circle(40% at center calc(50% - 7.5px));
|
||||
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
clip-path: circle(40% at center calc(50% - 7.5px));
|
||||
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
75% {
|
||||
clip-path: circle(80% at center calc(50% - 7.5px));
|
||||
-webkit-clip-path: circle(80% at center calc(50% - 7.5px));
|
||||
transform: rotate(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html.cursor-ew-resize, html.cursor-ew-resize * {
|
||||
cursor: ew-resize !important;
|
||||
}
|
||||
|
||||
html.cursor-grabbing, html.cursor-grabbing * {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
.lovely-chart--spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: relative;
|
||||
|
||||
&.lovely-chart--size-big {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' version='1.1' width='512px' height='512px' viewBox='0 0 16 16'%3E%3Cg%3E%3Cpath fill='%23D2D5D7' d='M9.9 0.2l-0.2 1c3 0.8 5.3 3.5 5.3 6.8 0 3.9-3.1 7-7 7s-7-3.1-7-7c0-3.3 2.3-6 5.3-6.8l-0.2-1c-3.5 0.9-6.1 4.1-6.1 7.8 0 4.4 3.6 8 8 8s8-3.6 8-8c0-3.7-2.6-6.9-6.1-7.8z' data-original='%23444444' class='active-path' data-old_color='%23444444'/%3E%3C/g%3E%3C/svg%3E%0A");
|
||||
background-size: 100%;
|
||||
animation: lovely-chart--animation-spin 1s infinite linear;
|
||||
}
|
||||
}
|
||||
54
src/lib/lovely-chart/styles/_header.scss
Normal file
54
src/lib/lovely-chart/styles/_header.scss
Normal file
@ -0,0 +1,54 @@
|
||||
.lovely-chart--header {
|
||||
height: 30px;
|
||||
font-weight: bold;
|
||||
margin: 0 10px;
|
||||
|
||||
&-title, &-caption {
|
||||
color: var(--text-color);
|
||||
line-height: 30px;
|
||||
// transition: color 300ms ease;
|
||||
|
||||
&.lovely-chart--state-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 16px;
|
||||
float: left;
|
||||
margin-right: 1em;
|
||||
text-transform: lowercase;
|
||||
|
||||
&:first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
&-caption {
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
float: right;
|
||||
}
|
||||
|
||||
&-zoom-out-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--zoom-out-text);
|
||||
cursor: pointer;
|
||||
text-transform: none;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' version='1.1' id='Capa_1' x='0px' y='0px' viewBox='0 0 512 512' style='enable-background:new 0 0 512 512;' xml:space='preserve' width='512px' height='512px'%3E%3Cg%3E%3Cg%3E%3Cpath d='M497.938,430.063l-112-112c-0.367-0.367-0.805-0.613-1.18-0.965C404.438,285.332,416,248.035,416,208 C416,93.313,322.695,0,208,0S0,93.313,0,208s93.305,208,208,208c40.035,0,77.332-11.563,109.098-31.242 c0.354,0.375,0.598,0.813,0.965,1.18l112,112C439.43,507.313,451.719,512,464,512c12.281,0,24.57-4.688,33.938-14.063 C516.688,479.203,516.688,448.797,497.938,430.063z M64,208c0-79.406,64.602-144,144-144s144,64.594,144,144 c0,79.406-64.602,144-144,144S64,287.406,64,208z' data-original='%23000000' class='active-path' data-old_color='%232d98e6' fill='%232d98e6'/%3E%3Cpath d='M272,176H144c-17.672,0-32,14.328-32,32s14.328,32,32,32h128c17.672,0,32-14.328,32-32S289.672,176,272,176z' data-original='%23000000' class='active-path' data-old_color='%232d98e6' fill='%232d98e6'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
142
src/lib/lovely-chart/styles/_minimap.scss
Normal file
142
src/lib/lovely-chart/styles/_minimap.scss
Normal file
@ -0,0 +1,142 @@
|
||||
.lovely-chart--minimap {
|
||||
position: relative;
|
||||
margin: 0 10px 16px 10px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
transition: opacity 400ms ease;
|
||||
|
||||
&.lovely-chart--state-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.lovely-chart--state-transparent {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.lovely-chart--minimap-ruler {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.lovely-chart--minimap-mask {
|
||||
display: inline-block;
|
||||
width: 33.3%;
|
||||
height: 100%;
|
||||
background: var(--minimap-mask);
|
||||
// transition: background-color 300ms ease-out;
|
||||
|
||||
&:last-child {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -5px;
|
||||
width: 5px;
|
||||
background: var(--minimap-mask);
|
||||
// transition: background-color 300ms ease-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lovely-chart--minimap-slider {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
width: 33.3%;
|
||||
height: 100%;
|
||||
min-width: 16px;
|
||||
|
||||
&-inner {
|
||||
box-sizing: border-box;
|
||||
border-top: 1px solid var(--minimap-slider);
|
||||
border-bottom: 1px solid var(--minimap-slider);
|
||||
float: left;
|
||||
width: calc(100% - 16px);
|
||||
height: 100%;
|
||||
background: transparent !important;
|
||||
cursor: grab;
|
||||
// transition: border-color 300ms ease-out;
|
||||
}
|
||||
|
||||
&-handle {
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background: var(--minimap-mask);
|
||||
cursor: ew-resize;
|
||||
// transition: background-color 300ms ease-out;
|
||||
|
||||
&::before, &::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&::before {
|
||||
background: var(--minimap-slider);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
// transition: background-color 300ms ease-out;
|
||||
}
|
||||
|
||||
&-pin {
|
||||
display: block;
|
||||
position: absolute;
|
||||
background: #ffffff;
|
||||
width: 2px;
|
||||
height: 8px;
|
||||
top: calc(50% - 4px);
|
||||
left: calc(50% - 1px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
float: left;
|
||||
|
||||
&::before {
|
||||
border-top-left-radius: 6px;
|
||||
border-bottom-left-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
float: right;
|
||||
|
||||
&::before {
|
||||
border-top-right-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
&:after {
|
||||
top: -10px;
|
||||
bottom: -10px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&:first-child:after {
|
||||
left: -26px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&:last-child:after {
|
||||
left: 0;
|
||||
right: -26px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/lib/lovely-chart/styles/_tools.scss
Normal file
14
src/lib/lovely-chart/styles/_tools.scss
Normal file
@ -0,0 +1,14 @@
|
||||
.lovely-chart--tools {
|
||||
opacity: 1;
|
||||
transition: opacity 400ms ease;
|
||||
|
||||
&.lovely-chart--state-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.lovely-chart--state-transparent {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
padding: 0 10px 16px 10px;
|
||||
}
|
||||
100
src/lib/lovely-chart/styles/_tooltip.scss
Normal file
100
src/lib/lovely-chart/styles/_tooltip.scss
Normal file
@ -0,0 +1,100 @@
|
||||
.lovely-chart--tooltip {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.lovely-chart--tooltip-balloon {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 0;
|
||||
min-width: 130px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
white-space: nowrap;
|
||||
background: var(--tooltip-background);
|
||||
color: var(--text-color);
|
||||
box-shadow: 0 1px 2px 1px rgba(211, 211, 211, 0.8);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-out;
|
||||
cursor: pointer;
|
||||
pointer-events: none;
|
||||
|
||||
&.lovely-chart--state-shown {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
&.lovely-chart--state-inactive {
|
||||
cursor: default;
|
||||
|
||||
.lovely-chart--tooltip-title::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.lovely-chart--state-loading {
|
||||
.lovely-chart--spinner {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.lovely-chart--spinner {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 8px;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.lovely-chart--tooltip-title {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
position: relative;
|
||||
padding-bottom: 5px;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' version='1.1' id='Capa_1' x='0px' y='0px' width='512px' height='512px' viewBox='0 0 451.846 451.847' style='enable-background:new 0 0 451.846 451.847;' xml:space='preserve' class=''%3E%3Cg%3E%3Cg%3E%3Cpath d='M345.441,248.292L151.154,442.573c-12.359,12.365-32.397,12.365-44.75,0c-12.354-12.354-12.354-32.391,0-44.744 L278.318,225.92L106.409,54.017c-12.354-12.359-12.354-32.394,0-44.748c12.354-12.359,32.391-12.359,44.75,0l194.287,194.284 c6.177,6.18,9.262,14.271,9.262,22.366C354.708,234.018,351.617,242.115,345.441,248.292z' data-original='%23000000' class='active-path' data-old_color='%23757B84' fill='%23D2D5D7'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 75%;
|
||||
}
|
||||
}
|
||||
|
||||
.lovely-chart--tooltip-dataset {
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
color: var(--text-color);
|
||||
|
||||
.lovely-chart--tooltip-dataset-value {
|
||||
float: right;
|
||||
font-weight: bold;
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.lovely-chart--percentage-title {
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
width: 35px;
|
||||
margin-right: 5px;
|
||||
text-align: right;
|
||||
|
||||
&.lovely-chart--transition ~ .lovely-chart--dataset-title {
|
||||
padding-left: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
html.theme-dark .lovely-chart--tooltip-balloon {
|
||||
box-shadow: none;
|
||||
}
|
||||
21
src/lib/lovely-chart/styles/_variables.scss
Normal file
21
src/lib/lovely-chart/styles/_variables.scss
Normal file
@ -0,0 +1,21 @@
|
||||
.lovely-chart--container {
|
||||
--background-color: #ffffff;
|
||||
--text-color: #222222;
|
||||
--minimap-mask: #{rgba(#E2EEF9, 0.6)};
|
||||
--minimap-slider: #C0D1E1;
|
||||
--grid-lines: #{rgba(#182D3B, 0.1)};
|
||||
--zoom-out-text: #108BE3;
|
||||
--tooltip-background: #ffffff;
|
||||
--tooltip-arrow: #D2D5D7;
|
||||
}
|
||||
|
||||
html.theme-dark .lovely-chart--container {
|
||||
--background-color: #242F3E;
|
||||
--text-color: #ffffff;
|
||||
--minimap-mask: #{rgba(#304259, 0.6)};
|
||||
--minimap-slider: #56626D;
|
||||
--grid-lines: #{rgba(#FFFFFF, 0.1)};
|
||||
--zoom-out-text: #48AAF0;
|
||||
--tooltip-background: #1c2533;
|
||||
--tooltip-arrow: #D2D5D7;
|
||||
}
|
||||
8
src/lib/lovely-chart/styles/index.scss
Normal file
8
src/lib/lovely-chart/styles/index.scss
Normal file
@ -0,0 +1,8 @@
|
||||
@import 'variables';
|
||||
@import 'animations';
|
||||
@import 'common';
|
||||
@import 'header';
|
||||
@import 'minimap';
|
||||
@import 'tooltip';
|
||||
@import 'tools';
|
||||
@import 'buttons';
|
||||
38
src/lib/lovely-chart/toggleText.js
Normal file
38
src/lib/lovely-chart/toggleText.js
Normal file
@ -0,0 +1,38 @@
|
||||
import { createElement } from './minifiers';
|
||||
|
||||
export function toggleText(element, newText, className = '', inverse = false) {
|
||||
const container = element.parentNode;
|
||||
container.classList.add('lovely-chart--transition-container');
|
||||
|
||||
const newElement = createElement(element.tagName);
|
||||
newElement.className = `${className} lovely-chart--transition lovely-chart--position-${inverse ? 'top' : 'bottom'} lovely-chart--state-hidden`;
|
||||
newElement.innerHTML = newText;
|
||||
|
||||
const selector = className.length ? `.${className.split(' ').join('.')}` : '';
|
||||
const oldElements = container.querySelectorAll(`${selector}.lovely-chart--state-hidden`);
|
||||
oldElements.forEach(e => e.remove());
|
||||
|
||||
element.classList.add('lovely-chart--transition');
|
||||
element.classList.remove('lovely-chart--position-bottom', 'lovely-chart--position-top');
|
||||
element.classList.add(inverse ? 'lovely-chart--position-bottom' : 'lovely-chart--position-top');
|
||||
container.insertBefore(newElement, element.nextSibling);
|
||||
|
||||
toggleElementIn(newElement);
|
||||
toggleElementOut(element);
|
||||
|
||||
return newElement;
|
||||
}
|
||||
|
||||
function toggleElementIn(element) {
|
||||
// Remove and add `animated` class to re-trigger animation
|
||||
element.classList.remove('lovely-chart--state-animated');
|
||||
element.classList.add('lovely-chart--state-animated');
|
||||
element.classList.remove('lovely-chart--state-hidden');
|
||||
}
|
||||
|
||||
function toggleElementOut(element) {
|
||||
// Remove and add `animated` class to re-trigger animation
|
||||
element.classList.remove('lovely-chart--state-animated');
|
||||
element.classList.add('lovely-chart--state-animated');
|
||||
element.classList.add('lovely-chart--state-hidden');
|
||||
}
|
||||
126
src/lib/lovely-chart/utils.js
Normal file
126
src/lib/lovely-chart/utils.js
Normal file
@ -0,0 +1,126 @@
|
||||
// https://jsperf.com/finding-maximum-element-in-an-array
|
||||
export function getMaxMin(array) {
|
||||
const length = array.length;
|
||||
let max = array[0];
|
||||
let min = array[0];
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const value = array[i];
|
||||
|
||||
if (value > max) {
|
||||
max = value;
|
||||
} else if (value < min) {
|
||||
min = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { max, min };
|
||||
}
|
||||
|
||||
// https://jsperf.com/multi-array-concat/24
|
||||
export function mergeArrays(arrays) {
|
||||
return [].concat.apply([], arrays);
|
||||
}
|
||||
|
||||
export function sumArrays(arrays) {
|
||||
const sums = [];
|
||||
const n = arrays.length;
|
||||
|
||||
for (let i = 0, l = arrays[0].length; i < l; i++) {
|
||||
sums[i] = 0;
|
||||
|
||||
for (let j = 0; j < n; j++) {
|
||||
sums[i] += arrays[j][i];
|
||||
}
|
||||
}
|
||||
|
||||
return sums;
|
||||
}
|
||||
|
||||
export function proxyMerge(obj1, obj2) {
|
||||
return new Proxy({}, {
|
||||
get: (obj, prop) => {
|
||||
if (obj[prop] !== undefined) {
|
||||
return obj[prop];
|
||||
} else if (obj2[prop] !== undefined) {
|
||||
return obj2[prop];
|
||||
} else {
|
||||
return obj1[prop];
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function throttle(
|
||||
fn,
|
||||
ms,
|
||||
shouldRunFirst = true,
|
||||
) {
|
||||
let interval = null;
|
||||
let isPending;
|
||||
let args;
|
||||
|
||||
return (..._args) => {
|
||||
isPending = true;
|
||||
args = _args;
|
||||
|
||||
if (!interval) {
|
||||
if (shouldRunFirst) {
|
||||
isPending = false;
|
||||
// @ts-ignore
|
||||
fn(...args);
|
||||
}
|
||||
|
||||
interval = window.setInterval(() => {
|
||||
if (!isPending) {
|
||||
window.clearInterval(interval);
|
||||
interval = null;
|
||||
return;
|
||||
}
|
||||
|
||||
isPending = false;
|
||||
// @ts-ignore
|
||||
fn(...args);
|
||||
}, ms);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function throttleWithRaf(fn) {
|
||||
let waiting = false;
|
||||
let args;
|
||||
|
||||
return function (..._args) {
|
||||
args = _args;
|
||||
|
||||
if (!waiting) {
|
||||
waiting = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
waiting = false;
|
||||
fn(...args);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function debounce(fn, ms, shouldRunFirst = true, shouldRunLast = true) {
|
||||
let waitingTimeout = null;
|
||||
|
||||
return function () {
|
||||
if (waitingTimeout) {
|
||||
clearTimeout(waitingTimeout);
|
||||
waitingTimeout = null;
|
||||
} else if (shouldRunFirst) {
|
||||
fn();
|
||||
}
|
||||
|
||||
waitingTimeout = setTimeout(() => {
|
||||
if (shouldRunLast) {
|
||||
fn();
|
||||
}
|
||||
|
||||
waitingTimeout = null;
|
||||
}, ms);
|
||||
};
|
||||
}
|
||||
@ -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';
|
||||
|
||||
53
src/modules/actions/api/statistics.ts
Normal file
53
src/modules/actions/api/statistics.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { addReducer, getGlobal, setGlobal } from '../../../lib/teact/teactn';
|
||||
|
||||
import { callApi } from '../../../api/gramjs';
|
||||
import { updateStatistics, updateStatisticsGraph } from '../../reducers';
|
||||
import { selectChatMessages, selectChat } from '../../selectors';
|
||||
|
||||
addReducer('loadStatistics', (global, actions, payload) => {
|
||||
const { chatId } = payload;
|
||||
const chat = selectChat(global, chatId);
|
||||
if (!chat?.fullInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const result = await callApi('fetchStatistics', { chat });
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
global = getGlobal();
|
||||
|
||||
if (result?.recentTopMessages.length) {
|
||||
const messages = selectChatMessages(global, chatId);
|
||||
|
||||
result.recentTopMessages = result.recentTopMessages
|
||||
.map((message) => ({ ...message, ...messages[message.msgId] }));
|
||||
}
|
||||
|
||||
global = updateStatistics(global, chatId, result);
|
||||
|
||||
setGlobal(global);
|
||||
})();
|
||||
});
|
||||
|
||||
addReducer('loadStatisticsAsyncGraph', (global, actions, payload) => {
|
||||
const { chatId, token, name, isPercentage } = payload;
|
||||
const chat = selectChat(global, chatId);
|
||||
if (!chat?.fullInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const dcId = chat.fullInfo!.statisticsDcId;
|
||||
const result = await callApi('fetchStatisticsAsyncGraph', { token, dcId, isPercentage });
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
setGlobal(updateStatisticsGraph(getGlobal(), chatId, name, result));
|
||||
})();
|
||||
});
|
||||
@ -109,6 +109,13 @@ addReducer('openChat', (global) => {
|
||||
};
|
||||
});
|
||||
|
||||
addReducer('toggleStatistics', (global) => {
|
||||
return {
|
||||
...global,
|
||||
isStatisticsShown: !global.isStatisticsShown,
|
||||
};
|
||||
});
|
||||
|
||||
addReducer('toggleLeftColumn', (global) => {
|
||||
return {
|
||||
...global,
|
||||
|
||||
@ -8,3 +8,4 @@ export * from './management';
|
||||
export * from './settings';
|
||||
export * from './twoFaSettings';
|
||||
export * from './payments';
|
||||
export * from './statistics';
|
||||
|
||||
33
src/modules/reducers/statistics.ts
Normal file
33
src/modules/reducers/statistics.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { GlobalState } from '../../global/types';
|
||||
import { ApiStatistics, StatisticsGraph } from '../../api/types';
|
||||
|
||||
export function updateStatistics(
|
||||
global: GlobalState, chatId: string, statistics: ApiStatistics,
|
||||
): GlobalState {
|
||||
return {
|
||||
...global,
|
||||
statistics: {
|
||||
byChatId: {
|
||||
...global.statistics.byChatId,
|
||||
[chatId]: statistics,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function updateStatisticsGraph(
|
||||
global: GlobalState, chatId: string, name: string, update: StatisticsGraph,
|
||||
): GlobalState {
|
||||
return {
|
||||
...global,
|
||||
statistics: {
|
||||
byChatId: {
|
||||
...global.statistics.byChatId,
|
||||
[chatId]: {
|
||||
...(global.statistics.byChatId[chatId] || {}),
|
||||
[name]: update,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -8,3 +8,4 @@ export * from './management';
|
||||
export * from './symbols';
|
||||
export * from './payments';
|
||||
export * from './settings';
|
||||
export * from './statistics';
|
||||
|
||||
19
src/modules/selectors/statistics.ts
Normal file
19
src/modules/selectors/statistics.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { GlobalState } from '../../global/types';
|
||||
|
||||
import { selectCurrentMessageList } from './messages';
|
||||
import { selectChat } from './chats';
|
||||
|
||||
export function selectStatistics(global: GlobalState, chatId: string) {
|
||||
return global.statistics.byChatId[chatId];
|
||||
}
|
||||
|
||||
export function selectIsStatisticsShown(global: GlobalState) {
|
||||
if (!global.isStatisticsShown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { chatId: currentChatId } = selectCurrentMessageList(global) || {};
|
||||
const chat = currentChatId ? selectChat(global, currentChatId) : undefined;
|
||||
|
||||
return chat?.fullInfo?.canViewStatistics;
|
||||
}
|
||||
@ -4,7 +4,8 @@ import { NewChatMembersProgress, RightColumnContent } from '../../types';
|
||||
import { getSystemTheme, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
|
||||
import { selectCurrentMessageList, selectIsPollResultsOpen } from './messages';
|
||||
import { selectCurrentTextSearch } from './localSearch';
|
||||
import { selectCurrentGifSearch, selectCurrentStickerSearch } from './symbols';
|
||||
import { selectCurrentStickerSearch, selectCurrentGifSearch } from './symbols';
|
||||
import { selectIsStatisticsShown } from './statistics';
|
||||
import { selectCurrentManagement } from './management';
|
||||
|
||||
export function selectIsMediaViewerOpen(global: GlobalState) {
|
||||
@ -19,6 +20,8 @@ export function selectRightColumnContentKey(global: GlobalState) {
|
||||
RightColumnContent.Search
|
||||
) : selectCurrentManagement(global) ? (
|
||||
RightColumnContent.Management
|
||||
) : selectIsStatisticsShown(global) ? (
|
||||
RightColumnContent.Statistics
|
||||
) : selectCurrentStickerSearch(global).query !== undefined ? (
|
||||
RightColumnContent.StickerSearch
|
||||
) : selectCurrentGifSearch(global).query !== undefined ? (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -51,6 +51,9 @@
|
||||
.icon-volume-3:before {
|
||||
content: "\e991";
|
||||
}
|
||||
.icon-stats:before {
|
||||
content: "\e996";
|
||||
}
|
||||
.icon-copy-media:before {
|
||||
content: "\e995";
|
||||
}
|
||||
|
||||
@ -236,6 +236,7 @@ export enum RightColumnContent {
|
||||
ChatInfo,
|
||||
Search,
|
||||
Management,
|
||||
Statistics,
|
||||
StickerSearch,
|
||||
GifSearch,
|
||||
PollResults,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user