logic-flow.js 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. /* ============================================================
  2. * lib/components/logic-flow.js · 逻辑图组件(mermaid 封装)
  3. *
  4. * 契约:一行标签 + 一段 JSON,file:// 可用,零 fetch 零 module。
  5. * <logic-flow data-json='{ "dir":"LR", "nodes":[...], "edges":[...] }'>
  6. * <logic-flow data-mermaid='flowchart LR ...'>(进阶:直接写 mermaid 原文)
  7. *
  8. * 节点 cls 可选值:warn(红)/ ok(绿)/ brand(金)/ sub(紫)/ plain(灰)
  9. * 颜色全部读取 tokens.css 的 var(--...)(getComputedStyle),不硬编码,
  10. * 因此暗/浅主题切换自动生效(MutationObserver 监听 html[data-theme] 重绘)。
  11. * mermaid 需先于本脚本加载(lib/vendor/mermaid/mermaid.min.js);缺失时降级显示原文。
  12. * ============================================================ */
  13. (function () {
  14. if (window.customElements && customElements.get('logic-flow')) return;
  15. function cssVar(name) {
  16. var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
  17. return v || '#888888';
  18. }
  19. /* 两个 hex 色按比例混合(mermaid classDef 不支持 rgba 小数 alpha,用实体 hex 表达 tint) */
  20. function mix(hex1, hex2, r) {
  21. function p(h) { return [parseInt(h.substr(1, 2), 16), parseInt(h.substr(3, 2), 16), parseInt(h.substr(5, 2), 16)]; }
  22. function toHex(n) { var s = Math.round(n).toString(16); return s.length === 1 ? '0' + s : s; }
  23. var a = p(hex1), b = p(hex2);
  24. return '#' + a.map(function (c, i) { return toHex(c * (1 - r) + b[i] * r); }).join('');
  25. }
  26. function esc(s) {
  27. return String(s).replace(/"/g, '&quot;').replace(/\n/g, '<br/>');
  28. }
  29. /* 结构化 JSON → mermaid flowchart 文本 */
  30. function buildMermaid(json) {
  31. var dir = json.dir || 'LR';
  32. var lines = ['flowchart ' + dir];
  33. (json.nodes || []).forEach(function (n) {
  34. var cls = n.cls ? ':::' + n.cls : '';
  35. lines.push(' ' + n.id + '["' + esc(n.label) + '"]' + cls);
  36. });
  37. (json.edges || []).forEach(function (e) {
  38. var label = e.label ? '|"' + esc(e.label) + '"|' : '';
  39. var arr = e.dotted ? '-.->' : '-->';
  40. lines.push(' ' + e.from + ' ' + arr + label + ' ' + e.to);
  41. });
  42. return lines.join('\n');
  43. }
  44. function readTokens() {
  45. return {
  46. bg0: cssVar('--bg-0'), bg1: cssVar('--bg-1'), bg2: cssVar('--bg-2'), bg3: cssVar('--bg-3'),
  47. text1: cssVar('--text-1'), text2: cssVar('--text-2'), text3: cssVar('--text-3'),
  48. brand1: cssVar('--brand-1'), brand2: cssVar('--brand-2'), brand3: cssVar('--brand-3'),
  49. ok: cssVar('--brand-ok'), border: cssVar('--border-subtle'),
  50. fontBody: cssVar('--font-body'), fontMono: cssVar('--font-mono')
  51. };
  52. }
  53. function initMermaid(t) {
  54. window.mermaid.initialize({
  55. startOnLoad: false,
  56. theme: 'base',
  57. securityLevel: 'loose',
  58. htmlLabels: true,
  59. flowchart: { curve: 'basis', padding: 14, nodeSpacing: 40, rankSpacing: 52, useMaxWidth: true },
  60. themeVariables: {
  61. background: t.bg0,
  62. primaryColor: t.bg2,
  63. primaryTextColor: t.text1,
  64. primaryBorderColor: t.brand1,
  65. secondaryColor: t.bg1,
  66. secondaryTextColor: t.text2,
  67. secondaryBorderColor: t.brand2,
  68. tertiaryColor: t.bg3,
  69. tertiaryTextColor: t.text2,
  70. tertiaryBorderColor: t.brand3,
  71. lineColor: t.brand1,
  72. textColor: t.text2,
  73. edgeLabelBackground: t.bg2,
  74. fontFamily: t.fontBody,
  75. fontSize: '15px',
  76. clusterBkg: t.bg1,
  77. clusterBorder: t.border
  78. }
  79. });
  80. }
  81. function classDefs(t) {
  82. return [
  83. 'classDef warn fill:' + mix(t.brand3, t.bg2, .88) + ',stroke:' + t.brand3 + ',color:' + t.text1 + ',stroke-width:1.5px',
  84. 'classDef ok fill:' + mix(t.ok, t.bg2, .88) + ',stroke:' + t.ok + ',color:' + t.text1 + ',stroke-width:1.5px',
  85. 'classDef brand fill:' + mix(t.brand1, t.bg2, .86) + ',stroke:' + t.brand1 + ',color:' + t.text1 + ',stroke-width:1.5px',
  86. 'classDef sub fill:' + mix(t.brand2, t.bg2, .88) + ',stroke:' + t.brand2 + ',color:' + t.text1 + ',stroke-width:1.5px',
  87. 'classDef plain fill:' + t.bg2 + ',stroke:' + t.text3 + ',color:' + t.text2 + ',stroke-width:1px'
  88. ].join('\n');
  89. }
  90. /* 读取组件可用的纵向高度预算(CSS 变量 --lf-max-h,支持 px/vh/%),默认 54vh。
  91. * 防止 TB 方向逻辑图按宽等比后高度超过 .s-wrap 剩余空间,被 .screen{overflow:hidden} 裁掉。 */
  92. function parseMaxH(el) {
  93. var v = getComputedStyle(el).getPropertyValue('--lf-max-h').trim() || '54vh';
  94. var m = v.match(/^([\d.]+)\s*(px|vh|%)$/);
  95. if (!m) return 0.54 * window.innerHeight;
  96. var n = parseFloat(m[1]);
  97. if (m[2] === 'vh') return n / 100 * window.innerHeight;
  98. if (m[2] === '%') return n / 100 * (el.clientHeight || window.innerHeight);
  99. return n;
  100. }
  101. /* 按 viewBox 等比缩放 svg,同时满足容器宽与纵向预算(只缩不放) */
  102. function fitSvg(svg) {
  103. var vb = svg.viewBox && svg.viewBox.baseVal;
  104. if (!vb || !vb.width || !vb.height) return;
  105. var wrap = svg.parentElement;
  106. var maxW = wrap.clientWidth || 600;
  107. var maxH = parseMaxH(wrap);
  108. var scale = Math.min(maxW / vb.width, maxH / vb.height);
  109. if (scale > 1) scale = 1; // 只缩不放
  110. svg.style.width = Math.round(vb.width * scale) + 'px';
  111. svg.style.height = Math.round(vb.height * scale) + 'px';
  112. svg.style.maxWidth = '100%';
  113. svg.style.maxHeight = maxH + 'px'; // 兜底:CSS 也约束
  114. svg.style.margin = '0 auto';
  115. }
  116. class LogicFlow extends HTMLElement {
  117. connectedCallback() {
  118. this._box = null;
  119. this._timer = null;
  120. this._observer = new MutationObserver(function () { this._schedule(); }.bind(this));
  121. this._observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
  122. this._fitHandler = function () { this._schedule(); }.bind(this);
  123. window.addEventListener('resize', this._fitHandler);
  124. this._schedule();
  125. }
  126. disconnectedCallback() {
  127. if (this._observer) this._observer.disconnect();
  128. if (this._timer) clearTimeout(this._timer);
  129. if (this._fitHandler) window.removeEventListener('resize', this._fitHandler);
  130. }
  131. _schedule() {
  132. // 主题切换防抖:等 mermaid 就绪
  133. if (this._timer) clearTimeout(this._timer);
  134. this._timer = setTimeout(function () { this._render(); }.bind(this), 30);
  135. }
  136. _text() {
  137. var raw = this.getAttribute('data-mermaid');
  138. if (raw) return raw;
  139. try { return buildMermaid(JSON.parse(this.getAttribute('data-json') || '{}')); }
  140. catch (e) { return null; }
  141. }
  142. _render() {
  143. var text = this._text();
  144. if (!this._box) {
  145. this._box = document.createElement('div');
  146. this._box.className = 'logic-flow';
  147. this.innerHTML = '';
  148. this.appendChild(this._box);
  149. }
  150. var target = this._box;
  151. if (!window.mermaid || !text) {
  152. target.innerHTML = '<pre class="lf-degraded">' + (text || this.getAttribute('data-json') || '') + '</pre>';
  153. return;
  154. }
  155. initMermaid(readTokens());
  156. target.innerHTML = '';
  157. var full = text + '\n' + classDefs(readTokens());
  158. window.mermaid.render('lf_' + Date.now().toString(36), full).then(function (res) {
  159. target.innerHTML = res.svg;
  160. var svg = target.querySelector('svg');
  161. if (svg) fitSvg(svg);
  162. this.dispatchEvent(new CustomEvent('logicflow-rendered', { detail: { ok: true } }));
  163. }.bind(this)).catch(function (e) {
  164. target.innerHTML = '<pre class="lf-error">逻辑图渲染失败:' + e.message + '</pre>';
  165. this.dispatchEvent(new CustomEvent('logicflow-rendered', { detail: { ok: false } }));
  166. }.bind(this));
  167. }
  168. }
  169. customElements.define('logic-flow', LogicFlow);
  170. })();