casibase/web/src/WordsetGraph.js

501 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React from "react";
import {Button, Card, Col, Empty, InputNumber, List, Row, Select, Slider, Spin, Switch, Tooltip} from "antd";
import ForceGraph2D from "react-force-graph-2d";
import ForceGraph3D from "react-force-graph-3d";
import * as d3 from "d3-force";
import * as WordsetBackend from "./backend/WordsetBackend";
import i18next from "i18next";
import FileSaver from "file-saver";
import XLSX from "xlsx";
import * as Setting from "./Setting";
const {Option} = Select;
const fg = React.createRef();
class ForceGraph extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
};
}
render() {
if (!this.props.enable3D) {
return (
<ForceGraph2D ref={fg} {...this.props} />
);
} else {
return (
<ForceGraph3D ref={fg} {...this.props} />
);
}
}
}
class WordsetGraph extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
wordsetName: props.wordsetName !== undefined ? props.wordsetName : props.match.params.wordsetName,
graph: null,
loading: false,
enableStatic: false,
// enableCurve: true,
enableBolderLink: false,
enable3D: false,
// particlePercent: 100,
strength: 20,
distanceMax: 100,
clusterNumber: 100,
distanceLimit: this.props.wordset.distanceLimit,
selectedType: null,
selectedId: null,
selectedIds: [],
};
}
UNSAFE_componentWillMount() {
this.getWordsetGraph();
}
componentDidUpdate(prevProps, prevState, snapshot) {
fg.current?.d3Force("collision", d3.forceCollide(15));
}
getWordsetGraph() {
this.setState({
loading: true,
});
WordsetBackend.getWordsetGraph("admin", this.state.wordsetName, this.state.clusterNumber, this.state.distanceLimit)
.then((graph) => {
if (graph.status === "ok") {
this.setState({
graph: graph.data,
loading: false,
selectedType: null,
selectedId: null,
selectedIds: [],
});
} else {
Setting.showMessage("error", `Failed to get wordset graph: ${graph.msg}`);
}
});
}
downloadClusters() {
let data = [];
this.state.graph.nodes.forEach((node, i) => {
const row = {};
row[0] = node.id;
row[1] = parseInt(node.tag) + 1;
data.push(row);
});
data = data.sort((a, b) => {return a[1] - b[1];});
const sheet = XLSX.utils.json_to_sheet(data, {skipHeader: true});
const blob = Setting.sheet2blob(sheet, "clusters");
const fileName = `clusters-${this.state.wordsetName}-${this.state.graph.nodes.length}-${this.state.clusterNumber}.xlsx`;
FileSaver.saveAs(blob, fileName);
}
renderSubMenu(node) {
const links = this.state.graph.links.filter(link => (link.source === node.id || link.target === node.id || link.source.id === node.id || link.target.id === node.id)).sort((a, b) => {return a.tag - b.tag;});
return (
<List
// style={{height: "200px"}}
size="small"
header={
<div>
边数{links.length}
</div>
}
dataSource={links}
renderItem={link => {
return (
<List.Item>
{`${(link.source !== node.id && link.source.id !== node.id) ? link.source.id : link.target.id} | ${link.tag}`}
</List.Item>
);
}}
pagination={{pageSize: 20, size: "small", showLessItems: true, simple: true}}
/>
);
}
renderNodeMenu(selectedIds) {
return (
<List
// style={{height: "200px"}}
size="small"
header={
<div>
节点数{selectedIds.length}
</div>
}
dataSource={selectedIds}
renderItem={node => {
return (
<Tooltip placement="right" color={"rgb(255,255,255)"} title={this.renderSubMenu(node)}>
<List.Item style={{backgroundColor: (this.state.selectedId === node.id) ? "rgb(249,198,205)" : ""}}>
{`${node.id} | ${node.weight}`}
</List.Item>
</Tooltip>
);
}}
pagination={{pageSize: 20, size: "small", showLessItems: true, simple: true}}
/>
);
}
renderLeftToolbar() {
if (this.state.graph === null) {
return;
}
let selectedIds = this.state.graph.nodes;
let categoryName = "全部";
if (this.state.selectedIds.length !== 0) {
selectedIds = this.state.selectedIds;
categoryName = this.state.selectedIds[0].tag;
}
selectedIds = selectedIds.sort((a, b) => {return b.weight - a.weight;});
return (
<div style={{width: "100%", position: "fixed", zIndex: 2, pointerEvents: "none"}}>
<div style={{width: "200px", marginLeft: "20px", marginTop: "20px", pointerEvents: "auto"}}>
<Card style={{marginTop: "20px"}} size="small" title={
<div>
分类{categoryName}
</div>
} type="inner">
{
this.renderNodeMenu(selectedIds)
}
</Card>
</div>
</div>
);
}
renderRightToolbar() {
return (
<div style={{width: "100%", position: "fixed", zIndex: 2, pointerEvents: "none"}}>
<div style={{width: "200px", float: "right", marginRight: "20px", marginTop: "20px", pointerEvents: "auto"}}>
<Card size="small" title={
<div>
图形选项
</div>
} type="inner">
<Row>
<Col style={{marginTop: "5px", textAlign: "center"}} span={12}>
<span style={{verticalAlign: "middle"}}>
保持静止
</span>
</Col>
<Col style={{marginTop: "5px", textAlign: "center"}} span={12}>
<Switch checked={this.state.enableStatic} onChange={(checked, e) => {
this.setState({
enableStatic: checked,
});
}} />
</Col>
</Row>
{/* <Row>*/}
{/* <Col style={{marginTop: '5px', textAlign: 'center'}} span={12}>*/}
{/* 显示曲线:*/}
{/* </Col>*/}
{/* <Col style={{marginTop: '5px', textAlign: 'center'}} span={12}>*/}
{/* <Switch checked={this.state.enableCurve} onChange={(checked, e) => {*/}
{/* this.setState({*/}
{/* enableCurve: checked,*/}
{/* });*/}
{/* }} />*/}
{/* </Col>*/}
{/* </Row>*/}
<Row>
<Col style={{marginTop: "5px", textAlign: "center"}} span={12}>
<span style={{verticalAlign: "middle"}}>
启用粗线
</span>
</Col>
<Col style={{marginTop: "5px", textAlign: "center"}} span={12}>
<Switch checked={this.state.enableBolderLink} onChange={(checked, e) => {
this.setState({
enableBolderLink: checked,
});
}} />
</Col>
</Row>
<Row>
<Col style={{marginTop: "5px", textAlign: "center"}} span={12}>
<span style={{verticalAlign: "middle"}}>
启用3D
</span>
</Col>
<Col style={{marginTop: "5px", textAlign: "center"}} span={12}>
<Switch checked={this.state.enable3D} onChange={(checked, e) => {
this.setState({
enable3D: checked,
});
}} />
</Col>
</Row>
{/* <Row>*/}
{/* <Col style={{marginTop: '5px', textAlign: 'center'}} span={12}>*/}
{/* 粒子数量:*/}
{/* </Col>*/}
{/* <Col style={{marginTop: '5px', textAlign: 'center'}} span={12}>*/}
{/* <Slider value={this.state.particlePercent} dots={true} min={0} max={100} onChange={(value => {*/}
{/* this.setState({*/}
{/* particlePercent: value,*/}
{/* });*/}
{/* })} />*/}
{/* </Col>*/}
{/* </Row>*/}
<Row>
<Col style={{marginTop: "5px", textAlign: "center"}} span={12}>
<span style={{verticalAlign: "middle"}}>
扩散度
</span>
</Col>
<Col style={{marginTop: "5px", textAlign: "center"}} span={12}>
<Slider value={this.state.strength} dots={true} min={0} max={1000} onChange={(value => {
this.setState({
strength: value,
});
// https://github.com/vasturiano/react-force-graph/issues/25
fg.current.d3Force("charge").strength(-value);
})} />
</Col>
</Row>
<Row>
<Col style={{marginTop: "5px", textAlign: "center"}} span={12}>
<span style={{verticalAlign: "middle"}}>
最大距离
</span>
</Col>
<Col style={{marginTop: "5px", textAlign: "center"}} span={12}>
<Slider value={this.state.distanceMax} dots={true} min={0} max={1000} onChange={(value => {
this.setState({
distanceMax: value,
});
// https://github.com/vasturiano/react-force-graph/issues/25
fg.current.d3Force("charge").distanceMax(value);
})} />
</Col>
</Row>
</Card>
<Card style={{marginTop: "20px"}} size="small" title={
<div>
聚类选项
</div>
} type="inner">
<Row>
<Col style={{marginTop: "5px", textAlign: "center"}} span={8}>
<span style={{verticalAlign: "middle"}}>
算法
</span>
</Col>
<Col style={{marginTop: "5px", textAlign: "center"}} span={16}>
<Select virtual={false} style={{width: "100%"}} value={"K-Means"} onChange={(value => {
this.setState({
algorithm: value,
});
})}>
{
[
{id: "K-Means", name: "K-Means"},
].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
}
</Select>
</Col>
</Row>
<Row>
<Col style={{marginTop: "5px", textAlign: "center"}} span={12}>
<span style={{verticalAlign: "middle"}}>
聚类个数
</span>
</Col>
<Col style={{marginTop: "5px", textAlign: "center"}} span={12}>
<InputNumber style={{width: "100%"}} min={2} max={this.state.graph?.nodes.length} step={1} value={this.state.clusterNumber} onChange={value => {
this.setState({
clusterNumber: value,
});
}} />
</Col>
</Row>
<Row>
<Col style={{marginTop: "5px", textAlign: "center"}} span={12}>
<span style={{verticalAlign: "middle"}}>
距离上限
</span>
</Col>
<Col style={{marginTop: "5px", textAlign: "center"}} span={12}>
<InputNumber style={{width: "100%"}} min={1} max={100} step={1} value={this.state.distanceLimit} onChange={value => {
this.setState({
distanceLimit: value,
});
}} />
</Col>
</Row>
<Row style={{textAlign: "center", paddingTop: "10px"}}>
<Button style={{margin: "auto"}} type="primary" onClick={() => {
this.setState({
graph: null,
});
this.getWordsetGraph();
}}>
重新聚类
</Button>
<Button style={{margin: "auto", marginTop: "10px"}} onClick={() => {
this.downloadClusters();
}}>
下载结果
</Button>
</Row>
</Card>
</div>
</div>
);
}
getNodeId(node) {
return node.id;
}
isLinkSelected(link) {
return false;
}
renderGraph() {
if (this.state.loading) {
return (
// https://codesandbox.io/s/antd-reproduction-template-q2dwk
<div className="App">
<Spin size="large" tip={i18next.t("general:Loading...")} style={{paddingTop: "10%"}} />
</div>
);
}
if (this.state.graph === null || this.state.graph.nodes === null || this.state.graph.links === null) {
return (
<div className="App">
<Empty style={{paddingTop: "10%"}} image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
);
}
// highlight example
// https://github.com/vasturiano/react-force-graph/blob/master/example/highlight/index.html
return (
<ForceGraph
enable3D={this.state.enable3D}
width={window.innerWidth}
height={window.innerHeight - 147}
graphData={this.state.graph}
// nodeCanvasObjectMode={() => 'after'}
linkWidth={link => {
let width = this.state.enableBolderLink ? link.value : Math.sqrt(link.value);
if (this.isLinkSelected(link)) {
width = width + 6;
}
return width;
}}
linkDirectionalParticleWidth={link => {
let width = 5;
if (this.isLinkSelected(link)) {
width = width + 3;
}
return width;
}}
linkColor={link => this.isLinkSelected(link) ? "red" : link.color}
// linkCurvature={this.state.enableCurve ? 0.02 : 0}
// linkDirectionalParticles={link => this.state.particlePercent * link.value / 100}
linkDirectionalParticleSpeed={link => link.value * 0.001}
cooldownTicks={this.state.enableStatic ? 0 : Infinity}
onNodeClick={(node, event) => {
if (this.state.selectedId !== this.getNodeId(node)) {
this.setState({
selectedType: "节点",
selectedId: this.getNodeId(node),
selectedIds: this.state.graph.nodes.filter(n => (n.tag === node.tag)),
});
} else {
this.setState({
selectedType: null,
selectedId: null,
selectedIds: [],
});
}
}}
nodeCanvasObject={(node, ctx, globalScale) => {
// const fontSize = 12 / globalScale;
// ctx.font = `${fontSize}px Sans-Serif`;
// const textWidth = ctx.measureText(label).width;
// ctx.fillRect(node.x - bckgDimensions[0] / 2, node.y - bckgDimensions[1] / 2, ...bckgDimensions);
ctx.save();
if (this.state.selectedId === node.id) {
ctx.beginPath();
ctx.arc(node.x, node.y, node.val * 1.7, 0, 2 * Math.PI, false);
ctx.fillStyle = "red";
ctx.fill();
} else if (this.state.selectedIds.filter(n => n.id === node.id).length > 0) {
ctx.beginPath();
ctx.arc(node.x, node.y, node.val * 1.4, 0, 2 * Math.PI, false);
ctx.fillStyle = "red";
ctx.fill();
} else if (this.state.selectedId !== null) {
ctx.globalAlpha = 0.3;
}
ctx.beginPath();
ctx.fillStyle = node.color;
ctx.arc(node.x, node.y, node.val, 0, 2 * Math.PI, false);
ctx.fill();
ctx.strokeStyle = "rgb(255,255,255)";
ctx.stroke();
ctx.font = "5px Lucida Console";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "black"; // node.color;
ctx.fillText(node.name, node.x, node.y + 10);
ctx.restore();
}}
/>
);
}
render() {
return (
<div>
{
this.renderLeftToolbar()
}
{
this.renderRightToolbar()
}
{
this.renderGraph()
}
</div>
);
}
}
export default WordsetGraph;