forked from ci4s/knowlegegraph-studio
Compare commits
63 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
bf087a2f2e | |
|
|
ec7c145de1 | |
|
|
f3c02a1f5b | |
|
|
9f17f8be5c | |
|
|
2bc0f558c1 | |
|
|
640b780354 | |
|
|
36f4aec33d | |
|
|
392b45722a | |
|
|
a8b9740fa3 | |
|
|
54f0040db4 | |
|
|
09ddfe8131 | |
|
|
53773173e6 | |
|
|
076a48148d | |
|
|
35af9a62c7 | |
|
|
806bb95079 | |
|
|
0afbbefc81 | |
|
|
1842e83de3 | |
|
|
cfc73ee63e | |
|
|
f31a1dcd38 | |
|
|
71b065cc35 | |
|
|
b46fce6861 | |
|
|
b8d0d9e5d4 | |
|
|
ca356a9f88 | |
|
|
65aa4d85c1 | |
|
|
77dcd315a9 | |
|
|
921d66f89b | |
|
|
233b521268 | |
|
|
0be580578a | |
|
|
2ac0556ebe | |
|
|
0c65f1cafd | |
|
|
28cf3668b4 | |
|
|
5a2f688ec9 | |
|
|
157e21e1a2 | |
|
|
6c02151d31 | |
|
|
e22cdfa806 | |
|
|
7ce0f57b1b | |
|
|
9643a781a3 | |
|
|
64e53afd89 | |
|
|
202327163a | |
|
|
5ef1b43e15 | |
|
|
6220a02470 | |
|
|
2bcb638a8f | |
|
|
34203033d7 | |
|
|
e8c3cfb9f5 | |
|
|
258d1246c1 | |
|
|
ab960b1b5c | |
|
|
2b9f0c96ed | |
|
|
d937b91019 | |
|
|
b91edc658c | |
|
|
599731dd51 | |
|
|
52028a9302 | |
|
|
db01cb3d61 | |
|
|
61d2156c84 | |
|
|
0698f7038f | |
|
|
180e3c1cfd | |
|
|
a758dedf21 | |
|
|
75d7196e14 | |
|
|
f59515e514 | |
|
|
6d054db5bd | |
|
|
f250859f37 | |
|
|
af06ff1576 | |
|
|
c6f7ec5348 | |
|
|
0c1b08b191 |
|
|
@ -0,0 +1 @@
|
|||
/app/iconfont/
|
||||
17
Dockerfile
17
Dockerfile
|
|
@ -2,20 +2,25 @@ FROM 172.20.32.187/tempimagefile/node:18-alpine as nodebuilder
|
|||
LABEL stage=nodebuilder
|
||||
# Set the working directory to /app
|
||||
WORKDIR /web
|
||||
|
||||
ENV HTTP_PROXY="http://172.20.32.253:3128" \
|
||||
HTTPS_PROXY="http://172.20.32.253:3128" \
|
||||
NO_PROXY="172.20.32.0/24"
|
||||
|
||||
# Copy the current directory contents into the container at /web
|
||||
COPY package.json /web/
|
||||
|
||||
# Install any needed packages
|
||||
RUN npm cache clear --force
|
||||
RUN npm config set registry https://registry.npmmirror.com/
|
||||
RUN npm install
|
||||
RUN npm install --verbose
|
||||
COPY . /web/
|
||||
|
||||
# build and remove front source code
|
||||
ENV NODE_OPTIONS=--max_old_space_size=2048
|
||||
RUN npm run build
|
||||
|
||||
FROM 172.20.32.187/tempimagefile/golang:1.21-alpine-good AS gobuilder
|
||||
FROM 172.20.32.187/tempimagefile/go:1.21-alpine-good AS gobuilder
|
||||
|
||||
LABEL stage=gobuilder
|
||||
|
||||
|
|
@ -26,6 +31,12 @@ WORKDIR /server
|
|||
|
||||
COPY server .
|
||||
COPY --from=nodebuilder /web/dist/ /server/api/studio/assets
|
||||
|
||||
|
||||
ENV HTTP_PROXY="http://172.20.32.253:3128" \
|
||||
HTTPS_PROXY="http://172.20.32.253:3128" \
|
||||
NO_PROXY="172.20.32.0/24"
|
||||
|
||||
RUN go env -w GOPROXY=https://goproxy.cn,direct
|
||||
RUN go mod download
|
||||
#RUN apk add build-base
|
||||
|
|
@ -36,7 +47,7 @@ FROM 172.20.32.187/tempimagefile/alpine:latest
|
|||
WORKDIR /app
|
||||
COPY --from=gobuilder /server/server /app/server
|
||||
COPY --from=gobuilder /server/api/studio/etc /app/etc/
|
||||
COPY ./dlv /usr/local/bin
|
||||
#COPY ./dlv /usr/local/bin
|
||||
RUN sed -i "s/9000/7001/g" /app/etc/studio-api.yaml
|
||||
|
||||
EXPOSE 7001
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
FROM 172.20.32.187/tempimagefile/node:18-alpine-arm as nodebuilder
|
||||
LABEL stage=nodebuilder
|
||||
# Set the working directory to /app
|
||||
WORKDIR /web
|
||||
|
||||
ENV HTTP_PROXY="http://172.20.32.253:3128" \
|
||||
HTTPS_PROXY="http://172.20.32.253:3128" \
|
||||
NO_PROXY="172.20.32.0/24"
|
||||
|
||||
# Copy the current directory contents into the container at /web
|
||||
COPY . /web/
|
||||
|
||||
# Install any needed packages
|
||||
|
||||
RUN npm cache clear --force
|
||||
RUN npm config set registry https://registry.npmmirror.com/
|
||||
RUN npm ci --verbose
|
||||
RUN npx patch-package
|
||||
|
||||
# build and remove front source code
|
||||
RUN npm ci --verbose
|
||||
RUN npx patch-package
|
||||
|
||||
# build and remove front source code
|
||||
# ENV NODE_OPTIONS=--max_old_space_size=4096
|
||||
#
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM 172.20.32.187/tempimagefile/go:1.21-alpine-good-arm AS gobuilder
|
||||
|
||||
LABEL stage=gobuilder
|
||||
|
||||
ENV CGO_ENABLED 1
|
||||
ENV GOOS linux
|
||||
|
||||
WORKDIR /server
|
||||
|
||||
COPY server .
|
||||
COPY --from=nodebuilder /web/dist/ /server/api/studio/assets
|
||||
|
||||
|
||||
ENV HTTP_PROXY="http://172.20.32.253:3128" \
|
||||
HTTPS_PROXY="http://172.20.32.253:3128" \
|
||||
NO_PROXY="172.20.32.0/24"
|
||||
|
||||
RUN go env -w GOPROXY=https://goproxy.cn,direct
|
||||
RUN go mod download
|
||||
|
||||
RUN cp /etc/apk/repositories /etc/apk/repositories.bak \
|
||||
&& echo "https://mirrors.aliyun.com/alpine/v3.20/main" > /etc/apk/repositories \
|
||||
&& echo "https://mirrors.aliyun.com/alpine/v3.20/community" >> /etc/apk/repositories
|
||||
|
||||
RUN apk add build-base
|
||||
#RUN apk add build-base
|
||||
#RUN go build -gcflags="all=-N -l" -o /server/server /server/api/studio/studio.go
|
||||
RUN go build -gcflags="all=-N -l" -o /server/server /server/api/studio/studio.go
|
||||
FROM 172.20.32.187/tempimagefile/alpine:latest-arm
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=gobuilder /server/server /app/server
|
||||
COPY --from=gobuilder /server/api/studio/etc /app/etc/
|
||||
#COPY ./dlv /usr/local/bin
|
||||
RUN sed -i "s/9000/7001/g" /app/etc/studio-api.yaml
|
||||
|
||||
EXPOSE 7001
|
||||
|
||||
CMD ["./server"]
|
||||
|
|
@ -35,7 +35,6 @@
|
|||
}
|
||||
|
||||
.studioCenterLayout {
|
||||
width: @containerWidth;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
|
@ -234,4 +233,8 @@ svg {
|
|||
:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.monaco-editor {
|
||||
outline-offset: 0 !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
@white: #FFFFFF;
|
||||
@gray: #D5DDEB;
|
||||
@lightWhite: #F8F8F8;
|
||||
@white: #ffffff;
|
||||
@gray: #d5ddeb;
|
||||
@lightWhite: #f8f8f8;
|
||||
@containerWidth: 1180px;
|
||||
@darkGray: #8697B0;
|
||||
@red: #EB5757;
|
||||
@blue: #0091FF;
|
||||
@lightGray: #E9EDEF;
|
||||
@lightBlue: #F3F6F9;
|
||||
@darkBlue: #465B7A;
|
||||
@lightBlack: #172F52;
|
||||
@headerBlack: #2F3A4A;
|
||||
@darkGray: #8697b0;
|
||||
@red: #c73131;
|
||||
@blue: #1664ff;
|
||||
@lightGray: #e9edef;
|
||||
@lightBlue: #f3f6f9;
|
||||
@darkBlue: #465b7a;
|
||||
@lightBlack: #172f52;
|
||||
@headerBlack: #2f3a4a;
|
||||
|
||||
@navHeight: 64px;
|
||||
@controlHeight: 65px;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-left: 25px;
|
||||
}
|
||||
.breadcrumb {
|
||||
flex: 1;
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ const NebulaBreadcrumb: React.FC<IProps> = (props: IProps) => {
|
|||
<PageHeader
|
||||
className={styles.studioBreadcrumb}
|
||||
breadcrumbRender={() => (
|
||||
<div className={cls(styles.breadcrumbContainer, 'studioCenterLayout')}>
|
||||
<div className={cls(styles.breadcrumbContainer)}>
|
||||
<Breadcrumb className={styles.breadcrumb} items={routes} itemRender={itemRender} />
|
||||
{extraNode}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
@import '@app/common.less';
|
||||
|
||||
.panel-btn-item,
|
||||
.menu-color {
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel-menu-icon {
|
||||
svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.panel-disabled {
|
||||
cursor: not-allowed;
|
||||
color: #d9d9d9;
|
||||
|
||||
svg {
|
||||
fill: #d9d9d9;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-actived {
|
||||
color: @blue;
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
@import '@app/common.less';
|
||||
|
||||
svg.btnIcon {
|
||||
color: #262626;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
svg.btnActived {
|
||||
color: @blue;
|
||||
}
|
||||
|
||||
svg.btnDisabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
|
@ -1,16 +1,17 @@
|
|||
import classnames from 'classnames';
|
||||
|
||||
import Icon from '@app/components/Icon';
|
||||
import styles from './index.module.less';
|
||||
import IconFont from '@app/components/UnicodeIcon';
|
||||
import { Tooltip } from 'antd';
|
||||
import './index.less';
|
||||
|
||||
interface IBtnProps {
|
||||
disabled?: boolean;
|
||||
action?: () => void;
|
||||
mouseDownAction?: () => void;
|
||||
mouseUpAction?: () => void;
|
||||
icon?: string;
|
||||
iconfont?: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
actived?: boolean;
|
||||
active?: boolean;
|
||||
component?: any;
|
||||
trackCategory?: string;
|
||||
trackAction?: string;
|
||||
|
|
@ -19,40 +20,121 @@ interface IBtnProps {
|
|||
|
||||
interface IMenuButton extends IBtnProps {
|
||||
tips?: string;
|
||||
id?:string;
|
||||
}
|
||||
const MenuButton: React.FC<IMenuButton> = (props: IMenuButton) => {
|
||||
const { icon, action, disabled, title, actived, component, className, trackCategory, trackAction, trackLabel } =
|
||||
props;
|
||||
return <div
|
||||
className={classnames(
|
||||
{
|
||||
[styles.btnDisabled]: disabled,
|
||||
[styles.btnActived]: actived,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
if (!disabled && action) {
|
||||
action();
|
||||
}
|
||||
}}
|
||||
data-track-category={trackCategory}
|
||||
data-track-action={trackAction}
|
||||
data-track-label={trackLabel}
|
||||
>
|
||||
{icon && (
|
||||
<Icon
|
||||
type={icon}
|
||||
data-track-category={trackCategory}
|
||||
data-track-action={trackAction}
|
||||
data-track-label={trackLabel}
|
||||
className={styles.btnIcon}
|
||||
/>
|
||||
)}
|
||||
{component}
|
||||
{title && <span>{title}</span>}
|
||||
</div>;
|
||||
const CustomizeButton = (props: IBtnProps) => {
|
||||
const {
|
||||
icon,
|
||||
iconfont,
|
||||
action,
|
||||
disabled,
|
||||
title,
|
||||
active,
|
||||
component,
|
||||
className,
|
||||
trackCategory,
|
||||
trackAction,
|
||||
trackLabel,
|
||||
} = props;
|
||||
return (
|
||||
<div
|
||||
className={classnames({
|
||||
'menu-color': className,
|
||||
'panel-btn-item': !className,
|
||||
'panel-disabled': disabled,
|
||||
'panel-actived': active,
|
||||
})}
|
||||
onClick={!disabled && action ? action : undefined}
|
||||
data-track-category={trackCategory}
|
||||
data-track-action={trackAction}
|
||||
data-track-label={trackLabel}
|
||||
>
|
||||
{icon && (
|
||||
<Icon
|
||||
type={icon}
|
||||
data-track-category={trackCategory}
|
||||
data-track-action={trackAction}
|
||||
data-track-label={trackLabel}
|
||||
className="panel-menu-icon"
|
||||
/>
|
||||
)}
|
||||
{iconfont && (
|
||||
<IconFont
|
||||
type={iconfont}
|
||||
data-track-category={trackCategory}
|
||||
data-track-action={trackAction}
|
||||
data-track-label={trackLabel}
|
||||
className="panel-menu-icon"
|
||||
/>
|
||||
)}
|
||||
{component}
|
||||
{title && <span>{title}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// antd Tooltip can't wrap custom component
|
||||
const CustomizeTooltipBtn = (props: IMenuButton) => {
|
||||
const {
|
||||
icon,
|
||||
iconfont,
|
||||
action,
|
||||
disabled,
|
||||
active,
|
||||
tips,
|
||||
component,
|
||||
trackAction,
|
||||
trackCategory,
|
||||
trackLabel,
|
||||
} = props;
|
||||
return (
|
||||
<Tooltip title={tips}>
|
||||
{icon ? (
|
||||
<Icon
|
||||
type={icon}
|
||||
className={classnames('panel-menu-icon', {
|
||||
'panel-disabled': disabled,
|
||||
'panel-actived': active,
|
||||
})}
|
||||
data-track-category={trackCategory}
|
||||
data-track-action={trackAction}
|
||||
data-track-label={trackLabel}
|
||||
onClick={!disabled ? action : undefined}
|
||||
/>
|
||||
) : iconfont ? (
|
||||
<IconFont
|
||||
type={iconfont}
|
||||
className={classnames('panel-menu-icon', {
|
||||
'panel-disabled': disabled,
|
||||
'panel-actived': active,
|
||||
})}
|
||||
data-track-category={trackCategory}
|
||||
data-track-action={trackAction}
|
||||
data-track-label={trackLabel}
|
||||
onClick={!disabled ? action : undefined}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={classnames({
|
||||
'panel-disabled': disabled,
|
||||
'panel-actived': active,
|
||||
})}
|
||||
onClick={!disabled && action ? action : undefined}
|
||||
data-track-category={trackCategory}
|
||||
data-track-action={trackAction}
|
||||
data-track-label={trackLabel}
|
||||
>
|
||||
{component}
|
||||
</div>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuButton = ({ tips, ...rest }: IMenuButton) => {
|
||||
if (tips) {
|
||||
return <CustomizeTooltipBtn tips={tips} {...rest} />;
|
||||
} else {
|
||||
return <CustomizeButton {...rest} />;
|
||||
}
|
||||
}
|
||||
export default MenuButton;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { COLOR_PICK_LIST } from '@app/config/explore';
|
|||
import styles from './index.module.less';
|
||||
|
||||
interface IProps {
|
||||
onChangeComplete?: (color: string) => void;
|
||||
onChangeComplete?: (color: { hex: string }) => void;
|
||||
onChange?: (color: string) => void;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@ function ErrorPanel(props: { errInfo: string }) {
|
|||
<Button type="primary" onClick={() => window.location.reload()}>
|
||||
{intl.get('warning.refreshPage')}
|
||||
</Button>
|
||||
<Button onClick={() => window.open(intl.get('link.forumHref'), '_blank')}>
|
||||
{/* <Button onClick={() => window.open(intl.get('link.forumHref'), '_blank')}>
|
||||
{intl.get('warning.contactStaff')}
|
||||
</Button>
|
||||
</Button> */}
|
||||
</div>
|
||||
<div className={styles.errContainer}>
|
||||
<div className={styles.header}>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@
|
|||
:global(.ant-collapse-content-box div) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.exportGqlEditor {
|
||||
min-height: 300px;
|
||||
overflow: hidden;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const GQLCodeMirror = (props: { currentGQL: string; option?: IOptions }) => {
|
|||
{
|
||||
key: 'ngql',
|
||||
label: intl.get('common.exportNGQL'),
|
||||
children: <MonacoEditor value={props.currentGQL} readOnly height="80px" />,
|
||||
children: <MonacoEditor value={props.currentGQL} readOnly className={styles.exportGqlEditor} />,
|
||||
},
|
||||
];
|
||||
return <Collapse className={styles.exportGql} items={items} />;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
@import '@app/common.less';
|
||||
|
||||
.modal-gql {
|
||||
.ant-modal-title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
resize: vertical;
|
||||
position: relative;
|
||||
border: 1px solid @gray;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
min-height: 300px;
|
||||
|
||||
&__editor {
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { Button, message, Modal } from 'antd';
|
||||
import React, { useCallback } from 'react';
|
||||
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||
import intl from 'react-intl-universal';
|
||||
import MonacoEditor from '@app/components/MonacoEditor';
|
||||
import './index.less';
|
||||
|
||||
interface IProps {
|
||||
gql: string;
|
||||
visible?: boolean;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const GQLModal: React.FC<IProps> = ({ gql, visible = false, onCancel }) => {
|
||||
const handleCopy = useCallback(() => {
|
||||
message.success(intl.get('common.copySuccess'));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="modal-gql"
|
||||
title={intl.get('common.exportNGQL')}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
footer={false}
|
||||
width={700}
|
||||
>
|
||||
<div className="modal-gql__body">
|
||||
<MonacoEditor value={gql} height="100%" className="modal-gql__body__editor" />
|
||||
</div>
|
||||
|
||||
<div className="footer">
|
||||
<CopyToClipboard text={gql} onCopy={handleCopy}>
|
||||
<Button type="primary">{intl.get('common.copy')}</Button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default GQLModal;
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import cls from 'classnames';
|
||||
import '@app/iconfont/iconfont.js';
|
||||
|
||||
interface IProps extends React.HTMLProps<HTMLSpanElement> {
|
||||
type: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
const IconCfg = [
|
||||
{
|
||||
type: 'iconimage-iconUnselect',
|
||||
content: '',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon1',
|
||||
content: '\ue6fd',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon2',
|
||||
content: '\ue6ff',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon3',
|
||||
content: '\ue6fe',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon4',
|
||||
content: '\ue700',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon5',
|
||||
content: '\ue701',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon6',
|
||||
content: '\ue702',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon7',
|
||||
content: '\ue703',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon8',
|
||||
content: '\ue704',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon9',
|
||||
content: '\ue705',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon10',
|
||||
content: '\ue706',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon11',
|
||||
content: '\ue709',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon12',
|
||||
content: '\ue707',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon13',
|
||||
content: '\ue708',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon14',
|
||||
content: '\ue714',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon15',
|
||||
content: '\ue70e',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon16',
|
||||
content: '\ue70b',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon17',
|
||||
content: '\ue70a',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon18',
|
||||
content: '\ue712',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon19',
|
||||
content: '\ue710',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon20',
|
||||
content: '\ue70d',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon21',
|
||||
content: '\ue70c',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon22',
|
||||
content: '\ue70f',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon23',
|
||||
content: '\ue711',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon24',
|
||||
content: '\ue715',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon25',
|
||||
content: '\ue713',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon26',
|
||||
content: '\ue716',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon27',
|
||||
content: '\ue719',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon28',
|
||||
content: '\ue717',
|
||||
},
|
||||
{
|
||||
type: 'iconimage-icon29',
|
||||
content: '\ue718',
|
||||
},
|
||||
];
|
||||
|
||||
export default IconCfg;
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
@import '@app/common.less';
|
||||
|
||||
.icon-picker {
|
||||
.icon-group {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
display: inline-block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
padding: 5px;
|
||||
background-color: #f3f2f2;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
color: #000;
|
||||
|
||||
|
||||
.nebula-cloud-icon {
|
||||
display: inline-block;
|
||||
font-size: 30px;
|
||||
margin-left: 4px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: @blue;
|
||||
}
|
||||
}
|
||||
|
||||
.slick-slide:nth-child(2) {
|
||||
.icon-box:first-child {
|
||||
color: @red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { Carousel, Popover } from 'antd';
|
||||
import { chunk } from 'lodash';
|
||||
import React from 'react';
|
||||
import Icon from '@app/components/UnicodeIcon';
|
||||
import IconCfg from './iconCfg';
|
||||
import './index.less';
|
||||
|
||||
interface IIcon {
|
||||
type: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
onIconChangeComplete?: (icon: IIcon) => void;
|
||||
}
|
||||
|
||||
const iconGroup = chunk(IconCfg, 16);
|
||||
|
||||
interface IIconItem {
|
||||
onClick: (icon: IIcon) => void;
|
||||
icon: IIcon;
|
||||
}
|
||||
|
||||
const IconItem = (props: IIconItem) => {
|
||||
const {
|
||||
onClick,
|
||||
icon: { type, content },
|
||||
} = props;
|
||||
const iconElement = <Icon type={type} key={type} onClick={() => onClick(props.icon)} />;
|
||||
|
||||
return (
|
||||
<div className="icon-box">{content ? iconElement : <Popover content="Remove Icon">{iconElement}</Popover>}</div>
|
||||
);
|
||||
};
|
||||
|
||||
const IconPickerBtn: React.FC<IProps> = ({ onIconChangeComplete }) => {
|
||||
const handleChange = (icon: IIcon) => {
|
||||
onIconChangeComplete?.(icon);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="icon-picker">
|
||||
<Carousel lazyLoad="progressive" dots={true} adaptiveHeight>
|
||||
{iconGroup.map((group, index) => (
|
||||
<div className="icon-group" key={index}>
|
||||
{group.map((icon) => (
|
||||
<IconItem icon={icon} key={icon.type} onClick={handleChange} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconPickerBtn;
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { HTMLProps } from 'react';
|
||||
import '@app/iconfont/unicode/iconfont.css';
|
||||
|
||||
interface IIconFontProps extends HTMLProps<HTMLElement> {
|
||||
type: string;
|
||||
}
|
||||
|
||||
const IconFont = (props: IIconFontProps) => {
|
||||
const { type, className, ...others } = props;
|
||||
return (
|
||||
<span className={`nebula-cloud-icon ${type} ${className}`} {...others} />
|
||||
);
|
||||
};
|
||||
|
||||
export default IconFont;
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import classnames from 'classnames';
|
||||
import React from 'react';
|
||||
import Icon from '@app/components/UnicodeIcon';
|
||||
|
||||
interface IProps {
|
||||
icon?: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
class DisplayBtn extends React.PureComponent<IProps> {
|
||||
render() {
|
||||
const { icon, color } = this.props;
|
||||
return (
|
||||
<div className="btn-nodeStyle-set">
|
||||
<div className="color-group">
|
||||
<div className={classnames('btn-color')} style={{ background: color }}>
|
||||
{icon && <Icon className="icon-selected" type={icon} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DisplayBtn;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { Tabs } from 'antd';
|
||||
import intl from 'react-intl-universal';
|
||||
import ColorPicker from '@app/components/ColorPicker';
|
||||
import IconPicker from '@app/components/IconPicker';
|
||||
interface IIcon {
|
||||
type: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
onColorChangeComplete: (color: string) => void;
|
||||
onIconChangeComplete: (icon: IIcon) => void;
|
||||
}
|
||||
|
||||
const StyleSetTabs = ({ onColorChangeComplete, onIconChangeComplete }: IProps) => {
|
||||
const handleColorChange = (color: { hex: string }) => {
|
||||
onColorChangeComplete(color.hex);
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: 'color',
|
||||
label: intl.get('common.color'),
|
||||
children: <ColorPicker onChangeComplete={handleColorChange} />,
|
||||
},
|
||||
{
|
||||
key: 'icon',
|
||||
label: intl.get('common.icon'),
|
||||
children: <IconPicker onIconChangeComplete={onIconChangeComplete} />,
|
||||
},
|
||||
];
|
||||
return <Tabs className="tab-type-set" items={items}></Tabs>;
|
||||
};
|
||||
|
||||
export default StyleSetTabs;
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { Popover } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
import DisplayBtn from './DisplayBtn';
|
||||
import StyleSetTabs from './StyleSetTabs';
|
||||
interface IIcon {
|
||||
type: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
icon?: string;
|
||||
color: string;
|
||||
handleChangeColorComplete: (color: string) => void;
|
||||
handleChangeIconComplete: (icon: IIcon) => void;
|
||||
}
|
||||
|
||||
class VertexStyleSet extends React.PureComponent<IProps> {
|
||||
render() {
|
||||
const { icon, color, handleChangeColorComplete, handleChangeIconComplete } = this.props;
|
||||
return (
|
||||
<Popover
|
||||
overlayClassName="nodeStyle-popover"
|
||||
trigger={'click'}
|
||||
content={
|
||||
<StyleSetTabs
|
||||
onColorChangeComplete={handleChangeColorComplete}
|
||||
onIconChangeComplete={handleChangeIconComplete}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<DisplayBtn icon={icon} color={color} />
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default VertexStyleSet;
|
||||
|
|
@ -2,6 +2,10 @@ import { NodeObject } from '@vesoft-inc/force-graph';
|
|||
import BigNumber from 'bignumber.js';
|
||||
import JSONBigint from 'json-bigint';
|
||||
import { remove } from 'lodash';
|
||||
import Papa from 'papaparse';
|
||||
|
||||
import { INode, IPath } from '@app/interfaces/explore';
|
||||
|
||||
export const LINE_LENGTH = 150;
|
||||
export const FONT_SIZE = 10;
|
||||
export const NODE_SIZE = 18;
|
||||
|
|
@ -76,7 +80,7 @@ export const DEFAULT_COLOR_PICK_LIST = [
|
|||
'#7331B9',
|
||||
];
|
||||
|
||||
export const flattenData = data => {
|
||||
export const flattenData = (data) => {
|
||||
const result = {};
|
||||
const fieldData = [] as any;
|
||||
function recurse(cur: any, prop) {
|
||||
|
|
@ -94,7 +98,7 @@ export const flattenData = data => {
|
|||
result[prop] = cur;
|
||||
} else {
|
||||
let isEmpty = true;
|
||||
Object.keys(cur).forEach(p => {
|
||||
Object.keys(cur).forEach((p) => {
|
||||
isEmpty = false;
|
||||
recurse(cur[p], prop ? prop + '.' + p : p);
|
||||
if (isEmpty) {
|
||||
|
|
@ -107,47 +111,19 @@ export const flattenData = data => {
|
|||
return { result, fieldData };
|
||||
};
|
||||
|
||||
|
||||
export const parseData = (data, type: 'vertex' | 'edge') => {
|
||||
const fields =
|
||||
type === 'vertex'
|
||||
? ['vid', 'attributes']
|
||||
: ['type', 'srcId', 'dstId', 'rank', 'attributes'];
|
||||
const tables: any = [];
|
||||
data.forEach((item: any) => {
|
||||
const _result = {} as any;
|
||||
const properties = item.properties;
|
||||
const { result } = flattenData(properties) as any;
|
||||
if (type === 'vertex') {
|
||||
_result.vid = item.id;
|
||||
_result.attributes = JSONBigint.stringify(result);
|
||||
tables.push(_result);
|
||||
} else if (type === 'edge') {
|
||||
_result.type = item.edgeType;
|
||||
_result.srcId = item.source;
|
||||
_result.dstId = item.target;
|
||||
_result.rank = item.rank;
|
||||
_result.attributes = JSONBigint.stringify(result);
|
||||
tables.push(_result);
|
||||
}
|
||||
});
|
||||
return { tables, headers: fields };
|
||||
};
|
||||
|
||||
|
||||
export const updateTagMap = (tagMap, vertexes) => {
|
||||
Object.keys(tagMap).forEach(tag => {
|
||||
Object.keys(tagMap).forEach((tag) => {
|
||||
const colorGroup = tagMap[tag];
|
||||
colorGroup.forEach(colorMap => {
|
||||
colorGroup.forEach((colorMap) => {
|
||||
colorMap.countIds = [];
|
||||
});
|
||||
});
|
||||
vertexes.forEach(vertex => {
|
||||
vertexes.forEach((vertex) => {
|
||||
const { color, tags = [], id } = vertex;
|
||||
const group = tags.sort().join('-');
|
||||
const colorMap = tagMap[group];
|
||||
if (colorMap) {
|
||||
const hasColor = colorMap.some(item => {
|
||||
const hasColor = colorMap.some((item) => {
|
||||
if (item.color === color && !item.countIds.includes(id)) {
|
||||
item.countIds.push(String(id));
|
||||
return true;
|
||||
|
|
@ -156,20 +132,22 @@ export const updateTagMap = (tagMap, vertexes) => {
|
|||
if (!hasColor) {
|
||||
colorMap.push({
|
||||
color,
|
||||
countIds: [String(id)]
|
||||
countIds: [String(id)],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
tagMap[group] = [{
|
||||
color,
|
||||
countIds: [String(id)]
|
||||
}];
|
||||
tagMap[group] = [
|
||||
{
|
||||
color,
|
||||
countIds: [String(id)],
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
// remove color without data, but need to remain one
|
||||
Object.keys(tagMap).forEach(tag => {
|
||||
Object.keys(tagMap).forEach((tag) => {
|
||||
const colorGroup = tagMap[tag];
|
||||
const noDataList = colorGroup.filter(item => item.countIds.length === 0).map(item => item.color);
|
||||
const noDataList = colorGroup.filter((item) => item.countIds.length === 0).map((item) => item.color);
|
||||
const removeList = colorGroup.length === noDataList.length ? noDataList.slice(1) : noDataList;
|
||||
remove(tagMap[tag], (item: any) => removeList.includes(item.color));
|
||||
});
|
||||
|
|
@ -177,12 +155,12 @@ export const updateTagMap = (tagMap, vertexes) => {
|
|||
};
|
||||
|
||||
export const updateEdgeMap = (edgeMap, edges) => {
|
||||
Object.keys(edgeMap).forEach(item => {
|
||||
Object.keys(edgeMap).forEach((item) => {
|
||||
edgeMap[item] = {
|
||||
countIds: []
|
||||
countIds: [],
|
||||
};
|
||||
});
|
||||
edges.forEach(edge => {
|
||||
edges.forEach((edge) => {
|
||||
const { edgeType, id } = edge;
|
||||
edgeMap[edgeType].countIds = [...edgeMap[edgeType].countIds, id];
|
||||
});
|
||||
|
|
@ -207,5 +185,107 @@ export const makeRoundPosition = (data: NodeObject[] | Set<NodeObject>, center:
|
|||
node.y = r * Math.cos(angle) + center.y;
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
};
|
||||
|
||||
// v3.2.5
|
||||
|
||||
export const MIN_SCALE = 0.3;
|
||||
export const MAX_SCALE = 3;
|
||||
|
||||
export const HOT_KEYS = (intl) => [
|
||||
{
|
||||
operation: `Shift + 'Enter'`,
|
||||
desc: intl.get('explore.expand'),
|
||||
},
|
||||
{
|
||||
operation: `Shift + '-'`,
|
||||
desc: intl.get('common.zoomOut'),
|
||||
},
|
||||
{
|
||||
operation: `Shift + '+'`,
|
||||
desc: intl.get('common.zoomIn'),
|
||||
},
|
||||
{
|
||||
operation: `Shift + 'l'`,
|
||||
desc: intl.get('common.show'),
|
||||
},
|
||||
{
|
||||
operation: `Shift + 'z'`,
|
||||
desc: intl.get('common.rollback'),
|
||||
},
|
||||
{
|
||||
operation: intl.get('common.selected') + ` + Shift + 'del'`,
|
||||
desc: intl.get('common.delete'),
|
||||
},
|
||||
];
|
||||
|
||||
export const GRAPH_ALOGORITHM = (intl) => [
|
||||
{
|
||||
label: intl.get('explore.allPath'),
|
||||
value: 'ALL',
|
||||
},
|
||||
{
|
||||
label: intl.get('explore.shortestPath'),
|
||||
value: 'SHORTEST',
|
||||
},
|
||||
{
|
||||
label: intl.get('explore.noLoopPath'),
|
||||
value: 'NOLOOP',
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_COLOR_PICKER = '#5CDBD3';
|
||||
export const DEFAULT_COLOR_MIX = 'linear-gradient(225deg, #32C5FF 0%, #B620E0 51%, #F7B500 100%)';
|
||||
|
||||
export const downloadCSVFiles = ({ headers, tables, title }) => {
|
||||
try {
|
||||
const result = Papa.unparse(tables, {
|
||||
columns: headers,
|
||||
});
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = 'data:text/csv;charset=utf-8,\uFEFF' + encodeURIComponent(result);
|
||||
link.download = `${title}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (err) {
|
||||
alert(err);
|
||||
}
|
||||
};
|
||||
export const exportDataToCSV = (data: INode[] | IPath[], type: 'vertex' | 'edge') => {
|
||||
const { headers, tables } = parseData(data, type);
|
||||
downloadCSVFiles({ headers, tables, title: type });
|
||||
};
|
||||
export const DEFAULT_EXPLORE_RULES = {
|
||||
edgeTypes: [],
|
||||
edgeDirection: 'outgoing',
|
||||
stepsType: 'single',
|
||||
step: 1,
|
||||
vertexStyle: 'colorGroupByTag',
|
||||
quantityLimit: 10,
|
||||
customColor: DEFAULT_COLOR_PICKER,
|
||||
};
|
||||
|
||||
export const parseData = (data: INode[] | IPath[], type: 'vertex' | 'edge') => {
|
||||
const fields = type === 'vertex' ? ['vid', 'attributes'] : ['type', 'srcId', 'dstId', 'rank', 'attributes'];
|
||||
const tables: any = [];
|
||||
data.forEach((item: any) => {
|
||||
const _result = {} as any;
|
||||
const properties = type === 'vertex' ? item.nodeProp.properties : item.edgeProp.properties;
|
||||
const { result } = flattenData(properties) as any;
|
||||
if (type === 'vertex') {
|
||||
_result.vid = item.name;
|
||||
_result.attributes = JSONBigint.stringify(result);
|
||||
tables.push(_result);
|
||||
} else if (type === 'edge') {
|
||||
_result.type = item.type;
|
||||
_result.srcId = item.source.name;
|
||||
_result.dstId = item.target.name;
|
||||
_result.rank = item.rank;
|
||||
_result.attributes = JSONBigint.stringify(result);
|
||||
tables.push(_result);
|
||||
}
|
||||
});
|
||||
return { tables, headers: fields };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ export default {
|
|||
copy: 'Copy',
|
||||
copySuccess: 'Copied successfully',
|
||||
sketch: 'Schema drafting',
|
||||
qa: 'QA',
|
||||
template: 'template',
|
||||
viewSchema: 'View Schema',
|
||||
beta: 'Beta',
|
||||
danglingEdge: 'Dangling edge',
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
export default {
|
||||
common: {
|
||||
requestError: '请求错误',
|
||||
currentSpace: '当前图空间',
|
||||
currentSpace: '当前知识图谱',
|
||||
seeTheHistory: '查看历史',
|
||||
table: '表格',
|
||||
log: '日志',
|
||||
sorryNGQLCannotBeEmpty: 'nGQL语句不能为空',
|
||||
disablesUseToSwitchSpace: '禁止使用命令切换Space',
|
||||
disablesUseToSwitchSpace: '禁止使用命令切换知识图谱',
|
||||
NGQLHistoryList: 'nGQL历史列表',
|
||||
empty: '清空',
|
||||
run: '运行',
|
||||
|
|
@ -19,7 +19,7 @@ export default {
|
|||
import: '导入',
|
||||
ask: '确定进行当前操作?',
|
||||
openInExplore: '导入图探索',
|
||||
schema: 'Schema',
|
||||
schema: '图谱',
|
||||
create: '创建',
|
||||
name: '名称',
|
||||
operation: '操作',
|
||||
|
|
@ -47,14 +47,17 @@ export default {
|
|||
total: '共计',
|
||||
namePlaceholder: '请输入{name}名',
|
||||
comment: '描述',
|
||||
space: '图空间',
|
||||
space: '知识图谱',
|
||||
version: '版本',
|
||||
statistics: '统计',
|
||||
duplicate: '复制',
|
||||
copy: '复制',
|
||||
copySuccess: '复制成功',
|
||||
sketch: 'Schema 草图',
|
||||
viewSchema: '查看 Schema',
|
||||
sketch: '图谱草图',
|
||||
qa: '智能问答',
|
||||
template: '智能模板',
|
||||
explore: '图探索',
|
||||
viewSchema: '查看图谱',
|
||||
beta: 'Beta',
|
||||
danglingEdge: '悬挂边',
|
||||
columnName: '列名',
|
||||
|
|
@ -73,20 +76,55 @@ export default {
|
|||
switchOn: '开启',
|
||||
switchOff: '关闭',
|
||||
pleaseConfigLLMFirst: '请先配置 LLM 接口地址',
|
||||
algorithm: '算法',
|
||||
zoomOut: '缩小',
|
||||
zoomIn: '放大',
|
||||
move: '移动',
|
||||
show: '显示',
|
||||
rollback: '撤销',
|
||||
unlock: '解锁',
|
||||
lock: '锁定',
|
||||
hotKeys: '快捷键',
|
||||
search: '查询',
|
||||
selected: '选中',
|
||||
exportSelectVertexes: '导出选中点 CSV',
|
||||
exportSelectEdges: '导出选中边 CSV',
|
||||
languageSelect: '语言',
|
||||
record: '记录',
|
||||
spaceTip: '仅对某个知识图谱进行操作时需要',
|
||||
noData: '没有相应数据',
|
||||
help: '帮助',
|
||||
use: '使用手册',
|
||||
release: '新发布',
|
||||
nebula: 'Nebula',
|
||||
setting: '设置',
|
||||
feedback: '问题反馈',
|
||||
forum: '求助论坛',
|
||||
forumLink: 'https://discuss.nebula-graph.com.cn/',
|
||||
output: '导出CSV文件',
|
||||
serialNumber: '序号',
|
||||
optionalParameters: '可选参数',
|
||||
field: '字段',
|
||||
list: '列表',
|
||||
description: '说明',
|
||||
moreSuggestion: '更多建议',
|
||||
viewDocs: '查看文档',
|
||||
icon: '图标',
|
||||
noSelectedData: '当前没有选中数据',
|
||||
},
|
||||
doc: {
|
||||
welcome: '欢迎使用',
|
||||
functionIntro: '功能介绍',
|
||||
schemaIntro: `您可以在 schema 模块对 ${window.gConfig.databaseName} 图空间进行管理。`,
|
||||
importIntro: `您可以使用导入模块将数据批量导入 ${window.gConfig.databaseName}。`,
|
||||
consoleIntro: `您可以使用控制台模块对 ${window.gConfig.databaseName} 内的数据进行查询操作。`,
|
||||
schemaIntro: `您可以在图谱模块对${window.gConfig.databaseName}知识图谱进行管理。`,
|
||||
importIntro: `您可以使用导入模块将数据批量导入${window.gConfig.databaseName}。`,
|
||||
consoleIntro: `您可以使用控制台模块对${window.gConfig.databaseName}内的数据进行查询操作。`,
|
||||
learningDoc: '学习文档',
|
||||
getStarted: `认识 ${window.gConfig.databaseName} Studio`,
|
||||
getStartedTip: `什么是 ${window.gConfig.databaseName} Studio。`,
|
||||
useGuide: `${window.gConfig.databaseName} Studio 使用手册`,
|
||||
useGuideTip: `学习如何使用 ${window.gConfig.databaseName} Studio。`,
|
||||
ngqlIntro: `${window.gConfig.databaseName} 查询语言 (nGQL)`,
|
||||
ngqlIntroTip: `nGQL 是 ${window.gConfig.databaseName} 使用的的声明式图查询语言,是为开发和运维人员设计的类 SQL 查询语言,易于学习。`,
|
||||
getStarted: `认识${window.gConfig.databaseName}`,
|
||||
getStartedTip: `什么是${window.gConfig.databaseName}`,
|
||||
useGuide: `${window.gConfig.databaseName}使用手册`,
|
||||
useGuideTip: `学习如何使用${window.gConfig.databaseName}`,
|
||||
ngqlIntro: `${window.gConfig.databaseName}查询语言 (nGQL)`,
|
||||
ngqlIntroTip: `nGQL 是 ${window.gConfig.databaseName}使用的的声明式图查询语言,是为开发和运维人员设计的类 SQL 查询语言,易于学习。`,
|
||||
start: '快速开始',
|
||||
sketchIntro: '您可以在画板上自行设计 Schema,直观展示点边关系。',
|
||||
basketballplayerIntro: '最简单的示例图谱空间,最广泛被文档中引用的数据集',
|
||||
|
|
@ -137,7 +175,7 @@ export default {
|
|||
vidRequired: '请选择 VID',
|
||||
vidTypeRequired: '请选择 VID 类型',
|
||||
fixStringLengthRequired: '请输入字符串固定长度',
|
||||
spaceRequired: '图空间不能为空',
|
||||
spaceRequired: '知识图谱不能为空',
|
||||
maxBytes: '不能超过 {max} 字节',
|
||||
ttlLimit: '属性的数据类型必须是int或者timestamp',
|
||||
associateNameRequired: '请选择关联 {type} 名称',
|
||||
|
|
@ -150,6 +188,11 @@ export default {
|
|||
accessKeyIdRequired: '请填写 Access Key ID',
|
||||
accessKeySecretRequired: '请填写 Access Key Secret',
|
||||
platformRequired: '请选择平台',
|
||||
nodeIdError: '格式错误,一行1个VID,按回车键分隔',
|
||||
idRequired: '请输入导入的节点id',
|
||||
propertyRequired: '请输入属性名称',
|
||||
defaultRequired: '请输入默认值',
|
||||
fixedStringLength: 'Fixed String 长度需为正整数',
|
||||
},
|
||||
console: {
|
||||
execTime: '执行时间消耗',
|
||||
|
|
@ -161,7 +204,7 @@ export default {
|
|||
addToFavorites: '添加到收藏夹',
|
||||
unfavorite: '取消收藏',
|
||||
clearFavorites: '清空收藏夹',
|
||||
selectSpace: '请选择图空间',
|
||||
selectSpace: '请选择知识图谱',
|
||||
planTree: '执行计划',
|
||||
historyTip: '您可以直接输入“/”键快速选择历史查询语句。',
|
||||
runSelectionRows: '运行选中行',
|
||||
|
|
@ -170,10 +213,99 @@ export default {
|
|||
copy2NGQL: '复制到控制台',
|
||||
},
|
||||
explore: {
|
||||
vertexStyle: '节点颜色',
|
||||
notExist: '不存在',
|
||||
clear: '清除',
|
||||
clearTip: '是否清除当前视图?',
|
||||
startWithVertices: '开始探索',
|
||||
addConfirm: '确认添加',
|
||||
undo: '回退',
|
||||
deleteSelectNodes: '删除选中',
|
||||
expand: '拓展',
|
||||
unExpand: '取消拓展',
|
||||
fileImport: '文件导入',
|
||||
sampleImport: '样本导入',
|
||||
importPlaceholder:
|
||||
'输入VID或者用于生成VID的数据,一行一个数据,按回车键断开。格式示例如下:\nstring1\nstring2\nstring3',
|
||||
outgoing: '流出',
|
||||
incoming: '流入',
|
||||
bidirect: '双向',
|
||||
filter: '自定义筛选条件',
|
||||
operator: '运算符',
|
||||
value: '值',
|
||||
selectSpace: '请选择知识图谱',
|
||||
selectReminder: '切换知识图谱会清除当前显示的数据,您确定要切换吗?',
|
||||
zoom: '缩放',
|
||||
showTags: '显示点',
|
||||
showEdges: '显示边',
|
||||
confirm: '确定',
|
||||
vertexStyle: '节点颜色/图标',
|
||||
quantityLimit: '结果数量限制',
|
||||
colorGroupByTag: '按标签类型分类',
|
||||
noVertexPrompt: '当前画板没有点数据,请',
|
||||
search: '探索',
|
||||
queryById: '按VID查询',
|
||||
queryByIndex: '按索引查询',
|
||||
queryByCustom: '自定义查询',
|
||||
idToBeQueried: '指定VID',
|
||||
idPretreatment: 'VID预处理',
|
||||
indexQueryPrompt_prefix: '当前知识图谱',
|
||||
indexQueryPrompt_suffix: '下,没有任何标签的索引,无法进行索引查询',
|
||||
indexQueryPrompt2: '请按如下示例创建标签索引',
|
||||
runCodeInConsole: '去控制台运行语句',
|
||||
indexLink: '关于索引的更多信息,请查看对应的',
|
||||
documentIntroduction: '文档介绍',
|
||||
selectIndex: '选择索引',
|
||||
paramFilter: '使用索引',
|
||||
relationship: '组合关系',
|
||||
operationConfirm: '删除操作会清空后续筛选条件。请确认是否继续执行',
|
||||
quiry: '查询',
|
||||
customQueryDescription: '可在控制台输入相应nGQL语句,查询得到结果后,点击上图中的“导入图探索”按钮,进行可视化探索',
|
||||
openInConsole: '去控制台',
|
||||
insertMethodSelect: '当前画板存在部分数据,请选择新增查询结果的插入方式',
|
||||
incrementalInsertion: '增量插入',
|
||||
insertAfterClear: '清除插入',
|
||||
emptyIndex: '索引为空',
|
||||
indexConditionDescription:
|
||||
'匹配字段时,必须以索引中左边第一个字段开始,如果需要匹配多个字段,不得跳过字段,但是可以省略后续字段。',
|
||||
timestampInput: '时间戳字段只支持输入数字',
|
||||
documentIntroductionUrl: 'https://docs.nebula-graph.com.cn/2.5.0/3.ngql-guide/14.native-index-statements/',
|
||||
customQueryUrl: 'https://cloud-cdn.nebula-graph.com.cn/studio-resource/go-to-explore_zh.png',
|
||||
pretreatmentExplaination:
|
||||
'Hash能预处理bool、double、int、string类型的数据生成VID,但是UUID仅支持预处理string类型的数据。如果您需要使用Hash或UUID预处理string生成VID,则使用单引号或双引号标示每个string。',
|
||||
exportToImg: '导出图形',
|
||||
exportToCSV: '导出CSV',
|
||||
export: '导出',
|
||||
toBlobError: '导出失败。当前画布尺寸过大,请缩放画布尺寸后重试。',
|
||||
expandTip: '双击任意点也可实现该点的拓展。',
|
||||
hotKeysInstructions: '图探索快捷键说明',
|
||||
graphAlgorithm: '图算法',
|
||||
allPath: '全路径',
|
||||
shortestPath: '最短路径',
|
||||
noLoopPath: '非循环路径',
|
||||
algorithmParams: '算法参数',
|
||||
srcId: '起点',
|
||||
dstId: '终点',
|
||||
relation: '关系',
|
||||
direction: '方向',
|
||||
stepLimit: '步数限制',
|
||||
steps: '步数',
|
||||
singleStep: '单步',
|
||||
rangeStep: '范围',
|
||||
addCondition: '添加条件',
|
||||
expansionConditions: '拓展条件',
|
||||
customStyle: '自定义颜色/图标',
|
||||
nodeSearch: '画板节点搜索',
|
||||
searchEmpty: '未查询到相应数据',
|
||||
selectedVertexes: '选中的点',
|
||||
selectedEdges: '选中的边',
|
||||
viewDetails: '查看详情',
|
||||
expandItem: '展开',
|
||||
collapseItem: '收起',
|
||||
searchTip: '当前支持以下比较符 [=, >, <, !=, <>, <=, >=]',
|
||||
expressionError: '表达式错误',
|
||||
expandTips: '双击节点默认按当前配置快捷展开',
|
||||
missingParams: '参数缺失',
|
||||
emptyIndexTips: '无属性索引暂不支持查询数据功能,建议选择带属性索引查询',
|
||||
docForFindPath: 'https://docs.nebula-graph.com.cn/2.5.0/3.ngql-guide/16.subgraph-and-path/2.find-path/',
|
||||
},
|
||||
import: {
|
||||
uploadFile: '上传文件',
|
||||
|
|
@ -245,7 +377,7 @@ export default {
|
|||
vidFunction: 'VID 函数',
|
||||
vidPrefix: 'VID 前缀',
|
||||
vidSuffix: 'VID 后缀',
|
||||
concurrencyTip: `${window.gConfig.databaseName} 客户端并发数`,
|
||||
concurrencyTip: `${window.gConfig.databaseName}客户端并发数`,
|
||||
batchSizeTip: '单批次插入数据的语句数量',
|
||||
retryTip: 'nGQL 语句执行失败的重试次数',
|
||||
vidFunctionTip: '生成 VID 的函数。目前只支持 hash 函数',
|
||||
|
|
@ -334,18 +466,73 @@ export default {
|
|||
draft: '草稿',
|
||||
saveDraft: '保存草稿',
|
||||
modifyTime: '编辑时间',
|
||||
taskNameRequired: '请填写任务名称并选择图空间',
|
||||
taskNameRequired: '请填写任务名称并选择知识图谱',
|
||||
fileMissing: '{files} 文件不存在,请重新上传文件或添加相关数据源',
|
||||
datasourceMissing: '{files} 所在数据源未找到,请重新添加相关数据源并重新配置任务',
|
||||
templateRerunTip: '模板导入生成的任务不支持编辑,请直接修改模板文件并导入',
|
||||
rerunError: '找不到任务配置记录,无法重跑任务',
|
||||
editTaskError: '找不到任务配置记录,无法继续编辑',
|
||||
s3SafetyTip: '出于数据安全考虑,建议 Bucket 中仅放和数据导入相关的文件,且 Bucket 设置为只读。',
|
||||
reset: '重置',
|
||||
import: '导入',
|
||||
selectSpace: '选择知识图谱',
|
||||
vertex: '关联点',
|
||||
next: '下一步',
|
||||
goback: '上一步',
|
||||
mountPath: '挂载路径',
|
||||
importConfigValidationSuccess: '配置验证成功',
|
||||
mountPathPlaceholder: '请输入docker启动的数据挂载路径',
|
||||
fileType: '类型',
|
||||
fileSizeErrorMsg: '文件必须小于100MB',
|
||||
confirm: '确认',
|
||||
importResults: '导入信息',
|
||||
newImport: '新建导入',
|
||||
againImport: '再次导入',
|
||||
propTip: '{name}中拥有的属性',
|
||||
mappingTip: '属性字段对应csv文件的哪一列',
|
||||
setMappingTip: '将属性{prop}对应当前csv的第{index}列',
|
||||
typeTip: '属性字段对应的数据类型',
|
||||
setVertexId: '设为ID',
|
||||
setVertexIdTip: '当前字段是否作为Vertex Id',
|
||||
useHash: 'ID Hash',
|
||||
useHashTip: 'id字段对应值插入数据库中所做的处理',
|
||||
unset: '保持原值',
|
||||
uuid: 'UUID',
|
||||
hash: 'Hash',
|
||||
setSrc: '设为起点',
|
||||
setSrcTip: '将当前字段值作为起点',
|
||||
setDst: '设为终点',
|
||||
setDstTip: '将当前字段值作为终点',
|
||||
setRank: '设为Rank',
|
||||
setRankTip: '将当前字段值作为rank',
|
||||
createConfigError: '创建配置文件错误',
|
||||
importErrorInfo: '导入数据错误,请检查配置或数据文件',
|
||||
clearAllConfigInfo: '是否确定清空所有配置?',
|
||||
configFile: '配置文件:',
|
||||
logFile: '日志文件:',
|
||||
vertexesFile: '点相关文件:',
|
||||
vertexFile: '该点配置文件:',
|
||||
vertexErrorFile: '该点错误数据文件:',
|
||||
edgesFilePath: '边配置文件:',
|
||||
edgeFilePath: '该边配置文件:',
|
||||
edgeErrorFilePath: '该边错误数据文件:',
|
||||
all: '全部',
|
||||
mountPathWarning: '导入数据需在应用启动时配置WORKING_DIR环境变量,否则无法进行。',
|
||||
notExist: '不存在',
|
||||
importError: '未知错误',
|
||||
importMappingError: '数据文件配置映射导入失败',
|
||||
importFormatError: '数据文件格式不统一',
|
||||
importFileConfigError: '数据文件配置相关错误',
|
||||
importFileDownloadError: '数据文件下载失败',
|
||||
importFileError: '文件相关错误',
|
||||
importNebulaError: '与实例交互相关错误',
|
||||
datasource: '数据源',
|
||||
importFinished: '导入任务已结束',
|
||||
},
|
||||
schema: {
|
||||
spaceList: '图空间列表',
|
||||
spaceList: '知识图谱列表',
|
||||
useSpaceErrTip:
|
||||
'图空间未找到。立刻尝试使用刚创建的图空间可能会失败,因为创建是异步实现的。为确保数据同步,后续操作能顺利进行,请等待 2 个心跳周期(20 秒)。',
|
||||
'知识图谱未找到。立刻尝试使用刚创建的知识图谱可能会失败,因为创建是异步实现的。为确保数据同步,后续操作能顺利进行,请等待 2 个心跳周期(20 秒)。',
|
||||
createSuccess: '创建成功',
|
||||
defineFields: '定义属性',
|
||||
uniqProperty: '属性名称不允许重名',
|
||||
|
|
@ -363,18 +550,18 @@ export default {
|
|||
indexedLengthDescription: '设置索引字符串的长度。如果索引定长字符串,则索引长度无法修改。',
|
||||
indexedLengthRequired: '索引长度应为正整数',
|
||||
rebuild: '重建索引',
|
||||
createSpace: '创建图空间',
|
||||
createSpace: '创建知识图谱',
|
||||
No: '序号',
|
||||
spaceName: '名称',
|
||||
partitionNumber: 'Partition Number',
|
||||
replicaFactor: 'Replica Factor',
|
||||
charset: 'Charset',
|
||||
collate: 'Collate',
|
||||
vidType: 'Vid Type',
|
||||
group: 'Group',
|
||||
comment: 'Comment',
|
||||
partitionNumber: '数据分区数',
|
||||
replicaFactor: '副本数',
|
||||
charset: '字符集',
|
||||
collate: '排序规则',
|
||||
vidType: '点ID类型',
|
||||
group: '分组',
|
||||
comment: '备注',
|
||||
operations: '操作',
|
||||
spaceNameEnter: '请输入图空间名称',
|
||||
spaceNameEnter: '请输入知识图谱名称',
|
||||
propertyCount: '属性数量',
|
||||
configTypeList: '{type}列表',
|
||||
configTypeAction: '{action}{type}',
|
||||
|
|
@ -402,9 +589,9 @@ export default {
|
|||
statsCount: '数量',
|
||||
statError: '统计失败,请重试',
|
||||
statFinished: '统计结束',
|
||||
deleteSpace: '删除图空间',
|
||||
clearSpace: '清空图空间',
|
||||
cloneSpace: '克隆图空间',
|
||||
deleteSpace: '删除知识图谱',
|
||||
clearSpace: '清空知识图谱',
|
||||
cloneSpace: '克隆知识图谱',
|
||||
length: '长度',
|
||||
selectVidTypeTip: '选择 Vid 类型',
|
||||
csvDownload: '导出 CSV',
|
||||
|
|
@ -412,12 +599,12 @@ export default {
|
|||
rebuildSuccess: '{names}重建完成',
|
||||
rebuildFailed: '{names}重建失败',
|
||||
startRebuildIndex: '开始重建索引{name}',
|
||||
getSchema: '获取 Schema',
|
||||
getSchemaTip: `因为当前 ${window.gConfig.databaseName} 版本下不存在点边的强绑定关系,结果将依据随机捞取到的数据生成,仅供参考。`,
|
||||
getSchema: '获取图谱',
|
||||
getSchemaTip: `因为当前${window.gConfig.databaseName}版本下不存在点边的强绑定关系,结果将依据随机捞取到的数据生成,仅供参考。`,
|
||||
danglingEdge: '悬挂边',
|
||||
showDDL: '查看 Schema DDL',
|
||||
showDDL: '查看图谱 DDL',
|
||||
downloadNGQL: '下载 .NGQL 文件',
|
||||
getDDLError: '获取 Schema DDL 失败, 请重试',
|
||||
getDDLError: '获取图谱 DDL 失败, 请重试',
|
||||
totalVertices: '总计点数量',
|
||||
totalEdges: '总计边数量',
|
||||
},
|
||||
|
|
@ -466,23 +653,23 @@ export default {
|
|||
type: '类型',
|
||||
list: '草图列表',
|
||||
new: '新建',
|
||||
applyToSpace: '应用到图空间',
|
||||
createSpace: '创建图空间',
|
||||
selectSpace: '选择图空间',
|
||||
applyToSpace: '应用到知识图谱',
|
||||
createSpace: '创建知识图谱',
|
||||
selectSpace: '选择知识图谱',
|
||||
noCurrentSketch: '当前没有选中草图',
|
||||
noCurrentSketchTips: '请在左侧列表中选择草图',
|
||||
sketchInvalid: '请完善当前 Schema 信息。',
|
||||
sketchInvalid: '请完善当前图谱信息。',
|
||||
saveSuccess: '保存成功',
|
||||
saveReminder: '当前草图有修改未保存,是否继续切换草图?',
|
||||
saveTip: '当前草图有修改未保存, 请先保存。',
|
||||
confirmDelete: '请确认是否删除',
|
||||
saveDraft: '保存草稿',
|
||||
export: '导出',
|
||||
applySpaceTip: '新的 schema 不会覆盖图空间中已有的 schema 信息',
|
||||
sameSchemaWarning: '图空间中已存在{content},请修改{hasType}名称,或者选择其他图空间。',
|
||||
applySpaceTip: '新的图谱不会覆盖知识图谱中已有的图谱信息',
|
||||
sameSchemaWarning: '知识图谱中已存在{content},请修改{hasType}名称,或者选择其他知识图谱。',
|
||||
noData: '暂无数据,请先导入数据',
|
||||
uniqName: '标签和边类型名称不允许重名',
|
||||
spaceExisted: '图空间已存在',
|
||||
spaceExisted: '知识图谱已存在',
|
||||
updateNameSuccess: '更新名称成功',
|
||||
search: '搜索草图名称',
|
||||
},
|
||||
|
|
@ -499,7 +686,7 @@ export default {
|
|||
demoIntro: 'Demo 介绍',
|
||||
loadWaiting: '数据加载中,请稍等...预计将在 {second}s 后结束',
|
||||
downloadSuccess: '数据集 `{space}` 下载成功',
|
||||
spaceExist: '图空间 `{space}` 已存在',
|
||||
spaceExist: '知识图谱 `{space}` 已存在',
|
||||
schemaModuleLink: 'https://docs.nebula-graph.com.cn/3.8.0/nebula-studio/quick-start/st-ug-create-schema/',
|
||||
importModuleLink: 'https://docs.nebula-graph.com.cn/3.8.0/nebula-studio/quick-start/st-ug-import-data/',
|
||||
consoleModuleLink: 'https://docs.nebula-graph.com.cn/3.8.0/nebula-studio/quick-start/st-ug-console/',
|
||||
|
|
@ -520,7 +707,7 @@ export default {
|
|||
setting: {
|
||||
globalSetting: '全局设置',
|
||||
betaFunction: 'Beta 功能',
|
||||
viewScmemaBetaFunDesc: '查看指定图空间的 schema 结构,位于 Schema --> [图空间] --> 查看 Schema',
|
||||
viewScmemaBetaFunDesc: '查看指定知识图谱的图谱结构,位于图谱 --> [知识图谱] --> 查看图谱',
|
||||
text2query: '文本转查询',
|
||||
text2queryDesc: '在控制台中使用 LLM 功能,将自然语言转换为查询语句',
|
||||
llmImport: 'AI 导入',
|
||||
|
|
@ -530,12 +717,12 @@ export default {
|
|||
},
|
||||
llm: {
|
||||
newAiImport: 'New AI Import',
|
||||
importTip: '请先创建图空间和 schema',
|
||||
importTip: '请先创建知识图谱',
|
||||
setup: '设置',
|
||||
confirm: '确认',
|
||||
file: '文件',
|
||||
filePath: '文件路径',
|
||||
importGraphSpace: '导入图空间',
|
||||
importGraphSpace: '导入知识图谱',
|
||||
exportNGQLFilePath: '导出 NGQL 文件路径',
|
||||
attachPrompt: '附加提示',
|
||||
next: '下一步',
|
||||
|
|
|
|||
|
|
@ -77,13 +77,32 @@ export const replicaRulesFn = (activeMachineNum) => [
|
|||
];
|
||||
|
||||
export const stringByteRulesFn = () => [
|
||||
{ validator: (_, value) => {
|
||||
const byteLength = getByteLength(value);
|
||||
if (byteLength <= MAX_COMMENT_BYTES) {
|
||||
return Promise.resolve();
|
||||
{
|
||||
validator: (_, value) => {
|
||||
const byteLength = getByteLength(value);
|
||||
if (byteLength <= MAX_COMMENT_BYTES) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(<Translation>
|
||||
{intl => intl.get('formRules.maxBytes', { max: MAX_COMMENT_BYTES })}
|
||||
</Translation>);
|
||||
}
|
||||
return Promise.reject(<Translation>
|
||||
{intl => intl.get('formRules.maxBytes', { max: MAX_COMMENT_BYTES })}
|
||||
</Translation>);
|
||||
} }
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
// v3.2.5
|
||||
export const nodeIdRulesFn = () => [
|
||||
{
|
||||
required: true,
|
||||
message: <Translation>
|
||||
{intl => intl.get('formRules.idRequired')}
|
||||
</Translation>
|
||||
},
|
||||
{
|
||||
pattern: /^(.+)*(\n.+)*(\n)*$/,
|
||||
message: <Translation>
|
||||
{intl => intl.get('formRules.nodeIdError')}
|
||||
</Translation>,
|
||||
},
|
||||
];
|
||||
|
|
@ -122,6 +122,27 @@ const service = {
|
|||
const { id, ...restParams } = params;
|
||||
return get(`/api/datasources/${id}/file-preview`)(restParams, config);
|
||||
},
|
||||
getAnswer: (params, config?) => {
|
||||
return post('/api/kbqa/ask-sync')(params, config);
|
||||
},
|
||||
getQATemplate: (params, config?) => {
|
||||
return post('/api/kbqa/qa_template/queryQA')(params, config);
|
||||
},
|
||||
updateQATemplate: (params, config?) => {
|
||||
return post('/api/kbqa/qa_template/update')(params, config);
|
||||
},
|
||||
getQASessionList: (params, config?) => {
|
||||
return post('/api/kbqa/sessions/list')(params, config);
|
||||
},
|
||||
getQASessionDetail: (params, config?) => {
|
||||
return post('/api/kbqa/dialogs')(params, config);
|
||||
},
|
||||
renameSession: (params, config?) => {
|
||||
return post('/api/kbqa/session/rename')(params, config);
|
||||
},
|
||||
removeSession: (params, config?) => {
|
||||
return post('/api/kbqa/session/delete')(params, config);
|
||||
},
|
||||
};
|
||||
|
||||
export const updateService = (partService: any) => {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,487 @@
|
|||
@font-face {
|
||||
font-family: "nebula-cloud-icon"; /* Project id 1846875 */
|
||||
src: url('iconfont.woff2?t=1631006849129') format('woff2'),
|
||||
url('iconfont.woff?t=1631006849129') format('woff'),
|
||||
url('iconfont.ttf?t=1631006849129') format('truetype');
|
||||
}
|
||||
|
||||
.nebula-cloud-icon {
|
||||
font-family: "nebula-cloud-icon" !important;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.iconimage-iconUnselect:before {
|
||||
content: "\e71a";
|
||||
}
|
||||
|
||||
.iconimage-icon17:before {
|
||||
content: "\e70a";
|
||||
}
|
||||
|
||||
.iconimage-icon16:before {
|
||||
content: "\e70b";
|
||||
}
|
||||
|
||||
.iconimage-icon21:before {
|
||||
content: "\e70c";
|
||||
}
|
||||
|
||||
.iconimage-icon20:before {
|
||||
content: "\e70d";
|
||||
}
|
||||
|
||||
.iconimage-icon15:before {
|
||||
content: "\e70e";
|
||||
}
|
||||
|
||||
.iconimage-icon22:before {
|
||||
content: "\e70f";
|
||||
}
|
||||
|
||||
.iconimage-icon19:before {
|
||||
content: "\e710";
|
||||
}
|
||||
|
||||
.iconimage-icon23:before {
|
||||
content: "\e711";
|
||||
}
|
||||
|
||||
.iconimage-icon18:before {
|
||||
content: "\e712";
|
||||
}
|
||||
|
||||
.iconimage-icon25:before {
|
||||
content: "\e713";
|
||||
}
|
||||
|
||||
.iconimage-icon14:before {
|
||||
content: "\e714";
|
||||
}
|
||||
|
||||
.iconimage-icon24:before {
|
||||
content: "\e715";
|
||||
}
|
||||
|
||||
.iconimage-icon26:before {
|
||||
content: "\e716";
|
||||
}
|
||||
|
||||
.iconimage-icon28:before {
|
||||
content: "\e717";
|
||||
}
|
||||
|
||||
.iconimage-icon29:before {
|
||||
content: "\e718";
|
||||
}
|
||||
|
||||
.iconimage-icon27:before {
|
||||
content: "\e719";
|
||||
}
|
||||
|
||||
.iconimage-icon1:before {
|
||||
content: "\e6fd";
|
||||
}
|
||||
|
||||
.iconimage-icon3:before {
|
||||
content: "\e6fe";
|
||||
}
|
||||
|
||||
.iconimage-icon2:before {
|
||||
content: "\e6ff";
|
||||
}
|
||||
|
||||
.iconimage-icon4:before {
|
||||
content: "\e700";
|
||||
}
|
||||
|
||||
.iconimage-icon5:before {
|
||||
content: "\e701";
|
||||
}
|
||||
|
||||
.iconimage-icon6:before {
|
||||
content: "\e702";
|
||||
}
|
||||
|
||||
.iconimage-icon7:before {
|
||||
content: "\e703";
|
||||
}
|
||||
|
||||
.iconimage-icon8:before {
|
||||
content: "\e704";
|
||||
}
|
||||
|
||||
.iconimage-icon9:before {
|
||||
content: "\e705";
|
||||
}
|
||||
|
||||
.iconimage-icon10:before {
|
||||
content: "\e706";
|
||||
}
|
||||
|
||||
.iconimage-icon12:before {
|
||||
content: "\e707";
|
||||
}
|
||||
|
||||
.iconimage-icon13:before {
|
||||
content: "\e708";
|
||||
}
|
||||
|
||||
.iconimage-icon11:before {
|
||||
content: "\e709";
|
||||
}
|
||||
|
||||
.iconstudio-unexpand:before {
|
||||
content: "\e67d";
|
||||
}
|
||||
|
||||
.iconDefault-image-left:before {
|
||||
content: "\e658";
|
||||
}
|
||||
|
||||
.iconstudio-unlock:before {
|
||||
content: "\e656";
|
||||
}
|
||||
|
||||
.iconstudio-lock:before {
|
||||
content: "\e657";
|
||||
}
|
||||
|
||||
.iconstudio-zoomout:before {
|
||||
content: "\e651";
|
||||
}
|
||||
|
||||
.iconstudio-zoomin:before {
|
||||
content: "\e652";
|
||||
}
|
||||
|
||||
.iconstudio-down:before {
|
||||
content: "\e640";
|
||||
}
|
||||
|
||||
.iconstudio-expandcondition:before {
|
||||
content: "\e641";
|
||||
}
|
||||
|
||||
.iconstudio-delete:before {
|
||||
content: "\e642";
|
||||
}
|
||||
|
||||
.iconstudio-back:before {
|
||||
content: "\e643";
|
||||
}
|
||||
|
||||
.iconstudio-indentright:before {
|
||||
content: "\e644";
|
||||
}
|
||||
|
||||
.iconstudio-moving:before {
|
||||
content: "\e645";
|
||||
}
|
||||
|
||||
.iconstudio-hotkey:before {
|
||||
content: "\e646";
|
||||
}
|
||||
|
||||
.iconstudio-expand:before {
|
||||
content: "\e647";
|
||||
}
|
||||
|
||||
.iconstudio-indentleft:before {
|
||||
content: "\e648";
|
||||
}
|
||||
|
||||
.iconstudio-search:before {
|
||||
content: "\e649";
|
||||
}
|
||||
|
||||
.iconstudio-exportcsv:before {
|
||||
content: "\e64a";
|
||||
}
|
||||
|
||||
.iconstudio-edge:before {
|
||||
content: "\e64b";
|
||||
}
|
||||
|
||||
.iconstudio-show:before {
|
||||
content: "\e64c";
|
||||
}
|
||||
|
||||
.iconstudio-vertex:before {
|
||||
content: "\e64d";
|
||||
}
|
||||
|
||||
.iconstudio-seletexpand:before {
|
||||
content: "\e64e";
|
||||
}
|
||||
|
||||
.iconstudio-remake:before {
|
||||
content: "\e64f";
|
||||
}
|
||||
|
||||
.iconstudio-seletdelete:before {
|
||||
content: "\e650";
|
||||
}
|
||||
|
||||
.iconstudio-exportimage:before {
|
||||
content: "\e653";
|
||||
}
|
||||
|
||||
.iconstudio-window:before {
|
||||
content: "\e654";
|
||||
}
|
||||
|
||||
.iconstudio-seletup:before {
|
||||
content: "\e655";
|
||||
}
|
||||
|
||||
.iconfee-rmb:before {
|
||||
content: "\e63d";
|
||||
}
|
||||
|
||||
.iconfee-usd:before {
|
||||
content: "\e63f";
|
||||
}
|
||||
|
||||
.iconlogout:before {
|
||||
content: "\e63b";
|
||||
}
|
||||
|
||||
.iconbell:before {
|
||||
content: "\e63c";
|
||||
}
|
||||
|
||||
.iconuser:before {
|
||||
content: "\e63e";
|
||||
}
|
||||
|
||||
.iconcard-discover:before {
|
||||
content: "\e635";
|
||||
}
|
||||
|
||||
.iconcard-defaultcard:before {
|
||||
content: "\e634";
|
||||
}
|
||||
|
||||
.iconcard-jcb:before {
|
||||
content: "\e636";
|
||||
}
|
||||
|
||||
.iconcard-american-express:before {
|
||||
content: "\e637";
|
||||
}
|
||||
|
||||
.iconcard-visa:before {
|
||||
content: "\e638";
|
||||
}
|
||||
|
||||
.iconcard-unionpay:before {
|
||||
content: "\e639";
|
||||
}
|
||||
|
||||
.iconcard-mastercard:before {
|
||||
content: "\e63a";
|
||||
}
|
||||
|
||||
.icondetail:before {
|
||||
content: "\e630";
|
||||
}
|
||||
|
||||
.iconcheck:before {
|
||||
content: "\e631";
|
||||
}
|
||||
|
||||
.icondownload:before {
|
||||
content: "\e632";
|
||||
}
|
||||
|
||||
.iconclose:before {
|
||||
content: "\e633";
|
||||
}
|
||||
|
||||
.iconlogo-vesoft:before {
|
||||
content: "\e62f";
|
||||
}
|
||||
|
||||
.iconblog-right-icon:before {
|
||||
content: "\e62e";
|
||||
}
|
||||
|
||||
.iconblog-rss:before {
|
||||
content: "\e622";
|
||||
}
|
||||
|
||||
.iconblog-share:before {
|
||||
content: "\e624";
|
||||
}
|
||||
|
||||
.iconbanner-left:before {
|
||||
content: "\e61b";
|
||||
}
|
||||
|
||||
.iconbackup:before {
|
||||
content: "\e61c";
|
||||
}
|
||||
|
||||
.iconbanner-live:before {
|
||||
content: "\e61d";
|
||||
}
|
||||
|
||||
.iconblog-calendar:before {
|
||||
content: "\e61e";
|
||||
}
|
||||
|
||||
.iconbanner-right:before {
|
||||
content: "\e61f";
|
||||
}
|
||||
|
||||
.iconbanner-report:before {
|
||||
content: "\e620";
|
||||
}
|
||||
|
||||
.iconblog-tag:before {
|
||||
content: "\e621";
|
||||
}
|
||||
|
||||
.iconblog-writer:before {
|
||||
content: "\e623";
|
||||
}
|
||||
|
||||
.icondownload-exchange:before {
|
||||
content: "\e625";
|
||||
}
|
||||
|
||||
.icondownload-csv:before {
|
||||
content: "\e626";
|
||||
}
|
||||
|
||||
.icondownload-Docker:before {
|
||||
content: "\e627";
|
||||
}
|
||||
|
||||
.icondownload-Kubernetes:before {
|
||||
content: "\e628";
|
||||
}
|
||||
|
||||
.icondownload-Source:before {
|
||||
content: "\e629";
|
||||
}
|
||||
|
||||
.iconreadmore:before {
|
||||
content: "\e62a";
|
||||
}
|
||||
|
||||
.iconlogo-cloud:before {
|
||||
content: "\e62b";
|
||||
}
|
||||
|
||||
.iconlogo-nebula:before {
|
||||
content: "\e62c";
|
||||
}
|
||||
|
||||
.icondownload-linux:before {
|
||||
content: "\e62d";
|
||||
}
|
||||
|
||||
.iconzhihu:before {
|
||||
content: "\e61a";
|
||||
}
|
||||
|
||||
.iconbilibili:before {
|
||||
content: "\e619";
|
||||
}
|
||||
|
||||
.iconnav-order:before {
|
||||
content: "\e618";
|
||||
}
|
||||
|
||||
.iconnav-message:before {
|
||||
content: "\e616";
|
||||
}
|
||||
|
||||
.iconnav-model:before {
|
||||
content: "\e615";
|
||||
}
|
||||
|
||||
.iconteam:before {
|
||||
content: "\e614";
|
||||
}
|
||||
|
||||
.iconteam-newmember:before {
|
||||
content: "\e613";
|
||||
}
|
||||
|
||||
.iconteam-member:before {
|
||||
content: "\e612";
|
||||
}
|
||||
|
||||
.iconteam-owner:before {
|
||||
content: "\e611";
|
||||
}
|
||||
|
||||
.iconteam-admin:before {
|
||||
content: "\e610";
|
||||
}
|
||||
|
||||
.iconsele-down:before {
|
||||
content: "\e60f";
|
||||
}
|
||||
|
||||
.iconfilters:before {
|
||||
content: "\e60e";
|
||||
}
|
||||
|
||||
.iconserver-icon:before {
|
||||
content: "\e60d";
|
||||
}
|
||||
|
||||
.iconweibo:before {
|
||||
content: "\e60b";
|
||||
}
|
||||
|
||||
.iconassistantwechat:before {
|
||||
content: "\e601";
|
||||
}
|
||||
|
||||
.iconforum:before {
|
||||
content: "\e602";
|
||||
}
|
||||
|
||||
.iconfacebook:before {
|
||||
content: "\e603";
|
||||
}
|
||||
|
||||
.icongitHub:before {
|
||||
content: "\e604";
|
||||
}
|
||||
|
||||
.iconmail:before {
|
||||
content: "\e605";
|
||||
}
|
||||
|
||||
.iconlinkedin:before {
|
||||
content: "\e606";
|
||||
}
|
||||
|
||||
.iconphone:before {
|
||||
content: "\e607";
|
||||
}
|
||||
|
||||
.icontwitter:before {
|
||||
content: "\e608";
|
||||
}
|
||||
|
||||
.iconslack:before {
|
||||
content: "\e609";
|
||||
}
|
||||
|
||||
.iconwechat:before {
|
||||
content: "\e60a";
|
||||
}
|
||||
|
||||
.iconyoutube:before {
|
||||
content: "\e60c";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -64,13 +64,14 @@ const App = () => {
|
|||
theme={{
|
||||
token: {
|
||||
controlHeight: 38,
|
||||
colorPrimary: '#0091ff',
|
||||
colorPrimary: '#1664ff',
|
||||
colorLink: '#1664ff',
|
||||
fontFamily: 'Roboto-Regular, sans-serif',
|
||||
},
|
||||
components: {
|
||||
Menu: {
|
||||
itemBg: '#2f3a4a',
|
||||
colorPrimary: '#2f80ed',
|
||||
colorPrimary: '#1664ff',
|
||||
margin: 20,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import * as d3 from 'd3';
|
||||
export interface INode extends d3.SimulationNodeDatum {
|
||||
name: string;
|
||||
group: number;
|
||||
uuid: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface IPath extends d3.SimulationLinkDatum<INode> {
|
||||
id: string;
|
||||
source: INode;
|
||||
target: INode;
|
||||
size: number;
|
||||
type: string;
|
||||
uuid: string;
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ const Doc = [
|
|||
},
|
||||
{
|
||||
title: 'SHOW TAGS',
|
||||
description: '显示当前图空间内的所有 Tag 名称。',
|
||||
description: '显示当前知识图谱内的所有 Tag 名称。',
|
||||
},
|
||||
{
|
||||
title: 'DESC[RIBE] TAG <tag_name>',
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ import { useI18n } from '@vesoft-inc/i18n';
|
|||
import styles from './index.module.less';
|
||||
|
||||
interface IProps {
|
||||
onColorChange?: (color) => void;
|
||||
onColorChange?: (color: string) => void;
|
||||
title?: string;
|
||||
colorList: any;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ISetProps {
|
||||
onColorChange: (color: string) => void;
|
||||
onColorChange: (color: { hex: string }) => void;
|
||||
}
|
||||
|
||||
const SetContent: React.FC<ISetProps> = (props: ISetProps) => {
|
||||
|
|
@ -24,8 +24,8 @@ const SetContent: React.FC<ISetProps> = (props: ISetProps) => {
|
|||
{
|
||||
key: 'color',
|
||||
label: intl.get('common.color'),
|
||||
children: <ColorPicker onChangeComplete={onColorChange} />
|
||||
}
|
||||
children: <ColorPicker onChangeComplete={onColorChange} />,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -37,7 +37,7 @@ const SetContent: React.FC<ISetProps> = (props: ISetProps) => {
|
|||
const NodeStyleSetBtn: React.FC<IProps> = (props: IProps) => {
|
||||
const { title, colorList, onColorChange, disabled } = props;
|
||||
const [visible, setVisible] = useState(false);
|
||||
const handleColorUpdate = (color) => {
|
||||
const handleColorUpdate = (color: { hex: string }) => {
|
||||
setVisible(false);
|
||||
if (onColorChange) {
|
||||
onColorChange(color.hex);
|
||||
|
|
@ -64,11 +64,7 @@ const NodeStyleSetBtn: React.FC<IProps> = (props: IProps) => {
|
|||
<div className={styles.btnNodeStyleSet}>
|
||||
<Popover
|
||||
overlayClassName={styles.nodeStylePopover}
|
||||
content={
|
||||
<SetContent
|
||||
onColorChange={handleColorUpdate}
|
||||
/>
|
||||
}
|
||||
content={<SetContent onColorChange={handleColorUpdate} />}
|
||||
trigger={'click'}
|
||||
open={visible}
|
||||
onOpenChange={setVisible}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Button, Table, Tabs, Tooltip, Popover } from 'antd';
|
||||
import { BigNumber } from 'bignumber.js';
|
||||
import BigNumber from 'bignumber.js';
|
||||
import { useCallback, useEffect, useState, useMemo, useRef } from 'react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
|
|
@ -8,7 +8,6 @@ import { v4 as uuidv4 } from 'uuid';
|
|||
import Icon from '@app/components/Icon';
|
||||
import { parseSubGraph } from '@app/utils/parseData';
|
||||
import cls from 'classnames';
|
||||
import domtoimage from 'dom-to-image';
|
||||
import { GraphStore } from '@app/stores/graph';
|
||||
import { useI18n } from '@vesoft-inc/i18n';
|
||||
import type { HistoryResult } from '@app/stores/console';
|
||||
|
|
@ -165,40 +164,6 @@ const OutputBox = (props: IProps) => {
|
|||
link.click();
|
||||
};
|
||||
|
||||
const downloadPng = async () => {
|
||||
if (graph && tab === 'graph') {
|
||||
let canvas = graph.twoGraph.canvas;
|
||||
const shadowCanvas = document.createElement('canvas');
|
||||
shadowCanvas.width = canvas.width;
|
||||
shadowCanvas.height = canvas.height;
|
||||
const ctx = shadowCanvas.getContext('2d');
|
||||
ctx.fillStyle = '#F3F6F9';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(canvas, 0, 0);
|
||||
canvas = shadowCanvas;
|
||||
setTimeout(() => {
|
||||
canvas.toBlob((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = canvas.toDataURL('image/png');
|
||||
a.download = 'Image.png';
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
});
|
||||
}, 0);
|
||||
} else {
|
||||
// use canvg to convert svg to canvas
|
||||
const svg = nowOutputRef.current?.querySelector('.ve-editor');
|
||||
const url = await domtoimage.toPng(svg, { bgcolor: '#ddd' });
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'explain-graph.png';
|
||||
a.click();
|
||||
}
|
||||
|
||||
trackEvent('console', 'export_graph_png');
|
||||
};
|
||||
|
||||
const handleExplore = () => {
|
||||
if (
|
||||
data.tables.filter((item) => item._verticesParsedList || item._edgesParsedList || item._pathsParsedList).length >
|
||||
|
|
@ -356,9 +321,9 @@ const OutputBox = (props: IProps) => {
|
|||
<Button type="link" className={styles.downloadItem} onClick={downloadCsv}>
|
||||
{intl.get('schema.csvDownload')}
|
||||
</Button>
|
||||
<Button disabled={tab === 'table'} type="link" className={styles.downloadItem} onClick={downloadPng}>
|
||||
{/* <Button disabled={tab === 'table'} type="link" className={styles.downloadItem} onClick={downloadPng}>
|
||||
{intl.get('schema.pngDownload')}
|
||||
</Button>
|
||||
</Button> */}
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import MonacoEditor from '@app/components/MonacoEditor';
|
|||
import { useI18n } from '@vesoft-inc/i18n';
|
||||
import { safeParse } from '@app/utils/function';
|
||||
import { SchemaItemOverview } from '@app/stores/console';
|
||||
import LLMBot from '../LLMBot';
|
||||
// import LLMBot from '../LLMBot';
|
||||
import OutputBox from './OutputBox';
|
||||
import HistoryBtn from './HistoryBtn';
|
||||
import FavoriteBtn from './FavoriteBtn';
|
||||
|
|
@ -246,7 +246,7 @@ const Console = (props: IProps) => {
|
|||
<div className={styles.consoleContainer} ref={resultContainerRef}>
|
||||
<div className={styles.consolePanel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span className={styles.title}>{`${window.gConfig.databaseName} ${intl.get('common.console')}`}</span>
|
||||
<span className={styles.title}>{`${window.gConfig.databaseName}${intl.get('common.console')}`}</span>
|
||||
<div className={styles.operations}>
|
||||
<div className={styles.spaceSelect}>
|
||||
<Select
|
||||
|
|
@ -328,7 +328,7 @@ const Console = (props: IProps) => {
|
|||
onExplorer={handleExplorer}
|
||||
/>
|
||||
)}
|
||||
<LLMBot />
|
||||
{/* <LLMBot /> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
.algorithm-query {
|
||||
|
||||
.algorithm-form {
|
||||
.select-algorithm .icon-instruction {
|
||||
position: absolute;
|
||||
right: -25px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.query-button {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import { Button, Divider, Form, Input, Select } from 'antd';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
// import Instruction from '@app/components/Instruction';
|
||||
import GQLCodeMirror from '@app/components/GQLCodeMirror';
|
||||
import { GRAPH_ALOGORITHM } from '@app/config/explore';
|
||||
import { getPathGQL } from '@app/utils/gql';
|
||||
|
||||
import './index.less';
|
||||
|
||||
const Option = Select.Option;
|
||||
|
||||
interface IProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const splitNodes = (str: string | undefined | null) => {
|
||||
return str
|
||||
?.trim()
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v !== '');
|
||||
};
|
||||
|
||||
const AlgorithmQuery: React.FC<IProps> = observer(({ onClose }) => {
|
||||
const { schema, explore } = useStore();
|
||||
const { edgeTypes, spaceVidType, getEdges } = schema;
|
||||
const { asyncGetPathResult, currentSpace } = explore;
|
||||
const [currentGQL, setCurrentGQL] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
getEdges(currentSpace);
|
||||
}, [currentSpace, getEdges]);
|
||||
|
||||
// const viewDoc = () => {
|
||||
// window.open(intl.get('explore.docForFindPath'), '_blank');
|
||||
// };
|
||||
|
||||
const handleInquiry = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const { srcId, dstId } = values;
|
||||
const srcIdList = splitNodes(srcId);
|
||||
const dstIdList = splitNodes(dstId);
|
||||
setLoading(true);
|
||||
await asyncGetPathResult({
|
||||
spaceVidType,
|
||||
...values,
|
||||
srcId: srcIdList,
|
||||
dstId: dstIdList,
|
||||
});
|
||||
setLoading(false);
|
||||
onClose();
|
||||
} catch {
|
||||
// 校验失败
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValueChange = useCallback(() => {
|
||||
const values = form.getFieldsValue();
|
||||
const { type, srcId, dstId, relation, direction, stepLimit, quantityLimit } = values;
|
||||
const srcIdList = splitNodes(srcId);
|
||||
const dstIdList = splitNodes(dstId);
|
||||
|
||||
const currentGQL =
|
||||
type && srcId && dstId
|
||||
? getPathGQL({
|
||||
spaceVidType,
|
||||
type,
|
||||
srcId: srcIdList,
|
||||
dstId: dstIdList,
|
||||
relation,
|
||||
direction,
|
||||
stepLimit,
|
||||
quantityLimit,
|
||||
})
|
||||
: '';
|
||||
setCurrentGQL(currentGQL);
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<div className="algorithm-query">
|
||||
<Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
labelCol={{ span: 6 }}
|
||||
wrapperCol={{ span: 11 }}
|
||||
className="algorithm-form"
|
||||
onValuesChange={handleValueChange}
|
||||
initialValues={{
|
||||
stepLimit: 1,
|
||||
quantityLimit: 10,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
label={intl.get('common.algorithm')}
|
||||
name="type"
|
||||
className="select-algorithm"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select placeholder={`请选择${intl.get('common.algorithm')}`}>
|
||||
{GRAPH_ALOGORITHM(intl).map((item) => (
|
||||
<Option value={item.value} key={item.value}>
|
||||
{item.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{/* <Instruction description={intl.get('common.viewDocs')} onClick={viewDoc} /> */}
|
||||
<Divider orientation="center">{intl.get('explore.algorithmParams')}</Divider>
|
||||
<Form.Item label={intl.get('explore.srcId')} name="srcId" rules={[{ required: true }]}>
|
||||
{/* <Select mode="tags" /> */}
|
||||
<Input placeholder={`请输入${intl.get('explore.srcId')},以逗号分割`} allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item label={intl.get('explore.dstId')} name="dstId" rules={[{ required: true }]}>
|
||||
{/* <Select mode="tags" /> */}
|
||||
<Input placeholder={`请输入${intl.get('explore.dstId')},以逗号分割`} allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item label={intl.get('explore.relation')} name="relation">
|
||||
<Select mode="multiple" placeholder={`请选择${intl.get('explore.relation')}`} allowClear>
|
||||
{edgeTypes.map((e) => (
|
||||
<Option value={e} key={e}>
|
||||
{e}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={intl.get('explore.direction')} name="direction">
|
||||
<Select allowClear={true} placeholder={`请选择${intl.get('explore.direction')}`}>
|
||||
<Option value="REVERSELY" key="REVERSELY">
|
||||
反向
|
||||
</Option>
|
||||
<Option value="BIDIRECT" key="BIDIRECT">
|
||||
双向
|
||||
</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{/* <Form.Item
|
||||
label={intl.get('explore.stepLimit')}
|
||||
name="stepLimit"
|
||||
rules={[
|
||||
{
|
||||
message: intl.get('formRules.positiveIntegerRequired'),
|
||||
pattern: /^\d+$/,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input type="number" placeholder={`请输入${intl.get('explore.stepLimit')}`} />
|
||||
</Form.Item> */}
|
||||
<Form.Item
|
||||
label={intl.get('explore.quantityLimit')}
|
||||
name="quantityLimit"
|
||||
rules={[
|
||||
{
|
||||
message: intl.get('formRules.positiveIntegerRequired'),
|
||||
pattern: /^\d+$/,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input type="number" placeholder={`请输入${intl.get('explore.quantityLimit')}`} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<GQLCodeMirror currentGQL={currentGQL} />
|
||||
<div className="query-button">
|
||||
<Button
|
||||
data-track-category="explore"
|
||||
data-track-action="query_by_path"
|
||||
onClick={handleInquiry}
|
||||
type="primary"
|
||||
loading={loading}
|
||||
>
|
||||
{intl.get('explore.quiry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default AlgorithmQuery;
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
.query-custom {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: PingFangSC-Regular, serif;
|
||||
font-size: 16px;
|
||||
color: #646464;
|
||||
letter-spacing: 1.48px;
|
||||
|
||||
.logo {
|
||||
width: 92%;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin-top: 25px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { Button } from 'antd';
|
||||
import React from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { Link } from 'react-router-dom';
|
||||
import guideZhPng from '@app/static/images/go-to-explore_zh.png';
|
||||
import './index.less';
|
||||
|
||||
const CustomQuery: React.FC = () => {
|
||||
return (
|
||||
<div className="query-custom">
|
||||
<img className="logo" src={guideZhPng} />
|
||||
<p>{intl.get('explore.customQueryDescription')}</p>
|
||||
<div className="btn">
|
||||
<Button>
|
||||
<Link
|
||||
to="/console"
|
||||
data-track-category="navigation"
|
||||
data-track-action="view_console"
|
||||
data-track-label="from_explore_btn"
|
||||
>
|
||||
{intl.get('explore.openInConsole')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomQuery;
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
.import-node {
|
||||
.header {
|
||||
position: relative;
|
||||
margin-bottom: 15px;
|
||||
text-align: center;
|
||||
|
||||
> span {
|
||||
display: inline-block;
|
||||
font-family: PingFangSC-Semibold, serif;
|
||||
font-size: 16px;
|
||||
color: #646464;
|
||||
letter-spacing: 1.48px;
|
||||
}
|
||||
|
||||
.btn-upload {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
button:not(:last-child) {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-form-item label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> .anticon {
|
||||
vertical-align: initial;
|
||||
margin-left: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-wrap {
|
||||
text-align: center;
|
||||
|
||||
.ant-btn {
|
||||
width: 100px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { Button, Form, Input, Spin, Upload } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
|
||||
import { nodeIdRulesFn } from '@app/config/rules';
|
||||
import { readFileContent } from '@app/utils/file';
|
||||
|
||||
import './index.less';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface IProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const IdQuery: React.FC<IProps> = observer(({ onClose }) => {
|
||||
const { explore } = useStore();
|
||||
const { asyncImportNodes, asyncGetSampleVertic } = explore;
|
||||
|
||||
const [form] = Form.useForm();
|
||||
const [loadingState, setLoadingState] = useState(false);
|
||||
const [isSampleLoading, setIsSampleLoading] = useState(false);
|
||||
|
||||
const handleImport = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const ids = values.ids.trim().split('\n');
|
||||
asyncImportNodes({ ids });
|
||||
onClose();
|
||||
} catch {
|
||||
// 校验失败
|
||||
}
|
||||
};
|
||||
|
||||
// 文件导入
|
||||
const handleFileImport = async ({ file }) => {
|
||||
setLoadingState(true);
|
||||
try {
|
||||
const fileContent = await readFileContent(file);
|
||||
if (fileContent) {
|
||||
const ids = fileContent?.replaceAll('\r\n', '\n');
|
||||
setLoadingState(false);
|
||||
form.setFieldsValue({ ids });
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
// 样本导入
|
||||
const handleImportSample = async () => {
|
||||
setIsSampleLoading(true);
|
||||
const { code } = await asyncGetSampleVertic();
|
||||
setIsSampleLoading(false);
|
||||
if (code === 0) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Spin spinning={loadingState}>
|
||||
<div className="import-node">
|
||||
<div className="header">
|
||||
<span>{intl.get('explore.idToBeQueried')}</span>
|
||||
<div className="btn-upload">
|
||||
<Button onClick={handleImportSample} loading={isSampleLoading}>
|
||||
{intl.get('explore.sampleImport')}
|
||||
</Button>
|
||||
<Upload beforeUpload={() => false} onChange={handleFileImport} showUploadList={false}>
|
||||
<Button>{intl.get('explore.fileImport')}</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
</div>
|
||||
<Form form={form} layout="horizontal">
|
||||
<Form.Item name="ids" rules={nodeIdRulesFn()}>
|
||||
<TextArea placeholder={intl.get('explore.importPlaceholder')} rows={12} />
|
||||
</Form.Item>
|
||||
<Form.Item className="btn-wrap">
|
||||
<Button type="primary" data-track-category="explore" data-track-action="query_by_id" onClick={handleImport}>
|
||||
{intl.get('explore.addConfirm')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</Spin>
|
||||
);
|
||||
});
|
||||
|
||||
export default IdQuery;
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { Tabs } from 'antd';
|
||||
import React from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
|
||||
import AlgorithmQuery from './AlgorithmQuery';
|
||||
import CustomQuery from './CustomQuery';
|
||||
import IdQuery from './IdQuery';
|
||||
import IndexQuery from './IndexQuery';
|
||||
|
||||
interface IProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ImportNodes: React.FC<IProps> = ({ onClose }) => {
|
||||
return (
|
||||
<Tabs defaultActiveKey={'id'}>
|
||||
<Tabs.TabPane tab={intl.get('explore.queryById')} key="id">
|
||||
<IdQuery onClose={onClose} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={intl.get('explore.queryByIndex')} key="index">
|
||||
<IndexQuery onClose={onClose} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={intl.get('explore.graphAlgorithm')} key="algorithm">
|
||||
<AlgorithmQuery onClose={onClose} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={intl.get('explore.queryByCustom')} key="custom">
|
||||
<CustomQuery />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportNodes;
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
.demo-code {
|
||||
margin: 25px 0;
|
||||
background-color: #f6f6f6;
|
||||
padding-left: 18px;
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0 0 0 2px;
|
||||
margin: 10px 0;
|
||||
|
||||
> pre {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
font-family: PingFangSC-Semibold, serif;
|
||||
font-size: 18px;
|
||||
color: #262626;
|
||||
letter-spacing: 1.66px;
|
||||
line-height: 30px;
|
||||
padding-bottom: 28px;
|
||||
|
||||
i {
|
||||
width: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.guide,
|
||||
.link {
|
||||
font-family: PingFangSC-Regular, serif;
|
||||
font-size: 16px;
|
||||
color: #646464;
|
||||
letter-spacing: 1.48px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline-block;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.btn-tooltip {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.query-button {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { Button, Card } from 'antd';
|
||||
import React from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
import { InfoCircleTwoTone } from '@ant-design/icons';
|
||||
|
||||
import './IndexIntroduction.less';
|
||||
|
||||
const demo = `CREATE TAG INDEX index_player ON player(name, age);`;
|
||||
|
||||
const IndexIntroduction: React.FC = observer(() => {
|
||||
const { explore, console } = useStore();
|
||||
const { currentSpace } = explore;
|
||||
const { update } = console;
|
||||
const history = useHistory();
|
||||
|
||||
const runInConsole = () => {
|
||||
update({ currentGQL: demo, currentSpace });
|
||||
history.push('/console');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="warning">
|
||||
{/* <Icon type="info-circle" theme="twoTone" twoToneColor="#FAAD14" /> */}
|
||||
<InfoCircleTwoTone />
|
||||
<span style={{ marginLeft: 8 }}>{intl.get('explore.emptyIndex')}</span>
|
||||
</div>
|
||||
<p className="guide">
|
||||
{intl.get('explore.indexQueryPrompt_prefix')}
|
||||
<b> {`${currentSpace}`} </b>
|
||||
{intl.get('explore.indexQueryPrompt_suffix')}
|
||||
</p>
|
||||
<p className="guide">{intl.get('explore.indexQueryPrompt2')}</p>
|
||||
<Card className="demo-code">
|
||||
<pre>{demo}</pre>
|
||||
</Card>
|
||||
{/* <span className="link">
|
||||
{intl.get('explore.indexLink')}
|
||||
<a href={intl.get('explore.documentIntroductionUrl')} target="_blank">
|
||||
{intl.get('explore.documentIntroduction')}
|
||||
</a>
|
||||
</span> */}
|
||||
<div className="query-button">
|
||||
<Button type="primary" data-track-category="explore" data-track-action="query_by_index" onClick={runInConsole}>
|
||||
{intl.get('explore.runCodeInConsole')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default IndexIntroduction;
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
.query-index {
|
||||
padding: 16px;
|
||||
|
||||
.ant-form {
|
||||
> h3 {
|
||||
padding: 12px;
|
||||
border-top: 1px dashed #000;
|
||||
}
|
||||
|
||||
.ant-form-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
.ant-form-item-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-form-item-label {
|
||||
width: 120px;
|
||||
text-align: right;
|
||||
}
|
||||
.ant-form-item-control-wrapper {
|
||||
flex: 1;
|
||||
|
||||
.icon-instruction {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
td,
|
||||
th {
|
||||
text-align: center;
|
||||
|
||||
input {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-footer {
|
||||
.anticon-plus {
|
||||
font-size: 18px;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.ant-collapse {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ant-collapse > .ant-collapse-item > .ant-collapse-header {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.ant-collapse.filters .ant-table {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.explore-gql {
|
||||
text-align: left;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.explore-gql .ant-collapse-content-box div {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.query-button {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ant-table-footer {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,453 @@
|
|||
import { Button, Divider, Form, Input, List, message, Modal, Radio, Select, Table } from 'antd';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
import Instruction from '@app/components/Instruction';
|
||||
import GQLCodeMirror from '@app/components/GQLCodeMirror';
|
||||
import { ENUM_OF_COMPARE } from '@app/utils/constant';
|
||||
import { getExploreGQLWithIndex } from '@app/utils/gql';
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import './IndexMatch.less';
|
||||
|
||||
const Option = Select.Option;
|
||||
const { confirm } = Modal;
|
||||
|
||||
interface IProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface IField {
|
||||
Field: string;
|
||||
Type: string;
|
||||
}
|
||||
interface IIndex {
|
||||
indexName: string;
|
||||
props: IField[];
|
||||
}
|
||||
interface ITag {
|
||||
name: string;
|
||||
indexes: IIndex[];
|
||||
}
|
||||
|
||||
interface IFilterProps {
|
||||
Field: string;
|
||||
Type: string;
|
||||
}
|
||||
interface IFilters {
|
||||
relation?: string;
|
||||
field: string;
|
||||
operator: string;
|
||||
value: string | boolean;
|
||||
type: string | undefined;
|
||||
}
|
||||
|
||||
const IndexMatch: React.FC<IProps> = observer(({ onClose }) => {
|
||||
const { schema, explore } = useStore();
|
||||
const { tagIndexTree: tags } = schema;
|
||||
const { asyncImportNodesWithIndex } = explore;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [selectedTag, setSelectedTag] = useState<ITag | null>(null);
|
||||
const [filterProps, setFilterProps] = useState<IFilterProps[]>([]);
|
||||
const [filters, setFilters] = useState<IFilters[]>([]);
|
||||
const [currentGQL, setCurrentGQL] = useState('');
|
||||
|
||||
const handleValueChange = useCallback(() => {
|
||||
const values = form.getFieldsValue();
|
||||
const { tag, index, quantityLimit } = values;
|
||||
const gql =
|
||||
tag && index
|
||||
? getExploreGQLWithIndex({
|
||||
tag,
|
||||
quantityLimit,
|
||||
filters,
|
||||
})
|
||||
: '';
|
||||
|
||||
setCurrentGQL(gql);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
handleValueChange();
|
||||
}, [handleValueChange]);
|
||||
|
||||
// 选择 Tag
|
||||
const handleSelectTag = (value: string) => {
|
||||
const tag = tags.find((item: ITag) => item.name === value) || null;
|
||||
setSelectedTag(tag);
|
||||
setFilterProps([]);
|
||||
setFilters([]);
|
||||
form.setFieldsValue({ index: null });
|
||||
};
|
||||
|
||||
// 选择索引
|
||||
const handleSelectIndex = (value: string) => {
|
||||
if (!selectedTag) return;
|
||||
const index = selectedTag.indexes.find((item) => item.indexName === value);
|
||||
if (index && index.props.length > 0) {
|
||||
setFilterProps(index.props);
|
||||
setFilters([
|
||||
{
|
||||
field: index.props[0].Field,
|
||||
operator: '==',
|
||||
value: '',
|
||||
type: index.props[0].Type,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
setFilterProps([]);
|
||||
setFilters([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 过滤器值发生变化
|
||||
const handleFilterInputChange = (e: any, type?: string) => {
|
||||
const { index, key } = e.target.dataset;
|
||||
const reg = /^[0-9]*$/;
|
||||
let value = e.target.value;
|
||||
if (type === 'timestamp' && !reg.test(value)) {
|
||||
message.warning(intl.get('explore.timestampInput'));
|
||||
value = value.slice(0, value.length - 1);
|
||||
}
|
||||
const newFilters = [...filters];
|
||||
newFilters[index][key] = value;
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
// 过滤器布尔值发生变化
|
||||
const handleFilterBoolChange = (e: any, index: number, key: string) => {
|
||||
const newFilters = [...filters];
|
||||
newFilters[index][key] = e.target.value;
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
// 过滤器关系或者操作符发生变化
|
||||
const handleSelect = (value: any, key: string, index: number) => {
|
||||
const newFilters = [...filters];
|
||||
newFilters[index][key] = value;
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
// 过滤器字段发生变化
|
||||
const handleFieldSelect = (value: string, index: number) => {
|
||||
const _value = {
|
||||
relation: 'AND',
|
||||
field: value,
|
||||
operator: '==',
|
||||
value: '',
|
||||
type: filterProps.find((i) => i.Field === value)?.Type,
|
||||
};
|
||||
const nextField = index === filters.length - 1 ? null : filters[index + 1];
|
||||
if (nextField) {
|
||||
const selectedIndex = filterProps.findIndex((i) => i.Field === value);
|
||||
const nextIndex = filterProps.findIndex((i) => i.Field === nextField.field);
|
||||
if (nextIndex - selectedIndex > 1) {
|
||||
confirm({
|
||||
title: intl.get('explore.operationConfirm'),
|
||||
onOk() {
|
||||
const newFilters = [...filters];
|
||||
newFilters.splice(index, filters.length - index, _value);
|
||||
setFilters(newFilters);
|
||||
},
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
const newFilters = [...filters];
|
||||
newFilters.splice(index, 1, _value);
|
||||
setFilters(newFilters);
|
||||
}
|
||||
} else {
|
||||
const newFilters = [...filters];
|
||||
newFilters.splice(index, 1, _value);
|
||||
setFilters(newFilters);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除过滤器
|
||||
const handleFilterDelete = (index: number) => {
|
||||
const nextField = index === filters.length - 1 ? null : filters[index + 1];
|
||||
const preField = filters[index - 1];
|
||||
if (nextField) {
|
||||
const preIndex = filterProps.findIndex((i) => i.Field === preField.field);
|
||||
const nextIndex = filterProps.findIndex((i) => i.Field === nextField.field);
|
||||
if (nextIndex - preIndex > 1) {
|
||||
confirm({
|
||||
title: intl.get('explore.operationConfirm'),
|
||||
onOk() {
|
||||
const newFilters = [...filters];
|
||||
newFilters.splice(index, filters.length - index);
|
||||
setFilters(newFilters);
|
||||
},
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
const newFilters = [...filters];
|
||||
newFilters.splice(index, 1);
|
||||
setFilters(newFilters);
|
||||
}
|
||||
} else {
|
||||
const newFilters = [...filters];
|
||||
newFilters.splice(index, 1);
|
||||
setFilters(newFilters);
|
||||
}
|
||||
};
|
||||
|
||||
// 增加过滤器
|
||||
const handleFilterAdd = () => {
|
||||
if (filters && filters.length > 0 && filterProps.length > 0) {
|
||||
const lastField = filters[filters.length - 1].field;
|
||||
const idx = filterProps.findIndex((i) => i.Field === lastField);
|
||||
const newField = idx === filterProps.length - 1 ? filterProps[idx] : filterProps[idx + 1];
|
||||
setFilters([
|
||||
...filters,
|
||||
{
|
||||
relation: 'AND',
|
||||
field: newField.Field,
|
||||
operator: '==',
|
||||
value: '',
|
||||
type: newField.Type,
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInquiry = async () => {
|
||||
const missingField = filters.some((item) => {
|
||||
return Object.values(item).some((value) => value === '');
|
||||
});
|
||||
|
||||
if (missingField) {
|
||||
message.error('请填写每一个索引值');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const { tag, quantityLimit } = values;
|
||||
setLoading(true);
|
||||
await asyncImportNodesWithIndex({
|
||||
filters,
|
||||
tag,
|
||||
quantityLimit,
|
||||
});
|
||||
setLoading(false);
|
||||
onClose();
|
||||
} catch {
|
||||
// 校验失败
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: intl.get('explore.relationship'),
|
||||
key: 'relation',
|
||||
width: 120,
|
||||
render: (record, _, idx) =>
|
||||
idx > 0 && (
|
||||
<Select value={record.relation} onChange={(value) => handleSelect(value, 'relation', idx)}>
|
||||
<Option value="AND" key="AND">
|
||||
AND
|
||||
</Option>
|
||||
<Option value="OR" key="OR">
|
||||
OR
|
||||
</Option>
|
||||
</Select>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: intl.get('common.field'),
|
||||
key: 'field',
|
||||
render: (record, _, idx) => {
|
||||
let pre;
|
||||
let _list;
|
||||
if (idx > 0) {
|
||||
pre = filterProps.findIndex((i) => i.Field === filters[idx - 1].field);
|
||||
_list = filterProps.slice(pre, pre + 2);
|
||||
}
|
||||
return (
|
||||
<Select value={record.field} onChange={(value) => handleFieldSelect(value, idx)} disabled={idx === 0}>
|
||||
{_list?.map((i) => (
|
||||
<Option value={i.Field} key={i.Field}>
|
||||
{i.Field}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: intl.get('explore.operator'),
|
||||
key: 'operator',
|
||||
width: 120,
|
||||
render: (record, _, idx) => {
|
||||
const type = record.type.startsWith('fixed_string') ? 'string' : record.type;
|
||||
return (
|
||||
<Select
|
||||
popupMatchSelectWidth={false}
|
||||
value={record.operator}
|
||||
onChange={(value) => handleSelect(value, 'operator', idx)}
|
||||
>
|
||||
{ENUM_OF_COMPARE[type]?.map((i) => (
|
||||
<Option value={i.value} key={i.value}>
|
||||
{i.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: intl.get('explore.value'),
|
||||
key: 'value',
|
||||
render: (record, _, idx) => (
|
||||
<>
|
||||
{record.type !== 'bool' && record.type !== 'timestamp' && (
|
||||
<Input
|
||||
onChange={handleFilterInputChange}
|
||||
id={`${idx}-field`}
|
||||
data-index={idx}
|
||||
data-key="value"
|
||||
value={record.value}
|
||||
placeholder="请输入"
|
||||
/>
|
||||
)}
|
||||
{record.type === 'bool' && (
|
||||
<Radio.Group
|
||||
value={record.value}
|
||||
onChange={(e) => handleFilterBoolChange(e, idx, 'value')}
|
||||
id={`${idx}-field`}
|
||||
data-index={idx}
|
||||
data-key="value"
|
||||
>
|
||||
<Radio value={true}>true</Radio>
|
||||
<Radio value={false}>false</Radio>
|
||||
</Radio.Group>
|
||||
)}
|
||||
{record.type === 'timestamp' && (
|
||||
<Input
|
||||
onChange={(e) => handleFilterInputChange(e, 'timestamp')}
|
||||
id={`${idx}-field`}
|
||||
data-index={idx}
|
||||
data-key="value"
|
||||
value={record.value}
|
||||
placeholder="请输入"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'delete',
|
||||
render: (_record1, _record2, idx) =>
|
||||
idx > 0 && (
|
||||
<DeleteOutlined
|
||||
onClick={() => {
|
||||
handleFilterDelete(idx);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="query-index">
|
||||
<Form
|
||||
form={form}
|
||||
onValuesChange={handleValueChange}
|
||||
initialValues={{
|
||||
quantityLimit: 10,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
label="TAG"
|
||||
name="tag"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select onChange={handleSelectTag} placeholder="请选择 TAG">
|
||||
{tags.map((e: ITag) => (
|
||||
<Option value={e.name} key={e.name}>
|
||||
{e.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={intl.get('explore.selectIndex')}
|
||||
name="index"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select onChange={handleSelectIndex} placeholder={`请选择${intl.get('explore.selectIndex')}`}>
|
||||
{selectedTag &&
|
||||
selectedTag.indexes
|
||||
.filter((v) => v.props.length > 0)
|
||||
.map((e) => (
|
||||
<Option value={e.indexName} key={e.indexName}>
|
||||
{e.indexName} ({e.props.map((i) => i.Field).join(', ')})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
{selectedTag && selectedTag.indexes.filter((index) => index.props.length === 0).length > 0 && (
|
||||
<Instruction description={intl.get('explore.emptyIndexTips')} />
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={intl.get('explore.quantityLimit')}
|
||||
name="quantityLimit"
|
||||
rules={[
|
||||
{
|
||||
message: intl.get('formRules.positiveIntegerRequired'),
|
||||
pattern: /^\d+$/,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input type="number" placeholder={`请选择${intl.get('explore.quantityLimit')}`} />
|
||||
</Form.Item>
|
||||
<List
|
||||
className="comment-list"
|
||||
header={
|
||||
<Divider orientation="center">
|
||||
{intl.get('explore.paramFilter')}
|
||||
<Instruction description={intl.get('explore.indexConditionDescription')} />
|
||||
</Divider>
|
||||
}
|
||||
itemLayout="horizontal"
|
||||
>
|
||||
<li>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filters}
|
||||
rowKey={(_, idx) => idx.toString()}
|
||||
pagination={false}
|
||||
footer={() => <PlusOutlined onClick={handleFilterAdd} />}
|
||||
/>
|
||||
</li>
|
||||
</List>
|
||||
</Form>
|
||||
<GQLCodeMirror currentGQL={currentGQL} />
|
||||
<div className="query-button">
|
||||
<Button
|
||||
type="primary"
|
||||
data-track-category="explore"
|
||||
data-track-action="query_by_index"
|
||||
onClick={handleInquiry}
|
||||
loading={loading}
|
||||
>
|
||||
{intl.get('explore.quiry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default IndexMatch;
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
import { ISchemaEnum } from '@app/interfaces/schema';
|
||||
import IndexIntroduction from './IndexIntroduction';
|
||||
import IndexMatch from './IndexMatch';
|
||||
|
||||
interface IProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const IndexQuery: React.FC<IProps> = observer(({ onClose }) => {
|
||||
const { schema } = useStore();
|
||||
const { indexes, getIndexTree } = schema;
|
||||
|
||||
useEffect(() => {
|
||||
getIndexTree(ISchemaEnum.Tag);
|
||||
}, [getIndexTree]);
|
||||
|
||||
return <>{indexes.length === 0 ? <IndexIntroduction /> : <IndexMatch onClose={onClose} />}</>;
|
||||
});
|
||||
|
||||
export default IndexQuery;
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
@import '@app/common.less';
|
||||
|
||||
.nebula-explore .control {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: @controlHeight;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
border-bottom: 1px solid #eee;
|
||||
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.1);
|
||||
|
||||
> .ant-form-item {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
|
||||
&.left {
|
||||
display: flex;
|
||||
|
||||
> .ant-form-item-control-wrapper {
|
||||
.ant-select {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.right {
|
||||
text-align: right;
|
||||
|
||||
.ant-btn {
|
||||
margin: 0 16px;
|
||||
min-width: 89px;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
display: inline-block;
|
||||
width: 110px;
|
||||
|
||||
.ant-btn {
|
||||
margin: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.ant-dropdown-trigger {
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { Button, Form, Modal, Select } from 'antd';
|
||||
import { useEffect, useState, useImperativeHandle, forwardRef } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
import ExportBtn from '@app/pages/Explore/NebulaGraph/Panel/Export';
|
||||
import './index.less';
|
||||
import ImportNodes from './ImportNode';
|
||||
|
||||
const Option = Select.Option;
|
||||
|
||||
const Control = forwardRef((_props, ref) => {
|
||||
const { schema, explore } = useStore();
|
||||
const { spaces, getSpaces } = schema;
|
||||
const { currentSpace, switchSpace, vertexes, clear } = explore;
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (spaces.length === 0) {
|
||||
getSpaces();
|
||||
}
|
||||
}, [spaces, getSpaces]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
handleSearch: () => {
|
||||
setVisible(true);
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSelect = (space: string) => {
|
||||
Modal.confirm({
|
||||
content: intl.get('explore.selectReminder'),
|
||||
okText: intl.get('common.ok'),
|
||||
cancelText: intl.get('common.cancel'),
|
||||
onOk: async () => {
|
||||
clear();
|
||||
await switchSpace(space);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
Modal.confirm({
|
||||
content: intl.get('explore.clearTip'),
|
||||
okText: intl.get('common.ok'),
|
||||
cancelText: intl.get('common.cancel'),
|
||||
onOk: () => {
|
||||
clear();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="control">
|
||||
<Form.Item className="left" label={intl.get('common.currentSpace')}>
|
||||
<Select onChange={handleSelect} value={currentSpace} style={{ width: 200 }}>
|
||||
{spaces.map((space: string) => (
|
||||
<Option value={space} key={space}>
|
||||
{space}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item className="right">
|
||||
<Button
|
||||
type="default"
|
||||
disabled={vertexes.length === 0}
|
||||
onClick={handleClear}
|
||||
data-track-category="explore"
|
||||
data-track-action="clear_canvas"
|
||||
data-track-label="from_control"
|
||||
>
|
||||
{intl.get('explore.clear')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
data-track-category="explore"
|
||||
data-track-action="start_explore"
|
||||
data-track-label="from_control"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
{intl.get('explore.startWithVertices')}
|
||||
</Button>
|
||||
<ExportBtn disabled={vertexes.length === 0} />
|
||||
<Modal open={visible} footer={null} width="650px" onCancel={() => setVisible(false)} maskClosable={false}>
|
||||
<ImportNodes onClose={() => setVisible(false)} />
|
||||
</Modal>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default observer(Control);
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { Select, Modal, Form } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
// import { connect } from 'react-redux';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
|
||||
// import { Modal } from '@app/components';
|
||||
import { DEFAULT_EXPLORE_RULES } from '@app/config/explore';
|
||||
// import { IDispatch, IRootState } from '@app/store';
|
||||
|
||||
const Option = Select.Option;
|
||||
|
||||
// const mapState = (state: IRootState) => ({
|
||||
// currentSpace: state.nebula.currentSpace,
|
||||
// spaces: state.nebula.spaces,
|
||||
// });
|
||||
|
||||
// const mapDispatch = (dispatch: IDispatch) => ({
|
||||
// asyncSwitchSpace: async (space) => {
|
||||
// await dispatch.nebula.asyncSwitchSpace(space);
|
||||
// await dispatch.nebula.asyncGetTags();
|
||||
// await dispatch.nebula.asyncGetEdges();
|
||||
// dispatch.explore.update({
|
||||
// exploreRules: DEFAULT_EXPLORE_RULES,
|
||||
// });
|
||||
// },
|
||||
// asyncGetTags: dispatch.nebula.asyncGetTags,
|
||||
// asyncGetEdges: dispatch.nebula.asyncGetEdges,
|
||||
// });
|
||||
|
||||
// interface IProps extends ReturnType<typeof mapState>, ReturnType<typeof mapDispatch>, FormComponentProps {}
|
||||
|
||||
function Init() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { schema, explore } = useStore();
|
||||
const { update, currentSpace, switchSpace } = explore;
|
||||
const { spaces, getSpaces } = schema;
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
if (!currentSpace) {
|
||||
setVisible(true);
|
||||
if (spaces.length === 0) {
|
||||
getSpaces();
|
||||
}
|
||||
} else {
|
||||
await switchSpace(currentSpace);
|
||||
// 切换时,会自动调用下面的两个方法
|
||||
// await getTags(currentSpace);
|
||||
// await getEdges(currentSpace);
|
||||
}
|
||||
};
|
||||
init();
|
||||
// 不需要监听 currentSpace,spaces,这个组件只是初始化用到
|
||||
}, []);
|
||||
|
||||
const asyncSwitchSpace = async (space: string) => {
|
||||
await switchSpace(space);
|
||||
// 切换时,会自动调用下面的两个方法
|
||||
// await getTags(space);
|
||||
// await getEdges(space);
|
||||
update({
|
||||
exploreRules: DEFAULT_EXPLORE_RULES,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectChange = (space: string) => {
|
||||
asyncSwitchSpace(space);
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={visible} footer={null} closable={false} zIndex={8} maskClosable={false}>
|
||||
<h3>{intl.get('explore.selectSpace')}</h3>
|
||||
<Form>
|
||||
<Form.Item name="space">
|
||||
<Select onChange={handleSelectChange}>
|
||||
{spaces.map((space) => (
|
||||
<Option value={space} key={space}>
|
||||
{space}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Init);
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
.modal-insert {
|
||||
.ant-modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
padding: 50px;
|
||||
|
||||
.btn-operation {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
margin-top: 50px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import { Button, Modal } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
// import { connect } from 'react-redux';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
// import { Modal } from '@app/components';
|
||||
import { DEFAULT_EXPLORE_RULES } from '@app/config/explore';
|
||||
// import { IDispatch, IRootState } from '@app/store';
|
||||
import { useStore } from '@app/stores';
|
||||
|
||||
import './InitVertexes.less';
|
||||
// const mapState = (state: IRootState) => ({
|
||||
// currentSpace: state.nebula.currentSpace,
|
||||
// preloadData: state.explore.preloadData,
|
||||
// vertexes: state.explore.vertexes,
|
||||
// });
|
||||
|
||||
// const mapDispatch = (dispatch: IDispatch) => ({
|
||||
// clearExplore: () =>
|
||||
// dispatch.explore.update({
|
||||
// vertexes: [],
|
||||
// edges: [],
|
||||
// selectVertexes: [],
|
||||
// selectEdges: [],
|
||||
// actionHistory: [],
|
||||
// step: 0,
|
||||
// exploreRules: DEFAULT_EXPLORE_RULES,
|
||||
// }),
|
||||
// clearPreload: () =>
|
||||
// dispatch.explore.update({
|
||||
// preloadData: {
|
||||
// vertexes: [],
|
||||
// edges: [],
|
||||
// },
|
||||
// }),
|
||||
// asyncGetExploreInfo: dispatch.explore.asyncGetExploreInfo,
|
||||
// });
|
||||
|
||||
// interface IProps extends ReturnType<typeof mapState>, ReturnType<typeof mapDispatch> {}
|
||||
|
||||
// 这个功能需要和console一起使用,该版本console没有这个功能了
|
||||
|
||||
function InitVertexes() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { explore } = useStore();
|
||||
const { currentSpace, preloadData, vertexes, asyncGetExploreInfo, update } = explore;
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSpace && preloadData.vertexes.length > 0) {
|
||||
vertexes.length > 0 ? setVisible(true) : handleInsert();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearExplore = () => {
|
||||
update({
|
||||
vertexes: [],
|
||||
edges: [],
|
||||
selectVertexes: [],
|
||||
selectEdges: [],
|
||||
actionHistory: [],
|
||||
step: 0,
|
||||
exploreRules: DEFAULT_EXPLORE_RULES,
|
||||
});
|
||||
};
|
||||
|
||||
const clearPreload = () => {
|
||||
update({
|
||||
preloadData: {
|
||||
vertexes: [],
|
||||
edges: [],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleInsert = async (type?: string) => {
|
||||
if (type === 'clear') {
|
||||
clearExplore();
|
||||
}
|
||||
setVisible(false);
|
||||
await asyncGetExploreInfo({ data: preloadData });
|
||||
clearPreload();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="modal-insert"
|
||||
open={visible}
|
||||
footer={null}
|
||||
closable={false}
|
||||
zIndex={8}
|
||||
centered={true}
|
||||
maskClosable={false}
|
||||
onCancel={() => setVisible(false)}
|
||||
>
|
||||
<p>{intl.get('explore.insertMethodSelect')}</p>
|
||||
<div className="btn-operation">
|
||||
<Button type="primary" onClick={() => handleInsert('clear')}>
|
||||
{intl.get('explore.insertAfterClear')}
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => handleInsert()}>
|
||||
{intl.get('explore.incrementalInsertion')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(InitVertexes);
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
@import '@app/common.less';
|
||||
|
||||
.display-expand {
|
||||
height: 100%;
|
||||
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 72px;
|
||||
width: 100%;
|
||||
border-top: 1px solid #d4d4d4;
|
||||
padding: 20px 14px;
|
||||
|
||||
> span {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 12px;
|
||||
transform: translateY(-50%);
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: @blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
|
||||
> .ant-tabs {
|
||||
.ant-tabs-tab {
|
||||
margin-right: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
.ant-tabs-tab-btn {
|
||||
width: 65px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-view,
|
||||
.btn-disabled {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 54%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
i svg {
|
||||
fill: rgba(0, 145, 255, 1);
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-disabled i svg {
|
||||
fill: gainsboro;
|
||||
}
|
||||
|
||||
.btn-view:hover {
|
||||
background-color: #f0f5ff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
height: calc(100% - 116px);
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
|
||||
.row {
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
|
||||
i svg {
|
||||
height: 92px;
|
||||
width: 92px;
|
||||
fill: #9acfff;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
letter-spacing: 0;
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
import { Button, Tabs, Tooltip } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import { useState, useCallback } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
// import { connect } from 'react-redux';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import IconFont from '@app/components/UnicodeIcon';
|
||||
import { exportDataToCSV } from '@app/config/explore';
|
||||
// import { IRootState } from '@app/store';
|
||||
import { INode, IPath } from '@app/interfaces/explore';
|
||||
import { useStore } from '@app/stores';
|
||||
import ExpandItem from '../ExpandItem';
|
||||
import SelectedGraphDetailShowModal from '../SelectedGraphDetailShowModal';
|
||||
import './index.less';
|
||||
const TabPane = Tabs.TabPane;
|
||||
|
||||
// const mapState = (state: IRootState) => ({
|
||||
// selectVertexes: state.explore.selectVertexes,
|
||||
// selectEdges: state.explore.selectEdges,
|
||||
// spaceVidType: state.nebula.spaceVidType,
|
||||
// });
|
||||
|
||||
interface IProps {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const DisplayComponent = ({ close }: IProps) => {
|
||||
const { schema, explore } = useStore();
|
||||
const { spaceVidType } = schema;
|
||||
const { selectVertexes, selectEdges } = explore;
|
||||
const [tagType, setTagType] = useState<'vertex' | 'edge'>('vertex');
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const showModal = () => {
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const handleChangeType = useCallback((key: string) => {
|
||||
setTagType(key as 'vertex' | 'edge');
|
||||
}, []);
|
||||
|
||||
const exportDataToCSVHandler = useCallback(() => {
|
||||
const data = tagType === 'vertex' ? selectVertexes : selectEdges;
|
||||
exportDataToCSV(data, tagType);
|
||||
}, [selectVertexes, selectEdges, tagType]);
|
||||
|
||||
const flattenVertex = useCallback(
|
||||
(data: any) => {
|
||||
const _data = [
|
||||
{
|
||||
key: 'vid',
|
||||
value: data.name,
|
||||
vidType: spaceVidType,
|
||||
},
|
||||
] as any;
|
||||
const properties = data.nodeProp.properties;
|
||||
Object.keys(properties).forEach((property) => {
|
||||
const valueObj = properties[property];
|
||||
Object.keys(valueObj).forEach((field) => {
|
||||
_data.push({
|
||||
key: `${property}.${field}`,
|
||||
value: valueObj[field],
|
||||
});
|
||||
});
|
||||
});
|
||||
return _data;
|
||||
},
|
||||
[spaceVidType],
|
||||
);
|
||||
|
||||
const flattenEdge = useCallback((data: any) => {
|
||||
const _data = [
|
||||
{
|
||||
key: 'id',
|
||||
value: data.id,
|
||||
},
|
||||
];
|
||||
const name = data.type;
|
||||
const properties = data.edgeProp.properties;
|
||||
Object.keys(properties).forEach((property) => {
|
||||
const value = properties[property];
|
||||
_data.push({
|
||||
key: `${name}.${property}`,
|
||||
value,
|
||||
});
|
||||
});
|
||||
return _data;
|
||||
}, []);
|
||||
|
||||
const flattenProps = useCallback(
|
||||
(data: any) => {
|
||||
if (data.nodeProp && data.nodeProp.properties) {
|
||||
return flattenVertex(data);
|
||||
} else if (data.edgeProp && data.edgeProp.properties) {
|
||||
return flattenEdge(data);
|
||||
}
|
||||
},
|
||||
[flattenVertex, flattenEdge],
|
||||
);
|
||||
|
||||
const data = tagType === 'vertex' ? selectVertexes : selectEdges;
|
||||
|
||||
return (
|
||||
<div className="display-expand">
|
||||
<div className="header">
|
||||
<Tabs onChange={handleChangeType} activeKey={tagType}>
|
||||
<TabPane tab={intl.get('import.vertexText') + `(${selectVertexes.length})`} key="vertex" />
|
||||
<TabPane tab={intl.get('import.edgeText') + `(${selectEdges.length})`} key="edge" />
|
||||
</Tabs>
|
||||
<div
|
||||
className={data.length > 0 ? 'btn-view' : 'btn-disabled'}
|
||||
onClick={data.length > 0 ? showModal : undefined}
|
||||
data-track-category="explore"
|
||||
data-track-action="select_info_modal_view"
|
||||
>
|
||||
<Tooltip title={intl.get('explore.viewDetails')}>
|
||||
<IconFont type="iconstudio-window" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="content">
|
||||
{data.length > 0 &&
|
||||
data.map((item: INode | IPath, index) => (
|
||||
<ExpandItem key={item.uuid} data={flattenProps(item)} title={`${tagType} ${index + 1}`} index={index} />
|
||||
))}
|
||||
{data.length === 0 && (
|
||||
<div className="empty-tip">
|
||||
<IconFont type="iconDefault-image-left" />
|
||||
<span>{intl.get('common.noSelectedData')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="footer">
|
||||
<Button
|
||||
disabled={data.length === 0}
|
||||
data-track-category="explore"
|
||||
data-track-action="export_csv"
|
||||
data-track-label="from_left_sider"
|
||||
onClick={data.length > 0 ? _.debounce(exportDataToCSVHandler, 300) : undefined}
|
||||
>
|
||||
<IconFont type="iconstudio-exportcsv" />
|
||||
{tagType === 'vertex' ? intl.get('common.exportSelectVertexes') : intl.get('common.exportSelectEdges')}
|
||||
</Button>
|
||||
<IconFont
|
||||
type="iconstudio-indentright"
|
||||
data-track-category="explore"
|
||||
data-track-action="display_sider_close"
|
||||
onClick={close}
|
||||
/>
|
||||
</div>
|
||||
{visible && (
|
||||
<SelectedGraphDetailShowModal visible={visible} onCancel={() => setVisible(false)} initialType={tagType} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(DisplayComponent);
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
@import '@app/common.less';
|
||||
|
||||
.display-row-item {
|
||||
background: #e6f7ff;
|
||||
|
||||
.item-header {
|
||||
cursor: pointer;
|
||||
padding: 0 30px;
|
||||
background: #bae7ff;
|
||||
|
||||
.display-header-title {
|
||||
margin-left: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
border-right: 3px solid rgba(0, 145, 255, 1);
|
||||
}
|
||||
|
||||
.item-content {
|
||||
padding: 0 50px;
|
||||
background: #e6f7ff;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
|
||||
&:nth-child(2) {
|
||||
padding-top: 18px;
|
||||
margin-bottom: 13px;
|
||||
}
|
||||
|
||||
.item-key {
|
||||
max-width: 100px;
|
||||
font-size: 12px;
|
||||
margin-right: 5px;
|
||||
word-break: break-all;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.item-value {
|
||||
font-size: 12px;
|
||||
color: #000;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.item-operation {
|
||||
cursor: pointer;
|
||||
background: #ddf4ff;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: @blue;
|
||||
|
||||
i svg {
|
||||
fill: @blue;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
span {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
// import { Icon } from 'antd';
|
||||
import classnames from 'classnames';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import IconFont from '@app/components/UnicodeIcon';
|
||||
import { convertBigNumberToString } from '@app/utils/function';
|
||||
import { DownOutlined, RightOutlined } from '@ant-design/icons';
|
||||
|
||||
import './index.less';
|
||||
|
||||
interface IProps {
|
||||
data: any;
|
||||
title: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const EXPAND_NUM = 3;
|
||||
|
||||
const RowItem: React.FC<IProps> = ({ data, title, index }) => {
|
||||
const [expandedAll, setExpandedAll] = useState(false);
|
||||
const [expandedRestInfo, setExpandedRestInfo] = useState(false);
|
||||
const [needExpandRest, setNeedExpandRest] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.length > EXPAND_NUM) {
|
||||
setNeedExpandRest(true);
|
||||
}
|
||||
if (index === 0) {
|
||||
setExpandedAll(true);
|
||||
}
|
||||
}, [data, index]);
|
||||
|
||||
const toggleExpandAll = () => {
|
||||
setExpandedAll((prev) => !prev);
|
||||
};
|
||||
|
||||
const toggleExpandRest = () => {
|
||||
setExpandedRestInfo((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleShowValue = (data: any) => {
|
||||
const { key, value } = data;
|
||||
if (typeof value === 'string') {
|
||||
if (key === 'vid' && data.vidType === 'INT64') {
|
||||
return value;
|
||||
} else {
|
||||
return JSON.stringify(value, (_, v) => {
|
||||
return v.replace(/\u0000+$/, '');
|
||||
});
|
||||
}
|
||||
} else if (typeof value === 'boolean') {
|
||||
return value.toString();
|
||||
} else {
|
||||
return convertBigNumberToString(value); // TODO: bigint in props does not be convert
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="display-row-item">
|
||||
<div className={classnames('item-header', 'row', { active: expandedAll })} onClick={toggleExpandAll}>
|
||||
{expandedAll ? <DownOutlined /> : <RightOutlined />}
|
||||
<span className="display-header-title">{title}</span>
|
||||
</div>
|
||||
{expandedAll && (
|
||||
<>
|
||||
{data.slice(0, EXPAND_NUM).map((item: any) => (
|
||||
<div className="item-content" key={item.key}>
|
||||
<div className="item-key">{item.key} :</div>
|
||||
<div className="item-value">{handleShowValue(item)}</div>
|
||||
</div>
|
||||
))}
|
||||
{needExpandRest && (
|
||||
<>
|
||||
{expandedRestInfo ? (
|
||||
<>
|
||||
{data.slice(EXPAND_NUM).map((item: any) => (
|
||||
<div className="item-content" key={item.key}>
|
||||
<div className="item-key">{item.key}</div>
|
||||
<div className="item-value">{handleShowValue(item)}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="item-operation row" onClick={toggleExpandRest}>
|
||||
<IconFont type="iconstudio-seletup" />
|
||||
<span>{intl.get('explore.collapseItem')}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="item-operation row" onClick={toggleExpandRest}>
|
||||
<IconFont type="iconstudio-seletexpand" />
|
||||
<span>{intl.get('explore.expandItem')}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RowItem;
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
.modal-show-selected {
|
||||
.ant-modal-header {
|
||||
padding: 0;
|
||||
|
||||
.ant-tabs {
|
||||
// padding-left: 32px;
|
||||
|
||||
.ant-tabs-bar {
|
||||
margin: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.operation {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.btns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.icon-instruction svg {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
span:first-child {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vertex-tooltip {
|
||||
.ant-tooltip-inner {
|
||||
width: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
.edge-tooltip {
|
||||
.ant-tooltip-inner {
|
||||
width: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
import { Button, Input, message, Table, Tabs, Tooltip, Modal } from 'antd';
|
||||
import JSONBigint from 'json-bigint';
|
||||
import React, { useState } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import Instruction from '@app/components/Instruction';
|
||||
import { downloadCSVFiles, parseData } from '@app/config/explore';
|
||||
import { useStore } from '@app/stores';
|
||||
import './index.less';
|
||||
const TabPane = Tabs.TabPane;
|
||||
|
||||
interface IProps {
|
||||
initialType: 'vertex' | 'edge';
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const SelectedGraphDetailShowModal = ({ initialType, visible, onCancel }: IProps) => {
|
||||
const { explore } = useStore();
|
||||
const { selectVertexes: vertexes, selectEdges: edges } = explore;
|
||||
const [tagType, setTagType] = useState<'vertex' | 'edge'>(initialType);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const originVertexData = parseData(vertexes, 'vertex').tables;
|
||||
const originEdgeData = parseData(edges, 'edge').tables;
|
||||
const [vertexData, setVertexData] = useState(originVertexData);
|
||||
const [edgeData, setEdgeData] = useState(originEdgeData);
|
||||
|
||||
const handleTabsChange = (key: string) => {
|
||||
setTagType(key as 'vertex' | 'edge');
|
||||
};
|
||||
|
||||
const handleExportToCSV = () => {
|
||||
const data = tagType === 'vertex' ? vertexData : edgeData;
|
||||
if (!data.length) return;
|
||||
const headers = Object.keys(data[0]);
|
||||
downloadCSVFiles({ headers, tables: data, title: tagType });
|
||||
};
|
||||
|
||||
const handleUpdateValue = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
const data = tagType === 'vertex' ? originVertexData : originEdgeData;
|
||||
if (value) {
|
||||
const reg = /([a-zA-Z0-9_.]*)\s*([\=\>\<\!]+)\s*(.*)/g;
|
||||
const searchInfo = reg.exec(value) || [];
|
||||
if (searchInfo.length > 0) {
|
||||
const key = searchInfo[1];
|
||||
const compare = searchInfo[2];
|
||||
const result = searchInfo[3];
|
||||
const filters = data.filter((item) => {
|
||||
const { attributes, ...rest } = item;
|
||||
const flattenData = { ...rest, ...JSON.parse(attributes) };
|
||||
let checked = false;
|
||||
if (flattenData[key]) {
|
||||
const value = flattenData[key].toString();
|
||||
const strReg = /^['|"].*['|"]$/;
|
||||
switch (compare) {
|
||||
case '=':
|
||||
if (strReg.test(result)) {
|
||||
checked = value === result.slice(1, result.length - 1);
|
||||
} else {
|
||||
checked = value === result;
|
||||
}
|
||||
break;
|
||||
case '>':
|
||||
checked = value > result;
|
||||
break;
|
||||
case '<':
|
||||
checked = value < result;
|
||||
break;
|
||||
case '<=':
|
||||
checked = value <= result;
|
||||
break;
|
||||
case '>=':
|
||||
checked = value >= result;
|
||||
break;
|
||||
case '<>':
|
||||
case '!=':
|
||||
if (strReg.test(result)) {
|
||||
checked = value !== result.slice(1, result.length - 1);
|
||||
} else {
|
||||
checked = value !== result;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return checked;
|
||||
});
|
||||
if (tagType === 'vertex') {
|
||||
setVertexData(filters);
|
||||
} else {
|
||||
setEdgeData(filters);
|
||||
}
|
||||
} else {
|
||||
return message.warning(intl.get('explore.expressionError'));
|
||||
}
|
||||
} else {
|
||||
if (tagType === 'vertex') {
|
||||
setVertexData(originVertexData);
|
||||
} else {
|
||||
setEdgeData(originEdgeData);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const data = tagType === 'vertex' ? vertexData : edgeData;
|
||||
const columns =
|
||||
tagType === 'vertex'
|
||||
? [
|
||||
{
|
||||
title: 'vid',
|
||||
dataIndex: 'vid',
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: 'attributes',
|
||||
dataIndex: 'attributes',
|
||||
ellipsis: {
|
||||
showTitle: false,
|
||||
},
|
||||
align: 'center' as const,
|
||||
render: (record: string) => {
|
||||
return (
|
||||
<Tooltip
|
||||
placement="topLeft"
|
||||
rootClassName="vertex-tooltip"
|
||||
title={
|
||||
<pre>
|
||||
{JSONBigint.stringify(
|
||||
JSONBigint.parse(record),
|
||||
(_, value) => {
|
||||
if (typeof value === 'string') {
|
||||
return value.replace(/\u0000+$/, '');
|
||||
}
|
||||
return value;
|
||||
},
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
}
|
||||
>
|
||||
{record}
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: 'type',
|
||||
dataIndex: 'type',
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: 'rank',
|
||||
dataIndex: 'rank',
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: 'srcId',
|
||||
dataIndex: 'srcId',
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: 'dstId',
|
||||
dataIndex: 'dstId',
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: 'attributes',
|
||||
dataIndex: 'attributes',
|
||||
ellipsis: {
|
||||
showTitle: false,
|
||||
},
|
||||
align: 'center' as const,
|
||||
render: (record: string) => {
|
||||
return (
|
||||
<Tooltip
|
||||
placement="topLeft"
|
||||
rootClassName="edge-tooltip"
|
||||
title={<pre>{JSONBigint.stringify(JSONBigint.parse(record), null, 2)}</pre>}
|
||||
>
|
||||
{record}
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="modal-show-selected"
|
||||
width="70%"
|
||||
open={visible}
|
||||
footer={null}
|
||||
maskClosable={false}
|
||||
title={
|
||||
<Tabs onChange={handleTabsChange} activeKey={tagType}>
|
||||
<TabPane tab={intl.get('explore.selectedVertexes')} key="vertex" />
|
||||
<TabPane tab={intl.get('explore.selectedEdges')} key="edge" />
|
||||
</Tabs>
|
||||
}
|
||||
onCancel={onCancel}
|
||||
>
|
||||
<div className="operation">
|
||||
<span>
|
||||
{intl.get('common.total')}: {data.length}
|
||||
</span>
|
||||
<div className="btns">
|
||||
<Button
|
||||
type="primary"
|
||||
data-track-category="explore"
|
||||
data-track-action="export_csv"
|
||||
data-track-label="from_info_modal"
|
||||
onClick={handleExportToCSV}
|
||||
>
|
||||
{intl.get('explore.exportToCSV')}
|
||||
</Button>
|
||||
<Input.Search
|
||||
placeholder="person.name > xx"
|
||||
value={searchValue}
|
||||
onChange={handleUpdateValue}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
<Instruction description={intl.get('explore.searchTip')} />
|
||||
</div>
|
||||
</div>
|
||||
<Table columns={columns} dataSource={data} rowKey={(_, index) => index.toString()} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(SelectedGraphDetailShowModal);
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
@import '@app/common.less';
|
||||
|
||||
.display-drawer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
|
||||
.ant-drawer-content-wrapper {
|
||||
border-right: 1px solid #d9d9d9;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ant-drawer-header {
|
||||
padding: 14px 14px 0;
|
||||
border-bottom: none;
|
||||
|
||||
.ant-drawer-title {
|
||||
border-left: 3px solid @blue;
|
||||
padding-left: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.display-sider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 55px;
|
||||
height: 100%;
|
||||
border-right: 1px solid #eee;
|
||||
text-align: center;
|
||||
background: white;
|
||||
|
||||
.display-label {
|
||||
width: 45px;
|
||||
height: 56px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
margin-top: 10px;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
i svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: @blue;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 16px;
|
||||
color: #000;
|
||||
letter-spacing: 0;
|
||||
font-weight: 500;
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.sider-footer {
|
||||
width: 100%;
|
||||
height: 75px;
|
||||
border-top: 1px solid #d4d4d4;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
|
||||
.icon-collapse {
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
fill: @blue;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { Drawer } from 'antd';
|
||||
import { useStore } from '@app/stores';
|
||||
import IconFont from '@app/components/UnicodeIcon';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import Expand from './ExpandForm';
|
||||
import './index.less';
|
||||
|
||||
// const mapState = (state: IRootState) => ({
|
||||
// selectVertexes: state.explore.selectVertexes,
|
||||
// selectEdges: state.explore.selectEdges,
|
||||
// showDisplayPanel: state.d3Graph.showDisplayPanel,
|
||||
// currentSpace: state.nebula.currentSpace,
|
||||
// });
|
||||
|
||||
// const mapDispatch = (dispatch: IDispatch) => ({
|
||||
// toggleExpand: data => dispatch.d3Graph.update(data),
|
||||
// });
|
||||
|
||||
function DisplayPanel() {
|
||||
const { explore, d3Graph } = useStore();
|
||||
const { selectVertexes, selectEdges, currentSpace } = explore;
|
||||
const { showDisplayPanel, update } = d3Graph;
|
||||
|
||||
const toggleExpand = (data: any) => {
|
||||
update(data);
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
toggleExpand({
|
||||
showDisplayPanel: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
toggleExpand({
|
||||
showDisplayPanel: false,
|
||||
});
|
||||
};
|
||||
|
||||
if (currentSpace) {
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
open={showDisplayPanel}
|
||||
className="display-drawer"
|
||||
width="290"
|
||||
onClose={handleClose}
|
||||
closable={false}
|
||||
getContainer={false}
|
||||
mask={false}
|
||||
placement="left"
|
||||
>
|
||||
<Expand close={handleClose} />
|
||||
</Drawer>
|
||||
{!showDisplayPanel && (
|
||||
<div className="display-sider">
|
||||
<div
|
||||
className="display-label"
|
||||
data-track-category="explore"
|
||||
data-track-action="display_sider_open"
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<IconFont type="iconstudio-vertex" />
|
||||
{selectVertexes.length}
|
||||
</div>
|
||||
<div
|
||||
className="display-label"
|
||||
data-track-category="explore"
|
||||
data-track-action="display_sider_open"
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<IconFont type="iconstudio-edge" />
|
||||
{selectEdges.length}
|
||||
</div>
|
||||
<div className="sider-footer">
|
||||
<IconFont
|
||||
type="iconstudio-indentleft"
|
||||
className="icon-collapse"
|
||||
data-track-category="explore"
|
||||
data-track-action="display_sider_open"
|
||||
onClick={handleOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default observer(DisplayPanel);
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
|
||||
.form-add-filter {
|
||||
.ant-form-item {
|
||||
margin-bottom: 3px;
|
||||
|
||||
.ant-form-item-label {
|
||||
line-height: initial;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
width: 230px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.popover-footer {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
|
||||
button {
|
||||
height: 24px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { Button, Form, Input } from 'antd';
|
||||
import React from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import './index.less';
|
||||
|
||||
interface IProps {
|
||||
onConfirm: (values: { field: string; operator: string; value: string }) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const AddFilterForm: React.FC<IProps> = ({ onConfirm, onCancel }) => {
|
||||
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleAddFilters = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
onConfirm(values);
|
||||
} catch {
|
||||
// 校验失败不处理
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-add-filter">
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
label={intl.get('common.field')}
|
||||
name="field"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Input placeholder="prop1" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={intl.get('explore.operator')}
|
||||
name="operator"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Input placeholder="==" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={intl.get('explore.value')}
|
||||
name="value"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Input placeholder="value" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div className="popover-footer">
|
||||
<Button onClick={handleAddFilters} type="primary">
|
||||
{intl.get('common.confirm')}
|
||||
</Button>
|
||||
<Button onClick={onCancel}>
|
||||
{intl.get('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(AddFilterForm);
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
@import '@app/common.less';
|
||||
|
||||
.graph-expand {
|
||||
height: 100%;
|
||||
|
||||
.expand-config {
|
||||
padding: 12px 12px 0;
|
||||
height: calc(100% - 72px);
|
||||
overflow: auto;
|
||||
|
||||
.menu-color {
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.expand-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 72px;
|
||||
width: 100%;
|
||||
border-top: 1px solid #d4d4d4;
|
||||
padding: 20px 14px;
|
||||
text-align: center;
|
||||
|
||||
.btn-collapse {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 12px;
|
||||
transform: translateY(-50%);
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: @blue;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-instruction {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 155px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-component {
|
||||
text-align: center;
|
||||
|
||||
.filter-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
|
||||
.btn-reset {
|
||||
fill: @blue;
|
||||
color: @blue;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 0;
|
||||
|
||||
.ant-form-item-control {
|
||||
line-height: 34px;
|
||||
}
|
||||
}
|
||||
|
||||
.select-relation {
|
||||
width: 70px;
|
||||
|
||||
.ant-select-selection {
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-expression {
|
||||
margin-right: 0;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-left: 15px;
|
||||
font-size: 14px;
|
||||
white-space: pre-line;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.btn-add-filter {
|
||||
i svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-form {
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid #d4d4d4;
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.ant-form-item-label {
|
||||
line-height: initial;
|
||||
}
|
||||
|
||||
.ant-radio-group {
|
||||
padding-top: 5px;
|
||||
margin-left: 15px;
|
||||
position: relative;
|
||||
|
||||
.ant-radio-wrapper {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.btn-color {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.select-step-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.input-step {
|
||||
padding-left: 25px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.ant-form-item {
|
||||
display: inline-block;
|
||||
margin-bottom: 0;
|
||||
padding: 0 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-gql {
|
||||
width: 100%;
|
||||
color: black;
|
||||
border-radius: 2px;
|
||||
margin: 17px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.select-relation-dropdown {
|
||||
border-radius: 2px;
|
||||
background: #f5f5f5;
|
||||
|
||||
.ant-select-dropdown-menu-item-selected {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,425 @@
|
|||
import { Button, Form, Input, Popover, Radio, Select, Tag } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
import Instruction from '@app/components/Instruction';
|
||||
import GQLModal from '@app/components/GQLModal';
|
||||
import IconFont from '@app/components/UnicodeIcon';
|
||||
import VertexStyleSet from '@app/components/VertexStyleSet';
|
||||
import { DEFAULT_COLOR_PICKER } from '@app/config/explore';
|
||||
import { RELATION_OPERATORS } from '@app/utils/constant';
|
||||
import { getExploreMatchGQL } from '@app/utils/gql';
|
||||
import AddFilterForm from '../AddFilterForm';
|
||||
import './index.less';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
const Option = Select.Option;
|
||||
|
||||
interface IFilter {
|
||||
expression: string;
|
||||
relation?: string;
|
||||
}
|
||||
|
||||
const Expand: React.FC<{ close: () => void }> = observer(({ close }) => {
|
||||
const { schema, explore } = useStore();
|
||||
const { edgeTypes, edgesFields, spaceVidType, getEdgeTypesFields, getEdges } = schema;
|
||||
const { selectVertexes, exploreRules, asyncGetExpand, update, currentSpace } = explore;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [gqlModalVisible, setGqlModalVisible] = useState(false);
|
||||
const [customColor, setCustomColor] = useState(exploreRules.customColor ?? DEFAULT_COLOR_PICKER);
|
||||
const [filters, setFilters] = useState<IFilter[]>(exploreRules.filters ?? []);
|
||||
const [customIcon, setCustomIcon] = useState(exploreRules.customIcon ?? '');
|
||||
const [currentGQL, setCurrentGQL] = useState('');
|
||||
|
||||
// 表单初始值
|
||||
const initialValues = {
|
||||
edgeTypes: exploreRules.edgeTypes && exploreRules.edgeTypes.length > 0 ? exploreRules.edgeTypes : edgeTypes,
|
||||
edgeDirection: exploreRules.edgeDirection || 'outgoing',
|
||||
stepsType: exploreRules.stepsType || 'single',
|
||||
step: exploreRules.step || '1',
|
||||
minStep: exploreRules.minStep || '',
|
||||
maxStep: exploreRules.maxStep || '',
|
||||
vertexStyle: exploreRules.vertexStyle || 'colorGroupByTag',
|
||||
quantityLimit: exploreRules.quantityLimit || 10,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 获取边类型和边类型字段
|
||||
const getEdgesAndFields = async () => {
|
||||
const initEdgeTypes =
|
||||
exploreRules.edgeTypes && exploreRules.edgeTypes.length > 0 ? exploreRules.edgeTypes : edgeTypes;
|
||||
if (initEdgeTypes.length === 0) {
|
||||
const res = await getEdges(currentSpace);
|
||||
if (res && Array.isArray(res) && res.length > 0) {
|
||||
form.setFieldValue('edgeTypes', res);
|
||||
await getEdgeTypesFields({ edgeTypes: res });
|
||||
}
|
||||
}
|
||||
};
|
||||
getEdgesAndFields();
|
||||
}, [currentSpace, edgeTypes, exploreRules.edgeTypes, getEdges, getEdgeTypesFields]);
|
||||
|
||||
const updateExploreRules = (rules: any) => {
|
||||
update({
|
||||
exploreRules: {
|
||||
...exploreRules,
|
||||
...rules,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 更新 exploreRules
|
||||
const handleUpdateRules = () => {
|
||||
const { edgeTypes, edgeDirection, stepsType, step, minStep, maxStep, vertexStyle, quantityLimit } =
|
||||
form.getFieldsValue();
|
||||
setTimeout(() => {
|
||||
updateExploreRules({
|
||||
edgeTypes,
|
||||
edgeDirection,
|
||||
vertexStyle,
|
||||
quantityLimit,
|
||||
stepsType,
|
||||
step,
|
||||
minStep,
|
||||
maxStep,
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 渲染筛选条件
|
||||
const renderFilters = () =>
|
||||
filters.map((item, index) => (
|
||||
<div key={index} className="form-item">
|
||||
{index > 0 && (
|
||||
<Form.Item>
|
||||
<Select
|
||||
value={item.relation}
|
||||
onChange={(value) => handleUpdateFilter(value, index)}
|
||||
className="select-relation"
|
||||
dropdownClassName="select-relation-dropdown"
|
||||
size="small"
|
||||
>
|
||||
{RELATION_OPERATORS.map((item) => (
|
||||
<Option value={item.value} key={item.value}>
|
||||
{item.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item>
|
||||
<Tag className="tag-expression" closable={true} onClose={(_) => handleDeleteFilter(index)}>
|
||||
{item.expression}
|
||||
</Tag>
|
||||
</Form.Item>
|
||||
</div>
|
||||
));
|
||||
|
||||
// 更新单个筛选条件的 relation
|
||||
const handleUpdateFilter = (value: string, index: number) => {
|
||||
const newFilters = filters.map((v, i) => {
|
||||
if (i === index) {
|
||||
return {
|
||||
...v,
|
||||
relation: value,
|
||||
};
|
||||
}
|
||||
return v;
|
||||
});
|
||||
setFilters(newFilters);
|
||||
updateExploreRules({
|
||||
filters: newFilters,
|
||||
});
|
||||
};
|
||||
|
||||
// 删除筛选条件
|
||||
const handleDeleteFilter = (index: number) => {
|
||||
const newFilters = filters.filter((_, i) => i !== index);
|
||||
setFilters(newFilters);
|
||||
updateExploreRules({
|
||||
filters: newFilters,
|
||||
});
|
||||
};
|
||||
|
||||
// 添加筛选条件
|
||||
const handleAddFilter = (data: { field: string; operator: string; value: string }) => {
|
||||
const { field, operator, value } = data;
|
||||
const expression = `${field} ${operator} ${value}`;
|
||||
const newFilter = filters.length === 0 ? { expression } : { relation: 'AND', expression };
|
||||
const newFilters = [...filters, newFilter];
|
||||
setFilters(newFilters);
|
||||
setVisible(false);
|
||||
updateExploreRules({
|
||||
filters: newFilters,
|
||||
});
|
||||
};
|
||||
|
||||
// 重置筛选条件
|
||||
const handleResetFilters = () => {
|
||||
setFilters([]);
|
||||
updateExploreRules({
|
||||
filters: [],
|
||||
});
|
||||
};
|
||||
|
||||
// Popover 显隐
|
||||
const handleVisibleChange = (v: boolean) => setVisible(v);
|
||||
const hide = () => setVisible(false);
|
||||
|
||||
// GQL 弹窗
|
||||
const handleViewGQL = () => {
|
||||
// 当前表单值
|
||||
const formValues = form.getFieldsValue();
|
||||
const { edgeTypes, edgeDirection, stepsType, step, minStep, maxStep, quantityLimit } = {
|
||||
...initialValues,
|
||||
...formValues,
|
||||
};
|
||||
|
||||
// 当前 GQL
|
||||
const gql =
|
||||
edgeTypes && edgeTypes.length && selectVertexes.length > 0
|
||||
? getExploreMatchGQL({
|
||||
selectVertexes,
|
||||
edgeTypes,
|
||||
filters,
|
||||
edgeDirection,
|
||||
quantityLimit,
|
||||
spaceVidType,
|
||||
stepsType,
|
||||
step,
|
||||
minStep,
|
||||
maxStep,
|
||||
})
|
||||
: '';
|
||||
setCurrentGQL(gql);
|
||||
setGqlModalVisible(true);
|
||||
};
|
||||
|
||||
// 自定义颜色
|
||||
const handleCustomColor = (color: string) => {
|
||||
setCustomColor(color);
|
||||
updateExploreRules({
|
||||
customColor: color,
|
||||
});
|
||||
};
|
||||
|
||||
// 自定义图标
|
||||
const handleCustomIcon = (icon: any) => {
|
||||
const customIcon = icon.content ? icon.type : '';
|
||||
setCustomIcon(customIcon);
|
||||
updateExploreRules({
|
||||
customIcon: customIcon,
|
||||
});
|
||||
};
|
||||
|
||||
// 扩展操作
|
||||
const handleExpand = async () => {
|
||||
try {
|
||||
await form.validateFields();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const { edgeTypes, edgeDirection, stepsType, step, minStep, maxStep, vertexStyle, quantityLimit } =
|
||||
form.getFieldsValue();
|
||||
|
||||
setLoading(true);
|
||||
asyncGetExpand({
|
||||
filters,
|
||||
selectVertexes,
|
||||
edgeTypes,
|
||||
edgesFields,
|
||||
edgeDirection,
|
||||
vertexStyle,
|
||||
quantityLimit,
|
||||
stepsType,
|
||||
step,
|
||||
minStep,
|
||||
maxStep,
|
||||
customColor,
|
||||
customIcon,
|
||||
})
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="graph-expand">
|
||||
<div className="expand-config">
|
||||
<Form
|
||||
form={form}
|
||||
colon={false}
|
||||
layout="vertical"
|
||||
initialValues={initialValues}
|
||||
onValuesChange={handleUpdateRules}
|
||||
>
|
||||
<Form.Item label={intl.get('common.edge')} name="edgeTypes" rules={[{ required: true }]}>
|
||||
<Select mode="multiple" placeholder={`请选择${intl.get('common.edge')}`}>
|
||||
{edgeTypes.map((e) => (
|
||||
<Option value={e} key={e}>
|
||||
{e}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={intl.get('explore.direction')} name="edgeDirection" rules={[{ required: true }]}>
|
||||
<Select placeholder={`请选择${intl.get('explore.direction')}`}>
|
||||
<Option value="outgoing">{intl.get('explore.outgoing')}</Option>
|
||||
<Option value="incoming">{intl.get('explore.incoming')}</Option>
|
||||
<Option value="bidirect">{intl.get('explore.bidirect')}</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{/* <Form.Item
|
||||
label={intl.get('explore.steps')}
|
||||
name="stepsType"
|
||||
className="select-step-type"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio value="single">{intl.get('explore.singleStep')}</Radio>
|
||||
<Radio value="range">{intl.get('explore.rangeStep')}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{form.getFieldValue('stepsType') === 'single' && (
|
||||
<Form.Item
|
||||
className="input-step"
|
||||
name="step"
|
||||
rules={[
|
||||
{
|
||||
message: intl.get('formRules.positiveIntegerRequired'),
|
||||
pattern: /^\d+$/,
|
||||
},
|
||||
{ required: true, message: '请输入步数' },
|
||||
]}
|
||||
>
|
||||
<Input type="number" placeholder={`请输入步数`} />
|
||||
</Form.Item>
|
||||
)}
|
||||
{form.getFieldValue('stepsType') === 'range' && (
|
||||
<div className="input-step">
|
||||
<Form.Item
|
||||
name="minStep"
|
||||
rules={[
|
||||
{
|
||||
message: intl.get('formRules.positiveIntegerRequired'),
|
||||
pattern: /^\d+$/,
|
||||
},
|
||||
{ required: true },
|
||||
]}
|
||||
>
|
||||
<Input type="number" />
|
||||
</Form.Item>
|
||||
-
|
||||
<Form.Item
|
||||
name="maxStep"
|
||||
rules={[
|
||||
{
|
||||
message: intl.get('formRules.positiveIntegerRequired'),
|
||||
pattern: /^\d+$/,
|
||||
},
|
||||
{ required: true },
|
||||
]}
|
||||
>
|
||||
<Input type="number" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
)} */}
|
||||
<Form.Item label={intl.get('explore.vertexStyle')} name="vertexStyle" rules={[{ required: true }]}>
|
||||
<Radio.Group>
|
||||
<Radio value="colorGroupByTag">{intl.get('explore.colorGroupByTag')}</Radio>
|
||||
<Radio value="custom">{intl.get('explore.customStyle')}</Radio>
|
||||
<VertexStyleSet
|
||||
handleChangeColorComplete={handleCustomColor}
|
||||
handleChangeIconComplete={handleCustomIcon}
|
||||
icon={customIcon}
|
||||
color={customColor}
|
||||
/>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={intl.get('explore.quantityLimit')}
|
||||
name="quantityLimit"
|
||||
rules={[
|
||||
{ required: true },
|
||||
{
|
||||
message: intl.get('formRules.positiveIntegerRequired'),
|
||||
pattern: /^\d+$/,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input type="number" placeholder={`请输入${intl.get('explore.quantityLimit')}`} />
|
||||
</Form.Item>
|
||||
<div className="filter-component">
|
||||
<div className="filter-header">
|
||||
<span>{intl.get('explore.filter')}</span>
|
||||
<div
|
||||
className="btn-reset"
|
||||
data-track-category="explore"
|
||||
data-track-action="expand_filter_reset"
|
||||
onClick={handleResetFilters}
|
||||
>
|
||||
<IconFont type="iconstudio-remake" />
|
||||
<span>{intl.get('import.reset')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{renderFilters()}
|
||||
<Popover
|
||||
content={<AddFilterForm onConfirm={handleAddFilter} onCancel={hide} />}
|
||||
open={visible}
|
||||
onOpenChange={handleVisibleChange}
|
||||
trigger="click"
|
||||
>
|
||||
<Button
|
||||
className="btn-add-filter"
|
||||
data-track-category="explore"
|
||||
data-track-action="expand_filter_add"
|
||||
icon={<PlusOutlined />}
|
||||
type="link"
|
||||
>
|
||||
{intl.get('explore.addCondition')}
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
</Form>
|
||||
<GQLModal gql={currentGQL} visible={gqlModalVisible} onCancel={() => setGqlModalVisible(false)} />
|
||||
<Button
|
||||
className="btn-gql"
|
||||
data-track-category="explore"
|
||||
data-track-action="expand_gql_view"
|
||||
onClick={handleViewGQL}
|
||||
>
|
||||
{intl.get('common.exportNGQL')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="expand-footer">
|
||||
<IconFont
|
||||
type="iconstudio-indentleft"
|
||||
className="btn-collapse"
|
||||
onClick={close}
|
||||
data-track-category="explore"
|
||||
data-track-action="expand_sider_close"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleExpand}
|
||||
loading={loading}
|
||||
data-track-category="explore"
|
||||
data-track-action="graph_expand"
|
||||
data-track-label="from_sider"
|
||||
disabled={!selectVertexes.length}
|
||||
>
|
||||
{intl.get('explore.expand')}
|
||||
</Button>
|
||||
<Instruction description={intl.get('explore.expandTips')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Expand;
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
@import '@app/common.less';
|
||||
|
||||
.expand-drawer {
|
||||
top: 0;
|
||||
z-index: 0;
|
||||
position: absolute;
|
||||
|
||||
> .ant-drawer-content-wrapper {
|
||||
border-left: 1px solid #d9d9d9;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ant-drawer-header {
|
||||
padding: 14px 14px 0;
|
||||
border-bottom: none;
|
||||
|
||||
.ant-drawer-title {
|
||||
border-left: 3px solid @blue;
|
||||
padding-left: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 0;
|
||||
height: calc(100% - 36px);
|
||||
}
|
||||
}
|
||||
|
||||
.expand-sider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 55px;
|
||||
height: 100%;
|
||||
border-left: 1px solid #eee;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: white;
|
||||
text-align: center;
|
||||
|
||||
.btn-expand {
|
||||
width: 45px;
|
||||
height: 56px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
margin-top: 10px;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-expand,
|
||||
.icon-collapse {
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
fill: @blue;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-expand {
|
||||
svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
}
|
||||
|
||||
.sider-footer {
|
||||
width: 100%;
|
||||
height: 75px;
|
||||
border-top: 1px solid #d4d4d4;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.icon-collapse svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { Drawer } from 'antd';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import IconFont from '@app/components/UnicodeIcon';
|
||||
import { useStore } from '@app/stores';
|
||||
import Expand from './ExpandForm';
|
||||
import './index.less';
|
||||
|
||||
const ExpandBtn = observer(() => {
|
||||
const { d3Graph, explore } = useStore();
|
||||
const { showSider, update } = d3Graph;
|
||||
const { currentSpace } = explore;
|
||||
|
||||
const handleClose = () => {
|
||||
update({ showSider: false });
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
update({ showSider: true });
|
||||
};
|
||||
|
||||
if (currentSpace) {
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
title={<span>{intl.get('explore.expansionConditions')}</span>}
|
||||
open={showSider}
|
||||
className="expand-drawer"
|
||||
width="300"
|
||||
onClose={handleClose}
|
||||
getContainer={false}
|
||||
closable={false}
|
||||
mask={false}
|
||||
placement="right"
|
||||
>
|
||||
<Expand close={handleClose} />
|
||||
</Drawer>
|
||||
{!showSider && (
|
||||
<div className="expand-sider">
|
||||
<div className="btn-expand" onClick={handleOpen}>
|
||||
<IconFont
|
||||
type="iconstudio-expandcondition"
|
||||
className="icon-expand"
|
||||
data-track-category="explore"
|
||||
data-track-action="expand_sider_open"
|
||||
/>
|
||||
{intl.get('explore.expand')}
|
||||
</div>
|
||||
<div className="sider-footer">
|
||||
<IconFont
|
||||
type="iconstudio-indentright"
|
||||
className="icon-collapse"
|
||||
onClick={handleOpen}
|
||||
data-track-category="explore"
|
||||
data-track-action="expand_sider_open"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
export default ExpandBtn;
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
@import '@app/common.less';
|
||||
|
||||
.d3-click-right-menu {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
z-index: 1;
|
||||
background: #fff;
|
||||
border: 1px solid #d7d7d7;
|
||||
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.2);
|
||||
|
||||
.panel-btn-item,
|
||||
.menu-color {
|
||||
display: flex;
|
||||
width: 182px;
|
||||
height: 43px;
|
||||
padding: 8px 9px;
|
||||
align-items: center;
|
||||
|
||||
.btn-color {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.nebula-cloud-icon {
|
||||
width: auto;
|
||||
margin-left: 2px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.btn-nodeStyle-set {
|
||||
.btn-color {
|
||||
position: static;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.nebula-cloud-icon {
|
||||
margin-left: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-menu-icon {
|
||||
position: absolute;
|
||||
|
||||
svg {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-btn-item:not(:first-child) {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.panel-btn-item:not(:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.panel-btn-item:hover {
|
||||
border-color: initial;
|
||||
}
|
||||
|
||||
.panel-btn-item:not(.panel-disabled):hover,
|
||||
.menu-color:not(.panel-disabled):hover {
|
||||
background: @blue;
|
||||
color: #fff;
|
||||
|
||||
svg {
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import * as d3 from 'd3';
|
||||
import React, { Fragment, useEffect } from 'react';
|
||||
import DeleteBtn from '../Panel/Delete';
|
||||
import ExpandBtn from '../Panel/Expand';
|
||||
import Lock from '../Panel/Lock';
|
||||
import PropsDisplayBtn from '../Panel/PropsDisplay';
|
||||
import RollbackBtn from '../Panel/Rollback';
|
||||
import SearchBtn from '../Panel/Search';
|
||||
import UnExpandBtn from '../Panel/UnExpandBtn';
|
||||
import Unlock from '../Panel/Unlock';
|
||||
import VertexStyleSetBtn from '../Panel/VertexStyleSetBtn/modal';
|
||||
import ZoomBtn from '../Panel/Zoom';
|
||||
import './index.less';
|
||||
|
||||
interface IProps {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
const menuConfig = [
|
||||
{
|
||||
component: <ExpandBtn showTitle={true} />,
|
||||
},
|
||||
{
|
||||
component: <UnExpandBtn showTitle={true} />,
|
||||
},
|
||||
{
|
||||
component: <ZoomBtn type="zoom-in" showTitle={true} />,
|
||||
},
|
||||
{
|
||||
component: <ZoomBtn type="zoom-out" showTitle={true} />,
|
||||
},
|
||||
{
|
||||
component: <VertexStyleSetBtn showTitle={true} />,
|
||||
},
|
||||
{
|
||||
component: <PropsDisplayBtn showTitle={true} />,
|
||||
},
|
||||
{
|
||||
component: <Unlock showTitle={true} />,
|
||||
},
|
||||
{
|
||||
component: <Lock showTitle={true} />,
|
||||
},
|
||||
{
|
||||
component: <RollbackBtn showTitle={true} />,
|
||||
},
|
||||
{
|
||||
component: <DeleteBtn showTitle={true} />,
|
||||
},
|
||||
{
|
||||
component: <SearchBtn showTitle={true} />,
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_MENU_HEIGHT = menuConfig.length * 43;
|
||||
|
||||
const Menu: React.FC<IProps> = ({ width, height }) => {
|
||||
useEffect(() => {
|
||||
const renderRightMenu = (event: any) => {
|
||||
const $rightMenu = d3.select('.d3-click-right-menu');
|
||||
$rightMenu.style('visibility', 'hidden');
|
||||
if ($rightMenu) {
|
||||
const { width: menuWidth, height: menuHeight } = ($rightMenu.node() as HTMLElement).getBoundingClientRect();
|
||||
let left = event.offsetX;
|
||||
let top = event.offsetY;
|
||||
if (left + menuWidth > width) {
|
||||
left = left - menuWidth;
|
||||
}
|
||||
if (menuHeight + top > height) {
|
||||
if (height - top > top) {
|
||||
$rightMenu.style('height', `${height - top - 30}px`).style('overflow', 'auto');
|
||||
} else {
|
||||
top = top - menuHeight < 0 ? height - event.offsetY - 10 : top - menuHeight;
|
||||
let _menuHeight = DEFAULT_MENU_HEIGHT;
|
||||
if (top < 0) {
|
||||
_menuHeight = height - event.offsetY - 10;
|
||||
} else if (_menuHeight + top > height) {
|
||||
_menuHeight = height - top - 30;
|
||||
}
|
||||
$rightMenu.style('height', `${_menuHeight}px`).style('overflow', 'auto');
|
||||
}
|
||||
}
|
||||
$rightMenu.style('left', `${left}px`).style('top', `${top}px`).style('visibility', 'visible');
|
||||
}
|
||||
};
|
||||
|
||||
const svg = d3.select('#output-graph');
|
||||
svg
|
||||
.on('contextmenu', () => {
|
||||
renderRightMenu(d3.event);
|
||||
d3.event.preventDefault();
|
||||
})
|
||||
.on('click', () => {
|
||||
d3.select('.d3-click-right-menu').style('visibility', 'hidden').style('height', `${DEFAULT_MENU_HEIGHT}px`);
|
||||
});
|
||||
|
||||
d3.selectAll('.panel-btn-item').on('click', () => {
|
||||
d3.select('.d3-click-right-menu').style('visibility', 'hidden').style('height', `${DEFAULT_MENU_HEIGHT}px`);
|
||||
});
|
||||
|
||||
// 清理事件
|
||||
return () => {
|
||||
svg.on('contextmenu', null).on('click', null);
|
||||
d3.selectAll('.panel-btn-item').on('click', null);
|
||||
};
|
||||
}, [width, height]);
|
||||
|
||||
return (
|
||||
<div className="d3-click-right-menu">
|
||||
{menuConfig.map((item, index) => (
|
||||
<Fragment key={index}>{item.component}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Menu;
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import * as d3 from 'd3';
|
||||
import _ from 'lodash';
|
||||
import * as React from 'react';
|
||||
|
||||
import { IPath } from '@app/interfaces/explore';
|
||||
|
||||
interface IProps {
|
||||
links: any[];
|
||||
selectedPaths: any[];
|
||||
onUpdateLinks: () => void;
|
||||
onMouseInLink: (d, event) => void;
|
||||
onMouseOut: () => void;
|
||||
}
|
||||
|
||||
export default class Links extends React.Component<IProps, {}> {
|
||||
ref: SVGGElement;
|
||||
|
||||
componentDidMount() {
|
||||
this.linkRender(this.props.links, this.props.selectedPaths);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { links } = this.props;
|
||||
if (links.length < prevProps.links.length) {
|
||||
const removeLinks = _.differenceBy(prevProps.links, links, (v: any) => v.id);
|
||||
removeLinks.forEach((removeLink) => {
|
||||
const id = removeLink.uuid;
|
||||
d3.select('#text-path-' + id).remove();
|
||||
d3.select('#text-marker' + id).remove();
|
||||
d3.select('#text-marker-id' + id).remove();
|
||||
});
|
||||
} else {
|
||||
this.linkRender(this.props.links, this.props.selectedPaths);
|
||||
}
|
||||
}
|
||||
|
||||
getNormalWidth = (d) => {
|
||||
const { selectedPaths } = this.props;
|
||||
return selectedPaths.map((path) => path.id).includes(d.id) ? 3 : 2;
|
||||
};
|
||||
|
||||
linkRender(links: IPath[], selectedPaths: IPath[]) {
|
||||
// eslint-disable-next-line
|
||||
const self = this;
|
||||
const selectPathIds = selectedPaths.map((node) => node.id);
|
||||
d3.select(this.ref)
|
||||
.selectAll('path')
|
||||
.data(links)
|
||||
.classed('active-link', (d: IPath) => selectPathIds.includes(d.id))
|
||||
.enter()
|
||||
.append('svg:path')
|
||||
.attr('pointer-events', 'visibleStroke')
|
||||
.attr('class', 'link')
|
||||
.classed('ring', (d: IPath) => d.source.name === d.target.name)
|
||||
.style('fill', 'none')
|
||||
.style('stroke', '#595959')
|
||||
.style('stroke-width', 2)
|
||||
.on('mouseover', function (d) {
|
||||
self.props.onMouseInLink(d, d3.event);
|
||||
d3.select(this).classed('hovered-link', true).style('stroke-width', 3);
|
||||
})
|
||||
.on('mouseout', function () {
|
||||
self.props.onMouseOut();
|
||||
d3.select(this).classed('hovered-link', false).style('stroke-width', self.getNormalWidth);
|
||||
})
|
||||
.attr('id', (d: any) => 'text-path-' + d.uuid);
|
||||
|
||||
d3.select(this.ref)
|
||||
.selectAll('text')
|
||||
.data(links)
|
||||
.enter()
|
||||
.append('text')
|
||||
.attr('class', 'text')
|
||||
.attr('id', (d: any) => 'text-marker-id' + d.uuid)
|
||||
.append('textPath')
|
||||
.attr('id', (d: any) => 'text-marker' + d.uuid)
|
||||
.attr('class', 'textPath');
|
||||
|
||||
if (this.ref) {
|
||||
this.props.onUpdateLinks();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <g className="links" ref={(ref: SVGTextElement) => (this.ref = ref)} />;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import * as d3 from 'd3';
|
||||
import _ from 'lodash';
|
||||
import * as React from 'react';
|
||||
|
||||
import { INode } from '@app/interfaces/explore';
|
||||
|
||||
interface IProps {
|
||||
nodes: INode[];
|
||||
onUpDataNodeTexts: () => void;
|
||||
}
|
||||
|
||||
export default class NodeTexts extends React.Component<IProps, {}> {
|
||||
ref: SVGGElement;
|
||||
|
||||
componentDidMount() {
|
||||
this.labelRender(this.props.nodes);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { nodes } = this.props;
|
||||
if (nodes.length < prevProps.nodes.length) {
|
||||
const removeNodes = _.differenceBy(prevProps.nodes, nodes, (v: any) => v.name);
|
||||
removeNodes.forEach((removeNode) => {
|
||||
d3.select('#name_' + removeNode.uuid).remove();
|
||||
});
|
||||
} else {
|
||||
this.labelRender(this.props.nodes);
|
||||
}
|
||||
}
|
||||
|
||||
labelRender(nodes) {
|
||||
d3.select(this.ref)
|
||||
.selectAll('.label')
|
||||
.data<INode>(nodes)
|
||||
.enter()
|
||||
.append('text')
|
||||
.attr('class', 'label')
|
||||
.attr('id', (d) => 'name_' + d.uuid)
|
||||
.attr('text-anchor', 'middle');
|
||||
|
||||
if (this.ref) {
|
||||
this.props.onUpDataNodeTexts();
|
||||
}
|
||||
}
|
||||
render() {
|
||||
return <g className="labels" ref={(ref: SVGGElement) => (this.ref = ref)} />;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
import * as d3 from 'd3';
|
||||
import * as React from 'react';
|
||||
|
||||
import { INode, IPath } from '@app/interfaces/explore';
|
||||
|
||||
interface IProps {
|
||||
nodes: INode[];
|
||||
links: IPath[];
|
||||
selectedPaths: IPath[];
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
scale: number;
|
||||
onSelectVertexes: (vertexes: INode[]) => void;
|
||||
onSelectEdges: (vertexes: IPath[]) => void;
|
||||
}
|
||||
/**
|
||||
* Test line and line acrosses
|
||||
* @method isIntersectedLines
|
||||
* @param a1 - The start point of the first line
|
||||
* @param a2 - The end point of the first line
|
||||
* @param b1 - The start point of the second line
|
||||
* @param b2 - The end point of the second line
|
||||
*/
|
||||
|
||||
function isIntersectedLines(a1, a2, b1, b2) {
|
||||
// b1->b2 向量 与 a1->b1向量的向量积
|
||||
const u1 = (b2.x - b1.x) * (a1.y - b1.y) - (b2.y - b1.y) * (a1.x - b1.x);
|
||||
// a1->a2向量 与 a1->b1向量的向量积
|
||||
const u2 = (a2.x - a1.x) * (a1.y - b1.y) - (a2.y - a1.y) * (a1.x - b1.x);
|
||||
// a1->a2向量 与 b1->b2向量的向量积
|
||||
const u3 = (b2.y - b1.y) * (a2.x - a1.x) - (b2.x - b1.x) * (a2.y - a1.y);
|
||||
// u3 == 0时,角度为0或者180 平行或者共线不属于相交
|
||||
if (u3 !== 0) {
|
||||
const ua = u1 / u3;
|
||||
const ub = u2 / u3;
|
||||
if (0 <= ua && ua <= 1 && 0 <= ub && ub <= 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default class SelectIds extends React.Component<IProps, {}> {
|
||||
componentDidMount() {
|
||||
const { nodes, links } = this.props;
|
||||
if (nodes.length !== 0 || links.length !== 0) {
|
||||
this.rectRender(nodes, links);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const { nodes, links } = this.props;
|
||||
if (nodes.length !== 0 || links.length !== 0) {
|
||||
this.rectRender(nodes, links);
|
||||
}
|
||||
}
|
||||
|
||||
rectRender(nodes: INode[], links: IPath[]) {
|
||||
const selectStartPosition = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
const rect = d3
|
||||
.selectAll('.rect')
|
||||
.style('stroke', 'gray')
|
||||
.style('stroke-width', '0.6')
|
||||
.style('fill', 'transparent')
|
||||
.style('stroke-opacity', '0.6');
|
||||
|
||||
d3.select('#output-graph')
|
||||
.on('mousedown', () => {
|
||||
// Prohibit right click trigger, conflict with right click menu
|
||||
if (d3.event.button === 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectStartPosition.x = d3.event.offsetX;
|
||||
selectStartPosition.y = d3.event.offsetY;
|
||||
})
|
||||
.on('mousemove', () => {
|
||||
if (selectStartPosition.x !== 0) {
|
||||
rect
|
||||
.attr('x', Math.min(d3.event.offsetX, selectStartPosition.x))
|
||||
.attr('y', Math.min(d3.event.offsetY, selectStartPosition.y))
|
||||
.attr('width', Math.abs(d3.event.offsetX - selectStartPosition.x))
|
||||
.attr('height', Math.abs(d3.event.offsetY - selectStartPosition.y));
|
||||
}
|
||||
})
|
||||
.on('mouseup', () => {
|
||||
// Prohibit right click trigger, conflict with right click menu
|
||||
if (d3.event.button === 2) {
|
||||
return;
|
||||
}
|
||||
const selectEndPosition = {
|
||||
x: d3.event.offsetX,
|
||||
y: d3.event.offsetY,
|
||||
};
|
||||
this.props.onSelectVertexes(
|
||||
nodes.filter((node) => !this.isNotSelected(node, selectStartPosition, selectEndPosition)),
|
||||
);
|
||||
if (selectStartPosition.x !== selectEndPosition.x && selectStartPosition.y !== selectEndPosition.y) {
|
||||
const _edges = [] as IPath[];
|
||||
links.forEach((link: IPath) => {
|
||||
if (
|
||||
!this.isNotSelected(link.source, selectStartPosition, selectEndPosition) &&
|
||||
!this.isNotSelected(link.target, selectStartPosition, selectEndPosition)
|
||||
) {
|
||||
// startPoint and endPoint are in rect
|
||||
_edges.push(link);
|
||||
} else if (
|
||||
this.isLinkIntersectedWithRect(link.source, link.target, selectStartPosition, selectEndPosition)
|
||||
) {
|
||||
// no point in rect but line between two point is acrossed with rect
|
||||
_edges.push(link);
|
||||
}
|
||||
});
|
||||
this.props.onSelectEdges(_edges);
|
||||
} else if (d3.event.target.nodeName === 'svg') {
|
||||
// when click on canvas, clean the select result
|
||||
this.props.onSelectEdges([]);
|
||||
}
|
||||
selectStartPosition.x = 0;
|
||||
selectStartPosition.y = 0;
|
||||
rect.attr('width', 0).attr('height', 0);
|
||||
});
|
||||
}
|
||||
|
||||
isNotSelected(nodePoint, selectStartPosition, selectEndPosition) {
|
||||
const { scale, offsetX, offsetY } = this.props;
|
||||
const x = nodePoint.x * scale + offsetX;
|
||||
const y = nodePoint.y * scale + offsetY;
|
||||
if (
|
||||
(x > selectStartPosition.x && x > selectEndPosition.x) ||
|
||||
(x < selectStartPosition.x && x < selectEndPosition.x) ||
|
||||
(y > selectStartPosition.y && y > selectEndPosition.y) ||
|
||||
(y < selectStartPosition.y && y < selectEndPosition.y)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isLinkIntersectedWithRect = (source, target, selectStartPosition, selectEndPosition) => {
|
||||
const { scale, offsetX, offsetY } = this.props;
|
||||
const startPoint = {
|
||||
x: source.x * scale + offsetX,
|
||||
y: source.y * scale + offsetY,
|
||||
};
|
||||
const endPoint = {
|
||||
x: target.x * scale + offsetX,
|
||||
y: target.y * scale + offsetY,
|
||||
};
|
||||
const r0 = {
|
||||
x: selectStartPosition.x,
|
||||
y: selectStartPosition.y,
|
||||
};
|
||||
const r1 = {
|
||||
x: selectStartPosition.x,
|
||||
y: selectEndPosition.y,
|
||||
};
|
||||
const r2 = {
|
||||
x: selectEndPosition.x,
|
||||
y: selectStartPosition.y,
|
||||
};
|
||||
const r3 = {
|
||||
x: selectEndPosition.x,
|
||||
y: selectEndPosition.y,
|
||||
};
|
||||
if (isIntersectedLines(startPoint, endPoint, r0, r1)) {
|
||||
return true;
|
||||
}
|
||||
if (isIntersectedLines(startPoint, endPoint, r1, r2)) {
|
||||
return true;
|
||||
}
|
||||
if (isIntersectedLines(startPoint, endPoint, r2, r3)) {
|
||||
return true;
|
||||
}
|
||||
if (isIntersectedLines(startPoint, endPoint, r3, r0)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
render() {
|
||||
return <rect className="rect" />;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
#output-graph {
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
|
||||
.links path {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.nebula-d3-nodes {
|
||||
cursor: pointer;
|
||||
stroke: #fff;
|
||||
stroke-width: 2.5;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.node {
|
||||
.circle {
|
||||
&.active {
|
||||
stroke: rgba(0, 0, 0, 0.5);
|
||||
stroke-width: 6;
|
||||
r: 20;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.labels text {
|
||||
line-height: 40px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
fill: #333;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #333;
|
||||
pointer-events: none;
|
||||
text-anchor: middle;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.graph-btn {
|
||||
float: right;
|
||||
margin-right: 20px;
|
||||
|
||||
i {
|
||||
font-size: 16px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.cursor-move {
|
||||
cursor: move;
|
||||
}
|
||||
|
|
@ -0,0 +1,484 @@
|
|||
import * as d3 from 'd3';
|
||||
import * as React from 'react';
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
|
||||
import IconCfg from '@app/components/IconPicker/iconCfg';
|
||||
import Menu from '@app/pages/Explore/NebulaGraph/Menu';
|
||||
import { INode, IPath } from '@app/interfaces/explore';
|
||||
|
||||
import './index.less';
|
||||
import Links from './Links';
|
||||
import Labels from './NodeTexts';
|
||||
import SelectIds from './SelectIds';
|
||||
|
||||
interface IProps {
|
||||
width: number;
|
||||
height: number;
|
||||
data: {
|
||||
vertexes: INode[];
|
||||
edges: IPath[];
|
||||
};
|
||||
showTagFields: string[];
|
||||
showEdgeFields: string[];
|
||||
selectedNodes: INode[];
|
||||
selectedPaths: IPath[];
|
||||
onSelectVertexes: (vertexes: INode[]) => void;
|
||||
onSelectEdges: (edges: IPath[]) => void;
|
||||
onMouseInNode: (node: INode, event: MouseEvent) => void;
|
||||
onMouseOut: () => void;
|
||||
onMouseInLink: (link: IPath, event: MouseEvent) => void;
|
||||
onDblClickNode: () => void;
|
||||
}
|
||||
|
||||
const NebulaD3: React.FC<IProps> = observer((props) => {
|
||||
const { d3Graph } = useStore();
|
||||
const { canvasOffsetX: offsetX, canvasOffsetY: offsetY, isZoom, canvasScale: scale } = d3Graph;
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
showTagFields,
|
||||
showEdgeFields,
|
||||
selectedNodes,
|
||||
selectedPaths,
|
||||
onSelectVertexes,
|
||||
onSelectEdges,
|
||||
onMouseInNode,
|
||||
onMouseOut,
|
||||
onMouseInLink,
|
||||
onDblClickNode,
|
||||
} = props;
|
||||
|
||||
const nodeRef = useRef<SVGGElement>(null);
|
||||
const canvasBoardRef = useRef<SVGCircleElement>(null);
|
||||
const forceRef = useRef<any>(null);
|
||||
const svgRef = useRef<any>(null);
|
||||
|
||||
const linkRef = useRef<any>(null);
|
||||
const linksTextRef = useRef<any>(null);
|
||||
const iconTextRef = useRef<any>(null);
|
||||
const circleNodeRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
svgRef.current = d3.select('#output-graph');
|
||||
initMarker();
|
||||
d3.select('.nebula-d3-canvas').attr('transform', `translate(${offsetX},${offsetY}) scale(${scale})`);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
computeDataByD3Force();
|
||||
}, [props]);
|
||||
|
||||
useEffect(() => {
|
||||
handleDeleteNodes(data.vertexes);
|
||||
handleUpdateNodes(data.vertexes, selectedNodes);
|
||||
handleUpdateIcons(data.vertexes);
|
||||
if (forceRef.current) {
|
||||
forceRef.current.on('tick', tick);
|
||||
}
|
||||
}, [props]);
|
||||
|
||||
const handleNodeClick = (d: any) => {
|
||||
const event = d3.event;
|
||||
if (event.shiftKey) {
|
||||
const data = selectedNodes.find((n) => n.name === d.name)
|
||||
? selectedNodes.filter((n) => n.name !== d.name)
|
||||
: [...selectedNodes, d];
|
||||
onSelectVertexes(data);
|
||||
} else {
|
||||
onSelectVertexes([d]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdgeClick = (d: any) => {
|
||||
const event = d3.event;
|
||||
if (event.shiftKey) {
|
||||
const data = selectedPaths.find((n) => n.id === d.id)
|
||||
? selectedPaths.filter((n) => n.id !== d.id)
|
||||
: [...selectedPaths, d];
|
||||
onSelectEdges(data);
|
||||
} else {
|
||||
onSelectEdges([d]);
|
||||
}
|
||||
};
|
||||
|
||||
const dragged = (d) => {
|
||||
d.fx = d3.event.x;
|
||||
d.fy = d3.event.y;
|
||||
d.isFixed = true;
|
||||
};
|
||||
|
||||
const dragstart = (d: any) => {
|
||||
if (!d3.event.active) {
|
||||
forceRef.current.alphaTarget(0.6).restart();
|
||||
}
|
||||
return d;
|
||||
};
|
||||
|
||||
const dragEnded = () => {
|
||||
if (!d3.event.active) {
|
||||
forceRef.current.alphaTarget(0);
|
||||
}
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
linkRef.current?.attr('d', (d: any) => {
|
||||
if (d.target.name === d.source.name) {
|
||||
const param = d.size > 1 ? 50 : 30;
|
||||
const dr = param / d.linknum;
|
||||
return (
|
||||
'M' + d.source.x + ',' + d.source.y + 'A' + dr + ',' + dr + ' 0 1,1 ' + d.target.x + ',' + (d.target.y + 1)
|
||||
);
|
||||
} else if (d.size % 2 !== 0 && d.linknum === 1) {
|
||||
return 'M ' + d.source.x + ' ' + d.source.y + ' L ' + d.target.x + ' ' + d.target.y;
|
||||
}
|
||||
const curve = 3;
|
||||
const homogeneous = 0.5;
|
||||
const dx = d.target.x - d.source.x;
|
||||
const dy = d.target.y - d.source.y;
|
||||
const dr = (Math.sqrt(dx * dx + dy * dy) * (d.linknum + homogeneous)) / (curve * homogeneous);
|
||||
if (d.linknum < 0) {
|
||||
const dr = (Math.sqrt(dx * dx + dy * dy) * (-1 * d.linknum + homogeneous)) / (curve * homogeneous);
|
||||
return 'M' + d.source.x + ',' + d.source.y + 'A' + dr + ',' + dr + ' 0 0,0 ' + d.target.x + ',' + d.target.y;
|
||||
}
|
||||
return 'M' + d.source.x + ',' + d.source.y + 'A' + dr + ',' + dr + ' 0 0, 1 ' + d.target.x + ',' + d.target.y;
|
||||
});
|
||||
circleNodeRef.current?.attr('cx', (d) => d.x).attr('cy', (d) => d.y);
|
||||
iconTextRef.current?.attr('x', (d) => d.x).attr('y', (d) => d.y);
|
||||
|
||||
d3.selectAll('.text')
|
||||
.attr('transform-origin', (d: any) => {
|
||||
return `${(d.source.x + d.target.x) / 2} ${(d.source.y + d.target.y) / 2}`;
|
||||
})
|
||||
.attr('rotate', (d: any) => {
|
||||
if (d.source.x - d.target.x > 0) {
|
||||
return 180;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
linksTextRef.current
|
||||
?.attr('x', (d: any) => {
|
||||
return (d.source.x + d.target.x) / 2;
|
||||
})
|
||||
.attr('y', (d: any) => {
|
||||
return (d.source.y + d.target.y) / 2;
|
||||
})
|
||||
.text((d: any) => {
|
||||
if (d.source.x - d.target.x > 0) {
|
||||
return edgeName(d).join(' & ').split('').reverse().join('') || d.type.split('').reverse().join('');
|
||||
}
|
||||
return edgeName(d).join(' & ') || d.type;
|
||||
});
|
||||
nodeRenderText();
|
||||
};
|
||||
|
||||
const handleDeleteNodes = (nodes: INode[]) => {
|
||||
const currentNodes = d3.selectAll('.node');
|
||||
if (nodes.length === 0) {
|
||||
currentNodes.remove();
|
||||
return;
|
||||
} else if (currentNodes.size() > nodes.length) {
|
||||
const ids = nodes.map((i) => i.name);
|
||||
const deleteNodes = currentNodes.filter((data: any) => {
|
||||
return !ids.includes(data.name);
|
||||
});
|
||||
deleteNodes.remove();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateNodes = (nodes: INode[], selectNodes: INode[]) => {
|
||||
const selectNodeIds = selectNodes.map((node) => node.uuid);
|
||||
d3.select(nodeRef.current)
|
||||
.selectAll('circle')
|
||||
.data(nodes)
|
||||
.style('fill', (d: INode) => d.color)
|
||||
.classed('active', (d: INode) => selectNodeIds.includes(d.uuid))
|
||||
.attr('id', (d: INode) => `circle-${d.uuid}`)
|
||||
.enter()
|
||||
.append<SVGGElement>('g')
|
||||
.attr('id', (d: INode) => `node_${d.uuid}`)
|
||||
.attr('class', 'node')
|
||||
.append<SVGCircleElement>('circle')
|
||||
.attr('class', 'circle')
|
||||
.attr('r', 20)
|
||||
.style('fill', (d: INode) => d.color) // HACK: Color distortion caused by delete node
|
||||
.on('mouseover', (d: INode) => {
|
||||
if (onMouseInNode) {
|
||||
onMouseInNode(d, d3.event);
|
||||
}
|
||||
})
|
||||
.on('mouseout', () => {
|
||||
if (onMouseOut) {
|
||||
onMouseOut();
|
||||
}
|
||||
});
|
||||
|
||||
d3.select(nodeRef.current)
|
||||
.selectAll('g')
|
||||
.data(nodes)
|
||||
.classed('active-node', (d: INode) => selectNodeIds.includes(d.uuid));
|
||||
|
||||
circleNodeRef.current = d3
|
||||
.selectAll('.circle')
|
||||
.on('click', handleNodeClick)
|
||||
.on('dblclick', onDblClickNode)
|
||||
.call(
|
||||
d3
|
||||
.drag()
|
||||
.on('start', (d) => dragstart(d))
|
||||
.on('drag', (d) => dragged(d))
|
||||
.on('end', dragEnded) as any,
|
||||
);
|
||||
};
|
||||
|
||||
const handleUpdateIcons = (nodes: INode[]) => {
|
||||
nodes.forEach((a) => {
|
||||
if (
|
||||
a.icon &&
|
||||
!d3
|
||||
.select('#node_' + a.uuid)
|
||||
.select('.icon')
|
||||
.node()
|
||||
) {
|
||||
d3.selectAll('#node_' + a.uuid)
|
||||
.append('text')
|
||||
.attr('class', 'icon')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'central')
|
||||
.attr('stroke', 'black')
|
||||
.attr('stroke-width', '0.00001%')
|
||||
.attr('font-family', 'nebula-cloud-icon')
|
||||
.attr('x', (d: any) => d.x)
|
||||
.attr('y', (d: any) => d.y)
|
||||
.attr('id', (d: any) => d.uuid)
|
||||
.attr('font-size', '20px')
|
||||
.text(IconCfg.filter((icon) => icon.type === a.icon)[0].content);
|
||||
}
|
||||
});
|
||||
if (d3.selectAll('.icon').node()) {
|
||||
iconTextRef.current = d3
|
||||
.selectAll('.icon')
|
||||
.on('click', handleNodeClick)
|
||||
.on('dblclick', onDblClickNode)
|
||||
.call(
|
||||
d3
|
||||
.drag()
|
||||
.on('start', (d) => dragstart(d))
|
||||
.on('drag', (d) => dragged(d))
|
||||
.on('end', dragEnded) as any,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdataNodeTexts = () => {
|
||||
if (forceRef.current) {
|
||||
d3.selectAll('.label')
|
||||
.on('click', handleNodeClick)
|
||||
.on('mouseover', () => {
|
||||
if (onMouseOut) {
|
||||
onMouseOut();
|
||||
}
|
||||
})
|
||||
.call(
|
||||
d3
|
||||
.drag()
|
||||
.on('start', (d) => dragstart(d))
|
||||
.on('drag', (d) => dragged(d))
|
||||
.on('end', dragEnded) as any,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateLinks = () => {
|
||||
if (forceRef.current) {
|
||||
linkRef.current = d3.selectAll('.link').on('click', handleEdgeClick);
|
||||
d3.selectAll('.link:not(.active-link):not(.hovered-link)')
|
||||
.attr('marker-end', 'url(#marker)')
|
||||
.style('stroke', '#595959')
|
||||
.style('stroke-width', 2);
|
||||
d3.selectAll('.link.active-link')
|
||||
.attr('marker-end', 'url(#marker-actived)')
|
||||
.style('stroke', '#1664ff')
|
||||
.style('stroke-width', 3);
|
||||
linksTextRef.current = d3
|
||||
.selectAll('.text')
|
||||
.selectAll('.textPath')
|
||||
.attr(':href', (d: any) => '#text-path-' + d.uuid)
|
||||
.attr('startOffset', '50%');
|
||||
}
|
||||
};
|
||||
|
||||
const computeDataByD3Force = () => {
|
||||
const linkForce = d3
|
||||
.forceLink(data.edges)
|
||||
.id((d: any) => {
|
||||
return d.name;
|
||||
})
|
||||
.distance(210);
|
||||
if (!forceRef.current) {
|
||||
forceRef.current = d3
|
||||
.forceSimulation()
|
||||
.force('charge', d3.forceManyBody().strength(-20))
|
||||
.force('collide', d3.forceCollide().radius(35).iterations(2));
|
||||
}
|
||||
forceRef.current.nodes(data.vertexes).force('link', linkForce).restart();
|
||||
};
|
||||
|
||||
const isIncludeField = (node, field) => {
|
||||
let isInclude = false;
|
||||
if (node.nodeProp && node.nodeProp.properties) {
|
||||
const properties = node.nodeProp.properties;
|
||||
isInclude = Object.keys(properties).some((v) => {
|
||||
const valueObj = properties[v];
|
||||
return Object.keys(valueObj).some((nodeField) => field === v + '.' + nodeField);
|
||||
});
|
||||
}
|
||||
return isInclude;
|
||||
};
|
||||
|
||||
const edgeName = (edge) => {
|
||||
const edgeText: any = [];
|
||||
if (showEdgeFields.includes(`${edge.type}.type`)) {
|
||||
if (showEdgeFields.includes(`${edge.type}._rank`)) {
|
||||
edgeText.push(`${edge.type}@${edge.rank}`);
|
||||
} else {
|
||||
edgeText.push(edge.type);
|
||||
}
|
||||
}
|
||||
|
||||
showEdgeFields.forEach((field) => {
|
||||
Object.keys(edge.edgeProp.properties).forEach((property) => {
|
||||
if (field === `${edge.type}.${property}`) {
|
||||
edgeText.push(edge.edgeProp.properties[property]);
|
||||
}
|
||||
});
|
||||
});
|
||||
return edgeText;
|
||||
};
|
||||
|
||||
const targetName = (node, field) => {
|
||||
let nodeText = '';
|
||||
const properties = node.nodeProp.properties;
|
||||
Object.keys(properties).some((property) => {
|
||||
const value = properties[property];
|
||||
return Object.keys(value).some((nodeField) => {
|
||||
const fieldStr = property + '.' + nodeField;
|
||||
if (fieldStr === field) {
|
||||
nodeText = `${value[nodeField]}`;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
return nodeText;
|
||||
};
|
||||
|
||||
const nodeRenderText = () => {
|
||||
d3.selectAll('tspan').remove();
|
||||
data.vertexes.forEach((node: any) => {
|
||||
let line = 1;
|
||||
if (node.nodeProp) {
|
||||
showTagFields.forEach((field) => {
|
||||
if (isIncludeField(node, field)) {
|
||||
line++;
|
||||
d3.select('#name_' + node.uuid)
|
||||
.append('tspan')
|
||||
.attr('x', (d: any) => d.x)
|
||||
.attr('y', (d: any) => d.y - 20 + 20 * line)
|
||||
.attr('dy', '1em')
|
||||
.text((d) => targetName(d, field));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// const iconRenderText = () => {
|
||||
// data.vertexes.forEach((node: any) => {
|
||||
// if (node.nodeProp) {
|
||||
// d3.select('#icon_' + node.uuid)
|
||||
// .append('tspan')
|
||||
// .attr('x', (d: any) => d.x)
|
||||
// .attr('y', (d: any) => d.y)
|
||||
// .attr('dy', '1em');
|
||||
// }
|
||||
// });
|
||||
// };
|
||||
|
||||
const initMarker = () => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
const defs = svg.append('defs');
|
||||
defs
|
||||
.append('marker')
|
||||
.attr('id', 'marker')
|
||||
.attr('markerUnits', 'userSpaceOnUse')
|
||||
.attr('viewBox', '-20 -10 20 20')
|
||||
.attr('refX', 20)
|
||||
.attr('refY', 0)
|
||||
.attr('orient', 'auto')
|
||||
.attr('markerWidth', 20)
|
||||
.attr('markerHeight', 20)
|
||||
.attr('xoverflow', 'visible')
|
||||
.append('path')
|
||||
.attr('d', 'M-10, -5 L 0,0 L -10, 5')
|
||||
.attr('fill', '#595959')
|
||||
.attr('stroke', '#595959');
|
||||
defs
|
||||
.append('marker')
|
||||
.attr('id', 'marker-actived')
|
||||
.attr('markerUnits', 'userSpaceOnUse')
|
||||
.attr('viewBox', '-20 -10 20 20')
|
||||
.attr('refX', 25.5)
|
||||
.attr('refY', 0)
|
||||
.attr('orient', 'auto')
|
||||
.attr('markerWidth', 16)
|
||||
.attr('markerHeight', 16)
|
||||
.attr('xoverflow', 'visible')
|
||||
.append('path')
|
||||
.attr('d', 'M-16, -8 L 0,0 L -16, 8')
|
||||
.attr('fill', '#0091FF')
|
||||
.attr('stroke', '#0091FF')
|
||||
.attr('stroke-opacity', '0.6')
|
||||
.attr('fill-opacity', '0.9');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<svg
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
id="output-graph"
|
||||
className={isZoom ? 'cursor-move' : undefined}
|
||||
width={width}
|
||||
height={height}
|
||||
>
|
||||
<g className="nebula-d3-canvas" ref={canvasBoardRef}>
|
||||
<Links
|
||||
links={data.edges}
|
||||
selectedPaths={selectedPaths}
|
||||
onUpdateLinks={handleUpdateLinks}
|
||||
onMouseInLink={onMouseInLink}
|
||||
onMouseOut={onMouseOut}
|
||||
/>
|
||||
<g className="nebula-d3-nodes" ref={nodeRef} />
|
||||
<Labels nodes={data.vertexes} onUpDataNodeTexts={handleUpdataNodeTexts} />
|
||||
</g>
|
||||
<SelectIds
|
||||
nodes={data.vertexes}
|
||||
links={data.edges}
|
||||
offsetX={offsetX}
|
||||
offsetY={offsetY}
|
||||
scale={scale}
|
||||
onSelectVertexes={onSelectVertexes}
|
||||
onSelectEdges={onSelectEdges}
|
||||
selectedPaths={selectedPaths}
|
||||
/>
|
||||
</svg>
|
||||
<Menu width={width} height={height} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default NebulaD3;
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { useCallback, useImperativeHandle, forwardRef } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
import MenuButton from '@app/components/Button';
|
||||
|
||||
interface IProps {
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
const DeleteBtn = forwardRef(({ showTitle }: IProps, ref) => {
|
||||
const { explore } = useStore();
|
||||
const { selectVertexes, selectEdges, deleteNodesAndEdges } = explore;
|
||||
|
||||
const onKeydown = useCallback(
|
||||
(onKeydownDelete?: () => void) => {
|
||||
if (selectVertexes.length > 0 || selectEdges.length > 0) {
|
||||
deleteNodesAndEdges();
|
||||
onKeydownDelete?.();
|
||||
// toolTipRef?.style('visibility', 'hidden');
|
||||
}
|
||||
},
|
||||
[selectVertexes, selectEdges, deleteNodesAndEdges],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
onKeydown,
|
||||
}),
|
||||
[onKeydown],
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuButton
|
||||
tips={!showTitle ? intl.get('common.delete') : undefined}
|
||||
iconfont="iconstudio-seletdelete"
|
||||
action={deleteNodesAndEdges}
|
||||
title={showTitle ? intl.get('common.delete') : undefined}
|
||||
trackCategory="explore"
|
||||
trackAction="node_delete"
|
||||
trackLabel="from_panel"
|
||||
disabled={selectVertexes.length === 0 && selectEdges.length === 0}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default observer(DeleteBtn);
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { useImperativeHandle, forwardRef } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
|
||||
import MenuButton from '@app/components/Button';
|
||||
|
||||
interface IProps {
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
const ExpandBtn = forwardRef(({ showTitle }: IProps, ref) => {
|
||||
const { explore } = useStore();
|
||||
const { selectVertexes, asyncAutoExpand } = explore;
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
onKeydown: () => {
|
||||
if (selectVertexes.length > 0) {
|
||||
asyncAutoExpand();
|
||||
}
|
||||
},
|
||||
}),
|
||||
[selectVertexes, asyncAutoExpand],
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuButton
|
||||
tips={!showTitle ? intl.get('explore.expand') : undefined}
|
||||
iconfont="iconstudio-expand"
|
||||
title={showTitle ? intl.get('explore.expand') : undefined}
|
||||
action={asyncAutoExpand}
|
||||
trackCategory="explore"
|
||||
trackAction="graph_expand"
|
||||
trackLabel="from_panel"
|
||||
disabled={selectVertexes.length === 0}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default observer(ExpandBtn);
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
:root .ant-dropdown-trigger > .anticon.btn-icon {
|
||||
right: 10px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid #d9d9d9;
|
||||
}
|
||||
|
||||
.btn-export {
|
||||
width: 105px;
|
||||
position: relative;
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { Button, Dropdown, Menu, message } from 'antd';
|
||||
import * as d3 from 'd3';
|
||||
import { saveAs } from 'file-saver';
|
||||
import React from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
import IconFont from '@app/components/UnicodeIcon';
|
||||
import { exportDataToCSV } from '@app/config/explore';
|
||||
import { trackEvent } from '@app/utils/stat';
|
||||
import { DownOutlined } from '@ant-design/icons';
|
||||
import './index.less';
|
||||
|
||||
function save(dataBlob, _filesize) {
|
||||
saveAs(dataBlob, 'Graph.png');
|
||||
}
|
||||
|
||||
const svgToDataURL = (svgString) => {
|
||||
// 使用 TextEncoder 将字符串转为 UTF-8 的 Uint8Array
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(svgString);
|
||||
// 将 Uint8Array 转换为二进制字符串
|
||||
const binaryString = Array.from(data, (byte) => String.fromCharCode(byte)).join('');
|
||||
// Base64 编码
|
||||
const base64 = btoa(binaryString);
|
||||
return `data:image/svg+xml;base64,${base64}`;
|
||||
};
|
||||
|
||||
function svgString2Image(svgString, size, callback) {
|
||||
const imgsrc = svgToDataURL(svgString); // Convert SVG string to data URL
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d') as any;
|
||||
const { width, height } = size;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
// fill white backgroud color
|
||||
context.globalCompositeOperation = 'destination-over';
|
||||
context.fillStyle = '#fff';
|
||||
context.fillRect(0, 0, width, height);
|
||||
|
||||
canvas.toBlob((blob: any) => {
|
||||
if (!blob) {
|
||||
// TODO: toBlob return null when size of canvas is to large,like 20000 * 20000
|
||||
return message.warning(intl.get('explore.toBlobError'));
|
||||
}
|
||||
const filesize = Math.round(blob.length / 1024) + ' KB';
|
||||
if (callback) {
|
||||
trackEvent('explore', 'export_graph_png');
|
||||
callback(blob, filesize);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
image.src = imgsrc;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const ExportButton: React.FC<IProps> = ({ disabled }) => {
|
||||
const { explore } = useStore();
|
||||
const { vertexes, edges } = explore;
|
||||
|
||||
const handleExportImg = () => {
|
||||
const svg = d3.select('#output-graph') as any;
|
||||
if (svg) {
|
||||
const _svgNode = svg.node().cloneNode(true);
|
||||
const size = svg.node().getBBox();
|
||||
_svgNode.setAttribute('viewBox', size.x + ' ' + size.y + ' ' + size.width + ' ' + size.height);
|
||||
_svgNode.setAttribute('width', size.width);
|
||||
_svgNode.setAttribute('height', size.height);
|
||||
const serializer = new XMLSerializer();
|
||||
const svgString = serializer.serializeToString(_svgNode);
|
||||
svgString2Image(svgString, size, save);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCSV = () => {
|
||||
exportDataToCSV(vertexes, 'vertex');
|
||||
exportDataToCSV(edges, 'edge');
|
||||
};
|
||||
|
||||
const menu = (
|
||||
<Menu>
|
||||
<Menu.Item
|
||||
key="img"
|
||||
disabled={disabled}
|
||||
data-track-category="explore"
|
||||
data-track-action="export_img"
|
||||
data-track-label="from_control"
|
||||
onClick={handleExportImg}
|
||||
>
|
||||
<IconFont type="iconstudio-exportimage" style={{ marginRight: 8 }} />
|
||||
{intl.get('explore.exportToImg')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="csv"
|
||||
disabled={disabled}
|
||||
data-track-category="explore"
|
||||
data-track-action="export_csv"
|
||||
data-track-label="from_control"
|
||||
onClick={handleExportCSV}
|
||||
>
|
||||
<IconFont type="iconstudio-exportcsv" style={{ marginRight: 8 }} />
|
||||
{intl.get('explore.exportToCSV')}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown overlay={menu}>
|
||||
<Button className="btn-export">
|
||||
<span style={{ marginRight: 20 }}>{intl.get('explore.export')}</span>
|
||||
<DownOutlined className="btn-icon" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(ExportButton);
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { Table, Modal } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import MenuButton from '@app/components/Button';
|
||||
import { HOT_KEYS } from '@app/config/explore';
|
||||
|
||||
const HotKeysDesc: React.FC = () => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: intl.get('common.operation'),
|
||||
dataIndex: 'operation',
|
||||
align: 'center' as const,
|
||||
width: '50%',
|
||||
},
|
||||
{
|
||||
title: intl.get('common.description'),
|
||||
dataIndex: 'desc',
|
||||
align: 'center' as const,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton
|
||||
tips={intl.get('common.hotKeys')}
|
||||
iconfont="iconstudio-hotkey"
|
||||
trackCategory="explore"
|
||||
trackAction="hot_keys_show"
|
||||
trackLabel="from_panel"
|
||||
action={handleOpenModal}
|
||||
/>
|
||||
<Modal
|
||||
width="700px"
|
||||
open={visible}
|
||||
title={intl.get('explore.hotKeysInstructions')}
|
||||
footer={null}
|
||||
onCancel={() => setVisible(false)}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Table
|
||||
dataSource={HOT_KEYS(intl)}
|
||||
columns={columns}
|
||||
rowKey={(_, index) => index.toString()}
|
||||
pagination={false}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HotKeysDesc;
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { useCallback, useImperativeHandle, forwardRef } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
|
||||
import MenuButton from '@app/components/Button';
|
||||
|
||||
interface IProps {
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
const LockBtn = forwardRef(({ showTitle }: IProps, ref) => {
|
||||
const { explore } = useStore();
|
||||
const { selectVertexes, update } = explore;
|
||||
|
||||
const handleLock = useCallback(() => {
|
||||
const vertexes = selectVertexes.map((selectVertexe: any) => {
|
||||
selectVertexe.fx = selectVertexe.x;
|
||||
selectVertexe.fy = selectVertexe.y;
|
||||
selectVertexe.isFixed = true;
|
||||
return selectVertexe;
|
||||
});
|
||||
update({ selectVertexes: vertexes });
|
||||
}, [selectVertexes, update]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeydown: handleLock,
|
||||
}), [handleLock]);
|
||||
|
||||
return (
|
||||
<MenuButton
|
||||
tips={!showTitle ? intl.get('common.lock') : undefined}
|
||||
iconfont="iconstudio-lock"
|
||||
title={showTitle ? intl.get('common.lock') : undefined}
|
||||
action={handleLock}
|
||||
trackCategory="explore"
|
||||
trackAction="node_lock"
|
||||
trackLabel="from_panel"
|
||||
disabled={selectVertexes.every((selectVertexe: any) => selectVertexe.isFixed)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default observer(LockBtn);
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import * as d3 from 'd3';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
|
||||
import MenuButton from '@app/components/Button';
|
||||
import { MAX_SCALE, MIN_SCALE } from '@app/config/explore';
|
||||
|
||||
interface IProps {
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
const MoveBtn: React.FC<IProps> = observer(({ showTitle }) => {
|
||||
const { d3Graph, explore } = useStore();
|
||||
const { canvasScale: scale, isZoom } = d3Graph;
|
||||
const { vertexes } = explore;
|
||||
const scaleRef = useRef(scale);
|
||||
|
||||
// 事件监听器 changeTranslatePosition 无法得到正确的 scale
|
||||
useEffect(() => {
|
||||
scaleRef.current = scale;
|
||||
}, [scale]);
|
||||
|
||||
const handleStartZoom = () => {
|
||||
const svg = d3.select('#output-graph') as any;
|
||||
if (isZoom) {
|
||||
svg.on('.zoom', null);
|
||||
d3Graph.update({
|
||||
isZoom: false,
|
||||
});
|
||||
} else {
|
||||
svg.call(
|
||||
d3
|
||||
.zoom()
|
||||
.scaleExtent([MIN_SCALE, MAX_SCALE])
|
||||
.on('zoom', changeTranslatePosition)
|
||||
.on('end', () => {
|
||||
d3Graph.update({
|
||||
canvasOffsetX: d3.event.transform.x,
|
||||
canvasOffsetY: d3.event.transform.y,
|
||||
// canvasScale: d3.event.transform.k,
|
||||
});
|
||||
}),
|
||||
);
|
||||
d3Graph.update({
|
||||
isZoom: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const changeTranslatePosition = () => {
|
||||
d3.select('.nebula-d3-canvas').attr(
|
||||
'transform',
|
||||
`translate(${d3.event.transform.x},${d3.event.transform.y}) scale(${scaleRef.current ?? scale})`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuButton
|
||||
tips={intl.get('common.move')}
|
||||
iconfont="iconstudio-moving"
|
||||
action={handleStartZoom}
|
||||
disabled={vertexes.length === 0}
|
||||
trackCategory="explore"
|
||||
trackAction="canvas_move"
|
||||
trackLabel="from_panel"
|
||||
title={showTitle ? intl.get('common.move') : undefined}
|
||||
active={isZoom ? true : false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default MoveBtn;
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
.modal-setting-fields {
|
||||
.ant-modal-header {
|
||||
padding: 0;
|
||||
|
||||
.ant-tabs {
|
||||
//padding-left: 32px;
|
||||
|
||||
.ant-tabs-bar {
|
||||
margin: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
position: relative;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 90px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import { Button, Tabs, Modal } from 'antd';
|
||||
import { useState, useCallback, useImperativeHandle, forwardRef } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
import MenuButton from '@app/components/Button';
|
||||
import './index.less';
|
||||
import Setting from './setting';
|
||||
import { IndexType, ISchemaEnum } from '@app/interfaces/schema';
|
||||
|
||||
const TabPane = Tabs.TabPane;
|
||||
|
||||
interface IProps {
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
const PropsDisplayBtn = forwardRef(({ showTitle }: IProps, ref) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { schema, explore } = useStore();
|
||||
const { tagsFields, edgesFields, tags, edgeTypes, getTagsFields, getEdgeTypesFields } = schema;
|
||||
const { showTagFields, showEdgeFields, update } = explore;
|
||||
const [tagType, setTagType] = useState<IndexType>(ISchemaEnum.Tag);
|
||||
const [_showTagFields, setShowTagFields] = useState<string[]>([]);
|
||||
const [_showEdgeFields, setShowEdgeFields] = useState<string[]>([]);
|
||||
|
||||
// 初始化checkbox
|
||||
const initCheckbox = useCallback(() => {
|
||||
setShowTagFields(showTagFields);
|
||||
setShowEdgeFields(showEdgeFields);
|
||||
}, [showTagFields, showEdgeFields]);
|
||||
|
||||
// 获取字段
|
||||
const handleGetFields = useCallback(
|
||||
async (type: string) => {
|
||||
if (type === ISchemaEnum.Tag) {
|
||||
await getTagsFields({ tags });
|
||||
} else {
|
||||
await getEdgeTypesFields({ edgeTypes });
|
||||
}
|
||||
},
|
||||
[tags, edgeTypes, getTagsFields, getEdgeTypesFields],
|
||||
);
|
||||
|
||||
// 处理打开modal
|
||||
const handleOpenModal = useCallback(async () => {
|
||||
await handleGetFields(tagType);
|
||||
initCheckbox();
|
||||
setVisible(true);
|
||||
}, [handleGetFields, initCheckbox]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
onKeydown: handleOpenModal,
|
||||
}),
|
||||
[handleOpenModal],
|
||||
);
|
||||
|
||||
// 处理关闭modal
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
initCheckbox();
|
||||
};
|
||||
|
||||
// 切换tab
|
||||
const handleChangeType = async (key: string) => {
|
||||
await handleGetFields(key);
|
||||
setTagType(key as IndexType);
|
||||
};
|
||||
|
||||
// 更新显示字段
|
||||
const confirm = () => {
|
||||
update({
|
||||
showTagFields: _showTagFields,
|
||||
showEdgeFields: _showEdgeFields,
|
||||
});
|
||||
handleClose();
|
||||
};
|
||||
|
||||
// 选择字段
|
||||
const handleUpdateSelectedFields = (value: string[]) => {
|
||||
if (tagType === ISchemaEnum.Tag) {
|
||||
setShowTagFields(value);
|
||||
} else {
|
||||
setShowEdgeFields(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton
|
||||
tips={!showTitle ? intl.get('common.show') : undefined}
|
||||
iconfont="iconstudio-show"
|
||||
title={showTitle ? intl.get('common.show') : undefined}
|
||||
trackCategory="explore"
|
||||
trackAction="props_display"
|
||||
trackLabel="from_panel"
|
||||
action={handleOpenModal}
|
||||
/>
|
||||
<Modal
|
||||
afterClose={handleClose}
|
||||
className="modal-setting-fields"
|
||||
width="700px"
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
title={
|
||||
<Tabs onChange={handleChangeType} activeKey={tagType}>
|
||||
<TabPane tab={intl.get('explore.showTags')} key={ISchemaEnum.Tag} />
|
||||
<TabPane tab={intl.get('explore.showEdges')} key={ISchemaEnum.Edge} />
|
||||
</Tabs>
|
||||
}
|
||||
footer={null}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Setting
|
||||
value={tagType === ISchemaEnum.Tag ? _showTagFields : _showEdgeFields}
|
||||
fields={tagType === ISchemaEnum.Tag ? tagsFields : edgesFields}
|
||||
onNameChange={handleUpdateSelectedFields}
|
||||
/>
|
||||
<Button onClick={confirm} type="primary" className="btn-confirm">
|
||||
{intl.get('common.confirm')}
|
||||
</Button>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default observer(PropsDisplayBtn);
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
.checkbox-props {
|
||||
display: block;
|
||||
|
||||
.props {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 27px;
|
||||
|
||||
.ant-col {
|
||||
margin-bottom: 5px;
|
||||
|
||||
.ant-checkbox-wrapper {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { Checkbox, Col, Row } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
import './setting.less';
|
||||
interface IProps {
|
||||
value: string[];
|
||||
fields: any[];
|
||||
onNameChange: (showFields) => void;
|
||||
}
|
||||
|
||||
const Setting: React.FC<IProps> = ({ value, fields, onNameChange }) => {
|
||||
const nameToOptions = (tag: string, name: any) => {
|
||||
if (!Array.isArray(name)) {
|
||||
return;
|
||||
}
|
||||
return name.map((item) => (
|
||||
<Col key={`${tag}.${item}`} span={12}>
|
||||
<Checkbox value={`${tag}.${item}`} disabled={!value.includes(`${tag}.type`) && item === '_rank'}>
|
||||
{`${tag}.${item}`}
|
||||
</Checkbox>
|
||||
</Col>
|
||||
));
|
||||
};
|
||||
|
||||
const renderTagName = () => {
|
||||
if (!Array.isArray(fields)) {
|
||||
return;
|
||||
}
|
||||
return fields.map((item) => {
|
||||
const tag = Object.keys(item)[0];
|
||||
const name = Object.values(item)[0];
|
||||
return (
|
||||
<div key={tag}>
|
||||
<h3>{tag}</h3>
|
||||
<Row>
|
||||
<div className="props">{nameToOptions(tag, name)}</div>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Checkbox.Group onChange={onNameChange} value={value} className="checkbox-props">
|
||||
{renderTagName()}
|
||||
</Checkbox.Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Setting;
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import _ from 'lodash';
|
||||
import { useImperativeHandle, useCallback, forwardRef } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
|
||||
import MenuButton from '@app/components/Button';
|
||||
|
||||
interface IProps {
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
const RollbackBtn = forwardRef(({ showTitle }: IProps, ref) => {
|
||||
const { explore } = useStore();
|
||||
const { actionHistory, vertexes, edges, update } = explore;
|
||||
|
||||
const handleRollback = useCallback(() => {
|
||||
if (actionHistory.length > 0) {
|
||||
const data = actionHistory[actionHistory.length - 1];
|
||||
const newHistory = actionHistory.slice(0, -1);
|
||||
if (data.type === 'ADD') {
|
||||
update({
|
||||
actionHistory: newHistory,
|
||||
edges: _.differenceBy(edges, data.edges, (e: any) => e.uuid),
|
||||
vertexes: _.differenceBy(vertexes, data.vertexes, (v: any) => v.uuid),
|
||||
selectVertexes: [],
|
||||
});
|
||||
} else {
|
||||
update({
|
||||
actionHistory: newHistory,
|
||||
edges: _.unionBy(edges, data.edges, (e: any) => e.uuid),
|
||||
vertexes: _.unionBy(vertexes, data.vertexes, (v: any) => v.uuid),
|
||||
selectVertexes: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [actionHistory, edges, vertexes, update]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeydown: handleRollback,
|
||||
}), [handleRollback]);
|
||||
|
||||
return (
|
||||
<MenuButton
|
||||
tips={!showTitle ? intl.get('common.rollback') : undefined}
|
||||
iconfont="iconstudio-back"
|
||||
title={showTitle ? intl.get('common.rollback') : undefined}
|
||||
action={handleRollback}
|
||||
trackCategory="explore"
|
||||
trackAction="explore_rollback"
|
||||
trackLabel="from_panel"
|
||||
disabled={actionHistory.length === 0}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default observer(RollbackBtn);
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import { Input, message, Modal } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
|
||||
import MenuButton from '@app/components/Button';
|
||||
import { INode } from '@app/interfaces/explore';
|
||||
|
||||
interface IProps {
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
const SearchBtn: React.FC<IProps> = ({ showTitle }) => {
|
||||
const { explore } = useStore();
|
||||
const { vertexes, update } = explore;
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const getFieldValue = (node: any, field: string) => {
|
||||
if (field === 'vid') {
|
||||
return node.name;
|
||||
}
|
||||
const properties = node.nodeProp.properties;
|
||||
let nodeValue = null as any;
|
||||
Object.keys(properties).some((property) => {
|
||||
const value = properties[property];
|
||||
return Object.keys(value).some((nodeField) => {
|
||||
const fieldStr = property + '.' + nodeField;
|
||||
if (fieldStr === field) {
|
||||
nodeValue = value[nodeField];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
return nodeValue;
|
||||
};
|
||||
|
||||
const handleSearchNodes = (value: string) => {
|
||||
const selectVertexes: INode[] = [];
|
||||
const reg = /([a-zA-Z0-9_.]*)\s*([\=\>\<\!]+)\s*(.*)/g;
|
||||
const searchInfo = reg.exec(value) || [];
|
||||
if (searchInfo.length > 0) {
|
||||
const key = searchInfo[1];
|
||||
const compare = searchInfo[2];
|
||||
const result = searchInfo[3];
|
||||
if (compare) {
|
||||
vertexes.forEach((vertex: any) => {
|
||||
const fieldValue = getFieldValue(vertex, key);
|
||||
const strReg = /^['|"].*['|"]$/;
|
||||
switch (compare) {
|
||||
case '=':
|
||||
if (strReg.test(result) && fieldValue === result.slice(1, result.length - 1)) {
|
||||
selectVertexes.push(vertex);
|
||||
} else if (fieldValue && fieldValue.toString() === result) {
|
||||
selectVertexes.push(vertex);
|
||||
}
|
||||
break;
|
||||
case '>':
|
||||
if (fieldValue > result) {
|
||||
selectVertexes.push(vertex);
|
||||
}
|
||||
break;
|
||||
case '<':
|
||||
if (fieldValue < result) {
|
||||
selectVertexes.push(vertex);
|
||||
}
|
||||
break;
|
||||
case '<=':
|
||||
if (fieldValue <= result) {
|
||||
selectVertexes.push(vertex);
|
||||
}
|
||||
break;
|
||||
case '>=':
|
||||
if (fieldValue >= result) {
|
||||
selectVertexes.push(vertex);
|
||||
}
|
||||
break;
|
||||
case '<>':
|
||||
case '!=':
|
||||
if (strReg.test(result) && fieldValue !== result.slice(1, result.length - 1)) {
|
||||
selectVertexes.push(vertex);
|
||||
} else if (!strReg.test(result) && fieldValue && fieldValue.toString() !== result) {
|
||||
selectVertexes.push(vertex);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return message.warning(intl.get('explore.expressionError'));
|
||||
}
|
||||
if (selectVertexes.length !== 0) {
|
||||
update({ selectVertexes });
|
||||
handleClose();
|
||||
} else {
|
||||
message.warning(intl.get('explore.searchEmpty'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton
|
||||
tips={!showTitle ? intl.get('common.search') : undefined}
|
||||
iconfont="iconstudio-search"
|
||||
title={showTitle ? intl.get('common.search') : undefined}
|
||||
trackCategory="explore"
|
||||
trackAction="node_search"
|
||||
trackLabel="from_panel"
|
||||
action={handleOpenModal}
|
||||
/>
|
||||
<Modal
|
||||
width="700px"
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
maskClosable={false}
|
||||
title={intl.get('explore.nodeSearch')}
|
||||
footer={null}
|
||||
>
|
||||
<Input.Search placeholder="person.name > xx" onSearch={handleSearchNodes} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(SearchBtn);
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
|
||||
import MenuButton from '@app/components/Button';
|
||||
|
||||
interface IProps {
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
const UnExpandBtn: React.FC<IProps> = observer(({ showTitle }) => {
|
||||
const { explore } = useStore();
|
||||
const { selectVertexes, asyncUnExpand } = explore;
|
||||
|
||||
return (
|
||||
<MenuButton
|
||||
tips={!showTitle ? intl.get('explore.unExpand') : undefined}
|
||||
iconfont="iconstudio-unexpand"
|
||||
action={asyncUnExpand}
|
||||
title={showTitle ? intl.get('explore.unExpand') : undefined}
|
||||
trackCategory="explore"
|
||||
trackAction="node_undo_expand"
|
||||
trackLabel="from_panel"
|
||||
disabled={selectVertexes.length === 0}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default UnExpandBtn;
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { useImperativeHandle, useCallback, forwardRef } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
import MenuButton from '@app/components/Button';
|
||||
|
||||
interface IProps {
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
const UnlockBtn = forwardRef(({ showTitle }: IProps, ref) => {
|
||||
const { explore } = useStore();
|
||||
const { selectVertexes, update } = explore;
|
||||
const handleUnlock = useCallback(() => {
|
||||
const vertexes = selectVertexes.map((selectVertexe: any) => {
|
||||
selectVertexe.fx = null;
|
||||
selectVertexe.fy = null;
|
||||
selectVertexe.isFixed = false;
|
||||
return selectVertexe;
|
||||
});
|
||||
update({ selectVertexes: vertexes });
|
||||
}, [selectVertexes, update]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeydown: handleUnlock,
|
||||
}));
|
||||
|
||||
return (
|
||||
<MenuButton
|
||||
tips={!showTitle ? intl.get('common.unlock') : undefined}
|
||||
iconfont="iconstudio-unlock"
|
||||
title={showTitle ? intl.get('common.unlock') : undefined}
|
||||
action={handleUnlock}
|
||||
trackCategory="explore"
|
||||
trackAction="node_unlock"
|
||||
trackLabel="from_panel"
|
||||
disabled={
|
||||
selectVertexes.length === 0 ||
|
||||
selectVertexes.some((selectVertexe: any) => !selectVertexe.isFixed)
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default observer(UnlockBtn);
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
.btn-nodeStyle-set {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
margin-right: 10px;
|
||||
|
||||
.color-group {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
width: 30px;
|
||||
|
||||
.btn-color {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
> span {
|
||||
display: inline-block;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.icon-selected {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
fill: white;
|
||||
z-index: 10;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.circle {
|
||||
display: inline-block;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
z-index: 3;
|
||||
|
||||
&:nth-child(2) {
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-title {
|
||||
padding-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.nodeStyle-popover {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.tab-type-set {
|
||||
.ant-tabs-nav-list {
|
||||
width: 100%;
|
||||
|
||||
.ant-tabs-tab {
|
||||
width: 50%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 46px;
|
||||
|
||||
.ant-tabs-tab-btn {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-carousel .slick-dots-bottom {
|
||||
bottom: 0;
|
||||
height: 12px;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
width: 24px;
|
||||
height: 10px !important;
|
||||
}
|
||||
|
||||
li button {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 10px;
|
||||
background-color: #cec9c9;
|
||||
}
|
||||
|
||||
li.slick-active button {
|
||||
width: 10px;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
li.slick-active::after {
|
||||
transform: translate(0, 10px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import * as d3 from 'd3';
|
||||
import _ from 'lodash';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
import MenuButton from '@app/components/Button';
|
||||
import VertexStyleSet from '@app/components/VertexStyleSet';
|
||||
import DisplayBtn from '@app/components/VertexStyleSet/DisplayBtn';
|
||||
import { DEFAULT_COLOR_MIX } from '@app/config/explore';
|
||||
import { INode } from '@app/interfaces/explore';
|
||||
|
||||
import './index.less';
|
||||
|
||||
interface IProps {
|
||||
showTitle?: boolean;
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
const VertexStyleSetBtn: React.FC<IProps> = observer(({ showTitle }) => {
|
||||
const { explore } = useStore();
|
||||
const { selectVertexes, update } = explore;
|
||||
const [color, setColor] = useState(DEFAULT_COLOR_MIX);
|
||||
const [icon, setIcon] = useState('');
|
||||
|
||||
// 获取唯一属性
|
||||
const getUniqProperty = useCallback((list: INode[], prop: string) => {
|
||||
const props = _.uniq(list.map((i: INode) => i[prop]));
|
||||
if (props.length === 1) {
|
||||
return props[0];
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// 根据选中节点设置样式
|
||||
const getStyle = useCallback(
|
||||
(list: INode[]) => {
|
||||
let color = DEFAULT_COLOR_MIX;
|
||||
let icon = '';
|
||||
if (list.length > 0) {
|
||||
color = getUniqProperty(list, 'color') || color;
|
||||
icon = getUniqProperty(list, 'icon') || icon;
|
||||
}
|
||||
setColor(color);
|
||||
setIcon(icon);
|
||||
},
|
||||
[getUniqProperty],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
getStyle(selectVertexes);
|
||||
}, [selectVertexes, getStyle]);
|
||||
|
||||
const handleChangeColorComplete = (color: string) => {
|
||||
selectVertexes.forEach((vertex: INode) => (vertex.color = color));
|
||||
update({ selectVertexes: [...selectVertexes] });
|
||||
};
|
||||
|
||||
const handleChangeIconComplete = (iconObj: any) => {
|
||||
d3.selectAll('.active-node .icon').remove();
|
||||
selectVertexes.forEach((vertex: INode) => (vertex.icon = iconObj.type));
|
||||
update({ selectVertexes: [...selectVertexes] });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton
|
||||
tips={!showTitle ? intl.get('explore.vertexStyle') : undefined}
|
||||
disabled={selectVertexes.length === 0}
|
||||
component={
|
||||
selectVertexes.length > 0 ? (
|
||||
<VertexStyleSet
|
||||
handleChangeColorComplete={handleChangeColorComplete}
|
||||
handleChangeIconComplete={handleChangeIconComplete}
|
||||
icon={icon}
|
||||
color={color}
|
||||
/>
|
||||
) : (
|
||||
<DisplayBtn icon={icon} color={color} />
|
||||
)
|
||||
}
|
||||
title={showTitle ? intl.get('explore.vertexStyle') : undefined}
|
||||
trackCategory="explore"
|
||||
trackAction="color_picker"
|
||||
trackLabel="from_panel"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default VertexStyleSetBtn;
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import * as d3 from 'd3';
|
||||
import _ from 'lodash';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
import MenuButton from '@app/components/Button';
|
||||
import StyleSetTabs from '@app/components/VertexStyleSet/StyleSetTabs';
|
||||
import DisplayBtn from '@app/components/VertexStyleSet/DisplayBtn';
|
||||
import { DEFAULT_COLOR_MIX } from '@app/config/explore';
|
||||
import { INode } from '@app/interfaces/explore';
|
||||
import { Button, Modal } from 'antd';
|
||||
|
||||
import './index.less';
|
||||
|
||||
interface IProps {
|
||||
showTitle?: boolean;
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
const VertexStyleSetBtn: React.FC<IProps> = observer(({ showTitle }) => {
|
||||
const { explore } = useStore();
|
||||
const { selectVertexes, update } = explore;
|
||||
const [color, setColor] = useState(DEFAULT_COLOR_MIX);
|
||||
const [icon, setIcon] = useState('');
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
// 获取唯一属性
|
||||
const getUniqProperty = useCallback((list: INode[], prop: string) => {
|
||||
const props = _.uniq(list.map((i: INode) => i[prop]));
|
||||
if (props.length === 1) {
|
||||
return props[0];
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// 根据选中节点设置样式
|
||||
const getStyle = useCallback(
|
||||
(list: INode[]) => {
|
||||
let color = DEFAULT_COLOR_MIX;
|
||||
let icon = '';
|
||||
if (list.length > 0) {
|
||||
color = getUniqProperty(list, 'color') || color;
|
||||
icon = getUniqProperty(list, 'icon') || icon;
|
||||
}
|
||||
setColor(color);
|
||||
setIcon(icon);
|
||||
},
|
||||
[getUniqProperty],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
getStyle(selectVertexes);
|
||||
}, [selectVertexes, getStyle]);
|
||||
|
||||
const handleChangeColorComplete = (color: string) => {
|
||||
selectVertexes.forEach((vertex: INode) => (vertex.color = color));
|
||||
update({ selectVertexes: [...selectVertexes] });
|
||||
};
|
||||
|
||||
const handleChangeIconComplete = (iconObj: any) => {
|
||||
d3.selectAll('.active-node .icon').remove();
|
||||
selectVertexes.forEach((vertex: INode) => (vertex.icon = iconObj.type));
|
||||
update({ selectVertexes: [...selectVertexes] });
|
||||
};
|
||||
|
||||
// 处理关闭modal
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton
|
||||
tips={!showTitle ? intl.get('explore.vertexStyle') : undefined}
|
||||
disabled={selectVertexes.length === 0}
|
||||
component={<DisplayBtn icon={icon} color={color} />}
|
||||
title={showTitle ? intl.get('explore.vertexStyle') : undefined}
|
||||
trackCategory="explore"
|
||||
trackAction="color_picker"
|
||||
trackLabel="from_panel"
|
||||
action={() => setVisible(true)}
|
||||
/>
|
||||
<Modal
|
||||
afterClose={handleClose}
|
||||
className="modal-setting-fields"
|
||||
width="280px"
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
maskClosable={false}
|
||||
>
|
||||
<>
|
||||
<StyleSetTabs
|
||||
onColorChangeComplete={handleChangeColorComplete}
|
||||
onIconChangeComplete={handleChangeIconComplete}
|
||||
/>
|
||||
<Button onClick={handleClose} type="primary" className="btn-confirm" style={{ marginTop: 20 }}>
|
||||
{intl.get('common.confirm')}
|
||||
</Button>
|
||||
</>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default VertexStyleSetBtn;
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import * as d3 from 'd3';
|
||||
import { useCallback, useImperativeHandle, forwardRef } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@app/stores';
|
||||
import MenuButton from '@app/components/Button';
|
||||
import { MAX_SCALE, MIN_SCALE } from '@app/config/explore';
|
||||
|
||||
interface IProps {
|
||||
type: string;
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
const ZoomBtn = forwardRef(({ type, showTitle }: IProps, ref) => {
|
||||
const { d3Graph, explore } = useStore();
|
||||
const { canvasOffsetX: offsetX, canvasOffsetY: offsetY, canvasScale: scale, update } = d3Graph;
|
||||
const { vertexes } = explore;
|
||||
|
||||
const getScaleValue = useCallback(() => {
|
||||
if (type === 'zoom-out' && scale > MIN_SCALE) {
|
||||
return Math.max(MIN_SCALE, scale * 0.9);
|
||||
} else if (type === 'zoom-in' && scale < MAX_SCALE) {
|
||||
return Math.min(MAX_SCALE, scale * 1.1);
|
||||
}
|
||||
return 1;
|
||||
}, [type, scale]);
|
||||
|
||||
const handleZoom = useCallback(() => {
|
||||
if (vertexes.length > 0) {
|
||||
const value = getScaleValue();
|
||||
if (value) {
|
||||
update({ canvasScale: value });
|
||||
d3.select('.nebula-d3-canvas')
|
||||
.transition()
|
||||
.duration(500)
|
||||
.attr('transform', `translate(${offsetX},${offsetY}) scale(${value})`);
|
||||
}
|
||||
}
|
||||
}, [vertexes, getScaleValue, update, offsetX, offsetY]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
onKeydown: handleZoom,
|
||||
}),
|
||||
[handleZoom],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{type === 'zoom-out' && (
|
||||
<MenuButton
|
||||
tips={!showTitle ? intl.get('common.zoomOut') : undefined}
|
||||
iconfont="iconstudio-zoomout"
|
||||
title={showTitle ? intl.get('common.zoomOut') : undefined}
|
||||
action={handleZoom}
|
||||
disabled={vertexes.length === 0 || scale === MIN_SCALE}
|
||||
trackCategory="explore"
|
||||
trackAction="canvas_zoom_out"
|
||||
trackLabel="from_panel"
|
||||
/>
|
||||
)}
|
||||
{type === 'zoom-in' && (
|
||||
<MenuButton
|
||||
tips={!showTitle ? intl.get('common.zoomIn') : undefined}
|
||||
iconfont="iconstudio-zoomin"
|
||||
title={showTitle ? intl.get('common.zoomIn') : undefined}
|
||||
action={handleZoom}
|
||||
disabled={vertexes.length === 0 || scale === MAX_SCALE}
|
||||
trackCategory="explore"
|
||||
trackAction="canvas_zoom_in"
|
||||
trackLabel="from_panel"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default observer(ZoomBtn);
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
@import '@app/common.less';
|
||||
|
||||
.graph-wrap {
|
||||
position: relative;
|
||||
height: calc(100% - @controlHeight);
|
||||
overflow: hidden;
|
||||
|
||||
> .panel {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: 20px;
|
||||
display: inline-flex;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
padding: 5px 12px 5px 22px;
|
||||
|
||||
.panel-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
> .nebula-cloud-icon {
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid #e8e8e8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import React, { Fragment, useEffect, useRef } from 'react';
|
||||
import DeleteBtn from './Delete';
|
||||
import ExpandBtn from './Expand';
|
||||
import HotkeysDescBtn from './HotKeysDescription';
|
||||
import './index.less';
|
||||
import Lock from './Lock';
|
||||
import MoveBtn from './Move';
|
||||
import PropsDisplayBtn from './PropsDisplay';
|
||||
import RollbackBtn from './Rollback';
|
||||
import SearchBtn from './Search';
|
||||
import UnExpandBtn from './UnExpandBtn';
|
||||
import Unlock from './Unlock';
|
||||
import VertexStyleSetBtn from './VertexStyleSetBtn';
|
||||
import ZoomBtn from './Zoom';
|
||||
|
||||
interface IProps {
|
||||
onKeydownDelete?: () => void;
|
||||
}
|
||||
|
||||
const Panel: React.FC<IProps> = ({ onKeydownDelete }: IProps) => {
|
||||
const zoomInBtn = useRef<any>(null);
|
||||
const zoomOutBtn = useRef<any>(null);
|
||||
const displayBtn = useRef<any>(null);
|
||||
const rollbackBtn = useRef<any>(null);
|
||||
const deleteBtn = useRef<any>(null);
|
||||
const expandBtn = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
switch (true) {
|
||||
case e.key === 'Enter' && e.shiftKey:
|
||||
expandBtn.current?.onKeydown?.();
|
||||
break;
|
||||
case e.key === '_' && e.shiftKey:
|
||||
zoomOutBtn.current?.onKeydown?.();
|
||||
break;
|
||||
case e.key === '+' && e.shiftKey:
|
||||
zoomInBtn.current?.onKeydown?.();
|
||||
break;
|
||||
case (e.key === 'L' || e.key === 'l') && e.shiftKey:
|
||||
displayBtn.current?.onKeydown?.();
|
||||
break;
|
||||
case (e.key === 'Z' || e.key === 'z') && e.shiftKey:
|
||||
rollbackBtn.current?.onKeydown?.();
|
||||
break;
|
||||
case e.key === 'Delete' && e.shiftKey:
|
||||
deleteBtn.current?.onKeydown?.(onKeydownDelete);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const menuConfig = [
|
||||
[
|
||||
{
|
||||
component: <ExpandBtn ref={expandBtn} />,
|
||||
},
|
||||
{
|
||||
component: <UnExpandBtn />,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
component: <ZoomBtn type="zoom-in" ref={zoomInBtn} />,
|
||||
},
|
||||
{
|
||||
component: <ZoomBtn type="zoom-out" ref={zoomOutBtn} />,
|
||||
},
|
||||
{
|
||||
component: <MoveBtn />,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
component: <VertexStyleSetBtn />,
|
||||
},
|
||||
{
|
||||
component: <PropsDisplayBtn ref={displayBtn} />,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
component: <Lock />,
|
||||
},
|
||||
{
|
||||
component: <Unlock />,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
component: <RollbackBtn ref={rollbackBtn} />,
|
||||
},
|
||||
{
|
||||
component: <DeleteBtn ref={deleteBtn} />,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
component: <HotkeysDescBtn />,
|
||||
},
|
||||
{
|
||||
component: <SearchBtn />,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
{menuConfig.map((group, i) => (
|
||||
<div className="panel-group" key={i}>
|
||||
{group.map((item, index) => (
|
||||
<Fragment key={index}>{item.component}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Panel;
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
.graph-wrap {
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
background: #e6f7ff;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
max-height: 100%;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
padding: 0 19px;
|
||||
text-align: left;
|
||||
z-index: 100;
|
||||
|
||||
div {
|
||||
padding: 12px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
span:nth-child(2) {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
div:first-child {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.history-undo {
|
||||
float: right;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.show-btn {
|
||||
float: right;
|
||||
margin-right: 30px;
|
||||
}
|
||||
|
||||
.show-edges {
|
||||
margin-right: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.graph-setting {
|
||||
.ant-col {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ant-checkbox-group {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue