/* eslint-disable max-lines */ import type { IParseTreeNode } from 'tiddlywiki'; import type { IScriptAddon } from '../../scriptAddon'; const colors = [ '#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', ]; const CategoriesEn = [ 'Focusing', 'History', 'Link To', 'Backlink From', 'Tag To', 'Tag By', 'Transclude', ].map((name, index) => ({ name, itemStyle: { color: colors[index % colors.length] }, })); const CategoriesZh = [ '聚焦', '历史', '链接', '反链', '标签', '作为标签', '嵌套', ].map((name, index) => ({ name, itemStyle: { color: colors[index % colors.length] }, })); const attributes = new Set([ 'focussedTiddler', 'levels', 'graphTitle', 'aliasField', 'excludeFilter', 'previewDelay', ]); const getPlatteColor = (name: string) => $tw.wiki.renderText( 'text/plain', 'text/vnd.tiddlywiki', `<$transclude tiddler={{$:/palette}} index="${name}"><$transclude tiddler="$:/palettes/Vanilla" index="${name}"><$transclude tiddler="$:/config/DefaultColourMappings/${name}"/>`, {}, ); const findIcon = (title: string) => { const fields = $tw.wiki.getTiddler(title)?.fields; if (!fields?.icon) { return undefined; } const iconFields = $tw.wiki.getTiddler(fields.icon as string)?.fields; if (!iconFields) { if (/^https?:\/\//.test(fields.icon as string)) { return `image://${fields.icon as string}`; } return undefined; } if (iconFields._canonical_uri) { return `image://${iconFields._canonical_uri}`; } else if (iconFields.title.startsWith('$:/core/images/')) { return undefined; } else { return `image://data:${iconFields.type};base64,${iconFields.text}`; } }; const getAliasOrTitle = ( tiddlerTitle: string, aliasField: string | undefined, ): [string, boolean] => { if (aliasField === undefined || aliasField === 'title') { return [tiddlerTitle, Boolean($tw.wiki.getTiddler(tiddlerTitle))]; } const tiddler = $tw.wiki.getTiddler(tiddlerTitle); if (tiddler) { const aliasValue = tiddler.fields[aliasField]; return [ typeof aliasValue === 'string' ? $tw.wiki.renderText('text/plain', 'text/vnd.tiddlywiki', aliasValue, { variables: { currentTiddler: tiddlerTitle }, }) : tiddlerTitle, true, ]; } else { return [tiddlerTitle, false]; } }; interface ITheBrainState { currentlyFocused?: string; historyTiddlers: string[]; viewingTiddlers: Set; focusing?: string; } const TheBrainAddon: IScriptAddon = { onMount: (myChart, attributes) => { myChart.on('click', { dataType: 'node' }, (event: any) => { new $tw.Story().navigateTiddler(event.data.name); }); return { historyTiddlers: [], viewingTiddlers: new Set(), focusing: attributes.focussedTiddler, }; }, shouldUpdate: ( { viewingTiddlers, focusing, currentlyFocused }, changedTiddlers, changedAttributes, ) => { return ( Object.keys(changedTiddlers).some(title => viewingTiddlers.has(title)) || Object.keys(changedAttributes).some(attribute => attributes.has(attribute), ) || (focusing === undefined && $tw.wiki.getTiddlerText('$:/temp/focussedTiddler') !== currentlyFocused) ); }, // eslint-disable-next-line complexity onUpdate: ( myCharts, state, addonAttributes: { focussedTiddler?: string; levels?: number; graphTitle?: string; aliasField?: string; excludeFilter?: string; previewDelay?: string; }, ) => { /** 参数:focussedTiddler 是图的中央节点 */ let focussedTiddler = addonAttributes.focussedTiddler || $tw.wiki.getTiddlerText('$:/temp/focussedTiddler')!; state.viewingTiddlers.clear(); state.focusing = addonAttributes.focussedTiddler; state.currentlyFocused = focussedTiddler; if (!focussedTiddler) { return; } state.viewingTiddlers.add(focussedTiddler); if ($tw.wiki.getTiddler(focussedTiddler)?.fields['draft.of']) { focussedTiddler = $tw.wiki.getTiddler(focussedTiddler)!.fields[ 'draft.of' ] as string; } const nodes: any[] = []; const edges: any[] = []; const ifChinese = $tw.wiki.getTiddlerText('$:/language')?.includes('zh') === true; /** 参数:levels 指定图向外展开几级 */ let levels = Number(addonAttributes.levels); if (Number.isNaN(levels)) { levels = 1; } levels = Math.max(levels, 0); /** 参数:graphTitle 指定右下角显示的标题 */ const graphTitle = addonAttributes.graphTitle || (ifChinese ? '聚焦' : 'Focusing Map'); /** 参数:aliasField 用于指定展示为节点标题的字段,例如 caption */ const aliasField = addonAttributes.aliasField === '' ? undefined : addonAttributes.aliasField; /** 参数:excludeFilter 用于排除部分节点 */ const excludeFilter = addonAttributes.excludeFilter === '' ? undefined : $tw.wiki.compileFilter( addonAttributes.excludeFilter ?? '[prefix[$:/]]', ); const nodeMap: Map = new Map(); // 聚焦点 nodes.push({ name: focussedTiddler, // fixed: true, category: 0, label: { formatter: getAliasOrTitle(focussedTiddler, aliasField)[0], fontWeight: 'bold', fontSize: '15px', }, symbol: findIcon(focussedTiddler), symbolSize: 15, select: { disabled: true, }, itemStyle: { opacity: 1, borderColor: `${colors[0]}66`, borderWidth: 15, }, isTag: false, tooltip: { show: false, }, }); // 初始化:当前关注的 Tiddler let tiddlerQueue = [focussedTiddler]; if (excludeFilter) { const tiddlers = new Set(tiddlerQueue); for (const excluded of excludeFilter.call($tw.wiki, tiddlerQueue)) { tiddlers.delete(excluded); } tiddlerQueue = Array.from(tiddlers); } nodeMap.set(focussedTiddler, true); nodeMap.set('', false); const tryPush = ( title: string, node: (label: string, exist: boolean) => any, edge: (exist: boolean) => any, ) => { if (excludeFilter && excludeFilter.call($tw.wiki, [title]).length > 0) { return false; } const nodeState = nodeMap.get(title); const [label, exist] = nodeState === undefined ? getAliasOrTitle(title, aliasField) : ['', nodeState]; if (nodeState === undefined) { nodes.push(node(label, exist)); nodeMap.set(title, exist); if (exist) { tiddlerQueue.push(title); } } edges.push(edge(exist)); return exist; }; // 广搜 levels 层 while (tiddlerQueue.length && levels-- > 0) { const tiddlers = tiddlerQueue; tiddlerQueue = []; for (const tiddler of tiddlers) { // 链接 for (const linksTo of $tw.wiki.getTiddlerLinks(tiddler)) { tryPush( linksTo, (label, exist) => ({ name: linksTo, label: { formatter: label }, itemStyle: { opacity: exist ? 1 : 0.65 }, symbol: findIcon(linksTo), category: 2, isTag: false, }), exist => ({ source: tiddler, target: linksTo, lineStyle: { color: colors[2], type: exist ? 'solid' : 'dashed', }, }), ); } // 反链 for (const backlinksFrom of $tw.wiki.getTiddlerBacklinks(tiddler)) { tryPush( backlinksFrom, (label, exist) => ({ name: backlinksFrom, label: { formatter: label }, itemStyle: { opacity: exist ? 1 : 0.65 }, symbol: findIcon(backlinksFrom), category: 3, isTag: false, }), exist => ({ source: backlinksFrom, target: tiddler, lineStyle: { color: colors[3], type: exist ? 'solid' : 'dashed', }, }), ); } // 标签 for (const tag of $tw.wiki.getTiddler(focussedTiddler)?.fields?.tags ?? []) { tryPush( tag, (label, exist) => ({ name: tag, label: { formatter: label }, itemStyle: { opacity: exist ? 1 : 0.65 }, symbol: findIcon(tag), category: 4, isTag: true, }), exist => ({ source: tiddler, target: tag, lineStyle: { color: colors[4], type: exist ? 'solid' : 'dashed', }, }), ); } // 作为标签 for (const tagBy of $tw.wiki.getTiddlersWithTag(tiddler)) { tryPush( tagBy, (label, exist) => ({ name: tagBy, label: { formatter: label }, itemStyle: { opacity: exist ? 1 : 0.65 }, symbol: findIcon(tagBy), category: 5, isTag: false, }), exist => ({ source: tagBy, target: tiddler, lineStyle: { color: colors[5], type: exist ? 'solid' : 'dashed', }, }), ); } // 嵌入 const tiddler_ = $tw.wiki.getTiddler(tiddler); if (tiddler_) { const type = tiddler_.fields.type || 'text/vnd.tiddlywiki'; if (type === 'text/vnd.tiddlywiki' || type === 'text/x-markdown') { const transcluded: Set = new Set(); const findTransclude = (children: IParseTreeNode[]) => { const { length } = children; for (let i = 0; i < length; i++) { const node = children[i]; if (node.type === 'tiddler') { const title = node.attributes!.tiddler?.value as | string | undefined; if (title) { transcluded.add(title); } } else if (Array.isArray((node as any).children)) { findTransclude((node as any).children); } } }; findTransclude($tw.wiki.parseTiddler(tiddler).tree); // eslint-disable-next-line max-depth for (const transcludeTiddler of transcluded) { tryPush( transcludeTiddler, (label, exist) => ({ name: transcludeTiddler, label: { formatter: label }, itemStyle: { opacity: exist ? 1 : 0.65 }, symbol: findIcon(transcludeTiddler), category: 6, isTag: false, }), exist => ({ source: tiddler, target: transcludeTiddler, lineStyle: { color: colors[6], type: exist ? 'solid' : 'dashed', }, }), ); } } } } } // 历史路径 let nextTiddler = focussedTiddler; const historyMap: Set = new Set(); for (let index = state.historyTiddlers.length - 2; index >= 0; index--) { const tiddlerTitle = state.historyTiddlers[index]; if ( historyMap.has(tiddlerTitle) || tiddlerTitle === nextTiddler || tiddlerTitle.startsWith('$:/') ) { continue; } tryPush( tiddlerTitle, (label, exist) => ({ name: tiddlerTitle, label: { formatter: label, fontSize: '10px' }, category: 1, symbol: findIcon(tiddlerTitle), symbolSize: 3, itemStyle: { opacity: exist ? 0.65 : 0.4 }, isTag: false, }), // eslint-disable-next-line @typescript-eslint/no-loop-func exist => ({ source: tiddlerTitle, target: nextTiddler, lineStyle: { color: colors[1], type: exist ? 'dashed' : 'dotted', opacity: 0.5, }, }), ); nextTiddler = tiddlerTitle; } // 更新历史 const historyIndex = state.historyTiddlers.indexOf(focussedTiddler); if (historyIndex > -1) { state.historyTiddlers.splice(historyIndex, 1); } state.historyTiddlers.push(focussedTiddler); state.historyTiddlers.slice(-10); let lastTitle = ''; let cache: Element[] | undefined; const cachedTooltipFormatter = ({ data: { name, isTag }, dataType, }: { data: { name: string; isTag: boolean }; dataType: string; }) => { if (dataType !== 'node') { return []; } if (name !== lastTitle || !cache) { const container = $tw.utils.domMaker('div', { style: { maxWidth: '40vw', maxHeight: '50vh', overflowY: 'auto', whiteSpace: 'normal', }, class: 'gk0wk-echarts-thebrain-popuptiddler-container', }); if (isTag) { const ul = $tw.utils.domMaker('ul', {}); const tiddlers = $tw.wiki.getTiddlersWithTag(name); const len = tiddlers.length; for (let i = 0; i < len; i++) { const tiddler = tiddlers[i]; const li = $tw.utils.domMaker('li', {}); const a = $tw.utils.domMaker('a', { text: tiddler, class: 'tc-tiddlylink tc-tiddlylink-resolves tc-popup-handle tc-popup-absolute', style: { cursor: 'pointer', }, }); // eslint-disable-next-line @typescript-eslint/no-loop-func a.addEventListener('click', () => new $tw.Story().navigateTiddler(tiddler), ); li.appendChild(a); ul.appendChild(li); } cache = [ul]; } else { // 不可以直接 renderText, 那种是 headless 渲染 $tw.wiki .makeWidget( $tw.wiki.parseTiddler( '$:/plugins/Gk0Wk/echarts/addons/TheBrainPopup', ), { document, parseAsInline: true, variables: { currentTiddler: name }, } as any, ) .render(container, null); cache = [ container, $tw.utils.domMaker('style', { innerHTML: `.gk0wk-echarts-thebrain-popuptiddler-container::-webkit-scrollbar {display: none;} .gk0wk-echarts-thebrain-popuptiddler-container .tc-tiddler-controls { display: none; }`, }), ]; } lastTitle = name; } return cache; }; let previewDelay = Number(addonAttributes.previewDelay || '1000'); if (!Number.isSafeInteger(previewDelay)) { previewDelay = -1; } myCharts.setOption({ backgroundColor: 'transparent', legend: [ { data: (ifChinese ? CategoriesZh : CategoriesEn).map(a => { return a.name; }), icon: 'circle', }, ], title: { text: graphTitle, show: true, top: 'bottom', left: 'right', }, toolbox: { show: true, left: 0, bottom: 0, feature: { restore: {}, saveAsImage: {}, }, }, tooltip: { position: 'top', formatter: cachedTooltipFormatter, triggerOn: previewDelay >= 0 ? 'mousemove' : 'none', enterable: true, showDelay: Math.max(0, previewDelay), hideDelay: 800, confine: true, textStyle: { color: 'inherit', fontFamily: 'inherit', fontSize: 'inherit', }, appendToBody: true, backgroundColor: getPlatteColor('page-background'), borderColor: getPlatteColor('very-muted-foreground'), }, series: [ { name: graphTitle, type: 'graph', layout: 'force', top: 0, bottom: 0, left: 0, right: 0, height: '100%', width: '100%', nodes, edges, categories: ifChinese ? CategoriesZh : CategoriesEn, roam: true, draggable: true, zoom: 4, label: { position: 'right', show: true, backgroundColor: 'transparent', }, labelLayout: { moveOverlap: true, }, force: { repulsion: 50, }, cursor: 'pointer', symbolSize: 6, edgeSymbol: ['none', 'arrow'], edgeSymbolSize: [0, 5], lineStyle: { width: 1, opacity: 0.75, curveness: 0.15, }, itemStyle: { opacity: 0.9, }, }, ], } as any); }, }; export default TheBrainAddon; /* eslint-enable max-lines */