From f983873dba71cfad2694653e74effd0a99defac3 Mon Sep 17 00:00:00 2001 From: figmar Date: Wed, 7 Oct 2026 01:59:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20graph-viewer=20=E9=80=9A=E7=94=A8?= =?UTF-8?q?=E5=9B=BE=E8=B0=B1=E7=BB=84=E4=BB=B6=20v1.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- graph-viewer.js | 96 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 graph-viewer.js diff --git a/graph-viewer.js b/graph-viewer.js new file mode 100644 index 0000000..2fd5bd8 --- /dev/null +++ b/graph-viewer.js @@ -0,0 +1,96 @@ +/* global React, ReactDOM, ForceGraph2D */ +// GraphViewer — 通用时序知识图谱查看组件 (喂 {nodes,edges} 就能看) +// 数据契约(标准 Neo4j 风格): +// node: { id, labels:[], properties:{ entity_type?, name?, description? } } +// edge: { id, source, target, type?, properties?:{ keywords?, invalid_at?, valid_at? } } +// 三种数据来源: mount(el,{data}) | mount(el,{api:'url'}) | iframe内 postMessage({type:'gv-data',data}) +(function(root, factory){ + if(typeof exports==='object'&&typeof module==='object'){module.exports=factory();} + else{root.GraphViewer=factory();} +})(typeof self!=='undefined'?self:this,function(){ + 'use strict'; + var R = window.React, RD = window.ReactDOM; + var PALETTE = ["#60a5fa","#f87171","#fbbf24","#34d399","#a78bfa","#fb7185","#22d3ee","#818cf8","#fb923c","#4ade80"]; + function trunc(s,n){ s=s==null?'':String(s); return s.length>n?s.slice(0,n)+'…':s; } + var h = R.createElement; + var useState=R.useState, useMemo=R.useMemo, useRef=R.useRef, useEffect=R.useEffect; + + function normalize(data){ + var nodes=(data&&data.nodes||[]).map(function(n){return n;}); + var edges=(data&&(data.edges||data.links)||[]).map(function(e){ + return {id:e.id,source:e.source,target:e.target,type:e.type||'', + keywords:(e.properties&&e.properties.keywords)||e.type||'', + invalid:!!(e.properties&&e.properties.invalid_at)}; + }); + return {nodes:nodes,edges:edges}; + } + + function Viewer(props){ + var data=props.data||{nodes:[],edges:[]}; + var sel=props.selected, onSelect=props.onSelect; + var norm=useMemo(function(){return normalize(data);},[data]); + var colorMap=useMemo(function(){ + var m=new Map(); var types=[]; + norm.nodes.forEach(function(n){var t=(n.properties&&n.properties.entity_type)||(n.labels&&n.labels[n.labels.length-1])||'default';if(types.indexOf(t)<0)types.push(t);}); + types.forEach(function(t,i){m.set(t,PALETTE[i%PALETTE.length]);}); + return m; + },[norm]); + var gd=useMemo(function(){ + return {nodes:norm.nodes.map(function(n){var t=(n.properties&&n.properties.entity_type)||(n.labels&&n.labels[n.labels.length-1])||'default'; + return Object.assign({},n,{color:colorMap.get(t)||'#888',val:6});}), + links:norm.edges}; + },[norm,colorMap]); + var ref=useRef(null), box=useRef(null), dim=useState({width:0,height:0}); + useEffect(function(){ if(!box.current)return; + var ro=new ResizeObserver(function(es){for(var i=0;iMath.PI/2||ang<-Math.PI/2); + ctx.save(); ctx.translate(mx,my); ctx.rotate(flip?ang+Math.PI:ang); ctx.font=fs+"px Sans-Serif"; ctx.textAlign="center"; ctx.textBaseline="middle"; + var txt=trunc(label,24), tw=ctx.measureText(txt).width; ctx.fillStyle="rgba(1,3,15,.7)"; ctx.fillRect(-tw/2-1,-fs/2,tw+2,fs); + ctx.fillStyle=l.invalid?"#8a8a94":"#94e2d5"; ctx.fillText(txt,0,0); ctx.restore(); }, + nodeCanvasObject:function(n,ctx,gs){var r=n.val||6;ctx.save();ctx.beginPath();ctx.arc(n.x,n.y,r,0,2*Math.PI);ctx.fillStyle=n.color;ctx.shadowBlur=8/gs;ctx.shadowColor=n.color;ctx.fill(); + if(gs>.5){ctx.font=(11/gs)+'px Sans-Serif';ctx.textAlign='center';ctx.textBaseline='top';ctx.fillStyle='#fff';ctx.shadowBlur=0;ctx.fillText(trunc((n.properties&&n.properties.name)||n.id,20),n.x,n.y+r+2);}ctx.restore();}, + onNodeClick:function(n){onSelect&&onSelect({type:'node',data:n});}, + onLinkClick:function(l){onSelect&&onSelect({type:'edge',data:l});}, + cooldownTicks:120})), + h('div',{style:{width:280,padding:14,borderLeft:'1px solid #14203a',overflowY:'auto',background:'#050a1a',fontSize:12}}, + h('div',{style:{color:'#94e2d5',marginBottom:8}},'节点 '+norm.nodes.length+' · 边 '+norm.edges.length), + h('div',{style:{marginBottom:8}},h('span',{style:{display:'inline-block',width:22,borderTop:'2px solid #fff'}}),' 有效 ',h('span',{className:'dash',style:{borderTopStyle:'dashed',borderColor:'#8a8a94',display:'inline-block',width:22}}),' 失效'), + h('div',{style:{display:'flex',flexWrap:'wrap',gap:6,marginBottom:12}},types.map(function(t){return h('span',{key:t,style:{padding:'2px 8px',borderRadius:10,fontSize:11,color:'#04101f',fontWeight:600,background:colorMap.get(t)}},t);})), + sel?h('div',null,h('div',{style:{color:'#7c8aa0',textTransform:'uppercase',letterSpacing:1,fontSize:11}},sel.type==='node'?'实体':'关系'), + h('div',{style:{color:'#94e2d5',margin:'6px 0',wordBreak:'break-all'}},trunc(sel.type==='node'?((sel.data.properties&&sel.data.properties.name)||sel.data.id):(sel.data.keywords||sel.data.type),60)), + sel.type==='node'&&sel.data.properties&&sel.data.properties.description?h('div',{style:{marginTop:8,padding:10,background:'#0b1428',borderRadius:8,whiteSpace:'pre-wrap',wordBreak:'break-word'}},sel.data.properties.description):null, + sel.type==='edge'?h('div',{style:{marginTop:8}},'valid:',(sel.data.properties&&sel.data.properties.valid_at)||'-',' / invalid:',(sel.data.properties&&sel.data.properties.invalid_at)||'-'):null) + :h('div',{style:{color:'#7c8aa0'}},'点击节点/连线查看详情'))); + } + + function resolveData(opts,cb){ + if(opts.data)return cb(opts.data); + if(opts.api){fetch(opts.api).then(function(r){return r.json();}).then(cb).catch(function(e){cb({error:String(e)});});return;} + cb({nodes:[],edges:[]}); + } + function App(opts){ + var state={data:{nodes:[],edges:[]},sel:null}; + return { _el:null,_root:null, + render:function(el){this._el=el;this._root=RD.createRoot(el);var self=this; + resolveData(opts,function(d){ if(d.error){el.textContent='加载失败:'+d.error;return;} self.state_data=d; self.paint(); }); + if(opts.listenPostMessage!==false){window.addEventListener('message',function(ev){var m=ev.data;if(m&&m.type==='gv-data'){self.state_data=m.data||{nodes:[],edges:[]};self.paint();}});} + return this;}, + setData:function(d){this.state_data=d;this.paint();}, + paint:function(){var self=this;this._root.render(h(Viewer,{data:this.state_data||{nodes:[],edges:[]},selected:this.sel,onSelect:function(s){self.sel=s;self.paint();}}));} + }; + } + return { mount:function(el,opts){ return new App(opts||{}).render(el); }, + VERSION:'1.0.0' }; +});