koishi-plugin-chat-analyse 1.4.2 → 1.4.4

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.
@@ -0,0 +1,24 @@
1
+ import { Context, Command } from 'koishi';
2
+ import { Config } from './index';
3
+ export interface WordCloudData {
4
+ title: string;
5
+ time: Date;
6
+ words: [string, number][];
7
+ }
8
+ /**
9
+ * @class Analyse
10
+ * @description 提供文本分析功能,如生成词云。
11
+ */
12
+ export declare class Analyse {
13
+ private ctx;
14
+ private config;
15
+ private renderer;
16
+ private readonly jieba;
17
+ constructor(ctx: Context, config: Config);
18
+ /**
19
+ * @public @method registerCommands
20
+ * @description 在主命令下注册子命令。
21
+ * @param cmd - 主命令实例。
22
+ */
23
+ registerCommands(cmd: Command): void;
24
+ }
@@ -0,0 +1,86 @@
1
+ import { Context } from 'koishi';
2
+ import { Config } from './index';
3
+ declare module 'koishi' {
4
+ interface Tables {
5
+ analyse_user: {
6
+ uid: number;
7
+ channelId: string;
8
+ userId: string;
9
+ channelName: string;
10
+ userName: string;
11
+ };
12
+ analyse_cmd: {
13
+ uid: number;
14
+ command: string;
15
+ count: number;
16
+ timestamp: Date;
17
+ };
18
+ analyse_msg: {
19
+ uid: number;
20
+ type: string;
21
+ count: number;
22
+ timestamp: Date;
23
+ };
24
+ analyse_rank: {
25
+ uid: number;
26
+ type: string;
27
+ count: number;
28
+ timestamp: Date;
29
+ };
30
+ analyse_cache: {
31
+ id: number;
32
+ uid: number;
33
+ content: string;
34
+ timestamp: Date;
35
+ };
36
+ analyse_at: {
37
+ id: number;
38
+ uid: number;
39
+ target: string;
40
+ content: string;
41
+ timestamp: Date;
42
+ };
43
+ }
44
+ }
45
+ /**
46
+ * @class Collector
47
+ * @description 核心数据收集器。根据配置,高效地监听、收集、缓冲并持久化聊天数据。
48
+ */
49
+ export declare class Collector {
50
+ private ctx;
51
+ private config;
52
+ private static readonly FLUSH_INTERVAL;
53
+ private static readonly BUFFER_THRESHOLD;
54
+ private msgStatBuffer;
55
+ private rankStatBuffer;
56
+ private cmdStatBuffer;
57
+ private oriCacheBuffer;
58
+ private whoAtBuffer;
59
+ private userCache;
60
+ private channelCache;
61
+ private pendingRequests;
62
+ private flushInterval;
63
+ /**
64
+ * @param ctx - Koishi 的插件上下文。
65
+ * @param config - 插件的配置对象。
66
+ */
67
+ constructor(ctx: Context, config: Config);
68
+ /**
69
+ * @private @method onMessage
70
+ * @description 统一的消息事件处理器,解析消息并更新各类统计数据的缓冲区。
71
+ * @param session - Koishi 的会话对象。
72
+ */
73
+ private onMessage;
74
+ /**
75
+ * @private @method sanitizeContent
76
+ * @description 将 Koishi 消息元素数组净化为纯文本字符串。
77
+ * @param elements - 消息元素数组。
78
+ * @returns 净化后的纯文本。
79
+ */
80
+ private sanitizeContent;
81
+ /**
82
+ * @private @method flushBuffers
83
+ * @description 将所有内存中的数据缓冲区批量写入数据库,并清空缓冲区。
84
+ */
85
+ private flushBuffers;
86
+ }
package/lib/Renderer.d.ts CHANGED
@@ -83,7 +83,7 @@ export declare class Renderer {
83
83
  * @public
84
84
  * @method renderWordCloud
85
85
  * @description 将词频数据渲染成一张词云图片,使用 Puppeteer 和 wordcloud2.js。
86
- * @param {WordCloudData} data - 包含标题、时间和词汇列表的对象。
86
+ * @param {WordCloudData} data - 包含标题、时间和词汇列表,以及从config传入的options。
87
87
  * @returns {AsyncGenerator<Buffer>} - 一个异步生成器,产出渲染后的图片 Buffer。
88
88
  */
89
89
  renderWordCloud(data: WordCloudData): AsyncGenerator<Buffer>;
package/lib/WhoAt.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { Context, Command } from 'koishi';
2
+ import { Config } from './index';
3
+ /**
4
+ * @class WhoAt
5
+ * @description 负责处理谁提及我相关功能,包括查询和定时清理。
6
+ */
7
+ export declare class WhoAt {
8
+ private ctx;
9
+ private config;
10
+ /**
11
+ * @param ctx - Koishi 的插件上下文。
12
+ * @param config - 插件的配置对象。
13
+ */
14
+ constructor(ctx: Context, config: Config);
15
+ /**
16
+ * @public @method registerCommand
17
+ * @description 在主命令下注册子命令。
18
+ * @param cmd - 主命令实例。
19
+ */
20
+ registerCommand(cmd: Command): void;
21
+ }
package/lib/index.d.ts CHANGED
@@ -22,6 +22,16 @@ export interface Config {
22
22
  cacheRetentionDays: number;
23
23
  enableSimilarActivity: boolean;
24
24
  enableAutoBackup: boolean;
25
+ fontFamily: string;
26
+ minFontSize: number;
27
+ maxFontSize: number;
28
+ shape: string;
29
+ gridSize: number;
30
+ rotateRatio: number;
31
+ minRotation: number;
32
+ maxRotation: number;
33
+ ellipticity: number;
34
+ maskImage: string;
25
35
  }
26
36
  /** @description 插件的配置项定义 */
27
37
  export declare const Config: Schema<Config>;
package/lib/index.js CHANGED
@@ -95,9 +95,10 @@ var Collector = class _Collector {
95
95
  * @param session - Koishi 的会话对象。
96
96
  */
97
97
  async onMessage(session) {
98
- const { userId, channelId, content, timestamp, argv, elements, bot } = session;
99
- if (!channelId || !userId || !content?.trim()) return;
100
- const cacheKey = `${channelId}:${userId}`;
98
+ const { userId, guildId, channelId, content, timestamp, argv, elements, bot } = session;
99
+ const effectiveChannelId = guildId || channelId;
100
+ if (!effectiveChannelId || !userId || !content?.trim()) return;
101
+ const cacheKey = `${effectiveChannelId}:${userId}`;
101
102
  let user;
102
103
  if (this.userCache.has(cacheKey)) {
103
104
  user = this.userCache.get(cacheKey);
@@ -106,13 +107,19 @@ var Collector = class _Collector {
106
107
  } else {
107
108
  const promise = (async () => {
108
109
  try {
109
- const [dbUser] = await this.ctx.database.get("analyse_user", { channelId, userId });
110
+ const [dbUser] = await this.ctx.database.get("analyse_user", { channelId: effectiveChannelId, userId });
110
111
  const currentUserName = session.username ?? "";
111
- let currentChannelName = this.channelCache.get(channelId);
112
+ let currentChannelName = this.channelCache.get(effectiveChannelId);
112
113
  if (currentChannelName === void 0) {
113
- const guild = bot.getGuild && typeof bot.getGuild === "function" ? await bot.getGuild(channelId).catch(() => null) : null;
114
- currentChannelName = guild?.name ?? "";
115
- if (currentChannelName) this.channelCache.set(channelId, currentChannelName);
114
+ let channelInfo = null;
115
+ if (bot.getGuild && typeof bot.getGuild === "function") {
116
+ channelInfo = await bot.getGuild(effectiveChannelId).catch(() => null);
117
+ }
118
+ if (!channelInfo && bot.getChannel && typeof bot.getChannel === "function") {
119
+ channelInfo = await bot.getChannel(effectiveChannelId).catch(() => null);
120
+ }
121
+ currentChannelName = channelInfo?.name ?? "";
122
+ if (currentChannelName) this.channelCache.set(effectiveChannelId, currentChannelName);
116
123
  }
117
124
  if (dbUser) {
118
125
  if (currentUserName && dbUser.userName !== currentUserName || currentChannelName && dbUser.channelName !== currentChannelName) {
@@ -124,7 +131,7 @@ var Collector = class _Collector {
124
131
  this.userCache.set(cacheKey, cacheData2);
125
132
  return cacheData2;
126
133
  }
127
- const createdUser = await this.ctx.database.create("analyse_user", { channelId, userId, userName: currentUserName, channelName: currentChannelName });
134
+ const createdUser = await this.ctx.database.create("analyse_user", { channelId: effectiveChannelId, userId, userName: currentUserName, channelName: currentChannelName });
128
135
  const cacheData = { uid: createdUser.uid, userName: createdUser.userName };
129
136
  this.userCache.set(cacheKey, cacheData);
130
137
  return cacheData;
@@ -1768,19 +1775,18 @@ var Renderer = class {
1768
1775
  * @public
1769
1776
  * @method renderWordCloud
1770
1777
  * @description 将词频数据渲染成一张词云图片,使用 Puppeteer 和 wordcloud2.js。
1771
- * @param {WordCloudData} data - 包含标题、时间和词汇列表的对象。
1778
+ * @param {WordCloudData} data - 包含标题、时间和词汇列表,以及从config传入的options。
1772
1779
  * @returns {AsyncGenerator<Buffer>} - 一个异步生成器,产出渲染后的图片 Buffer。
1773
1780
  */
1774
1781
  async *renderWordCloud(data) {
1775
1782
  const { title, time, words } = data;
1776
- if (!words?.length) return;
1783
+ const options = data.options;
1784
+ if (!words?.length || !options) return;
1777
1785
  const wordsJson = JSON.stringify(words);
1778
1786
  const selectedPalette = this.COLOR_PALETTES[Math.floor(Math.random() * this.COLOR_PALETTES.length)];
1779
1787
  const weights = words.map((w) => w[1]);
1780
1788
  const maxWeight = Math.max(...weights, 1);
1781
1789
  const minWeight = Math.min(...weights);
1782
- const MAX_FONT_SIZE = 64;
1783
- const MIN_FONT_SIZE = 4;
1784
1790
  const cardHtml = `
1785
1791
  <div class="container">
1786
1792
  <div class="header">
@@ -1788,31 +1794,55 @@ var Renderer = class {
1788
1794
  <h1 class="title-text">${title}</h1>
1789
1795
  <div class="time-label">${time.toLocaleString("zh-CN", { hour12: false })}</div>
1790
1796
  </div>
1791
- <div id="wordcloud-container" style="width: 512px; height: 512px; margin: auto;"></div>
1797
+ <div style="width: 512px; height: 512px; margin: auto; position: relative;">
1798
+ <canvas id="wordcloud-canvas" width="512" height="512"></canvas>
1799
+ </div>
1792
1800
  <script>${wordCloudScript}</script>
1793
1801
  <script>
1802
+ const canvas = document.getElementById('wordcloud-canvas');
1803
+ const maskImageUrl = ${JSON.stringify(options.maskImage)};
1794
1804
  const palette = ${JSON.stringify(selectedPalette)};
1795
- WordCloud(document.getElementById('wordcloud-container'), {
1796
- fontFamily: '"Noto Sans CJK SC", "Arial", sans-serif',
1805
+
1806
+ const wordCloudOptions = {
1807
+ list: ${wordsJson},
1808
+ fontFamily: ${JSON.stringify(options.fontFamily)},
1797
1809
  weightFactor: (size) => {
1798
- if (${maxWeight} === ${minWeight}) return (${MIN_FONT_SIZE} + ${MAX_FONT_SIZE}) / 2;
1810
+ if (${maxWeight} === ${minWeight}) return (${options.minFontSize} + ${options.maxFontSize}) / 2;
1799
1811
  const normalizedWeight = (size - ${minWeight}) / (${maxWeight} - ${minWeight});
1800
- return ${MIN_FONT_SIZE} + normalizedWeight * (${MAX_FONT_SIZE} - ${MIN_FONT_SIZE});
1812
+ return ${options.minFontSize} + normalizedWeight * (${options.maxFontSize} - ${options.minFontSize});
1801
1813
  },
1802
- color: (word, weight, fontSize, distance, theta) => {
1803
- return palette[Math.floor(Math.random() * palette.length)];
1804
- },
1805
- list: ${wordsJson},
1806
- shape: 'square',
1807
- gridSize: 1,
1808
- ellipticity: 1,
1809
- rotateRatio: 1,
1810
- minRotation: -Math.PI / 4,
1811
- maxRotation: Math.PI / 4,
1812
- backgroundColor: 'transparent',
1813
- clearCanvas: true,
1814
+ color: () => palette[Math.floor(Math.random() * palette.length)],
1815
+ shape: ${JSON.stringify(options.shape)},
1816
+ gridSize: ${options.gridSize},
1817
+ rotateRatio: ${options.rotateRatio},
1818
+ minRotation: ${options.minRotation},
1819
+ maxRotation: ${options.maxRotation},
1820
+ ellipticity: ${options.ellipticity},
1814
1821
  shuffle: true,
1815
- });
1822
+ drawOutOfBoundWords: false,
1823
+ backgroundColor: 'transparent',
1824
+ };
1825
+
1826
+ function drawWordCloud(isMasked) {
1827
+ const finalOptions = { ...wordCloudOptions, clearCanvas: !isMasked };
1828
+ WordCloud(canvas, finalOptions);
1829
+ }
1830
+
1831
+ if (maskImageUrl) {
1832
+ const img = new Image();
1833
+ img.crossOrigin = "anonymous";
1834
+ img.onload = () => {
1835
+ const ctx = canvas.getContext('2d');
1836
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
1837
+ drawWordCloud(true);
1838
+ };
1839
+ img.onerror = () => {
1840
+ drawWordCloud(false);
1841
+ };
1842
+ img.src = maskImageUrl;
1843
+ } else {
1844
+ drawWordCloud(false);
1845
+ }
1816
1846
  </script>
1817
1847
  </div>`;
1818
1848
  const fullHtml = `<!DOCTYPE html>
@@ -1861,7 +1891,7 @@ var Stat = class {
1861
1891
  if (options.all) return { uids: void 0, scopeDesc };
1862
1892
  if (options.user) scopeDesc.userId = import_koishi3.h.select(options.user, "at")[0]?.attrs.id ?? options.user.trim();
1863
1893
  if (options.guild) scopeDesc.guildId = options.guild;
1864
- if (!scopeDesc.guildId && !scopeDesc.userId) scopeDesc.guildId = session.guildId;
1894
+ if (!scopeDesc.guildId && !scopeDesc.userId) scopeDesc.guildId = session.guildId || session.channelId;
1865
1895
  if (!scopeDesc.guildId && !scopeDesc.userId) return { error: "请指定查询范围", scopeDesc };
1866
1896
  if (scopeDesc.guildId) query.channelId = scopeDesc.guildId;
1867
1897
  if (scopeDesc.userId) query.userId = scopeDesc.userId;
@@ -2286,9 +2316,10 @@ var Data = class {
2286
2316
  if (time && isNaN(until.getTime())) return "时间格式无效";
2287
2317
  try {
2288
2318
  const userQuery = {};
2319
+ const effectiveChannelId = session.guildId || session.channelId;
2289
2320
  if (!options.guild && !options.user) {
2290
- if (!session.guildId) return "请指定查询范围";
2291
- userQuery.channelId = session.guildId;
2321
+ if (!effectiveChannelId) return "请指定查询范围";
2322
+ userQuery.channelId = effectiveChannelId;
2292
2323
  } else {
2293
2324
  if (options.guild) userQuery.channelId = options.guild;
2294
2325
  if (options.user) userQuery.userId = import_koishi5.Element.select(options.user, "at")[0]?.attrs.id ?? options.user;
@@ -2414,9 +2445,10 @@ var Analyse = class {
2414
2445
  }
2415
2446
  if (this.config.enableSimilarActivity) {
2416
2447
  cmd.subcommand("simiactive", "相似活跃分析").usage("分析你和群友的活跃规律,找出谁和你的作息最相似。").option("hours", "-n <hours:number> 指定时长", { fallback: 24 }).option("separate", "-p 分时分析").action(async ({ session, options }) => {
2417
- if (!session.guildId) return "请在群组中使用此命令";
2448
+ const effectiveChannelId = session.guildId || session.channelId;
2449
+ if (!effectiveChannelId) return "请在群组中使用此命令";
2418
2450
  try {
2419
- const guildUsers = await this.ctx.database.get("analyse_user", { channelId: session.guildId });
2451
+ const guildUsers = await this.ctx.database.get("analyse_user", { channelId: effectiveChannelId });
2420
2452
  if (guildUsers.length < 2) return "暂无用户数据";
2421
2453
  const selfUser = guildUsers.find((u) => u.userId === session.userId);
2422
2454
  const guildUserUids = guildUsers.map((u) => u.uid);
@@ -2518,12 +2550,24 @@ var Config3 = import_koishi7.Schema.intersect([
2518
2550
  enableAutoBackup: import_koishi7.Schema.boolean().default(false).description("启用自动备份"),
2519
2551
  enableWordCloud: import_koishi7.Schema.boolean().default(true).description("启用词云生成"),
2520
2552
  enableSimilarActivity: import_koishi7.Schema.boolean().default(true).description("启用相似活跃分析")
2521
- }).description("高级分析配置")
2553
+ }).description("高级分析配置"),
2554
+ import_koishi7.Schema.object({
2555
+ ellipticity: import_koishi7.Schema.number().min(0).max(1).default(1).description("长宽比"),
2556
+ rotateRatio: import_koishi7.Schema.number().min(0).max(1).default(0.5).description("旋转比"),
2557
+ minRotation: import_koishi7.Schema.number().default(Math.PI / 2).description("最小旋转角"),
2558
+ maxRotation: import_koishi7.Schema.number().default(Math.PI / 2).description("最大旋转角"),
2559
+ minFontSize: import_koishi7.Schema.number().min(1).default(4).description("最小字号"),
2560
+ maxFontSize: import_koishi7.Schema.number().min(1).default(64).description("最大字号"),
2561
+ gridSize: import_koishi7.Schema.number().min(0).default(1).description("词云间距"),
2562
+ fontFamily: import_koishi7.Schema.string().default('"Noto Sans CJK SC", "Arial", sans-serif').description("词云字体"),
2563
+ shape: import_koishi7.Schema.union(["square", "circle", "cardioid", "diamond", "triangle-forward", "triangle", "pentagon", "star"]).default("square").description("词云形状"),
2564
+ maskImage: import_koishi7.Schema.string().role("link").description("词云蒙版")
2565
+ }).description("词云生成配置")
2522
2566
  ]);
2523
2567
  async function parseQueryScope(ctx, session, options) {
2524
2568
  const scopeDesc = { guildId: options.guild, userId: void 0 };
2525
2569
  if (options.user) scopeDesc.userId = import_koishi7.h.select(options.user, "at")[0]?.attrs.id ?? options.user.trim();
2526
- if (!options.all && !scopeDesc.guildId && !scopeDesc.userId) scopeDesc.guildId = session.guildId;
2570
+ if (!options.all && !scopeDesc.guildId && !scopeDesc.userId) scopeDesc.guildId = session.guildId || session.channelId;
2527
2571
  if (!options.all && !scopeDesc.guildId && !scopeDesc.userId) return { error: "请指定查询范围", scopeDesc };
2528
2572
  const query = {};
2529
2573
  if (scopeDesc.guildId) query.channelId = scopeDesc.guildId;
@@ -0,0 +1 @@
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";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-chat-analyse",
3
3
  "description": "强大而全面的聊天数据分析插件。支持多维度统计(命令、发言、消息类型、活跃度),可生成发言排行、词云图,并提供完善的数据管理。",
4
- "version": "1.4.2",
4
+ "version": "1.4.4",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],
@@ -17,7 +17,6 @@
17
17
  "dist"
18
18
  ],
19
19
  "license": "AGPL-3.0-only",
20
- "scripts": {},
21
20
  "keywords": [
22
21
  "chatbot",
23
22
  "koishi",
package/readme.md CHANGED
@@ -156,6 +156,21 @@
156
156
  `enableSimilarActivity`: **启用相似活跃分析**。
157
157
  > **!** 此功能依赖 **`启用发言排行`** 或 **`启用活跃统计`**。 (默认: `true`)
158
158
 
159
+ ### 词云生成配置
160
+
161
+ | 配置项 | 描述 | 默认值 |
162
+ | :--- | :--- | :--- |
163
+ | `maskImage` | **词云蒙版**:提供一个图片的URL作为词云的形状蒙版。**注意:这会覆盖“基础形状”选项。** | (空) |
164
+ | `fontFamily` | **词云字体**:用于渲染词云的字体列表。 | `"Noto Sans CJK SC", "Arial", sans-serif` |
165
+ | `minFontSize` | **最小字号**:权重最小的单词所使用的字号。 | `4` |
166
+ | `maxFontSize` | **最大字号**:权重最大的单词所使用的字号。 | `64` |
167
+ | `gridSize` | **词云间距**:用于分隔单词的网格大小(像素)。值越大,单词间距越大。 | `1` |
168
+ | `shape` | **基础形状**:选择词云的整体轮廓(无蒙版时生效)。 | `square` |
169
+ | `ellipticity` | **长宽比**:当形状为“椭圆”时,定义其扁平程度。值越小越扁。 | `1` |
170
+ | `rotateRatio` | **旋转比**:随机旋转的单词所占的比例(0 到 1)。 | `0.5` |
171
+ | `minRotation` | **最小旋转角**:单词随机旋转的最小角度(弧度)。 | `1.570796` (π/2) |
172
+ | `maxRotation` | **最大旋转角**:单词随机旋转的最大角度(弧度)。 | `1.570796` (π/2) |
173
+
159
174
  ## 📌 注意事项
160
175
 
161
176
  1. **Puppeteer 配置**:本插件的图片渲染强依赖 `puppeteer` 服务。请确保您已正确安装并配置了该服务,包括正确设置了可执行文件路径(如有需要)。渲染失败通常与此有关。