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/apiTl.js
|
||||||
src/lib/gramjs/tl/schemaTl.js
|
src/lib/gramjs/tl/schemaTl.js
|
||||||
|
|
||||||
|
src/lib/lovely-chart
|
||||||
|
|
||||||
webpack.config.js
|
webpack.config.js
|
||||||
jest.config.js
|
jest.config.js
|
||||||
src/lib/secret-sauce/
|
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,
|
migratedFromChatId,
|
||||||
migratedFromMaxId,
|
migratedFromMaxId,
|
||||||
canViewParticipants,
|
canViewParticipants,
|
||||||
|
canViewStats,
|
||||||
linkedChatId,
|
linkedChatId,
|
||||||
hiddenPrehistory,
|
hiddenPrehistory,
|
||||||
call,
|
call,
|
||||||
@ -413,6 +414,7 @@ async function getFullChannelInfo(
|
|||||||
defaultSendAs,
|
defaultSendAs,
|
||||||
requestsPending,
|
requestsPending,
|
||||||
recentRequesters,
|
recentRequesters,
|
||||||
|
statsDc,
|
||||||
} = result.fullChat;
|
} = result.fullChat;
|
||||||
|
|
||||||
const inviteLink = exportedInvite instanceof GramJs.ChatInviteExported
|
const inviteLink = exportedInvite instanceof GramJs.ChatInviteExported
|
||||||
@ -456,6 +458,7 @@ async function getFullChannelInfo(
|
|||||||
maxMessageId: migratedFromMaxId,
|
maxMessageId: migratedFromMaxId,
|
||||||
} : undefined,
|
} : undefined,
|
||||||
canViewMembers: canViewParticipants,
|
canViewMembers: canViewParticipants,
|
||||||
|
canViewStatistics: canViewStats,
|
||||||
isPreHistoryHidden: hiddenPrehistory,
|
isPreHistoryHidden: hiddenPrehistory,
|
||||||
members,
|
members,
|
||||||
kickedMembers,
|
kickedMembers,
|
||||||
@ -467,6 +470,7 @@ async function getFullChannelInfo(
|
|||||||
sendAsId: defaultSendAs ? getApiChatIdFromMtpPeer(defaultSendAs) : undefined,
|
sendAsId: defaultSendAs ? getApiChatIdFromMtpPeer(defaultSendAs) : undefined,
|
||||||
requestsPending,
|
requestsPending,
|
||||||
recentRequesterIds: recentRequesters?.map((userId) => buildApiPeerId(userId, 'user')),
|
recentRequesterIds: recentRequesters?.map((userId) => buildApiPeerId(userId, 'user')),
|
||||||
|
statisticsDcId: statsDc,
|
||||||
},
|
},
|
||||||
users: [...(users || []), ...(bannedUsers || []), ...(adminUsers || [])],
|
users: [...(users || []), ...(bannedUsers || []), ...(adminUsers || [])],
|
||||||
groupCall: call ? {
|
groupCall: call ? {
|
||||||
|
|||||||
@ -167,6 +167,8 @@ export async function invokeRequest<T extends GramJs.AnyRequest>(
|
|||||||
request: T,
|
request: T,
|
||||||
shouldReturnTrue: true,
|
shouldReturnTrue: true,
|
||||||
shouldThrow?: boolean,
|
shouldThrow?: boolean,
|
||||||
|
shouldIgnoreUpdates?: undefined,
|
||||||
|
dcId?: number,
|
||||||
): Promise<true | undefined>;
|
): Promise<true | undefined>;
|
||||||
|
|
||||||
export async function invokeRequest<T extends GramJs.AnyRequest>(
|
export async function invokeRequest<T extends GramJs.AnyRequest>(
|
||||||
@ -174,6 +176,7 @@ export async function invokeRequest<T extends GramJs.AnyRequest>(
|
|||||||
shouldReturnTrue?: boolean,
|
shouldReturnTrue?: boolean,
|
||||||
shouldThrow?: boolean,
|
shouldThrow?: boolean,
|
||||||
shouldIgnoreUpdates?: boolean,
|
shouldIgnoreUpdates?: boolean,
|
||||||
|
dcId?: number,
|
||||||
): Promise<T['__response'] | undefined>;
|
): Promise<T['__response'] | undefined>;
|
||||||
|
|
||||||
export async function invokeRequest<T extends GramJs.AnyRequest>(
|
export async function invokeRequest<T extends GramJs.AnyRequest>(
|
||||||
@ -181,6 +184,7 @@ export async function invokeRequest<T extends GramJs.AnyRequest>(
|
|||||||
shouldReturnTrue = false,
|
shouldReturnTrue = false,
|
||||||
shouldThrow = false,
|
shouldThrow = false,
|
||||||
shouldIgnoreUpdates = false,
|
shouldIgnoreUpdates = false,
|
||||||
|
dcId?: number,
|
||||||
) {
|
) {
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
@ -197,7 +201,7 @@ export async function invokeRequest<T extends GramJs.AnyRequest>(
|
|||||||
console.log(`[GramJs/client] INVOKE ${request.className}`);
|
console.log(`[GramJs/client] INVOKE ${request.className}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await client.invoke(request);
|
const result = await client.invoke(request, dcId);
|
||||||
|
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
|
|||||||
@ -75,3 +75,5 @@ export {
|
|||||||
getAvailableReactions, sendReaction, sendEmojiInteraction, fetchMessageReactionsList,
|
getAvailableReactions, sendReaction, sendEmojiInteraction, fetchMessageReactionsList,
|
||||||
setDefaultReaction, fetchMessageReactions, sendWatchingEmojiInteraction,
|
setDefaultReaction, fetchMessageReactions, sendWatchingEmojiInteraction,
|
||||||
} from './reactions';
|
} 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[];
|
botCommands?: ApiBotCommand[];
|
||||||
enabledReactions?: string[];
|
enabledReactions?: string[];
|
||||||
sendAsId?: string;
|
sendAsId?: string;
|
||||||
|
canViewStatistics?: boolean;
|
||||||
recentRequesterIds?: string[];
|
recentRequesterIds?: string[];
|
||||||
requestsPending?: number;
|
requestsPending?: number;
|
||||||
|
statisticsDcId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiChatMember {
|
export interface ApiChatMember {
|
||||||
|
|||||||
@ -8,3 +8,4 @@ export * from './settings';
|
|||||||
export * from './bots';
|
export * from './bots';
|
||||||
export * from './misc';
|
export * from './misc';
|
||||||
export * from './calls';
|
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 RightSearch } from '../components/right/RightSearch';
|
||||||
export { default as StickerSearch } from '../components/right/StickerSearch';
|
export { default as StickerSearch } from '../components/right/StickerSearch';
|
||||||
export { default as GifSearch } from '../components/right/GifSearch';
|
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 PollResults } from '../components/right/PollResults';
|
||||||
|
|
||||||
export { default as Management } from '../components/right/management/Management';
|
export { default as Management } from '../components/right/management/Management';
|
||||||
|
|||||||
@ -50,6 +50,7 @@ interface StateProps {
|
|||||||
canSearch?: boolean;
|
canSearch?: boolean;
|
||||||
canCall?: boolean;
|
canCall?: boolean;
|
||||||
canMute?: boolean;
|
canMute?: boolean;
|
||||||
|
canViewStatistics?: boolean;
|
||||||
canLeave?: boolean;
|
canLeave?: boolean;
|
||||||
canEnterVoiceChat?: boolean;
|
canEnterVoiceChat?: boolean;
|
||||||
canCreateVoiceChat?: boolean;
|
canCreateVoiceChat?: boolean;
|
||||||
@ -70,6 +71,7 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
|
|||||||
canSearch,
|
canSearch,
|
||||||
canCall,
|
canCall,
|
||||||
canMute,
|
canMute,
|
||||||
|
canViewStatistics,
|
||||||
canLeave,
|
canLeave,
|
||||||
canEnterVoiceChat,
|
canEnterVoiceChat,
|
||||||
canCreateVoiceChat,
|
canCreateVoiceChat,
|
||||||
@ -261,6 +263,7 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
|
|||||||
canSearch={canSearch}
|
canSearch={canSearch}
|
||||||
canCall={canCall}
|
canCall={canCall}
|
||||||
canMute={canMute}
|
canMute={canMute}
|
||||||
|
canViewStatistics={canViewStatistics}
|
||||||
canLeave={canLeave}
|
canLeave={canLeave}
|
||||||
canEnterVoiceChat={canEnterVoiceChat}
|
canEnterVoiceChat={canEnterVoiceChat}
|
||||||
canCreateVoiceChat={canCreateVoiceChat}
|
canCreateVoiceChat={canCreateVoiceChat}
|
||||||
@ -303,6 +306,7 @@ export default memo(withGlobal<OwnProps>(
|
|||||||
const canEnterVoiceChat = ARE_CALLS_SUPPORTED && chat.isCallActive;
|
const canEnterVoiceChat = ARE_CALLS_SUPPORTED && chat.isCallActive;
|
||||||
const canCreateVoiceChat = ARE_CALLS_SUPPORTED && !chat.isCallActive
|
const canCreateVoiceChat = ARE_CALLS_SUPPORTED && !chat.isCallActive
|
||||||
&& (chat.adminRights?.manageCall || (chat.isCreator && isChatBasicGroup(chat)));
|
&& (chat.adminRights?.manageCall || (chat.isCreator && isChatBasicGroup(chat)));
|
||||||
|
const canViewStatistics = chat.fullInfo?.canViewStatistics;
|
||||||
const pendingJoinRequests = chat.fullInfo?.requestsPending;
|
const pendingJoinRequests = chat.fullInfo?.requestsPending;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -315,6 +319,7 @@ export default memo(withGlobal<OwnProps>(
|
|||||||
canSearch,
|
canSearch,
|
||||||
canCall,
|
canCall,
|
||||||
canMute,
|
canMute,
|
||||||
|
canViewStatistics,
|
||||||
canLeave,
|
canLeave,
|
||||||
canEnterVoiceChat,
|
canEnterVoiceChat,
|
||||||
canCreateVoiceChat,
|
canCreateVoiceChat,
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export type OwnProps = {
|
|||||||
canSearch?: boolean;
|
canSearch?: boolean;
|
||||||
canCall?: boolean;
|
canCall?: boolean;
|
||||||
canMute?: boolean;
|
canMute?: boolean;
|
||||||
|
canViewStatistics?: boolean;
|
||||||
canLeave?: boolean;
|
canLeave?: boolean;
|
||||||
canEnterVoiceChat?: boolean;
|
canEnterVoiceChat?: boolean;
|
||||||
canCreateVoiceChat?: boolean;
|
canCreateVoiceChat?: boolean;
|
||||||
@ -67,6 +68,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
|
|||||||
canSearch,
|
canSearch,
|
||||||
canCall,
|
canCall,
|
||||||
canMute,
|
canMute,
|
||||||
|
canViewStatistics,
|
||||||
canLeave,
|
canLeave,
|
||||||
canEnterVoiceChat,
|
canEnterVoiceChat,
|
||||||
canCreateVoiceChat,
|
canCreateVoiceChat,
|
||||||
@ -91,6 +93,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
|
|||||||
openLinkedChat,
|
openLinkedChat,
|
||||||
addContact,
|
addContact,
|
||||||
openCallFallbackConfirm,
|
openCallFallbackConfirm,
|
||||||
|
toggleStatistics,
|
||||||
} = getDispatch();
|
} = getDispatch();
|
||||||
|
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(true);
|
const [isMenuOpen, setIsMenuOpen] = useState(true);
|
||||||
@ -166,6 +169,11 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
|
|||||||
closeMenu();
|
closeMenu();
|
||||||
}, [closeMenu, onSearchClick]);
|
}, [closeMenu, onSearchClick]);
|
||||||
|
|
||||||
|
const handleStatisticsClick = useCallback(() => {
|
||||||
|
toggleStatistics();
|
||||||
|
closeMenu();
|
||||||
|
}, [closeMenu, toggleStatistics]);
|
||||||
|
|
||||||
const handleSelectMessages = useCallback(() => {
|
const handleSelectMessages = useCallback(() => {
|
||||||
enterMessageSelectMode();
|
enterMessageSelectMode();
|
||||||
closeMenu();
|
closeMenu();
|
||||||
@ -266,6 +274,14 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
|
|||||||
>
|
>
|
||||||
{lang('ReportSelectMessages')}
|
{lang('ReportSelectMessages')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
{canViewStatistics && (
|
||||||
|
<MenuItem
|
||||||
|
icon="stats"
|
||||||
|
onClick={handleStatisticsClick}
|
||||||
|
>
|
||||||
|
{lang('Statistics')}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
{canLeave && (
|
{canLeave && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
destructive
|
destructive
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import Profile from './Profile';
|
|||||||
import Transition from '../ui/Transition';
|
import Transition from '../ui/Transition';
|
||||||
import RightSearch from './RightSearch.async';
|
import RightSearch from './RightSearch.async';
|
||||||
import Management from './management/Management.async';
|
import Management from './management/Management.async';
|
||||||
|
import Statistics from './statistics/Statistics.async';
|
||||||
import StickerSearch from './StickerSearch.async';
|
import StickerSearch from './StickerSearch.async';
|
||||||
import GifSearch from './GifSearch.async';
|
import GifSearch from './GifSearch.async';
|
||||||
import PollResults from './PollResults.async';
|
import PollResults from './PollResults.async';
|
||||||
@ -69,6 +70,7 @@ const RightColumn: FC<StateProps> = ({
|
|||||||
addChatMembers,
|
addChatMembers,
|
||||||
setNewChatMembersDialogState,
|
setNewChatMembersDialogState,
|
||||||
setEditingExportedInvite,
|
setEditingExportedInvite,
|
||||||
|
toggleStatistics,
|
||||||
setOpenedInviteInfo,
|
setOpenedInviteInfo,
|
||||||
requestNextManagementScreen,
|
requestNextManagementScreen,
|
||||||
} = getDispatch();
|
} = getDispatch();
|
||||||
@ -84,6 +86,7 @@ const RightColumn: FC<StateProps> = ({
|
|||||||
const isProfile = contentKey === RightColumnContent.ChatInfo;
|
const isProfile = contentKey === RightColumnContent.ChatInfo;
|
||||||
const isSearch = contentKey === RightColumnContent.Search;
|
const isSearch = contentKey === RightColumnContent.Search;
|
||||||
const isManagement = contentKey === RightColumnContent.Management;
|
const isManagement = contentKey === RightColumnContent.Management;
|
||||||
|
const isStatistics = contentKey === RightColumnContent.Statistics;
|
||||||
const isStickerSearch = contentKey === RightColumnContent.StickerSearch;
|
const isStickerSearch = contentKey === RightColumnContent.StickerSearch;
|
||||||
const isGifSearch = contentKey === RightColumnContent.GifSearch;
|
const isGifSearch = contentKey === RightColumnContent.GifSearch;
|
||||||
const isPollResults = contentKey === RightColumnContent.PollResults;
|
const isPollResults = contentKey === RightColumnContent.PollResults;
|
||||||
@ -147,6 +150,9 @@ const RightColumn: FC<StateProps> = ({
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case RightColumnContent.Statistics:
|
||||||
|
toggleStatistics();
|
||||||
|
break;
|
||||||
case RightColumnContent.Search: {
|
case RightColumnContent.Search: {
|
||||||
blurSearchInput();
|
blurSearchInput();
|
||||||
closeLocalTextSearch();
|
closeLocalTextSearch();
|
||||||
@ -168,7 +174,7 @@ const RightColumn: FC<StateProps> = ({
|
|||||||
}, [
|
}, [
|
||||||
contentKey, isScrolledDown, toggleChatInfo, closePollResults, setNewChatMembersDialogState,
|
contentKey, isScrolledDown, toggleChatInfo, closePollResults, setNewChatMembersDialogState,
|
||||||
managementScreen, toggleManagement, closeLocalTextSearch, setStickerSearchQuery, setGifSearchQuery,
|
managementScreen, toggleManagement, closeLocalTextSearch, setStickerSearchQuery, setGifSearchQuery,
|
||||||
setEditingExportedInvite, chatId, setOpenedInviteInfo,
|
setEditingExportedInvite, chatId, setOpenedInviteInfo, toggleStatistics,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleSelectChatMember = useCallback((memberId, isPromoted) => {
|
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:
|
case RightColumnContent.StickerSearch:
|
||||||
return <StickerSearch onClose={close} isActive={isOpen && isActive} />;
|
return <StickerSearch onClose={close} isActive={isOpen && isActive} />;
|
||||||
case RightColumnContent.GifSearch:
|
case RightColumnContent.GifSearch:
|
||||||
@ -284,6 +292,7 @@ const RightColumn: FC<StateProps> = ({
|
|||||||
isProfile={isProfile}
|
isProfile={isProfile}
|
||||||
isSearch={isSearch}
|
isSearch={isSearch}
|
||||||
isManagement={isManagement}
|
isManagement={isManagement}
|
||||||
|
isStatistics={isStatistics}
|
||||||
isStickerSearch={isStickerSearch}
|
isStickerSearch={isStickerSearch}
|
||||||
isGifSearch={isGifSearch}
|
isGifSearch={isGifSearch}
|
||||||
isPollResults={isPollResults}
|
isPollResults={isPollResults}
|
||||||
|
|||||||
@ -38,6 +38,7 @@ type OwnProps = {
|
|||||||
isProfile?: boolean;
|
isProfile?: boolean;
|
||||||
isSearch?: boolean;
|
isSearch?: boolean;
|
||||||
isManagement?: boolean;
|
isManagement?: boolean;
|
||||||
|
isStatistics?: boolean;
|
||||||
isStickerSearch?: boolean;
|
isStickerSearch?: boolean;
|
||||||
isGifSearch?: boolean;
|
isGifSearch?: boolean;
|
||||||
isPollResults?: boolean;
|
isPollResults?: boolean;
|
||||||
@ -52,6 +53,7 @@ type OwnProps = {
|
|||||||
type StateProps = {
|
type StateProps = {
|
||||||
canAddContact?: boolean;
|
canAddContact?: boolean;
|
||||||
canManage?: boolean;
|
canManage?: boolean;
|
||||||
|
canViewStatistics?: boolean;
|
||||||
isChannel?: boolean;
|
isChannel?: boolean;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
messageSearchQuery?: string;
|
messageSearchQuery?: string;
|
||||||
@ -69,6 +71,7 @@ enum HeaderContent {
|
|||||||
MemberList,
|
MemberList,
|
||||||
SharedMedia,
|
SharedMedia,
|
||||||
Search,
|
Search,
|
||||||
|
Statistics,
|
||||||
Management,
|
Management,
|
||||||
ManageInitial,
|
ManageInitial,
|
||||||
ManageChannelSubscribers,
|
ManageChannelSubscribers,
|
||||||
@ -102,6 +105,7 @@ const RightHeader: FC<OwnProps & StateProps> = ({
|
|||||||
isProfile,
|
isProfile,
|
||||||
isSearch,
|
isSearch,
|
||||||
isManagement,
|
isManagement,
|
||||||
|
isStatistics,
|
||||||
isStickerSearch,
|
isStickerSearch,
|
||||||
isGifSearch,
|
isGifSearch,
|
||||||
isPollResults,
|
isPollResults,
|
||||||
@ -119,6 +123,7 @@ const RightHeader: FC<OwnProps & StateProps> = ({
|
|||||||
gifSearchQuery,
|
gifSearchQuery,
|
||||||
shouldSkipAnimation,
|
shouldSkipAnimation,
|
||||||
isEditingInvite,
|
isEditingInvite,
|
||||||
|
canViewStatistics,
|
||||||
currentInviteInfo,
|
currentInviteInfo,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
@ -129,6 +134,7 @@ const RightHeader: FC<OwnProps & StateProps> = ({
|
|||||||
toggleManagement,
|
toggleManagement,
|
||||||
openHistoryCalendar,
|
openHistoryCalendar,
|
||||||
addContact,
|
addContact,
|
||||||
|
toggleStatistics,
|
||||||
setEditingExportedInvite,
|
setEditingExportedInvite,
|
||||||
deleteExportedChatInvite,
|
deleteExportedChatInvite,
|
||||||
} = getDispatch();
|
} = getDispatch();
|
||||||
@ -237,6 +243,8 @@ const RightHeader: FC<OwnProps & StateProps> = ({
|
|||||||
) : managementScreen === ManagementScreens.JoinRequests ? (
|
) : managementScreen === ManagementScreens.JoinRequests ? (
|
||||||
HeaderContent.ManageJoinRequests
|
HeaderContent.ManageJoinRequests
|
||||||
) : undefined // Never reached
|
) : undefined // Never reached
|
||||||
|
) : isStatistics ? (
|
||||||
|
HeaderContent.Statistics
|
||||||
) : undefined; // When column is closed
|
) : undefined; // When column is closed
|
||||||
|
|
||||||
const renderingContentKey = useCurrentOrPrev(contentKey, true) ?? -1;
|
const renderingContentKey = useCurrentOrPrev(contentKey, true) ?? -1;
|
||||||
@ -361,6 +369,8 @@ const RightHeader: FC<OwnProps & StateProps> = ({
|
|||||||
onChange={handleGifSearchQueryChange}
|
onChange={handleGifSearchQueryChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case HeaderContent.Statistics:
|
||||||
|
return <h3>{lang('Statistics')}</h3>;
|
||||||
case HeaderContent.SharedMedia:
|
case HeaderContent.SharedMedia:
|
||||||
return <h3>{lang('SharedMedia')}</h3>;
|
return <h3>{lang('SharedMedia')}</h3>;
|
||||||
case HeaderContent.ManageChannelSubscribers:
|
case HeaderContent.ManageChannelSubscribers:
|
||||||
@ -397,6 +407,17 @@ const RightHeader: FC<OwnProps & StateProps> = ({
|
|||||||
<i className="icon-edit" />
|
<i className="icon-edit" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{canViewStatistics && (
|
||||||
|
<Button
|
||||||
|
round
|
||||||
|
color="translucent"
|
||||||
|
size="smaller"
|
||||||
|
ariaLabel={lang('Statistics')}
|
||||||
|
onClick={toggleStatistics}
|
||||||
|
>
|
||||||
|
<i className="icon-stats" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -459,11 +480,13 @@ export default memo(withGlobal<OwnProps>(
|
|||||||
&& (isUserId(chat.id) || ((isChatAdmin(chat) || chat.isCreator) && !chat.isNotJoined)),
|
&& (isUserId(chat.id) || ((isChatAdmin(chat) || chat.isCreator) && !chat.isNotJoined)),
|
||||||
);
|
);
|
||||||
const isEditingInvite = Boolean(chatId && global.management.byChatId[chatId]?.editingInvite);
|
const isEditingInvite = Boolean(chatId && global.management.byChatId[chatId]?.editingInvite);
|
||||||
|
const canViewStatistics = chat?.fullInfo?.canViewStatistics;
|
||||||
const currentInviteInfo = chatId ? global.management.byChatId[chatId]?.inviteInfo?.invite : undefined;
|
const currentInviteInfo = chatId ? global.management.byChatId[chatId]?.inviteInfo?.invite : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canManage,
|
canManage,
|
||||||
canAddContact,
|
canAddContact,
|
||||||
|
canViewStatistics,
|
||||||
isChannel,
|
isChannel,
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
messageSearchQuery,
|
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: [],
|
serviceNotifications: [],
|
||||||
|
|
||||||
|
statistics: {
|
||||||
|
byChatId: {},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import {
|
|||||||
ApiAvailableReaction,
|
ApiAvailableReaction,
|
||||||
ApiAppConfig,
|
ApiAppConfig,
|
||||||
ApiSponsoredMessage,
|
ApiSponsoredMessage,
|
||||||
|
ApiStatistics,
|
||||||
ApiPaymentFormNativeParams,
|
ApiPaymentFormNativeParams,
|
||||||
} from '../api/types';
|
} from '../api/types';
|
||||||
import {
|
import {
|
||||||
@ -107,6 +108,7 @@ export interface ServiceNotification {
|
|||||||
export type GlobalState = {
|
export type GlobalState = {
|
||||||
appConfig?: ApiAppConfig;
|
appConfig?: ApiAppConfig;
|
||||||
isChatInfoShown: boolean;
|
isChatInfoShown: boolean;
|
||||||
|
isStatisticsShown?: boolean;
|
||||||
isLeftColumnShown: boolean;
|
isLeftColumnShown: boolean;
|
||||||
isPollModalOpen?: boolean;
|
isPollModalOpen?: boolean;
|
||||||
newChatMembersProgress?: NewChatMembersProgress;
|
newChatMembersProgress?: NewChatMembersProgress;
|
||||||
@ -498,6 +500,10 @@ export type GlobalState = {
|
|||||||
shouldShowContextMenuHint?: boolean;
|
shouldShowContextMenuHint?: boolean;
|
||||||
|
|
||||||
serviceNotifications: ServiceNotification[];
|
serviceNotifications: ServiceNotification[];
|
||||||
|
|
||||||
|
statistics: {
|
||||||
|
byChatId: Record<string, ApiStatistics>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ActionTypes = (
|
export type ActionTypes = (
|
||||||
@ -508,8 +514,8 @@ export type ActionTypes = (
|
|||||||
'toggleChatInfo' | 'setIsUiReady' | 'addRecentEmoji' | 'addRecentSticker' | 'toggleLeftColumn' |
|
'toggleChatInfo' | 'setIsUiReady' | 'addRecentEmoji' | 'addRecentSticker' | 'toggleLeftColumn' |
|
||||||
'toggleSafeLinkModal' | 'openHistoryCalendar' | 'closeHistoryCalendar' | 'disableContextMenuHint' |
|
'toggleSafeLinkModal' | 'openHistoryCalendar' | 'closeHistoryCalendar' | 'disableContextMenuHint' |
|
||||||
'setNewChatMembersDialogState' | 'disableHistoryAnimations' | 'setLeftColumnWidth' | 'resetLeftColumnWidth' |
|
'setNewChatMembersDialogState' | 'disableHistoryAnimations' | 'setLeftColumnWidth' | 'resetLeftColumnWidth' |
|
||||||
'openSeenByModal' | 'closeSeenByModal' | 'closeReactorListModal' |
|
'openSeenByModal' | 'closeSeenByModal' | 'closeReactorListModal' | 'openReactorListModal' |
|
||||||
'openReactorListModal' |
|
'toggleStatistics' |
|
||||||
// auth
|
// auth
|
||||||
'setAuthPhoneNumber' | 'setAuthCode' | 'setAuthPassword' | 'signUp' | 'returnToAuthPhoneNumber' | 'signOut' |
|
'setAuthPhoneNumber' | 'setAuthCode' | 'setAuthPassword' | 'signUp' | 'returnToAuthPhoneNumber' | 'signOut' |
|
||||||
'setAuthRememberMe' | 'clearAuthError' | 'uploadProfilePhoto' | 'goToAuthQrCode' | 'clearCache' |
|
'setAuthRememberMe' | 'clearAuthError' | 'uploadProfilePhoto' | 'goToAuthQrCode' | 'clearCache' |
|
||||||
@ -601,7 +607,9 @@ export type ActionTypes = (
|
|||||||
'toggleGroupCallVideo' | 'requestToSpeak' | 'setGroupCallParticipantVolume' | 'toggleGroupCallPanel' |
|
'toggleGroupCallVideo' | 'requestToSpeak' | 'setGroupCallParticipantVolume' | 'toggleGroupCallPanel' |
|
||||||
'createGroupCall' | 'joinVoiceChatByLink' | 'subscribeToGroupCallUpdates' | 'createGroupCallInviteLink' |
|
'createGroupCall' | 'joinVoiceChatByLink' | 'subscribeToGroupCallUpdates' | 'createGroupCallInviteLink' |
|
||||||
'loadMoreGroupCallParticipants' | 'connectToActiveGroupCall' | 'playGroupCallSound' |
|
'loadMoreGroupCallParticipants' | 'connectToActiveGroupCall' | 'playGroupCallSound' |
|
||||||
'openCallFallbackConfirm' | 'closeCallFallbackConfirm' | 'inviteToCallFallback'
|
'openCallFallbackConfirm' | 'closeCallFallbackConfirm' | 'inviteToCallFallback' |
|
||||||
|
// stats
|
||||||
|
'loadStatistics' | 'loadStatisticsAsyncGraph'
|
||||||
);
|
);
|
||||||
|
|
||||||
export interface DispatchOptions {
|
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 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>;
|
async uploadFile(uploadParams: UploadFileParams): ReturnType<typeof uploadFile>;
|
||||||
|
|
||||||
|
|||||||
@ -772,20 +772,20 @@ class TelegramClient {
|
|||||||
/**
|
/**
|
||||||
* Invokes a MTProtoRequest (sends and receives it) and returns its result
|
* Invokes a MTProtoRequest (sends and receives it) and returns its result
|
||||||
* @param request
|
* @param request
|
||||||
|
* @param dcId Optional dcId to use when sending the request
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
async invoke(request) {
|
async invoke(request, dcId) {
|
||||||
if (request.classType !== 'request') {
|
if (request.classType !== 'request') {
|
||||||
throw new Error('You can only invoke MTProtoRequests');
|
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();
|
this._lastRequest = new Date().getTime();
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
for (attempt = 0; attempt < this._requestRetries; attempt++) {
|
for (attempt = 0; attempt < this._requestRetries; attempt++) {
|
||||||
const promise = this._sender.sendWithInvokeSupport(request);
|
const promise = sender.sendWithInvokeSupport(request);
|
||||||
try {
|
try {
|
||||||
const result = await promise.promise;
|
const result = await promise.promise;
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@ -1165,4 +1165,6 @@ phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates;
|
|||||||
langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDifference;
|
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.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector<string> = Vector<LangPackString>;
|
||||||
langpack.getLanguages#42c6978f lang_pack:string = Vector<LangPackLanguage>;
|
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.setChatAvailableReactions",
|
||||||
"messages.getAvailableReactions",
|
"messages.getAvailableReactions",
|
||||||
"messages.setDefaultReaction",
|
"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/twoFaSettings';
|
||||||
import './api/payments';
|
import './api/payments';
|
||||||
import './api/reactions';
|
import './api/reactions';
|
||||||
|
import './api/statistics';
|
||||||
|
|
||||||
import './apiUpdaters/initial';
|
import './apiUpdaters/initial';
|
||||||
import './apiUpdaters/chats';
|
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) => {
|
addReducer('toggleLeftColumn', (global) => {
|
||||||
return {
|
return {
|
||||||
...global,
|
...global,
|
||||||
|
|||||||
@ -8,3 +8,4 @@ export * from './management';
|
|||||||
export * from './settings';
|
export * from './settings';
|
||||||
export * from './twoFaSettings';
|
export * from './twoFaSettings';
|
||||||
export * from './payments';
|
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 './symbols';
|
||||||
export * from './payments';
|
export * from './payments';
|
||||||
export * from './settings';
|
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 { getSystemTheme, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
|
||||||
import { selectCurrentMessageList, selectIsPollResultsOpen } from './messages';
|
import { selectCurrentMessageList, selectIsPollResultsOpen } from './messages';
|
||||||
import { selectCurrentTextSearch } from './localSearch';
|
import { selectCurrentTextSearch } from './localSearch';
|
||||||
import { selectCurrentGifSearch, selectCurrentStickerSearch } from './symbols';
|
import { selectCurrentStickerSearch, selectCurrentGifSearch } from './symbols';
|
||||||
|
import { selectIsStatisticsShown } from './statistics';
|
||||||
import { selectCurrentManagement } from './management';
|
import { selectCurrentManagement } from './management';
|
||||||
|
|
||||||
export function selectIsMediaViewerOpen(global: GlobalState) {
|
export function selectIsMediaViewerOpen(global: GlobalState) {
|
||||||
@ -19,6 +20,8 @@ export function selectRightColumnContentKey(global: GlobalState) {
|
|||||||
RightColumnContent.Search
|
RightColumnContent.Search
|
||||||
) : selectCurrentManagement(global) ? (
|
) : selectCurrentManagement(global) ? (
|
||||||
RightColumnContent.Management
|
RightColumnContent.Management
|
||||||
|
) : selectIsStatisticsShown(global) ? (
|
||||||
|
RightColumnContent.Statistics
|
||||||
) : selectCurrentStickerSearch(global).query !== undefined ? (
|
) : selectCurrentStickerSearch(global).query !== undefined ? (
|
||||||
RightColumnContent.StickerSearch
|
RightColumnContent.StickerSearch
|
||||||
) : selectCurrentGifSearch(global).query !== undefined ? (
|
) : selectCurrentGifSearch(global).query !== undefined ? (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -51,6 +51,9 @@
|
|||||||
.icon-volume-3:before {
|
.icon-volume-3:before {
|
||||||
content: "\e991";
|
content: "\e991";
|
||||||
}
|
}
|
||||||
|
.icon-stats:before {
|
||||||
|
content: "\e996";
|
||||||
|
}
|
||||||
.icon-copy-media:before {
|
.icon-copy-media:before {
|
||||||
content: "\e995";
|
content: "\e995";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -236,6 +236,7 @@ export enum RightColumnContent {
|
|||||||
ChatInfo,
|
ChatInfo,
|
||||||
Search,
|
Search,
|
||||||
Management,
|
Management,
|
||||||
|
Statistics,
|
||||||
StickerSearch,
|
StickerSearch,
|
||||||
GifSearch,
|
GifSearch,
|
||||||
PollResults,
|
PollResults,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user