]> Untitled Git - lemmy.git/blob - ui/src/utils.ts
Merge branch 'master' into federation
[lemmy.git] / ui / src / utils.ts
1 import 'moment/locale/es';
2 import 'moment/locale/eo';
3 import 'moment/locale/de';
4 import 'moment/locale/zh-cn';
5 import 'moment/locale/fr';
6 import 'moment/locale/sv';
7 import 'moment/locale/ru';
8 import 'moment/locale/nl';
9 import 'moment/locale/it';
10 import 'moment/locale/fi';
11 import 'moment/locale/ca';
12 import 'moment/locale/fa';
13 import 'moment/locale/pt-br';
14 import 'moment/locale/ja';
15
16 import {
17   UserOperation,
18   Comment,
19   CommentNode as CommentNodeI,
20   Post,
21   PrivateMessage,
22   User,
23   SortType,
24   CommentSortType,
25   ListingType,
26   DataType,
27   SearchType,
28   WebSocketResponse,
29   WebSocketJsonResponse,
30   SearchForm,
31   SearchResponse,
32   CommentResponse,
33   PostResponse,
34 } from './interfaces';
35 import { UserService, WebSocketService } from './services';
36
37 import Tribute from 'tributejs/src/Tribute.js';
38 import markdown_it from 'markdown-it';
39 import markdownitEmoji from 'markdown-it-emoji/light';
40 import markdown_it_container from 'markdown-it-container';
41 import twemoji from 'twemoji';
42 import emojiShortName from 'emoji-short-name';
43 import Toastify from 'toastify-js';
44 import tippy from 'tippy.js';
45
46 export const repoUrl = 'https://github.com/dessalines/lemmy';
47 export const helpGuideUrl = '/docs/about_guide.html';
48 export const markdownHelpUrl = `${helpGuideUrl}#markdown-guide`;
49 export const sortingHelpUrl = `${helpGuideUrl}#sorting`;
50 export const archiveUrl = 'https://archive.is';
51
52 export const postRefetchSeconds: number = 60 * 1000;
53 export const fetchLimit: number = 20;
54 export const mentionDropdownFetchLimit = 10;
55
56 export const languages = [
57   { code: 'ca', name: 'Català' },
58   { code: 'en', name: 'English' },
59   { code: 'eo', name: 'Esperanto' },
60   { code: 'es', name: 'Español' },
61   { code: 'de', name: 'Deutsch' },
62   { code: 'fa', name: 'فارسی' },
63   { code: 'ja', name: '日本語' },
64   { code: 'pt_BR', name: 'Português Brasileiro' },
65   { code: 'zh', name: '中文' },
66   { code: 'fi', name: 'Suomi' },
67   { code: 'fr', name: 'Français' },
68   { code: 'sv', name: 'Svenska' },
69   { code: 'ru', name: 'Русский' },
70   { code: 'nl', name: 'Nederlands' },
71   { code: 'it', name: 'Italiano' },
72 ];
73
74 export const themes = [
75   'litera',
76   'materia',
77   'minty',
78   'solar',
79   'united',
80   'cyborg',
81   'darkly',
82   'journal',
83   'sketchy',
84   'vaporwave',
85   'vaporwave-dark',
86   'i386',
87 ];
88
89 export function randomStr() {
90   return Math.random()
91     .toString(36)
92     .replace(/[^a-z]+/g, '')
93     .substr(2, 10);
94 }
95
96 export function wsJsonToRes(msg: WebSocketJsonResponse): WebSocketResponse {
97   let opStr: string = msg.op;
98   return {
99     op: UserOperation[opStr],
100     data: msg.data,
101   };
102 }
103
104 export const md = new markdown_it({
105   html: false,
106   linkify: true,
107   typographer: true,
108 })
109   .use(markdown_it_container, 'spoiler', {
110     validate: function(params: any) {
111       return params.trim().match(/^spoiler\s+(.*)$/);
112     },
113
114     render: function(tokens: any, idx: any) {
115       var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/);
116
117       if (tokens[idx].nesting === 1) {
118         // opening tag
119         return `<details><summary> ${md.utils.escapeHtml(m[1])} </summary>\n`;
120       } else {
121         // closing tag
122         return '</details>\n';
123       }
124     },
125   })
126   .use(markdownitEmoji, {
127     defs: objectFlip(emojiShortName),
128   });
129
130 md.renderer.rules.emoji = function(token, idx) {
131   return twemoji.parse(token[idx].content);
132 };
133
134 export function hotRankComment(comment: Comment): number {
135   return hotRank(comment.score, comment.published);
136 }
137
138 export function hotRankPost(post: Post): number {
139   return hotRank(post.score, post.newest_activity_time);
140 }
141
142 export function hotRank(score: number, timeStr: string): number {
143   // Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity
144   let date: Date = new Date(timeStr + 'Z'); // Add Z to convert from UTC date
145   let now: Date = new Date();
146   let hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5;
147
148   let rank =
149     (10000 * Math.log10(Math.max(1, 3 + score))) /
150     Math.pow(hoursElapsed + 2, 1.8);
151
152   // console.log(`Comment: ${comment.content}\nRank: ${rank}\nScore: ${comment.score}\nHours: ${hoursElapsed}`);
153
154   return rank;
155 }
156
157 export function mdToHtml(text: string) {
158   return { __html: md.render(text) };
159 }
160
161 export function getUnixTime(text: string): number {
162   return text ? new Date(text).getTime() / 1000 : undefined;
163 }
164
165 export function addTypeInfo<T>(
166   arr: Array<T>,
167   name: string
168 ): Array<{ type_: string; data: T }> {
169   return arr.map(e => {
170     return { type_: name, data: e };
171   });
172 }
173
174 export function canMod(
175   user: User,
176   modIds: Array<number>,
177   creator_id: number,
178   onSelf: boolean = false
179 ): boolean {
180   // You can do moderator actions only on the mods added after you.
181   if (user) {
182     let yourIndex = modIds.findIndex(id => id == user.id);
183     if (yourIndex == -1) {
184       return false;
185     } else {
186       // onSelf +1 on mod actions not for yourself, IE ban, remove, etc
187       modIds = modIds.slice(0, yourIndex + (onSelf ? 0 : 1));
188       return !modIds.includes(creator_id);
189     }
190   } else {
191     return false;
192   }
193 }
194
195 export function isMod(modIds: Array<number>, creator_id: number): boolean {
196   return modIds.includes(creator_id);
197 }
198
199 const imageRegex = new RegExp(
200   /(http)?s?:?(\/\/[^"']*\.(?:jpg|jpeg|gif|png|svg))/
201 );
202 const videoRegex = new RegExp(`(http)?s?:?(\/\/[^"']*\.(?:mp4))`);
203
204 export function isImage(url: string) {
205   return imageRegex.test(url);
206 }
207
208 export function isVideo(url: string) {
209   return videoRegex.test(url);
210 }
211
212 export function validURL(str: string) {
213   try {
214     return !!new URL(str);
215   } catch {
216     return false;
217   }
218 }
219
220 export function validEmail(email: string) {
221   let re = /^(([^\s"(),.:;<>@[\\\]]+(\.[^\s"(),.:;<>@[\\\]]+)*)|(".+"))@((\[(?:\d{1,3}\.){3}\d{1,3}])|(([\dA-Za-z\-]+\.)+[A-Za-z]{2,}))$/;
222   return re.test(String(email).toLowerCase());
223 }
224
225 export function capitalizeFirstLetter(str: string): string {
226   return str.charAt(0).toUpperCase() + str.slice(1);
227 }
228
229 export function routeSortTypeToEnum(sort: string): SortType {
230   if (sort == 'new') {
231     return SortType.New;
232   } else if (sort == 'hot') {
233     return SortType.Hot;
234   } else if (sort == 'topday') {
235     return SortType.TopDay;
236   } else if (sort == 'topweek') {
237     return SortType.TopWeek;
238   } else if (sort == 'topmonth') {
239     return SortType.TopMonth;
240   } else if (sort == 'topyear') {
241     return SortType.TopYear;
242   } else if (sort == 'topall') {
243     return SortType.TopAll;
244   }
245 }
246
247 export function routeListingTypeToEnum(type: string): ListingType {
248   return ListingType[capitalizeFirstLetter(type)];
249 }
250
251 export function routeDataTypeToEnum(type: string): DataType {
252   return DataType[capitalizeFirstLetter(type)];
253 }
254
255 export function routeSearchTypeToEnum(type: string): SearchType {
256   return SearchType[capitalizeFirstLetter(type)];
257 }
258
259 export async function getPageTitle(url: string) {
260   let res = await fetch(`/iframely/oembed?url=${url}`).then(res => res.json());
261   let title = await res.title;
262   return title;
263 }
264
265 export function debounce(
266   func: any,
267   wait: number = 1000,
268   immediate: boolean = false
269 ) {
270   // 'private' variable for instance
271   // The returned function will be able to reference this due to closure.
272   // Each call to the returned function will share this common timer.
273   let timeout: any;
274
275   // Calling debounce returns a new anonymous function
276   return function() {
277     // reference the context and args for the setTimeout function
278     var context = this,
279       args = arguments;
280
281     // Should the function be called now? If immediate is true
282     //   and not already in a timeout then the answer is: Yes
283     var callNow = immediate && !timeout;
284
285     // This is the basic debounce behaviour where you can call this
286     //   function several times, but it will only execute once
287     //   [before or after imposing a delay].
288     //   Each time the returned function is called, the timer starts over.
289     clearTimeout(timeout);
290
291     // Set the new timeout
292     timeout = setTimeout(function() {
293       // Inside the timeout function, clear the timeout variable
294       // which will let the next execution run when in 'immediate' mode
295       timeout = null;
296
297       // Check if the function already ran with the immediate flag
298       if (!immediate) {
299         // Call the original function with apply
300         // apply lets you define the 'this' object as well as the arguments
301         //    (both captured before setTimeout)
302         func.apply(context, args);
303       }
304     }, wait);
305
306     // Immediate mode and no wait timer? Execute the function..
307     if (callNow) func.apply(context, args);
308   };
309 }
310
311 export function getLanguage(): string {
312   let user = UserService.Instance.user;
313   let lang = user && user.lang ? user.lang : 'browser';
314
315   if (lang == 'browser') {
316     return getBrowserLanguage();
317   } else {
318     return lang;
319   }
320 }
321
322 export function getBrowserLanguage(): string {
323   return navigator.language;
324 }
325
326 export function getMomentLanguage(): string {
327   let lang = getLanguage();
328   if (lang.startsWith('zh')) {
329     lang = 'zh-cn';
330   } else if (lang.startsWith('sv')) {
331     lang = 'sv';
332   } else if (lang.startsWith('fr')) {
333     lang = 'fr';
334   } else if (lang.startsWith('de')) {
335     lang = 'de';
336   } else if (lang.startsWith('ru')) {
337     lang = 'ru';
338   } else if (lang.startsWith('es')) {
339     lang = 'es';
340   } else if (lang.startsWith('eo')) {
341     lang = 'eo';
342   } else if (lang.startsWith('nl')) {
343     lang = 'nl';
344   } else if (lang.startsWith('it')) {
345     lang = 'it';
346   } else if (lang.startsWith('fi')) {
347     lang = 'fi';
348   } else if (lang.startsWith('ca')) {
349     lang = 'ca';
350   } else if (lang.startsWith('fa')) {
351     lang = 'fa';
352   } else if (lang.startsWith('pt')) {
353     lang = 'pt-br';
354   } else if (lang.startsWith('ja')) {
355     lang = 'ja';
356   } else {
357     lang = 'en';
358   }
359   return lang;
360 }
361
362 export function setTheme(theme: string = 'darkly') {
363   // unload all the other themes
364   for (var i = 0; i < themes.length; i++) {
365     let styleSheet = document.getElementById(themes[i]);
366     if (styleSheet) {
367       styleSheet.setAttribute('disabled', 'disabled');
368     }
369   }
370
371   // Load the theme dynamically
372   if (!document.getElementById(theme)) {
373     var head = document.getElementsByTagName('head')[0];
374     var link = document.createElement('link');
375     link.id = theme;
376     link.rel = 'stylesheet';
377     link.type = 'text/css';
378     link.href = `/static/assets/css/themes/${theme}.min.css`;
379     link.media = 'all';
380     head.appendChild(link);
381   }
382   document.getElementById(theme).removeAttribute('disabled');
383 }
384
385 export function objectFlip(obj: any) {
386   const ret = {};
387   Object.keys(obj).forEach(key => {
388     ret[obj[key]] = key;
389   });
390   return ret;
391 }
392
393 export function pictshareAvatarThumbnail(src: string): string {
394   // sample url: http://localhost:8535/pictshare/gs7xuu.jpg
395   let split = src.split('pictshare');
396   let out = `${split[0]}pictshare/96${split[1]}`;
397   return out;
398 }
399
400 export function showAvatars(): boolean {
401   return (
402     (UserService.Instance.user && UserService.Instance.user.show_avatars) ||
403     !UserService.Instance.user
404   );
405 }
406
407 // Converts to image thumbnail
408 export function pictshareImage(
409   hash: string,
410   thumbnail: boolean = false
411 ): string {
412   let root = `/pictshare`;
413
414   // Necessary for other servers / domains
415   if (hash.includes('pictshare')) {
416     let split = hash.split('/pictshare/');
417     root = `${split[0]}/pictshare`;
418     hash = split[1];
419   }
420
421   let out = `${root}/${thumbnail ? '192/' : ''}${hash}`;
422   return out;
423 }
424
425 export function isCommentType(item: Comment | PrivateMessage): item is Comment {
426   return (item as Comment).community_id !== undefined;
427 }
428
429 export function toast(text: string, background: string = 'success') {
430   let backgroundColor = `var(--${background})`;
431   Toastify({
432     text: text,
433     backgroundColor: backgroundColor,
434     gravity: 'bottom',
435     position: 'left',
436   }).showToast();
437 }
438
439 export function messageToastify(
440   creator: string,
441   avatar: string,
442   body: string,
443   link: string,
444   router: any
445 ) {
446   let backgroundColor = `var(--light)`;
447
448   let toast = Toastify({
449     text: `${body}<br />${creator}`,
450     avatar: avatar,
451     backgroundColor: backgroundColor,
452     close: true,
453     gravity: 'top',
454     position: 'right',
455     duration: 0,
456     onClick: () => {
457       if (toast) {
458         toast.hideToast();
459         router.history.push(link);
460       }
461     },
462   }).showToast();
463 }
464
465 export function setupTribute(): Tribute {
466   return new Tribute({
467     collection: [
468       // Emojis
469       {
470         trigger: ':',
471         menuItemTemplate: (item: any) => {
472           let emoji = `:${item.original.key}:`;
473           return `${item.original.val} ${emoji}`;
474         },
475         selectTemplate: (item: any) => {
476           return `:${item.original.key}:`;
477         },
478         values: Object.entries(emojiShortName).map(e => {
479           return { key: e[1], val: e[0] };
480         }),
481         allowSpaces: false,
482         autocompleteMode: true,
483         menuItemLimit: mentionDropdownFetchLimit,
484         menuShowMinLength: 2,
485       },
486       // Users
487       {
488         trigger: '@',
489         selectTemplate: (item: any) => {
490           return `[/u/${item.original.key}](/u/${item.original.key})`;
491         },
492         values: (text: string, cb: any) => {
493           userSearch(text, (users: any) => cb(users));
494         },
495         allowSpaces: false,
496         autocompleteMode: true,
497         menuItemLimit: mentionDropdownFetchLimit,
498         menuShowMinLength: 2,
499       },
500
501       // Communities
502       {
503         trigger: '#',
504         selectTemplate: (item: any) => {
505           return `[/c/${item.original.key}](/c/${item.original.key})`;
506         },
507         values: (text: string, cb: any) => {
508           communitySearch(text, (communities: any) => cb(communities));
509         },
510         allowSpaces: false,
511         autocompleteMode: true,
512         menuItemLimit: mentionDropdownFetchLimit,
513         menuShowMinLength: 2,
514       },
515     ],
516   });
517 }
518
519 let tippyInstance = tippy('[data-tippy-content]');
520
521 export function setupTippy() {
522   tippyInstance.forEach(e => e.destroy());
523   tippyInstance = tippy('[data-tippy-content]', {
524     delay: [500, 0],
525     // Display on "long press"
526     touch: ['hold', 500],
527   });
528 }
529
530 function userSearch(text: string, cb: any) {
531   if (text) {
532     let form: SearchForm = {
533       q: text,
534       type_: SearchType[SearchType.Users],
535       sort: SortType[SortType.TopAll],
536       page: 1,
537       limit: mentionDropdownFetchLimit,
538     };
539
540     WebSocketService.Instance.search(form);
541
542     this.userSub = WebSocketService.Instance.subject.subscribe(
543       msg => {
544         let res = wsJsonToRes(msg);
545         if (res.op == UserOperation.Search) {
546           let data = res.data as SearchResponse;
547           let users = data.users.map(u => {
548             return { key: u.name };
549           });
550           cb(users);
551           this.userSub.unsubscribe();
552         }
553       },
554       err => console.error(err),
555       () => console.log('complete')
556     );
557   } else {
558     cb([]);
559   }
560 }
561
562 function communitySearch(text: string, cb: any) {
563   if (text) {
564     let form: SearchForm = {
565       q: text,
566       type_: SearchType[SearchType.Communities],
567       sort: SortType[SortType.TopAll],
568       page: 1,
569       limit: mentionDropdownFetchLimit,
570     };
571
572     WebSocketService.Instance.search(form);
573
574     this.communitySub = WebSocketService.Instance.subject.subscribe(
575       msg => {
576         let res = wsJsonToRes(msg);
577         if (res.op == UserOperation.Search) {
578           let data = res.data as SearchResponse;
579           let communities = data.communities.map(u => {
580             return { key: u.name };
581           });
582           cb(communities);
583           this.communitySub.unsubscribe();
584         }
585       },
586       err => console.error(err),
587       () => console.log('complete')
588     );
589   } else {
590     cb([]);
591   }
592 }
593
594 export function getListingTypeFromProps(props: any): ListingType {
595   return props.match.params.listing_type
596     ? routeListingTypeToEnum(props.match.params.listing_type)
597     : UserService.Instance.user
598     ? UserService.Instance.user.default_listing_type
599     : ListingType.All;
600 }
601
602 // TODO might need to add a user setting for this too
603 export function getDataTypeFromProps(props: any): DataType {
604   return props.match.params.data_type
605     ? routeDataTypeToEnum(props.match.params.data_type)
606     : DataType.Post;
607 }
608
609 export function getSortTypeFromProps(props: any): SortType {
610   return props.match.params.sort
611     ? routeSortTypeToEnum(props.match.params.sort)
612     : UserService.Instance.user
613     ? UserService.Instance.user.default_sort_type
614     : SortType.Hot;
615 }
616
617 export function getPageFromProps(props: any): number {
618   return props.match.params.page ? Number(props.match.params.page) : 1;
619 }
620
621 export function editCommentRes(
622   data: CommentResponse,
623   comments: Array<Comment>
624 ) {
625   let found = comments.find(c => c.id == data.comment.id);
626   if (found) {
627     found.content = data.comment.content;
628     found.updated = data.comment.updated;
629     found.removed = data.comment.removed;
630     found.deleted = data.comment.deleted;
631     found.upvotes = data.comment.upvotes;
632     found.downvotes = data.comment.downvotes;
633     found.score = data.comment.score;
634   }
635 }
636
637 export function saveCommentRes(
638   data: CommentResponse,
639   comments: Array<Comment>
640 ) {
641   let found = comments.find(c => c.id == data.comment.id);
642   if (found) {
643     found.saved = data.comment.saved;
644   }
645 }
646
647 export function createCommentLikeRes(
648   data: CommentResponse,
649   comments: Array<Comment>
650 ) {
651   let found: Comment = comments.find(c => c.id === data.comment.id);
652   if (found) {
653     found.score = data.comment.score;
654     found.upvotes = data.comment.upvotes;
655     found.downvotes = data.comment.downvotes;
656     if (data.comment.my_vote !== null) {
657       found.my_vote = data.comment.my_vote;
658     }
659   }
660 }
661
662 export function createPostLikeFindRes(data: PostResponse, posts: Array<Post>) {
663   let found = posts.find(c => c.id == data.post.id);
664   if (found) {
665     createPostLikeRes(data, found);
666   }
667 }
668
669 export function createPostLikeRes(data: PostResponse, post: Post) {
670   if (post) {
671     post.score = data.post.score;
672     post.upvotes = data.post.upvotes;
673     post.downvotes = data.post.downvotes;
674     if (data.post.my_vote !== null) {
675       post.my_vote = data.post.my_vote;
676     }
677   }
678 }
679
680 export function editPostFindRes(data: PostResponse, posts: Array<Post>) {
681   let found = posts.find(c => c.id == data.post.id);
682   if (found) {
683     editPostRes(data, found);
684   }
685 }
686
687 export function editPostRes(data: PostResponse, post: Post) {
688   if (post) {
689     post.url = data.post.url;
690     post.name = data.post.name;
691     post.nsfw = data.post.nsfw;
692   }
693 }
694
695 export function commentsToFlatNodes(
696   comments: Array<Comment>
697 ): Array<CommentNodeI> {
698   let nodes: Array<CommentNodeI> = [];
699   for (let comment of comments) {
700     nodes.push({ comment: comment });
701   }
702   return nodes;
703 }
704
705 export function commentSort(tree: Array<CommentNodeI>, sort: CommentSortType) {
706   // First, put removed and deleted comments at the bottom, then do your other sorts
707   if (sort == CommentSortType.Top) {
708     tree.sort(
709       (a, b) =>
710         +a.comment.removed - +b.comment.removed ||
711         +a.comment.deleted - +b.comment.deleted ||
712         b.comment.score - a.comment.score
713     );
714   } else if (sort == CommentSortType.New) {
715     tree.sort(
716       (a, b) =>
717         +a.comment.removed - +b.comment.removed ||
718         +a.comment.deleted - +b.comment.deleted ||
719         b.comment.published.localeCompare(a.comment.published)
720     );
721   } else if (sort == CommentSortType.Old) {
722     tree.sort(
723       (a, b) =>
724         +a.comment.removed - +b.comment.removed ||
725         +a.comment.deleted - +b.comment.deleted ||
726         a.comment.published.localeCompare(b.comment.published)
727     );
728   } else if (sort == CommentSortType.Hot) {
729     tree.sort(
730       (a, b) =>
731         +a.comment.removed - +b.comment.removed ||
732         +a.comment.deleted - +b.comment.deleted ||
733         hotRankComment(b.comment) - hotRankComment(a.comment)
734     );
735   }
736
737   // Go through the children recursively
738   for (let node of tree) {
739     if (node.children) {
740       commentSort(node.children, sort);
741     }
742   }
743 }
744
745 export function commentSortSortType(tree: Array<CommentNodeI>, sort: SortType) {
746   commentSort(tree, convertCommentSortType(sort));
747 }
748
749 function convertCommentSortType(sort: SortType): CommentSortType {
750   if (
751     sort == SortType.TopAll ||
752     sort == SortType.TopDay ||
753     sort == SortType.TopWeek ||
754     sort == SortType.TopMonth ||
755     sort == SortType.TopYear
756   ) {
757     return CommentSortType.Top;
758   } else if (sort == SortType.New) {
759     return CommentSortType.New;
760   } else if (sort == SortType.Hot) {
761     return CommentSortType.Hot;
762   } else {
763     return CommentSortType.Hot;
764   }
765 }
766
767 export function postSort(
768   posts: Array<Post>,
769   sort: SortType,
770   communityType: boolean
771 ) {
772   // First, put removed and deleted comments at the bottom, then do your other sorts
773   if (
774     sort == SortType.TopAll ||
775     sort == SortType.TopDay ||
776     sort == SortType.TopWeek ||
777     sort == SortType.TopMonth ||
778     sort == SortType.TopYear
779   ) {
780     posts.sort(
781       (a, b) =>
782         +a.removed - +b.removed ||
783         +a.deleted - +b.deleted ||
784         (communityType && +b.stickied - +a.stickied) ||
785         b.score - a.score
786     );
787   } else if (sort == SortType.New) {
788     posts.sort(
789       (a, b) =>
790         +a.removed - +b.removed ||
791         +a.deleted - +b.deleted ||
792         (communityType && +b.stickied - +a.stickied) ||
793         b.published.localeCompare(a.published)
794     );
795   } else if (sort == SortType.Hot) {
796     posts.sort(
797       (a, b) =>
798         +a.removed - +b.removed ||
799         +a.deleted - +b.deleted ||
800         (communityType && +b.stickied - +a.stickied) ||
801         hotRankPost(b) - hotRankPost(a)
802     );
803   }
804 }
805
806 export const colorList: Array<string> = [
807   hsl(0),
808   hsl(100),
809   hsl(150),
810   hsl(200),
811   hsl(250),
812   hsl(300),
813 ];
814
815 function hsl(num: number) {
816   return `hsla(${num}, 35%, 50%, 1)`;
817 }
818
819 function randomHsl() {
820   return `hsla(${Math.random() * 360}, 100%, 50%, 1)`;
821 }