koishi-plugin-chat-analyse 1.2.6 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/Stat.d.ts +28 -0
- package/lib/index.js +139 -112
- package/package.json +1 -1
- package/readme.md +48 -13
- package/lib/Renderer.d.ts +0 -88
- package/lib/wordcloud.d.ts +0 -1
package/lib/Stat.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Context, Command } from 'koishi';
|
|
2
|
+
import { Renderer } from './Renderer';
|
|
3
|
+
import { Config } from './index';
|
|
4
|
+
/**
|
|
5
|
+
* @class Stat
|
|
6
|
+
* @description 提供统一的统计查询服务。负责注册查询命令,从数据库获取数据,并调用渲染器生成图表。
|
|
7
|
+
*/
|
|
8
|
+
export declare class Stat {
|
|
9
|
+
private ctx;
|
|
10
|
+
private config;
|
|
11
|
+
renderer: Renderer;
|
|
12
|
+
/**
|
|
13
|
+
* @param ctx - Koishi 的插件上下文。
|
|
14
|
+
* @param config - 插件的配置对象。
|
|
15
|
+
*/
|
|
16
|
+
constructor(ctx: Context, config: Config);
|
|
17
|
+
/**
|
|
18
|
+
* @private @method parseScope
|
|
19
|
+
* @description 根据选项解析查询范围,返回 uids 和范围描述
|
|
20
|
+
*/
|
|
21
|
+
private parseScope;
|
|
22
|
+
/**
|
|
23
|
+
* @public @method registerCommands
|
|
24
|
+
* @description 根据配置,动态地将子命令注册到主命令下。
|
|
25
|
+
* @param cmd - 主命令实例。
|
|
26
|
+
*/
|
|
27
|
+
registerCommands(cmd: Command): void;
|
|
28
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -110,7 +110,7 @@ var Collector = class _Collector {
|
|
|
110
110
|
const currentUserName = session.username ?? "";
|
|
111
111
|
let currentChannelName = this.channelCache.get(channelId);
|
|
112
112
|
if (currentChannelName === void 0) {
|
|
113
|
-
const guild = await bot.getGuild(channelId).catch(() => null);
|
|
113
|
+
const guild = bot.getGuild && typeof bot.getGuild === "function" ? await bot.getGuild(channelId).catch(() => null) : null;
|
|
114
114
|
currentChannelName = guild?.name ?? "";
|
|
115
115
|
if (currentChannelName) this.channelCache.set(channelId, currentChannelName);
|
|
116
116
|
}
|
|
@@ -1808,141 +1808,168 @@ var Stat = class {
|
|
|
1808
1808
|
__name(this, "Stat");
|
|
1809
1809
|
}
|
|
1810
1810
|
renderer;
|
|
1811
|
+
/**
|
|
1812
|
+
* @private @method parseScope
|
|
1813
|
+
* @description 根据选项解析查询范围,返回 uids 和范围描述
|
|
1814
|
+
*/
|
|
1815
|
+
async parseScope(session, options) {
|
|
1816
|
+
const scopeDesc = { guildId: void 0, userId: void 0 };
|
|
1817
|
+
const query = {};
|
|
1818
|
+
if (options.all) return { uids: void 0, scopeDesc };
|
|
1819
|
+
if (options.user) scopeDesc.userId = import_koishi3.h.select(options.user, "at")[0]?.attrs.id ?? options.user.trim();
|
|
1820
|
+
if (options.guild) scopeDesc.guildId = options.guild;
|
|
1821
|
+
if (!scopeDesc.guildId && !scopeDesc.userId) scopeDesc.guildId = session.guildId;
|
|
1822
|
+
if (!scopeDesc.guildId && !scopeDesc.userId) return { error: "请指定查询范围", scopeDesc };
|
|
1823
|
+
if (scopeDesc.guildId) query.channelId = scopeDesc.guildId;
|
|
1824
|
+
if (scopeDesc.userId) query.userId = scopeDesc.userId;
|
|
1825
|
+
const users = await this.ctx.database.get("analyse_user", query, ["uid"]);
|
|
1826
|
+
if (users.length === 0) return { error: "暂无统计数据", scopeDesc };
|
|
1827
|
+
return { uids: users.map((u) => u.uid), scopeDesc };
|
|
1828
|
+
}
|
|
1811
1829
|
/**
|
|
1812
1830
|
* @public @method registerCommands
|
|
1813
1831
|
* @description 根据配置,动态地将子命令注册到主命令下。
|
|
1814
1832
|
* @param cmd - 主命令实例。
|
|
1815
1833
|
*/
|
|
1816
1834
|
registerCommands(cmd) {
|
|
1817
|
-
const
|
|
1818
|
-
|
|
1819
|
-
const
|
|
1820
|
-
if (
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
return "图片渲染失败";
|
|
1828
|
-
}
|
|
1829
|
-
};
|
|
1830
|
-
}, "createHandler");
|
|
1835
|
+
const handleAction = /* @__PURE__ */ __name(async (session, promise) => {
|
|
1836
|
+
try {
|
|
1837
|
+
const result = await promise;
|
|
1838
|
+
if (typeof result === "string") return result;
|
|
1839
|
+
for await (const buffer of result) await session.send(import_koishi3.h.image(buffer, "image/png"));
|
|
1840
|
+
} catch (error) {
|
|
1841
|
+
this.ctx.logger.error("图片渲染失败:", error);
|
|
1842
|
+
return "图片渲染失败";
|
|
1843
|
+
}
|
|
1844
|
+
}, "handleAction");
|
|
1831
1845
|
if (this.config.enableCmdStat) {
|
|
1832
|
-
cmd.subcommand("cmdstat", "命令统计").usage("查询命令统计,可指定查询范围,默认当前群组。").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("separate", "-s 分离展示").option("all", "-a
|
|
1833
|
-
const
|
|
1846
|
+
cmd.subcommand("cmdstat", "命令统计").usage("查询命令统计,可指定查询范围,默认当前群组。").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("separate", "-s 分离展示").option("all", "-a 全局统计").action(({ session, options }) => handleAction(session, (async () => {
|
|
1847
|
+
const scope = await this.parseScope(session, options);
|
|
1848
|
+
if (scope.error) return scope.error;
|
|
1849
|
+
const query = scope.uids ? { uid: { $in: scope.uids } } : {};
|
|
1850
|
+
const stats = await this.ctx.database.select("analyse_cmd").where(query).groupBy("command", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
|
|
1834
1851
|
if (stats.length === 0) return "暂无统计数据";
|
|
1835
1852
|
let processedStats;
|
|
1836
1853
|
if (options.separate) {
|
|
1837
1854
|
processedStats = stats;
|
|
1838
1855
|
} else {
|
|
1839
|
-
const
|
|
1856
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1840
1857
|
for (const stat of stats) {
|
|
1841
|
-
const
|
|
1842
|
-
const existing =
|
|
1858
|
+
const mainCmd = stat.command.split(".")[0];
|
|
1859
|
+
const existing = merged.get(mainCmd) || { count: 0, lastUsed: /* @__PURE__ */ new Date(0) };
|
|
1843
1860
|
existing.count += stat.count;
|
|
1844
1861
|
if (stat.lastUsed > existing.lastUsed) existing.lastUsed = stat.lastUsed;
|
|
1845
|
-
|
|
1862
|
+
merged.set(mainCmd, existing);
|
|
1846
1863
|
}
|
|
1847
|
-
processedStats = Array.from(
|
|
1848
|
-
command,
|
|
1849
|
-
count: data.count,
|
|
1850
|
-
lastUsed: data.lastUsed
|
|
1851
|
-
})).sort((a, b) => b.count - a.count);
|
|
1864
|
+
processedStats = Array.from(merged.entries()).map(([command, data]) => ({ ...data, command })).sort((a, b) => b.count - a.count);
|
|
1852
1865
|
}
|
|
1853
|
-
const total = processedStats.reduce((sum,
|
|
1866
|
+
const total = processedStats.reduce((sum, r) => sum + r.count, 0);
|
|
1854
1867
|
const list = processedStats.map((item) => [item.command, item.count, item.lastUsed]);
|
|
1855
1868
|
const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "命令" });
|
|
1856
1869
|
return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["命令", "次数", "最后使用"]);
|
|
1857
|
-
}));
|
|
1870
|
+
})()));
|
|
1858
1871
|
}
|
|
1859
1872
|
if (this.config.enableMsgStat) {
|
|
1860
|
-
cmd.subcommand("msgstat", "发言统计").usage("查询发言统计,可指定查询范围,默认当前群组。").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("all", "-a
|
|
1861
|
-
const
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
const
|
|
1873
|
+
cmd.subcommand("msgstat", "发言统计").usage("查询发言统计,可指定查询范围,默认当前群组。").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("all", "-a 全局统计").action(({ session, options }) => handleAction(session, (async () => {
|
|
1874
|
+
const scope = await this.parseScope(session, options);
|
|
1875
|
+
if (scope.error) return scope.error;
|
|
1876
|
+
const query = scope.uids ? { uid: { $in: scope.uids } } : {};
|
|
1877
|
+
if (options.type) query.type = options.type;
|
|
1878
|
+
const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "发言", subtype: options.type });
|
|
1879
|
+
if (options.user && options.guild) {
|
|
1880
|
+
const stats2 = await this.ctx.database.select("analyse_msg").where(query).groupBy("type", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
|
|
1881
|
+
if (stats2.length === 0) return "暂无统计数据";
|
|
1882
|
+
const total2 = stats2.reduce((sum, r) => sum + r.count, 0);
|
|
1883
|
+
const list2 = stats2.map((item) => [item.type, item.count, item.lastUsed]);
|
|
1884
|
+
return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total: total2, list: list2 }, ["类型", "条数", "最后发言"]);
|
|
1885
|
+
}
|
|
1886
|
+
if (options.user) {
|
|
1887
|
+
const userRecords = await this.ctx.database.get("analyse_user", { uid: { $in: scope.uids } });
|
|
1888
|
+
const uidToChannelMap = new Map(userRecords.map((u) => [u.uid, u.channelName || u.channelId]));
|
|
1889
|
+
const stats2 = await this.ctx.database.select("analyse_msg").where(query).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
|
|
1890
|
+
if (stats2.length === 0) return "暂无统计数据";
|
|
1891
|
+
const total2 = stats2.reduce((sum, r) => sum + r.count, 0);
|
|
1892
|
+
const list2 = stats2.map((item) => [uidToChannelMap.get(item.uid) || `未知群组`, item.count, item.lastUsed]);
|
|
1893
|
+
return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total: total2, list: list2 }, ["群组", "条数", "最后发言"]);
|
|
1894
|
+
}
|
|
1866
1895
|
const stats = await this.ctx.database.select("analyse_msg").where(query).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
|
|
1867
1896
|
if (stats.length === 0) return "暂无统计数据";
|
|
1897
|
+
const allUids = stats.map((s) => s.uid);
|
|
1898
|
+
const users = await this.ctx.database.get("analyse_user", { uid: { $in: allUids } }, ["uid", "userName"]);
|
|
1899
|
+
const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
|
|
1868
1900
|
const total = stats.reduce((sum, r) => sum + r.count, 0);
|
|
1869
1901
|
const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count, item.lastUsed]);
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, headers);
|
|
1873
|
-
}));
|
|
1902
|
+
return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["用户", "总计发言", "最后发言"]);
|
|
1903
|
+
})()));
|
|
1874
1904
|
}
|
|
1875
1905
|
if (this.config.enableRankStat) {
|
|
1876
|
-
cmd.subcommand("rankstat", "发言排行").usage("查询发言排行,可指定查询范围,默认当前群组。").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("
|
|
1877
|
-
const
|
|
1878
|
-
|
|
1879
|
-
const
|
|
1880
|
-
|
|
1881
|
-
const
|
|
1882
|
-
if (
|
|
1883
|
-
|
|
1906
|
+
cmd.subcommand("rankstat", "发言排行").usage("查询发言排行,可指定查询范围,默认当前群组。").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("duration", "-n <hours:number> 指定时长", { fallback: 24 }).option("offset", "-o <hours:number> 指定偏移").option("all", "-a 全局统计").action(({ session, options }) => handleAction(session, (async () => {
|
|
1907
|
+
const scope = await this.parseScope(session, options);
|
|
1908
|
+
if (scope.error) return scope.error;
|
|
1909
|
+
const until = new Date(Date.now() - options.offset * import_koishi3.Time.hour);
|
|
1910
|
+
const since = new Date(until.getTime() - options.duration * import_koishi3.Time.hour);
|
|
1911
|
+
const query = { timestamp: { $gte: since, $lt: until } };
|
|
1912
|
+
if (scope.uids) query.uid = { $in: scope.uids };
|
|
1913
|
+
if (options.type) query.type = options.type;
|
|
1914
|
+
const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "发言排行", timeRange: options.duration, subtype: options.type });
|
|
1915
|
+
if (options.user && options.guild) {
|
|
1916
|
+
const stats2 = await this.ctx.database.select("analyse_rank").where(query).groupBy("type", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").execute();
|
|
1917
|
+
if (stats2.length === 0) return "暂无统计数据";
|
|
1918
|
+
const total2 = stats2.reduce((sum, r) => sum + r.count, 0);
|
|
1919
|
+
const list2 = stats2.map((r) => [r.type, r.count, total2 > 0 ? `${(r.count / total2 * 100).toFixed(2)}%` : "0.00%"]);
|
|
1920
|
+
return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total: total2, list: list2 }, ["类型", "条数", "占比"]);
|
|
1921
|
+
}
|
|
1922
|
+
if (options.user) {
|
|
1923
|
+
const userRecords = await this.ctx.database.get("analyse_user", { uid: { $in: scope.uids } });
|
|
1924
|
+
const uidToChannelMap = new Map(userRecords.map((u) => [u.uid, u.channelName || u.channelId]));
|
|
1925
|
+
const stats2 = await this.ctx.database.select("analyse_rank").where(query).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").execute();
|
|
1926
|
+
if (stats2.length === 0) return "暂无统计数据";
|
|
1927
|
+
const total2 = stats2.reduce((sum, r) => sum + r.count, 0);
|
|
1928
|
+
const list2 = stats2.map((r) => [uidToChannelMap.get(r.uid) || "未知群组", r.count, total2 > 0 ? `${(r.count / total2 * 100).toFixed(2)}%` : "0.00%"]);
|
|
1929
|
+
return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total: total2, list: list2 }, ["群组", "条数", "占比"]);
|
|
1930
|
+
}
|
|
1931
|
+
const stats = await this.ctx.database.select("analyse_rank").where(query).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").execute();
|
|
1932
|
+
if (stats.length === 0) return "暂无统计数据";
|
|
1933
|
+
const allUids = stats.map((s) => s.uid);
|
|
1934
|
+
const users = await this.ctx.database.get("analyse_user", { uid: { $in: allUids } }, ["uid", "userName"]);
|
|
1884
1935
|
const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
|
|
1885
|
-
const total =
|
|
1886
|
-
const list =
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list: listWithPercentage }, ["用户", "总计发言", "占比"]);
|
|
1890
|
-
}));
|
|
1936
|
+
const total = stats.reduce((sum, r) => sum + r.count, 0);
|
|
1937
|
+
const list = stats.map((r) => [userNameMap.get(r.uid) || `UID ${r.uid}`, r.count, total > 0 ? `${(r.count / total * 100).toFixed(2)}%` : "0.00%"]);
|
|
1938
|
+
return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["用户", "总计发言", "占比"]);
|
|
1939
|
+
})()));
|
|
1891
1940
|
}
|
|
1892
1941
|
if (this.config.enableActivity) {
|
|
1893
|
-
cmd.subcommand("activity", "活跃统计").usage("查询活跃统计,可指定查询范围,默认当前群组。").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("
|
|
1894
|
-
const
|
|
1895
|
-
if (
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
if (daysAgo >= 0 && daysAgo < timeRangeInDays) {
|
|
1913
|
-
const index = timeRangeInDays - 1 - daysAgo;
|
|
1914
|
-
dailyCounts[index] += stat.count;
|
|
1915
|
-
}
|
|
1916
|
-
});
|
|
1917
|
-
const totalMessages = dailyCounts.reduce((a, b) => a + b, 0);
|
|
1918
|
-
const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "活跃", timeRange: timeRangeInDays, timeUnit: "天" });
|
|
1919
|
-
return this.renderer.renderCircadianChart({ title, time: /* @__PURE__ */ new Date(), total: totalMessages, data: dailyCounts, labels: dayLabels });
|
|
1920
|
-
} else {
|
|
1921
|
-
const timeWindowHours = 24;
|
|
1922
|
-
const offsetHours = typeof hours === "number" ? hours : 0;
|
|
1923
|
-
const now = /* @__PURE__ */ new Date();
|
|
1924
|
-
const until = new Date(now.getTime() - offsetHours * import_koishi3.Time.hour);
|
|
1925
|
-
const since = new Date(until.getTime() - timeWindowHours * import_koishi3.Time.hour);
|
|
1926
|
-
const hourlyStats = await this.ctx.database.select("analyse_rank").where({ uid: { $in: scope.uids }, timestamp: { $gte: since, $lt: until } }).groupBy("timestamp", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).execute();
|
|
1927
|
-
if (hourlyStats.length === 0) return "暂无统计数据";
|
|
1928
|
-
const processedCounts = Array(timeWindowHours).fill(0);
|
|
1929
|
-
const hourLabels = Array(timeWindowHours).fill("");
|
|
1930
|
-
for (let i = 0; i < timeWindowHours; i++) {
|
|
1931
|
-
const d = new Date(until.getTime() - (i + 1) * import_koishi3.Time.hour);
|
|
1932
|
-
hourLabels[timeWindowHours - 1 - i] = String(d.getHours());
|
|
1933
|
-
}
|
|
1934
|
-
hourlyStats.forEach((stat) => {
|
|
1935
|
-
const hoursBeforeUntil = Math.floor((until.getTime() - stat.timestamp.getTime()) / import_koishi3.Time.hour);
|
|
1936
|
-
if (hoursBeforeUntil >= 0 && hoursBeforeUntil < timeWindowHours) {
|
|
1937
|
-
const index = timeWindowHours - 1 - hoursBeforeUntil;
|
|
1938
|
-
processedCounts[index] += stat.count;
|
|
1939
|
-
}
|
|
1940
|
-
});
|
|
1941
|
-
const totalMessages = processedCounts.reduce((a, b) => a + b, 0);
|
|
1942
|
-
const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "活跃", timeRange: timeWindowHours, timeUnit: "小时" });
|
|
1943
|
-
return this.renderer.renderCircadianChart({ title, time: /* @__PURE__ */ new Date(), total: totalMessages, data: processedCounts, labels: hourLabels });
|
|
1942
|
+
cmd.subcommand("activity", "活跃统计").usage("查询活跃统计,可指定查询范围,默认当前群组。").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("duration", "-n <units:number> 指定时长", { fallback: 24 }).option("offset", "-o <units:number> 指定偏移").option("days", "-d 切换至天").option("all", "-a 全局统计").action(({ session, options }) => handleAction(session, (async () => {
|
|
1943
|
+
const scope = await this.parseScope(session, options);
|
|
1944
|
+
if (scope.error) return scope.error;
|
|
1945
|
+
const timeUnit = options.days ? import_koishi3.Time.day : import_koishi3.Time.hour;
|
|
1946
|
+
const timeUnitName = options.days ? "天" : "小时";
|
|
1947
|
+
const points = options.days ? 24 : 24;
|
|
1948
|
+
const until = new Date(Date.now() - options.offset * timeUnit);
|
|
1949
|
+
const since = new Date(until.getTime() - options.duration * timeUnit);
|
|
1950
|
+
const query = { timestamp: { $gte: since, $lt: until } };
|
|
1951
|
+
if (scope.uids) query.uid = { $in: scope.uids };
|
|
1952
|
+
const stats = await this.ctx.database.select("analyse_rank").where(query).project(["timestamp", "count"]).execute();
|
|
1953
|
+
if (stats.length === 0) return "暂无统计数据";
|
|
1954
|
+
const counts = Array(points).fill(0);
|
|
1955
|
+
const labels = Array(points).fill("");
|
|
1956
|
+
const now = /* @__PURE__ */ new Date();
|
|
1957
|
+
now.setMinutes(0, 0, 0);
|
|
1958
|
+
for (let i = 0; i < points; i++) {
|
|
1959
|
+
const pointTime = new Date(until.getTime() - (i + 1) * timeUnit);
|
|
1960
|
+
labels[points - 1 - i] = options.days ? String(pointTime.getDate()) : String(pointTime.getHours());
|
|
1944
1961
|
}
|
|
1945
|
-
|
|
1962
|
+
stats.forEach((stat) => {
|
|
1963
|
+
const diff = until.getTime() - stat.timestamp.getTime();
|
|
1964
|
+
const index = points - 1 - Math.floor(diff / timeUnit);
|
|
1965
|
+
if (index >= 0 && index < points) {
|
|
1966
|
+
counts[index] += stat.count;
|
|
1967
|
+
}
|
|
1968
|
+
});
|
|
1969
|
+
const total = counts.reduce((a, b) => a + b, 0);
|
|
1970
|
+
const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "活跃", timeRange: options.duration, timeUnit: timeUnitName });
|
|
1971
|
+
return this.renderer.renderCircadianChart({ title, time: /* @__PURE__ */ new Date(), total, data: counts, labels });
|
|
1972
|
+
})()));
|
|
1946
1973
|
}
|
|
1947
1974
|
}
|
|
1948
1975
|
};
|
|
@@ -1975,12 +2002,12 @@ var WhoAt = class {
|
|
|
1975
2002
|
registerCommand(cmd) {
|
|
1976
2003
|
cmd.subcommand("whoatme", "谁提及我").usage("查看最近提及我的消息,查看后自动删除。").action(async ({ session }) => {
|
|
1977
2004
|
if (!session.userId) return "无法获取用户信息";
|
|
2005
|
+
const records = await this.ctx.database.get("analyse_at", { target: session.userId }, {
|
|
2006
|
+
sort: { timestamp: "asc" },
|
|
2007
|
+
limit: 100
|
|
2008
|
+
});
|
|
2009
|
+
if (records.length === 0) return "最近没有人提及您";
|
|
1978
2010
|
try {
|
|
1979
|
-
const records = await this.ctx.database.get("analyse_at", { target: session.userId }, {
|
|
1980
|
-
sort: { timestamp: "asc" },
|
|
1981
|
-
limit: 100
|
|
1982
|
-
});
|
|
1983
|
-
if (records.length === 0) return "最近没有人提及您";
|
|
1984
2011
|
const uids = [...new Set(records.map((r) => r.uid))];
|
|
1985
2012
|
const users = await this.ctx.database.get("analyse_user", { uid: { $in: uids } }, ["uid", "userName", "userId"]);
|
|
1986
2013
|
const userInfoMap = new Map(users.map((u) => [u.uid, { name: u.userName, id: u.userId }]));
|
|
@@ -1989,9 +2016,9 @@ var WhoAt = class {
|
|
|
1989
2016
|
const author = (0, import_koishi4.h)("author", { id: senderInfo.id, name: senderInfo.name });
|
|
1990
2017
|
return (0, import_koishi4.h)("message", {}, [author, import_koishi4.h.text(record.content)]);
|
|
1991
2018
|
});
|
|
2019
|
+
await session.send((0, import_koishi4.h)("message", { forward: true }, messageElements));
|
|
1992
2020
|
const recordIdsToDelete = records.map((r) => r.id);
|
|
1993
2021
|
await this.ctx.database.remove("analyse_at", { id: { $in: recordIdsToDelete } });
|
|
1994
|
-
return (0, import_koishi4.h)("message", { forward: true }, messageElements);
|
|
1995
2022
|
} catch (error) {
|
|
1996
2023
|
this.ctx.logger.error("查询提及记录失败:", error);
|
|
1997
2024
|
return "查询失败,请稍后重试";
|
|
@@ -2174,7 +2201,7 @@ var Analyse = class {
|
|
|
2174
2201
|
*/
|
|
2175
2202
|
registerCommands(cmd) {
|
|
2176
2203
|
if (this.config.enableWordCloud) {
|
|
2177
|
-
cmd.subcommand("wordcloud", "生成词云").usage("基于聊天记录生成词云图,可指定范围,默认当前群组。").option("guild", "-g <guildId:string> 指定群组").option("user", "-u <user:string> 指定用户").option("hours", "-t <hours:number> 指定时长", { fallback: 24 }).
|
|
2204
|
+
cmd.subcommand("wordcloud", "生成词云").usage("基于聊天记录生成词云图,可指定范围,默认当前群组。").option("guild", "-g <guildId:string> 指定群组").option("user", "-u <user:string> 指定用户").option("hours", "-t <hours:number> 指定时长", { fallback: 24 }).action(async ({ session, options }) => {
|
|
2178
2205
|
try {
|
|
2179
2206
|
if (!this.jieba) return "Jieba 分词服务未就绪";
|
|
2180
2207
|
const scope = await parseQueryScope(this.ctx, session, options);
|
|
@@ -2196,7 +2223,7 @@ var Analyse = class {
|
|
|
2196
2223
|
const wordList = Array.from(wordCounts.entries()).sort((a, b) => b[1] - a[1]);
|
|
2197
2224
|
const topWordsPreview = wordList.slice(0, 10).map((item) => item[0]).join(", ");
|
|
2198
2225
|
session.send(`正在生成词云,热门词汇:${topWordsPreview}...`);
|
|
2199
|
-
const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "词云" });
|
|
2226
|
+
const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "词云", timeRange: options.hours });
|
|
2200
2227
|
const imageGenerator = this.renderer.renderWordCloud({ title, time: /* @__PURE__ */ new Date(), words: wordList });
|
|
2201
2228
|
for await (const buffer of imageGenerator) await session.send(import_koishi6.h.image(buffer, "image/png"));
|
|
2202
2229
|
} catch (error) {
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -37,23 +37,58 @@
|
|
|
37
37
|
|
|
38
38
|
| 指令 | 别名 | 描述 | 选项 |
|
|
39
39
|
| --- | --- | --- | --- |
|
|
40
|
-
| `analyse.cmdstat` | 命令统计 |
|
|
41
|
-
| `analyse.msgstat` | 发言统计 |
|
|
42
|
-
| `analyse.rankstat` | 发言排行 |
|
|
43
|
-
| `analyse.activity` | 活跃统计 | 查询周期性活跃度图表 | `-u <user>`, `-g <guild>`, `-a` (全局), `-d` (按天), `-
|
|
44
|
-
| `analyse.wordcloud` | 生成词云 | 基于聊天记录生成词云图 | `-u <user>`, `-g <guild>`, `-
|
|
40
|
+
| `analyse.cmdstat` | 命令统计 | 查询命令使用情况,展示方式根据选项变化 | `-u <user>`, `-g <guild>`, `-a` (全局), `-s` (分离子指令) |
|
|
41
|
+
| `analyse.msgstat` | 发言统计 | 查询用户发言统计,展示方式根据选项变化 | `-u <user>`, `-g <guild>`, `-a` (全局), `-t <type>` (指定消息类型) |
|
|
42
|
+
| `analyse.rankstat` | 发言排行 | 查询指定时间内的发言排行,展示方式根据选项变化 | `-u <user>`, `-g <guild>`, `-a` (全局), `-t <type>`, `-n <hours>`, `-o <hours>` |
|
|
43
|
+
| `analyse.activity` | 活跃统计 | 查询周期性活跃度图表 | `-u <user>`, `-g <guild>`, `-a` (全局), `-d` (按天), `-n <units>`, `-o <units>` |
|
|
44
|
+
| `analyse.wordcloud` | 生成词云 | 基于聊天记录生成词云图 | `-u <user>`, `-g <guild>`, `-t <hours>` (默认24小时) |
|
|
45
45
|
| `analyse.whoatme` | 谁提及我 | 查看最近谁提及了您 | (无) |
|
|
46
46
|
| `analyse.list` | 列出数据 | 列出已记录的频道和命令 | (无) |
|
|
47
47
|
| `analyse.backup` | 备份数据 | 将所有数据备份为本地 JSON 文件 | (无) |
|
|
48
48
|
| `analyse.restore` | 恢复数据 | 从本地 JSON 文件恢复数据 | (无) |
|
|
49
49
|
| `analyse.clear` | 清除数据 | 根据条件精确清理数据 | `-t <table>`, `-g <guild>`, `-u <user>`, `-d <days>`, `-c <command>`, `-a` (全部) |
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
### 🔎 指令详解
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
-
|
|
56
|
-
-
|
|
53
|
+
#### `analyse.cmdstat` (命令统计)
|
|
54
|
+
|
|
55
|
+
- **`analyse cmdstat`**: 查询**当前群组**所有用户的命令统计。
|
|
56
|
+
- **`analyse cmdstat -u @用户`**: 查询**指定用户**在**所有群组**的命令统计。
|
|
57
|
+
- **`analyse cmdstat -g <群组ID>`**: 查询**指定群组**所有用户的命令统计。
|
|
58
|
+
- **`analyse cmdstat -u @用户 -g <群组ID>`**: 查询**指定用户**在**指定群组**的命令统计。
|
|
59
|
+
- **`analyse cmdstat -a`**: 查询**全局**(所有用户+所有群组)的命令统计。
|
|
60
|
+
- **选项 `-s`**: 分离展示子命令,不合并到主命令。
|
|
61
|
+
|
|
62
|
+
#### `analyse.msgstat` (发言统计)
|
|
63
|
+
|
|
64
|
+
- **`analyse msgstat`**: 查询**当前群组**的发言统计 (按**用户**展示)。
|
|
65
|
+
- **`analyse msgstat -u @用户`**: 查询**指定用户**的发言统计 (按**群组**展示)。
|
|
66
|
+
- **`analyse msgstat -g <群组ID>`**: 查询**指定群组**的发言统计 (按**用户**展示)。
|
|
67
|
+
- **`analyse msgstat -u @用户 -g <群组ID>`**: 查询**指定用户**在**指定群组**的发言统计 (按**消息类型**展示)。
|
|
68
|
+
- **`analyse msgstat -a`**: 查询**全局**发言统计 (按**用户**展示)。
|
|
69
|
+
- **选项 `-t <类型>`**: 筛选指定消息类型,不改变上述展示逻辑。
|
|
70
|
+
|
|
71
|
+
#### `analyse.rankstat` (发言排行)
|
|
72
|
+
|
|
73
|
+
- **`analyse rankstat`**: 查询**当前群组**的发言排行 (按**用户**排名)。
|
|
74
|
+
- **`analyse rankstat -u @用户`**: 查询**指定用户**的发言排行 (按**群组**排名)。
|
|
75
|
+
- **`analyse rankstat -g <群组ID>`**: 查询**指定群组**的发言排行 (按**用户**排名)。
|
|
76
|
+
- **`analyse rankstat -u @用户 -g <群组ID>`**: 查询**指定用户**在**指定群组**的发言排行 (按**消息类型**排名)。
|
|
77
|
+
- **`analyse rankstat -a`**: 查询**全局**发言排行 (按**用户**排名)。
|
|
78
|
+
- **选项 `-n <小时数>`**: 指定查询范围的时长,默认为 `24`。
|
|
79
|
+
- **选项 `-o <小时数>`**: 指定查询结束时间的偏移量(从现在往前推的小时数),默认为 `0`。
|
|
80
|
+
- **选项 `-t <类型>`**: 筛选指定消息类型,不改变上述展示逻辑。
|
|
81
|
+
|
|
82
|
+
#### `analyse.activity` (活跃统计)
|
|
83
|
+
|
|
84
|
+
- **`analyse activity`**: 查询**当前群组**的活跃度。
|
|
85
|
+
- **`analyse activity -u @用户`**: 查询**指定用户**在**所有群组**的活跃度。
|
|
86
|
+
- **`analyse activity -g <群组ID>`**: 查询**指定群组**的活跃度。
|
|
87
|
+
- **`analyse activity -u @用户 -g <群组ID>`**: 查询**指定用户**在**指定群组**的活跃度。
|
|
88
|
+
- **`analyse activity -a`**: 查询**全局**活跃度。
|
|
89
|
+
- **选项 `-n <数值>`**: 指定查询范围的时长,默认为 `24`。
|
|
90
|
+
- **选项 `-o <数值>`**: 指定查询结束时间的偏移量,默认为 `0`。
|
|
91
|
+
- **选项 `-d`**: 将 `-n` 和 `-o` 的单位从**小时**切换为**天**。
|
|
57
92
|
|
|
58
93
|
### 配置项
|
|
59
94
|
|
|
@@ -70,12 +105,12 @@
|
|
|
70
105
|
- `enableMsgStat`: **启用消息统计**。 (默认: `true`)
|
|
71
106
|
- `enableActivity`: **启用活跃统计**。 (默认: `true`)
|
|
72
107
|
- `enableRankStat`: **启用发言排行**。 (默认: `true`)
|
|
73
|
-
- `rankRetentionDays`: **排行保留天数**。发言排行数据的保留时长(天),`0` 为永久保留。 (默认: `
|
|
108
|
+
- `rankRetentionDays`: **排行保留天数**。发言排行数据的保留时长(天),`0` 为永久保留。 (默认: `180`)
|
|
74
109
|
- `enableWhoAt`: **启用提及记录**。 (默认: `true`)
|
|
75
|
-
- `atRetentionDays`: **提及保留天数**。`whoatme` 数据的保留时长(天),`0` 为永久保留。 (默认: `
|
|
110
|
+
- `atRetentionDays`: **提及保留天数**。`whoatme` 数据的保留时长(天),`0` 为永久保留。 (默认: `3`)
|
|
76
111
|
|
|
77
112
|
#### 高级分析配置
|
|
78
113
|
|
|
79
114
|
- `enableOriRecord`: **启用原始记录**。是否记录原始消息内容以供词云等功能使用。 (默认: `true`)
|
|
80
115
|
- `enableWordCloud`: **启用词云生成**。 (默认: `true`)
|
|
81
|
-
- `cacheRetentionDays`: **原始记录保留天数**。原始消息记录的保留时长(天),`0` 为永久保留。 (默认: `
|
|
116
|
+
- `cacheRetentionDays`: **原始记录保留天数**。原始消息记录的保留时长(天),`0` 为永久保留。 (默认: `30`)
|
package/lib/Renderer.d.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { Context } from 'koishi';
|
|
2
|
-
import { WordCloudData } from './Analyse';
|
|
3
|
-
/**
|
|
4
|
-
* @interface ListRenderData
|
|
5
|
-
* @description 定义了调用 `renderList` 方法所需的数据结构。
|
|
6
|
-
*/
|
|
7
|
-
export interface ListRenderData {
|
|
8
|
-
title: string;
|
|
9
|
-
time: Date;
|
|
10
|
-
total?: string | number;
|
|
11
|
-
list: (string | number | Date)[][];
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* @interface CircadianChartData
|
|
15
|
-
* @description 定义了调用 `renderCircadianChart` 方法所需的数据结构。
|
|
16
|
-
*/
|
|
17
|
-
export interface CircadianChartData {
|
|
18
|
-
title: string;
|
|
19
|
-
time: Date;
|
|
20
|
-
total: string | number;
|
|
21
|
-
data: number[];
|
|
22
|
-
labels?: string[];
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
* @class Renderer
|
|
26
|
-
* @description 负责将结构化的数据渲染为设计精美的 PNG 图片。
|
|
27
|
-
*/
|
|
28
|
-
export declare class Renderer {
|
|
29
|
-
private ctx;
|
|
30
|
-
private readonly COLOR_PALETTES;
|
|
31
|
-
private readonly COMMON_STYLE;
|
|
32
|
-
/**
|
|
33
|
-
* @constructor
|
|
34
|
-
* @description Renderer 类的构造函数。
|
|
35
|
-
* @param {Context} ctx - Koishi 的插件上下文,用于访问 logger 和 puppeteer 服务。
|
|
36
|
-
*/
|
|
37
|
-
constructor(ctx: Context);
|
|
38
|
-
/**
|
|
39
|
-
* @private
|
|
40
|
-
* @method generateFullHtml
|
|
41
|
-
* @description 将卡片内容和特定样式组合成一个完整的 HTML 文档,以便进行渲染。
|
|
42
|
-
* @param {string} cardContent - 卡片主体部分的 HTML 字符串。
|
|
43
|
-
* @param {string} specificStyles - 针对该卡片类型的特定 CSS 样式字符串。
|
|
44
|
-
* @returns {string} - 一个完整的、可被浏览器渲染的 HTML 字符串。
|
|
45
|
-
*/
|
|
46
|
-
private generateFullHtml;
|
|
47
|
-
/**
|
|
48
|
-
* @private
|
|
49
|
-
* @method htmlToImage
|
|
50
|
-
* @description 使用 puppeteer 将给定的 HTML 字符串内容渲染成 PNG 图片的 Buffer。
|
|
51
|
-
* @param {string} fullHtmlContent - 完整的 HTML 内容字符串。
|
|
52
|
-
* @returns {Promise<Buffer | null>} - 成功时返回包含 PNG 图片数据的 Buffer,失败则返回 null。
|
|
53
|
-
*/
|
|
54
|
-
private htmlToImage;
|
|
55
|
-
/**
|
|
56
|
-
* @private
|
|
57
|
-
* @method formatDate
|
|
58
|
-
* @description 将 Date 对象格式化为易于理解的相对时间字符串(如“刚刚”,“5分钟前”)。
|
|
59
|
-
* @param {Date} date - 需要格式化的日期对象。
|
|
60
|
-
* @returns {string} - 格式化后的时间字符串。
|
|
61
|
-
*/
|
|
62
|
-
private formatDate;
|
|
63
|
-
/**
|
|
64
|
-
* @public
|
|
65
|
-
* @method renderList
|
|
66
|
-
* @description 将表格型数据渲染成列表形式的图片。如果数据过多,会通过异步生成器逐个产出图片。
|
|
67
|
-
* @param {ListRenderData} data - 包含标题、时间、总计和列表数据的对象。
|
|
68
|
-
* @param {string[]} [headers] - (可选)列表的表头数组。
|
|
69
|
-
* @returns {AsyncGenerator<Buffer>} - 一个异步生成器,每次迭代产出一张图片的 Buffer。
|
|
70
|
-
*/
|
|
71
|
-
renderList(data: ListRenderData, headers?: string[]): AsyncGenerator<Buffer>;
|
|
72
|
-
/**
|
|
73
|
-
* @public
|
|
74
|
-
* @method renderCircadianChart
|
|
75
|
-
* @description 将 24 小时制的活跃度数据渲染成一张柱状图图片。通过异步生成器产出图片。
|
|
76
|
-
* @param {CircadianChartData} data - 包含标题、时间、总计和 24 小时数据数组的对象。
|
|
77
|
-
* @returns {AsyncGenerator<Buffer>} - 一个异步生成器,产出渲染后的图片 Buffer。
|
|
78
|
-
*/
|
|
79
|
-
renderCircadianChart(data: CircadianChartData): AsyncGenerator<Buffer>;
|
|
80
|
-
/**
|
|
81
|
-
* @public
|
|
82
|
-
* @method renderWordCloud
|
|
83
|
-
* @description 将词频数据渲染成一张词云图片,使用 Puppeteer 和 wordcloud2.js。
|
|
84
|
-
* @param {WordCloudData} data - 包含标题、时间和词汇列表的对象。
|
|
85
|
-
* @returns {AsyncGenerator<Buffer>} - 一个异步生成器,产出渲染后的图片 Buffer。
|
|
86
|
-
*/
|
|
87
|
-
renderWordCloud(data: WordCloudData): AsyncGenerator<Buffer>;
|
|
88
|
-
}
|
package/lib/wordcloud.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare const wordCloudScript = "\n/*!\n * wordcloud2.js\n * http://timdream.org/wordcloud2.js/\n *\n * Copyright 2011 - 2019 Tim Guan-tin Chien and contributors.\n * Released under the MIT license\n */\n\n'use strict'\n\n// setImmediate\nif (!window.setImmediate) {\n window.setImmediate = (function setupSetImmediate () {\n return window.msSetImmediate ||\n window.webkitSetImmediate ||\n window.mozSetImmediate ||\n window.oSetImmediate ||\n (function setupSetZeroTimeout () {\n if (!window.postMessage || !window.addEventListener) {\n return null\n }\n\n var callbacks = [undefined]\n var message = 'zero-timeout-message'\n\n // Like setTimeout, but only takes a function argument. There's\n // no time argument (always zero) and no arguments (you have to\n // use a closure).\n var setZeroTimeout = function setZeroTimeout (callback) {\n var id = callbacks.length\n callbacks.push(callback)\n window.postMessage(message + id.toString(36), '*')\n\n return id\n }\n\n window.addEventListener('message', function setZeroTimeoutMessage (evt) {\n // Skipping checking event source, retarded IE confused this window\n // object with another in the presence of iframe\n if (typeof evt.data !== 'string' ||\n evt.data.substr(0, message.length) !== message/* ||\n evt.source !== window */) {\n return\n }\n\n evt.stopImmediatePropagation()\n\n var id = parseInt(evt.data.substr(message.length), 36)\n if (!callbacks[id]) {\n return\n }\n\n callbacks[id]()\n callbacks[id] = undefined\n }, true)\n\n /* specify clearImmediate() here since we need the scope */\n window.clearImmediate = function clearZeroTimeout (id) {\n if (!callbacks[id]) {\n return\n }\n\n callbacks[id] = undefined\n }\n\n return setZeroTimeout\n })() ||\n // fallback\n function setImmediateFallback (fn) {\n window.setTimeout(fn, 0)\n }\n })()\n}\n\nif (!window.clearImmediate) {\n window.clearImmediate = (function setupClearImmediate () {\n return window.msClearImmediate ||\n window.webkitClearImmediate ||\n window.mozClearImmediate ||\n window.oClearImmediate ||\n // \"clearZeroTimeout\" is implement on the previous block ||\n // fallback\n function clearImmediateFallback (timer) {\n window.clearTimeout(timer)\n }\n })()\n}\n\n(function (global) {\n // Check if WordCloud can run on this browser\n var isSupported = (function isSupported () {\n var canvas = document.createElement('canvas')\n if (!canvas || !canvas.getContext) {\n return false\n }\n\n var ctx = canvas.getContext('2d')\n if (!ctx) {\n return false\n }\n if (!ctx.getImageData) {\n return false\n }\n if (!ctx.fillText) {\n return false\n }\n\n if (!Array.prototype.some) {\n return false\n }\n if (!Array.prototype.push) {\n return false\n }\n\n return true\n }())\n\n // Find out if the browser impose minium font size by\n // drawing small texts on a canvas and measure it's width.\n var minFontSize = (function getMinFontSize () {\n if (!isSupported) {\n return\n }\n\n var ctx = document.createElement('canvas').getContext('2d')\n\n // start from 20\n var size = 20\n\n // two sizes to measure\n var hanWidth, mWidth\n\n while (size) {\n ctx.font = size.toString(10) + 'px sans-serif'\n if ((ctx.measureText('\uFF37').width === hanWidth) &&\n (ctx.measureText('m').width) === mWidth) {\n return (size + 1)\n }\n\n hanWidth = ctx.measureText('\uFF37').width\n mWidth = ctx.measureText('m').width\n\n size--\n }\n\n return 0\n })()\n\n var getItemExtraData = function (item) {\n if (Array.isArray(item)) {\n var itemCopy = item.slice()\n // remove data we already have (word and weight)\n itemCopy.splice(0, 2)\n return itemCopy\n } else {\n return []\n }\n }\n\n // Based on http://jsfromhell.com/array/shuffle\n var shuffleArray = function shuffleArray (arr) {\n for (var j, x, i = arr.length; i;) {\n j = Math.floor(Math.random() * i)\n x = arr[--i]\n arr[i] = arr[j]\n arr[j] = x\n }\n return arr\n }\n\n var timer = {};\n var WordCloud = function WordCloud (elements, options) {\n if (!isSupported) {\n return\n }\n\n var timerId = Math.floor(Math.random() * Date.now())\n\n if (!Array.isArray(elements)) {\n elements = [elements]\n }\n\n elements.forEach(function (el, i) {\n if (typeof el === 'string') {\n elements[i] = document.getElementById(el)\n if (!elements[i]) {\n throw new Error('The element id specified is not found.')\n }\n } else if (!el.tagName && !el.appendChild) {\n throw new Error('You must pass valid HTML elements, or ID of the element.')\n }\n })\n\n /* Default values to be overwritten by options object */\n var settings = {\n list: [],\n fontFamily: '\"Trebuchet MS\", \"Heiti TC\", \"\u5FAE\u8EDF\u6B63\u9ED1\u9AD4\", ' +\n '\"Arial Unicode MS\", \"Droid Fallback Sans\", sans-serif',\n fontWeight: 'normal',\n color: 'random-dark',\n minSize: 0, // 0 to disable\n weightFactor: 1,\n clearCanvas: true,\n backgroundColor: '#fff', // opaque white = rgba(255, 255, 255, 1)\n\n gridSize: 8,\n drawOutOfBound: false,\n shrinkToFit: false,\n origin: null,\n\n drawMask: false,\n maskColor: 'rgba(255,0,0,0.3)',\n maskGapWidth: 0.3,\n\n wait: 0,\n abortThreshold: 0, // disabled\n abort: function noop () {},\n\n minRotation: -Math.PI / 2,\n maxRotation: Math.PI / 2,\n rotationSteps: 0,\n\n shuffle: true,\n rotateRatio: 0.1,\n\n shape: 'circle',\n ellipticity: 0.65,\n\n classes: null,\n\n hover: null,\n click: null\n }\n\n if (options) {\n for (var key in options) {\n if (key in settings) {\n settings[key] = options[key]\n }\n }\n }\n\n /* Convert weightFactor into a function */\n if (typeof settings.weightFactor !== 'function') {\n var factor = settings.weightFactor\n settings.weightFactor = function weightFactor (pt) {\n return pt * factor // in px\n }\n }\n\n /* Convert shape into a function */\n if (typeof settings.shape !== 'function') {\n switch (settings.shape) {\n case 'circle':\n /* falls through */\n default:\n // 'circle' is the default and a shortcut in the code loop.\n settings.shape = 'circle'\n break\n\n case 'cardioid':\n settings.shape = function shapeCardioid (theta) {\n return 1 - Math.sin(theta)\n }\n break\n\n /*\n To work out an X-gon, one has to calculate \"m\",\n where 1/(cos(2*PI/X)+m*sin(2*PI/X)) = 1/(cos(0)+m*sin(0))\n http://www.wolframalpha.com/input/?i=1%2F%28cos%282*PI%2FX%29%2Bm*sin%28\n 2*PI%2FX%29%29+%3D+1%2F%28cos%280%29%2Bm*sin%280%29%29\n Copy the solution into polar equation r = 1/(cos(t') + m*sin(t'))\n where t' equals to mod(t, 2PI/X)\n */\n\n case 'diamond':\n // http://www.wolframalpha.com/input/?i=plot+r+%3D+1%2F%28cos%28mod+\n // %28t%2C+PI%2F2%29%29%2Bsin%28mod+%28t%2C+PI%2F2%29%29%29%2C+t+%3D\n // +0+..+2*PI\n settings.shape = function shapeSquare (theta) {\n var thetaPrime = theta % (2 * Math.PI / 4)\n return 1 / (Math.cos(thetaPrime) + Math.sin(thetaPrime))\n }\n break\n\n case 'square':\n // http://www.wolframalpha.com/input/?i=plot+r+%3D+min(1%2Fabs(cos(t\n // )),1%2Fabs(sin(t)))),+t+%3D+0+..+2*PI\n settings.shape = function shapeSquare (theta) {\n return Math.min(\n 1 / Math.abs(Math.cos(theta)),\n 1 / Math.abs(Math.sin(theta))\n )\n }\n break\n\n case 'triangle-forward':\n // http://www.wolframalpha.com/input/?i=plot+r+%3D+1%2F%28cos%28mod+\n // %28t%2C+2*PI%2F3%29%29%2Bsqrt%283%29sin%28mod+%28t%2C+2*PI%2F3%29\n // %29%29%2C+t+%3D+0+..+2*PI\n settings.shape = function shapeTriangle (theta) {\n var thetaPrime = theta % (2 * Math.PI / 3)\n return 1 / (Math.cos(thetaPrime) +\n Math.sqrt(3) * Math.sin(thetaPrime))\n }\n break\n\n case 'triangle':\n case 'triangle-upright':\n settings.shape = function shapeTriangle (theta) {\n var thetaPrime = (theta + Math.PI * 3 / 2) % (2 * Math.PI / 3)\n return 1 / (Math.cos(thetaPrime) +\n Math.sqrt(3) * Math.sin(thetaPrime))\n }\n break\n\n case 'pentagon':\n settings.shape = function shapePentagon (theta) {\n var thetaPrime = (theta + 0.955) % (2 * Math.PI / 5)\n return 1 / (Math.cos(thetaPrime) +\n 0.726543 * Math.sin(thetaPrime))\n }\n break\n\n case 'star':\n settings.shape = function shapeStar (theta) {\n var thetaPrime = (theta + 0.955) % (2 * Math.PI / 10)\n if ((theta + 0.955) % (2 * Math.PI / 5) - (2 * Math.PI / 10) >= 0) {\n return 1 / (Math.cos((2 * Math.PI / 10) - thetaPrime) +\n 3.07768 * Math.sin((2 * Math.PI / 10) - thetaPrime))\n } else {\n return 1 / (Math.cos(thetaPrime) +\n 3.07768 * Math.sin(thetaPrime))\n }\n }\n break\n }\n }\n\n /* Make sure gridSize is a whole number and is not smaller than 4px */\n settings.gridSize = Math.max(Math.floor(settings.gridSize), 4)\n\n /* shorthand */\n var g = settings.gridSize\n var maskRectWidth = g - settings.maskGapWidth\n\n /* normalize rotation settings */\n var rotationRange = Math.abs(settings.maxRotation - settings.minRotation)\n var rotationSteps = Math.abs(Math.floor(settings.rotationSteps))\n var minRotation = Math.min(settings.maxRotation, settings.minRotation)\n\n /* information/object available to all functions, set when start() */\n var grid, // 2d array containing filling information\n ngx, ngy, // width and height of the grid\n center, // position of the center of the cloud\n maxRadius\n\n /* timestamp for measuring each putWord() action */\n var escapeTime\n\n /* function for getting the color of the text */\n var getTextColor\n function randomHslColor (min, max) {\n return 'hsl(' +\n (Math.random() * 360).toFixed() + ',' +\n (Math.random() * 30 + 70).toFixed() + '%,' +\n (Math.random() * (max - min) + min).toFixed() + '%)'\n }\n switch (settings.color) {\n case 'random-dark':\n getTextColor = function getRandomDarkColor () {\n return randomHslColor(10, 50)\n }\n break\n\n case 'random-light':\n getTextColor = function getRandomLightColor () {\n return randomHslColor(50, 90)\n }\n break\n\n default:\n if (typeof settings.color === 'function') {\n getTextColor = settings.color\n }\n break\n }\n\n /* function for getting the font-weight of the text */\n var getTextFontWeight\n if (typeof settings.fontWeight === 'function') {\n getTextFontWeight = settings.fontWeight\n }\n\n /* function for getting the classes of the text */\n var getTextClasses = null\n if (typeof settings.classes === 'function') {\n getTextClasses = settings.classes\n }\n\n /* Interactive */\n var interactive = false\n var infoGrid = []\n var hovered\n\n var getInfoGridFromMouseTouchEvent =\n function getInfoGridFromMouseTouchEvent (evt) {\n var canvas = evt.currentTarget\n var rect = canvas.getBoundingClientRect()\n var clientX\n var clientY\n /** Detect if touches are available */\n if (evt.touches) {\n clientX = evt.touches[0].clientX\n clientY = evt.touches[0].clientY\n } else {\n clientX = evt.clientX\n clientY = evt.clientY\n }\n var eventX = clientX - rect.left\n var eventY = clientY - rect.top\n\n var x = Math.floor(eventX * ((canvas.width / rect.width) || 1) / g)\n var y = Math.floor(eventY * ((canvas.height / rect.height) || 1) / g)\n\n return infoGrid[x][y]\n }\n\n var wordcloudhover = function wordcloudhover (evt) {\n var info = getInfoGridFromMouseTouchEvent(evt)\n\n if (hovered === info) {\n return\n }\n\n hovered = info\n if (!info) {\n settings.hover(undefined, undefined, evt)\n\n return\n }\n\n settings.hover(info.item, info.dimension, evt)\n }\n\n var wordcloudclick = function wordcloudclick (evt) {\n var info = getInfoGridFromMouseTouchEvent(evt)\n if (!info) {\n return\n }\n\n settings.click(info.item, info.dimension, evt)\n evt.preventDefault()\n }\n\n /* Get points on the grid for a given radius away from the center */\n var pointsAtRadius = []\n var getPointsAtRadius = function getPointsAtRadius (radius) {\n if (pointsAtRadius[radius]) {\n return pointsAtRadius[radius]\n }\n\n // Look for these number of points on each radius\n var T = radius * 8\n\n // Getting all the points at this radius\n var t = T\n var points = []\n\n if (radius === 0) {\n points.push([center[0], center[1], 0])\n }\n\n while (t--) {\n // distort the radius to put the cloud in shape\n var rx = 1\n if (settings.shape !== 'circle') {\n rx = settings.shape(t / T * 2 * Math.PI) // 0 to 1\n }\n\n // Push [x, y, t] t is used solely for getTextColor()\n points.push([\n center[0] + radius * rx * Math.cos(-t / T * 2 * Math.PI),\n center[1] + radius * rx * Math.sin(-t / T * 2 * Math.PI) *\n settings.ellipticity,\n t / T * 2 * Math.PI])\n }\n\n pointsAtRadius[radius] = points\n return points\n }\n\n /* Return true if we had spent too much time */\n var exceedTime = function exceedTime () {\n return ((settings.abortThreshold > 0) &&\n ((new Date()).getTime() - escapeTime > settings.abortThreshold))\n }\n\n /* Get the deg of rotation according to settings, and luck. */\n var getRotateDeg = function getRotateDeg () {\n if (settings.rotateRatio === 0) {\n return 0\n }\n\n if (Math.random() > settings.rotateRatio) {\n return 0\n }\n\n if (rotationRange === 0) {\n return minRotation\n }\n\n if (rotationSteps > 0) {\n // Min rotation + zero or more steps * span of one step\n return minRotation +\n Math.floor(Math.random() * rotationSteps) *\n rotationRange / (rotationSteps - 1)\n } else {\n return minRotation + Math.random() * rotationRange\n }\n }\n\n var getTextInfo = function getTextInfo (word, weight, rotateDeg, extraDataArray) {\n // calculate the acutal font size\n // fontSize === 0 means weightFactor function wants the text skipped,\n // and size < minSize means we cannot draw the text.\n var debug = false\n var fontSize = settings.weightFactor(weight)\n if (fontSize <= settings.minSize) {\n return false\n }\n\n // Scale factor here is to make sure fillText is not limited by\n // the minium font size set by browser.\n // It will always be 1 or 2n.\n var mu = 1\n if (fontSize < minFontSize) {\n mu = (function calculateScaleFactor () {\n var mu = 2\n while (mu * fontSize < minFontSize) {\n mu += 2\n }\n return mu\n })()\n }\n\n // Get fontWeight that will be used to set fctx.font\n var fontWeight\n if (getTextFontWeight) {\n fontWeight = getTextFontWeight(word, weight, fontSize, extraDataArray)\n } else {\n fontWeight = settings.fontWeight\n }\n\n var fcanvas = document.createElement('canvas')\n var fctx = fcanvas.getContext('2d', { willReadFrequently: true })\n\n fctx.font = fontWeight + ' ' +\n (fontSize * mu).toString(10) + 'px ' + settings.fontFamily\n\n // Estimate the dimension of the text with measureText().\n var fw = fctx.measureText(word).width / mu\n var fh = Math.max(fontSize * mu,\n fctx.measureText('m').width,\n fctx.measureText('\uFF37').width\n ) / mu\n\n // Create a boundary box that is larger than our estimates,\n // so text don't get cut of (it sill might)\n var boxWidth = fw + fh * 2\n var boxHeight = fh * 3\n var fgw = Math.ceil(boxWidth / g)\n var fgh = Math.ceil(boxHeight / g)\n boxWidth = fgw * g\n boxHeight = fgh * g\n\n // Calculate the proper offsets to make the text centered at\n // the preferred position.\n\n // This is simply half of the width.\n var fillTextOffsetX = -fw / 2\n // Instead of moving the box to the exact middle of the preferred\n // position, for Y-offset we move 0.4 instead, so Latin alphabets look\n // vertical centered.\n var fillTextOffsetY = -fh * 0.4\n\n // Calculate the actual dimension of the canvas, considering the rotation.\n var cgh = Math.ceil((boxWidth * Math.abs(Math.sin(rotateDeg)) +\n boxHeight * Math.abs(Math.cos(rotateDeg))) / g)\n var cgw = Math.ceil((boxWidth * Math.abs(Math.cos(rotateDeg)) +\n boxHeight * Math.abs(Math.sin(rotateDeg))) / g)\n var width = cgw * g\n var height = cgh * g\n\n fcanvas.setAttribute('width', width)\n fcanvas.setAttribute('height', height)\n\n if (debug) {\n // Attach fcanvas to the DOM\n document.body.appendChild(fcanvas)\n // Save it's state so that we could restore and draw the grid correctly.\n fctx.save()\n }\n\n // Scale the canvas with |mu|.\n fctx.scale(1 / mu, 1 / mu)\n fctx.translate(width * mu / 2, height * mu / 2)\n fctx.rotate(-rotateDeg)\n\n // Once the width/height is set, ctx info will be reset.\n // Set it again here.\n fctx.font = fontWeight + ' ' +\n (fontSize * mu).toString(10) + 'px ' + settings.fontFamily\n\n // Fill the text into the fcanvas.\n // XXX: We cannot because textBaseline = 'top' here because\n // Firefox and Chrome uses different default line-height for canvas.\n // Please read https://bugzil.la/737852#c6.\n // Here, we use textBaseline = 'middle' and draw the text at exactly\n // 0.5 * fontSize lower.\n fctx.fillStyle = '#000'\n fctx.textBaseline = 'middle'\n fctx.fillText(\n word, fillTextOffsetX * mu,\n (fillTextOffsetY + fontSize * 0.5) * mu\n )\n\n // Get the pixels of the text\n var imageData = fctx.getImageData(0, 0, width, height).data\n\n if (exceedTime()) {\n return false\n }\n\n if (debug) {\n // Draw the box of the original estimation\n fctx.strokeRect(\n fillTextOffsetX * mu,\n fillTextOffsetY, fw * mu, fh * mu\n )\n fctx.restore()\n }\n\n // Read the pixels and save the information to the occupied array\n var occupied = []\n var gx = cgw\n var gy, x, y\n var bounds = [cgh / 2, cgw / 2, cgh / 2, cgw / 2]\n while (gx--) {\n gy = cgh\n while (gy--) {\n y = g\n /* eslint no-labels: [\"error\", { \"allowLoop\": true }] */\n singleGridLoop: while (y--) {\n x = g\n while (x--) {\n if (imageData[((gy * g + y) * width +\n (gx * g + x)) * 4 + 3]) {\n occupied.push([gx, gy])\n\n if (gx < bounds[3]) {\n bounds[3] = gx\n }\n if (gx > bounds[1]) {\n bounds[1] = gx\n }\n if (gy < bounds[0]) {\n bounds[0] = gy\n }\n if (gy > bounds[2]) {\n bounds[2] = gy\n }\n\n if (debug) {\n fctx.fillStyle = 'rgba(255, 0, 0, 0.5)'\n fctx.fillRect(gx * g, gy * g, g - 0.5, g - 0.5)\n }\n break singleGridLoop\n }\n }\n }\n if (debug) {\n fctx.fillStyle = 'rgba(0, 0, 255, 0.5)'\n fctx.fillRect(gx * g, gy * g, g - 0.5, g - 0.5)\n }\n }\n }\n\n if (debug) {\n fctx.fillStyle = 'rgba(0, 255, 0, 0.5)'\n fctx.fillRect(\n bounds[3] * g,\n bounds[0] * g,\n (bounds[1] - bounds[3] + 1) * g,\n (bounds[2] - bounds[0] + 1) * g\n )\n }\n\n // Return information needed to create the text on the real canvas\n return {\n mu: mu,\n occupied: occupied,\n bounds: bounds,\n gw: cgw,\n gh: cgh,\n fillTextOffsetX: fillTextOffsetX,\n fillTextOffsetY: fillTextOffsetY,\n fillTextWidth: fw,\n fillTextHeight: fh,\n fontSize: fontSize\n }\n }\n\n /* Determine if there is room available in the given dimension */\n var canFitText = function canFitText (gx, gy, gw, gh, occupied) {\n // Go through the occupied points,\n // return false if the space is not available.\n var i = occupied.length\n while (i--) {\n var px = gx + occupied[i][0]\n var py = gy + occupied[i][1]\n\n if (px >= ngx || py >= ngy || px < 0 || py < 0) {\n if (!settings.drawOutOfBound) {\n return false\n }\n continue\n }\n\n if (!grid[px][py]) {\n return false\n }\n }\n return true\n }\n\n /* Actually draw the text on the grid */\n var drawText = function drawText (gx, gy, info, word, weight, distance, theta, rotateDeg, attributes, extraDataArray) {\n var fontSize = info.fontSize\n var color\n if (getTextColor) {\n color = getTextColor(word, weight, fontSize, distance, theta, extraDataArray)\n } else {\n color = settings.color\n }\n\n // get fontWeight that will be used to set ctx.font and font style rule\n var fontWeight\n if (getTextFontWeight) {\n fontWeight = getTextFontWeight(word, weight, fontSize, extraDataArray)\n } else {\n fontWeight = settings.fontWeight\n }\n\n var classes\n if (getTextClasses) {\n classes = getTextClasses(word, weight, fontSize, extraDataArray)\n } else {\n classes = settings.classes\n }\n\n elements.forEach(function (el) {\n if (el.getContext) {\n var ctx = el.getContext('2d')\n var mu = info.mu\n\n // Save the current state before messing it\n ctx.save()\n ctx.scale(1 / mu, 1 / mu)\n\n ctx.font = fontWeight + ' ' +\n (fontSize * mu).toString(10) + 'px ' + settings.fontFamily\n ctx.fillStyle = color\n\n // Translate the canvas position to the origin coordinate of where\n // the text should be put.\n ctx.translate(\n (gx + info.gw / 2) * g * mu,\n (gy + info.gh / 2) * g * mu\n )\n\n if (rotateDeg !== 0) {\n ctx.rotate(-rotateDeg)\n }\n\n // Finally, fill the text.\n\n // XXX: We cannot because textBaseline = 'top' here because\n // Firefox and Chrome uses different default line-height for canvas.\n // Please read https://bugzil.la/737852#c6.\n // Here, we use textBaseline = 'middle' and draw the text at exactly\n // 0.5 * fontSize lower.\n ctx.textBaseline = 'middle'\n ctx.fillText(\n word, info.fillTextOffsetX * mu,\n (info.fillTextOffsetY + fontSize * 0.5) * mu\n )\n\n // The below box is always matches how <span>s are positioned\n /* ctx.strokeRect(info.fillTextOffsetX, info.fillTextOffsetY,\n info.fillTextWidth, info.fillTextHeight) */\n\n // Restore the state.\n ctx.restore()\n } else {\n // drawText on DIV element\n var span = document.createElement('span')\n var transformRule = ''\n transformRule = 'rotate(' + (-rotateDeg / Math.PI * 180) + 'deg) '\n if (info.mu !== 1) {\n transformRule +=\n 'translateX(-' + (info.fillTextWidth / 4) + 'px) ' +\n 'scale(' + (1 / info.mu) + ')'\n }\n var styleRules = {\n position: 'absolute',\n display: 'block',\n font: fontWeight + ' ' +\n (fontSize * info.mu) + 'px ' + settings.fontFamily,\n left: ((gx + info.gw / 2) * g + info.fillTextOffsetX) + 'px',\n top: ((gy + info.gh / 2) * g + info.fillTextOffsetY) + 'px',\n width: info.fillTextWidth + 'px',\n height: info.fillTextHeight + 'px',\n lineHeight: fontSize + 'px',\n whiteSpace: 'nowrap',\n transform: transformRule,\n webkitTransform: transformRule,\n msTransform: transformRule,\n transformOrigin: '50% 40%',\n webkitTransformOrigin: '50% 40%',\n msTransformOrigin: '50% 40%'\n }\n if (color) {\n styleRules.color = color\n }\n span.textContent = word\n for (var cssProp in styleRules) {\n span.style[cssProp] = styleRules[cssProp]\n }\n if (attributes) {\n for (var attribute in attributes) {\n span.setAttribute(attribute, attributes[attribute])\n }\n }\n if (classes) {\n span.className += classes\n }\n el.appendChild(span)\n }\n })\n }\n\n /* Help function to updateGrid */\n var fillGridAt = function fillGridAt (x, y, drawMask, dimension, item) {\n if (x >= ngx || y >= ngy || x < 0 || y < 0) {\n return\n }\n\n grid[x][y] = false\n\n if (drawMask) {\n var ctx = elements[0].getContext('2d')\n ctx.fillRect(x * g, y * g, maskRectWidth, maskRectWidth)\n }\n\n if (interactive) {\n infoGrid[x][y] = { item: item, dimension: dimension }\n }\n }\n\n /* Update the filling information of the given space with occupied points.\n Draw the mask on the canvas if necessary. */\n var updateGrid = function updateGrid (gx, gy, gw, gh, info, item) {\n var occupied = info.occupied\n var drawMask = settings.drawMask\n var ctx\n if (drawMask) {\n ctx = elements[0].getContext('2d')\n ctx.save()\n ctx.fillStyle = settings.maskColor\n }\n\n var dimension\n if (interactive) {\n var bounds = info.bounds\n dimension = {\n x: (gx + bounds[3]) * g,\n y: (gy + bounds[0]) * g,\n w: (bounds[1] - bounds[3] + 1) * g,\n h: (bounds[2] - bounds[0] + 1) * g\n }\n }\n\n var i = occupied.length\n while (i--) {\n var px = gx + occupied[i][0]\n var py = gy + occupied[i][1]\n\n if (px >= ngx || py >= ngy || px < 0 || py < 0) {\n continue\n }\n\n fillGridAt(px, py, drawMask, dimension, item)\n }\n\n if (drawMask) {\n ctx.restore()\n }\n }\n\n /* putWord() processes each item on the list,\n calculate it's size and determine it's position, and actually\n put it on the canvas. */\n var putWord = function putWord (item) {\n var word, weight, attributes\n if (Array.isArray(item)) {\n word = item[0]\n weight = item[1]\n } else {\n word = item.word\n weight = item.weight\n attributes = item.attributes\n }\n var rotateDeg = getRotateDeg()\n\n var extraDataArray = getItemExtraData(item)\n\n // get info needed to put the text onto the canvas\n var info = getTextInfo(word, weight, rotateDeg, extraDataArray)\n\n // not getting the info means we shouldn't be drawing this one.\n if (!info) {\n return false\n }\n\n if (exceedTime()) {\n return false\n }\n\n // If drawOutOfBound is set to false,\n // skip the loop if we have already know the bounding box of\n // word is larger than the canvas.\n if (!settings.drawOutOfBound && !settings.shrinkToFit) {\n var bounds = info.bounds;\n if ((bounds[1] - bounds[3] + 1) > ngx ||\n (bounds[2] - bounds[0] + 1) > ngy) {\n return false\n }\n }\n\n // Determine the position to put the text by\n // start looking for the nearest points\n var r = maxRadius + 1\n\n var tryToPutWordAtPoint = function (gxy) {\n var gx = Math.floor(gxy[0] - info.gw / 2)\n var gy = Math.floor(gxy[1] - info.gh / 2)\n var gw = info.gw\n var gh = info.gh\n\n // If we cannot fit the text at this position, return false\n // and go to the next position.\n if (!canFitText(gx, gy, gw, gh, info.occupied)) {\n return false\n }\n\n // Actually put the text on the canvas\n drawText(gx, gy, info, word, weight,\n (maxRadius - r), gxy[2], rotateDeg, attributes, extraDataArray)\n\n // Mark the spaces on the grid as filled\n updateGrid(gx, gy, gw, gh, info, item)\n\n // Return true so some() will stop and also return true.\n return true\n }\n\n while (r--) {\n var points = getPointsAtRadius(maxRadius - r)\n\n if (settings.shuffle) {\n points = [].concat(points)\n shuffleArray(points)\n }\n\n // Try to fit the words by looking at each point.\n // array.some() will stop and return true\n // when putWordAtPoint() returns true.\n // If all the points returns false, array.some() returns false.\n var drawn = points.some(tryToPutWordAtPoint)\n\n if (drawn) {\n // leave putWord() and return true\n return true\n }\n }\n if (settings.shrinkToFit) {\n if (Array.isArray(item)) {\n item[1] = item[1] * 3 / 4\n } else {\n item.weight = item.weight * 3 / 4\n }\n return putWord(item)\n }\n // we tried all distances but text won't fit, return false\n return false\n }\n\n /* Send DOM event to all elements. Will stop sending event and return\n if the previous one is canceled (for cancelable events). */\n var sendEvent = function sendEvent (type, cancelable, details) {\n if (cancelable) {\n return !elements.some(function (el) {\n var event = new CustomEvent(type, {\n detail: details || {}\n })\n return !el.dispatchEvent(event)\n }, this)\n } else {\n elements.forEach(function (el) {\n var event = new CustomEvent(type, {\n detail: details || {}\n })\n el.dispatchEvent(event)\n }, this)\n }\n }\n\n /* Start drawing on a canvas */\n var start = function start () {\n // For dimensions, clearCanvas etc.,\n // we only care about the first element.\n var canvas = elements[0]\n\n if (canvas.getContext) {\n ngx = Math.ceil(canvas.width / g)\n ngy = Math.ceil(canvas.height / g)\n } else {\n var rect = canvas.getBoundingClientRect()\n ngx = Math.ceil(rect.width / g)\n ngy = Math.ceil(rect.height / g)\n }\n\n // Sending a wordcloudstart event which cause the previous loop to stop.\n // Do nothing if the event is canceled.\n if (!sendEvent('wordcloudstart', true)) {\n return\n }\n\n // Determine the center of the word cloud\n center = (settings.origin)\n ? [settings.origin[0] / g, settings.origin[1] / g]\n : [ngx / 2, ngy / 2]\n\n // Maxium radius to look for space\n maxRadius = Math.floor(Math.sqrt(ngx * ngx + ngy * ngy))\n\n /* Clear the canvas only if the clearCanvas is set,\n if not, update the grid to the current canvas state */\n grid = []\n\n var gx, gy, i\n if (!canvas.getContext || settings.clearCanvas) {\n elements.forEach(function (el) {\n if (el.getContext) {\n var ctx = el.getContext('2d')\n ctx.fillStyle = settings.backgroundColor\n ctx.clearRect(0, 0, ngx * (g + 1), ngy * (g + 1))\n ctx.fillRect(0, 0, ngx * (g + 1), ngy * (g + 1))\n } else {\n el.textContent = ''\n el.style.backgroundColor = settings.backgroundColor\n el.style.position = 'relative'\n }\n })\n\n /* fill the grid with empty state */\n gx = ngx\n while (gx--) {\n grid[gx] = []\n gy = ngy\n while (gy--) {\n grid[gx][gy] = true\n }\n }\n } else {\n /* Determine bgPixel by creating\n another canvas and fill the specified background color. */\n var bctx = document.createElement('canvas').getContext('2d')\n\n bctx.fillStyle = settings.backgroundColor\n bctx.fillRect(0, 0, 1, 1)\n var bgPixel = bctx.getImageData(0, 0, 1, 1).data\n\n /* Read back the pixels of the canvas we got to tell which part of the\n canvas is empty.\n (no clearCanvas only works with a canvas, not divs) */\n var imageData =\n canvas.getContext('2d').getImageData(0, 0, ngx * g, ngy * g).data\n\n gx = ngx\n var x, y\n while (gx--) {\n grid[gx] = []\n gy = ngy\n while (gy--) {\n y = g\n /* eslint no-labels: [\"error\", { \"allowLoop\": true }] */\n singleGridLoop: while (y--) {\n x = g\n while (x--) {\n i = 4\n while (i--) {\n if (imageData[((gy * g + y) * ngx * g +\n (gx * g + x)) * 4 + i] !== bgPixel[i]) {\n grid[gx][gy] = false\n break singleGridLoop\n }\n }\n }\n }\n if (grid[gx][gy] !== false) {\n grid[gx][gy] = true\n }\n }\n }\n\n imageData = bctx = bgPixel = undefined\n }\n\n // fill the infoGrid with empty state if we need it\n if (settings.hover || settings.click) {\n interactive = true\n\n /* fill the grid with empty state */\n gx = ngx + 1\n while (gx--) {\n infoGrid[gx] = []\n }\n\n if (settings.hover) {\n canvas.addEventListener('mousemove', wordcloudhover)\n }\n\n if (settings.click) {\n canvas.addEventListener('click', wordcloudclick)\n canvas.style.webkitTapHighlightColor = 'rgba(0, 0, 0, 0)'\n }\n\n canvas.addEventListener('wordcloudstart', function stopInteraction () {\n canvas.removeEventListener('wordcloudstart', stopInteraction)\n canvas.removeEventListener('mousemove', wordcloudhover)\n canvas.removeEventListener('click', wordcloudclick)\n hovered = undefined\n })\n }\n\n i = 0\n var loopingFunction, stoppingFunction\n if (settings.wait !== 0) {\n loopingFunction = window.setTimeout\n stoppingFunction = window.clearTimeout\n } else {\n loopingFunction = window.setImmediate\n stoppingFunction = window.clearImmediate\n }\n\n var addEventListener = function addEventListener (type, listener) {\n elements.forEach(function (el) {\n el.addEventListener(type, listener)\n }, this)\n }\n\n var removeEventListener = function removeEventListener (type, listener) {\n elements.forEach(function (el) {\n el.removeEventListener(type, listener)\n }, this)\n }\n\n var anotherWordCloudStart = function anotherWordCloudStart () {\n removeEventListener('wordcloudstart', anotherWordCloudStart)\n stoppingFunction(timer[timerId])\n }\n\n addEventListener('wordcloudstart', anotherWordCloudStart)\n timer[timerId] = loopingFunction(function loop () {\n if (i >= settings.list.length) {\n stoppingFunction(timer[timerId])\n sendEvent('wordcloudstop', false)\n removeEventListener('wordcloudstart', anotherWordCloudStart)\n delete timer[timerId];\n return\n }\n escapeTime = (new Date()).getTime()\n var drawn = putWord(settings.list[i])\n var canceled = !sendEvent('wordclouddrawn', true, {\n item: settings.list[i],\n drawn: drawn\n })\n if (exceedTime() || canceled) {\n stoppingFunction(timer[timerId])\n settings.abort()\n sendEvent('wordcloudabort', false)\n sendEvent('wordcloudstop', false)\n removeEventListener('wordcloudstart', anotherWordCloudStart)\n delete timer[timerId]\n return\n }\n i++\n timer[timerId] = loopingFunction(loop, settings.wait)\n }, settings.wait)\n }\n\n // All set, start the drawing\n start()\n }\n\n WordCloud.isSupported = isSupported\n WordCloud.minFontSize = minFontSize\n WordCloud.stop = function stop () {\n if (timer) {\n for (var timerId in timer) {\n window.clearImmediate(timer[timerId])\n }\n }\n }\n\n // Expose the library as an AMD module\n if (typeof define === 'function' && define.amd) { // eslint-disable-line no-undef\n global.WordCloud = WordCloud\n define('wordcloud', [], function () { return WordCloud }) // eslint-disable-line no-undef\n } else if (typeof module !== 'undefined' && module.exports) { // eslint-disable-line no-undef\n module.exports = WordCloud // eslint-disable-line no-undef\n } else {\n global.WordCloud = WordCloud\n }\n})(this) // jshint ignore:line\n";
|