frontPanel/src/pages/Questions/BranchingMap/CsComponent.tsx

682 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { useEffect, useLayoutEffect, useRef, useState } from "react";
import Cytoscape from "cytoscape";
import CytoscapeComponent from "react-cytoscapejs";
import popper from "cytoscape-popper";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { updateRootContentId } from "@root/quizes/actions"
import { AnyTypedQuizQuestion } from "@model/questionTypes/shared"
import { useQuestionsStore } from "@root/questions/store";
import { cleardragQuestionContentId, updateQuestion, updateOpenedModalSettingsId, getQuestionByContentId } from "@root/questions/actions";
import { storeToNodes } from "./helper";
import "./styles.css";
import type {
Stylesheet,
Core,
NodeSingular,
AbstractEventObject,
ElementDefinition,
} from "cytoscape";
import { QuestionsList } from "../BranchingPanel/QuestionsList";
import { enqueueSnackbar } from "notistack";
type PopperItem = {
id: () => string;
};
type Modifier = {
name: string;
options: unknown;
};
type PopperConfig = {
popper: {
placement: string;
modifiers?: Modifier[];
};
content: (items: PopperItem[]) => void;
};
type Popper = {
update: () => Promise<void>;
setOptions: (modifiers: { modifiers?: Modifier[] }) => void;
};
type NodeSingularWithPopper = NodeSingular & {
popper: (config: PopperConfig) => Popper;
};
const stylesheet: Stylesheet[] = [
{
selector: "node",
style: {
shape: "round-rectangle",
width: 130,
height: 130,
backgroundColor: "#FFFFFF",
label: "data(label)",
"font-size": "16",
color: "#4D4D4D",
"text-halign": "center",
"text-valign": "center",
"text-wrap": "wrap",
"text-max-width": "80",
},
},
{
selector: ".multiline-auto",
style: {
"text-wrap": "wrap",
"text-max-width": "80",
},
},
{
selector: "edge",
style: {
width: 30,
"line-color": "#DEDFE7",
"curve-style": "taxi",
"taxi-direction": "horizontal",
"taxi-turn": 60,
},
},
{
selector: ":selected",
style: {
"border-style": "solid",
"border-width": 1.5,
"border-color": "#9A9AAF",
},
},
];
Cytoscape.use(popper);
interface Props {
modalQuestionParentContentId: string;
modalQuestionTargetContentId: string;
setOpenedModalQuestions: (open: boolean) => void;
setModalQuestionParentContentId: (id: string) => void;
setModalQuestionTargetContentId: (id: string) => void;
}
export const CsComponent = ({
modalQuestionParentContentId,
modalQuestionTargetContentId,
setOpenedModalQuestions,
setModalQuestionParentContentId,
setModalQuestionTargetContentId
}: Props) => {
const quiz = useCurrentQuiz();
const { dragQuestionContentId, questions } = useQuestionsStore()
const [startCreate, setStartCreate] = useState("");
const [startRemove, setStartRemove] = useState("");
const cyRef = useRef<Core | null>(null);
const layoutsContainer = useRef<HTMLDivElement | null>(null);
const plusesContainer = useRef<HTMLDivElement | null>(null);
const crossesContainer = useRef<HTMLDivElement | null>(null);
const gearsContainer = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
updateOpenedModalSettingsId()
}, [])
useEffect(() => {
if (modalQuestionTargetContentId.length !== 0 && modalQuestionParentContentId.length !== 0) {
addNode({ parentNodeContentId: modalQuestionParentContentId, targetNodeContentId: modalQuestionTargetContentId })
}
setModalQuestionParentContentId("")
setModalQuestionTargetContentId("")
}, [modalQuestionTargetContentId])
const addNode = ({ parentNodeContentId, targetNodeContentId }: { parentNodeContentId: string, targetNodeContentId?: string }) => {
const cy = cyRef?.current
const parentNodeChildren = cy?.$('edge[source = "' + parentNodeContentId + '"]')?.length
//если есть инфо о выбранном вопросе из модалки - берём родителя из инфо модалки. Иначе из значения дропа
const targetQuestion = { ...getQuestionByContentId(targetNodeContentId || dragQuestionContentId) } as AnyTypedQuizQuestion
if (Object.keys(targetQuestion).length !== 0 && Object.keys(targetQuestion).length !== 0 && parentNodeContentId && parentNodeChildren !== undefined) {
clearDataAfterAddNode({ parentNodeContentId, targetQuestion, parentNodeChildren })
cy?.data('changed', true)
cy?.add([
{
data: {
id: targetQuestion.content.id,
label: targetQuestion.title || "noname"
}
},
{
data: {
source: parentNodeContentId,
target: targetQuestion.content.id
}
}
])
cy?.layout(lyopts).run()
} else {
enqueueSnackbar("Добавляемый вопрос не найден")
}
}
const clearDataAfterAddNode = ({ parentNodeContentId, targetQuestion, parentNodeChildren }: { parentNodeContentId: string, targetQuestion: AnyTypedQuizQuestion, parentNodeChildren: number }) => {
//предупреждаем добавленный вопрос о том, кто его родитель
updateQuestion(targetQuestion.content.id, question => {
question.content.rule.parentId = parentNodeContentId
question.content.rule.main = []
})
//Если детей больше 1 - предупреждаем стор вопросов об открытии модалки ветвления
if (parentNodeChildren >= 1) {
updateOpenedModalSettingsId(targetQuestion.content.id)
} else {
//Если ребёнок первый - добавляем его родителю как дефолтный
updateQuestion(parentNodeContentId, question => question.content.rule.default = targetQuestion.content.id)
}
}
const removeNode = ({ targetNodeContentId }: { targetNodeContentId: string }) => {
const deleteNodes = [] as string[]
const deleteEdges: any = []
const cy = cyRef?.current
const findChildrenToDelete = (node) => {
//Узнаём грани, идущие от этой ноды
cy?.$('edge[source = "' + node.id() + '"]')?.toArray().forEach((edge) => {
const edgeData = edge.data()
//записываем id грани для дальнейшего удаления
deleteEdges.push(edge)
//ищем ноду на конце грани, записываем её ID для дальнейшего удаления
const targetNode = cy?.$("#" + edgeData.target)
deleteNodes.push(targetNode.data().id)
//вызываем функцию для анализа потомков уже у этой ноды
findChildrenToDelete(targetNode)
})
}
findChildrenToDelete(cy?.getElementById(targetNodeContentId))
const targetQuestion = getQuestionByContentId(targetNodeContentId)
if (targetQuestion.content.rule.parentId === "root" && quiz) {
updateRootContentId(quiz?.id, "")
updateQuestion(targetNodeContentId, question => {
question.content.rule.parentId = ""
question.content.rule.main = []
question.content.rule.default = ""
})
} else {
const parentQuestionContentId = cy?.$('edge[target = "' + targetNodeContentId + '"]')?.toArray()?.[0]?.data()?.source
if (targetNodeContentId && parentQuestionContentId) {
clearDataAfterRemoveNode({ targetQuestionContentId: targetNodeContentId, parentQuestionContentId })
cy?.remove(cy?.$('#' + targetNodeContentId)).layout(lyopts).run()
}
}
//После всех манипуляций удаляем грани из CS и ноды из бекенда
deleteNodes.forEach((nodeId) => {//Ноды
cy?.remove(cy?.$("#" + nodeId))
removeButtons(nodeId)
updateQuestion(nodeId, question => {
question.content.rule.parentId = ""
question.content.rule.main = []
question.content.rule.default = ""
})
})
deleteEdges.forEach((edge: any) => {//Грани
cy?.remove(edge)
})
removeButtons(targetNodeContentId)
cy?.data('changed', true)
cy?.layout(lyopts).run()
}
const clearDataAfterRemoveNode = ({ targetQuestionContentId, parentQuestionContentId }: { targetQuestionContentId: string, parentQuestionContentId: string }) => {
updateQuestion(targetQuestionContentId, question => {
question.content.rule.parentId = ""
question.content.rule.main = []
question.content.rule.default = ""
})
updateQuestion(parentQuestionContentId, question => {
//Заменяем id удаляемого вопроса либо на id одного из оставшихся, либо на пустую строку
if (question.content.rule.default === targetQuestionContentId) question.content.rule.default = ""
})
}
useEffect(() => {
if (startCreate) {
addNode({ parentNodeContentId: startCreate });
cleardragQuestionContentId()
setStartCreate("");
}
}, [startCreate]);
useEffect(() => {
if (startRemove) {
removeNode({ targetNodeContentId: startRemove });
setStartRemove("");
}
}, [startRemove]);
const readyLO = (e) => {
if (e.cy.data('firstNode') === 'nonroot') {
e.cy.data('firstNode', 'root')
e.cy.nodes().sort((a, b) => (a.data('root') ? 1 : -1)).layout(lyopts).run()
} else {
e.cy.data('changed', false)
e.cy.removeData('firstNode')
}
//удаляем иконки
e.cy.nodes().forEach((ele: any) => {
const data = ele.data()
data.id && removeButtons(data.id);
})
initialPopperIcons(e)
}
const lyopts = {
name: 'preset',
positions: (e) => {
if (!e.cy().data('changed')) {
return e.data('oldPos')
} else { e.removeData('oldPos') }
const id = e.id()
const incomming = e.cy().edges(`[target="${id}"]`)
const layer = 0
e.removeData('lastChild')
if (incomming.length === 0) {
if (e.cy().data('firstNode') === undefined)
e.cy().data('firstNode', 'root')
e.data('root', true)
const children = e.cy().edges(`[source="${id}"]`).targets()
e.data('layer', layer)
e.data('children', children.length)
const queue = []
children.forEach(n => {
queue.push({ task: n, layer: layer + 1 })
})
while (queue.length) {
const task = queue.pop()
task.task.data('layer', task.layer)
task.task.removeData('subtreeWidth')
const children = e.cy().edges(`[source="${task.task.id()}"]`).targets()
task.task.data('children', children.length)
if (children.length !== 0) {
children.forEach(n => queue.push({ task: n, layer: task.layer + 1 }))
}
}
queue.push({ parent: e, children: children })
while (queue.length) {
const task = queue.pop()
if (task.children.length === 0) {
task.parent.data('subtreeWidth', task.parent.height())
continue
}
const unprocessed = task?.children.filter(e => {
return (e.data('subtreeWidth') === undefined)
})
if (unprocessed.length !== 0) {
queue.push(task)
unprocessed.forEach(t => {
queue.push({ parent: t, children: t.cy().edges(`[source="${t.id()}"]`).targets() })
})
continue
}
task?.parent.data('subtreeWidth', task.children.reduce((p, n) => p + n.data('subtreeWidth'), 0))
}
const pos = { x: 0, y: 0 }
e.data('oldPos', pos)
return pos
} else {
if (e.cy().data('firstNode') !== 'root') {
e.cy().data('firstNode', 'nonroot')
return { x: 0, y: 0 }
}
if (e.cy().data('firstNode') === undefined)
e.cy().data('firstNode', 'nonroot')
const parent = e.cy().edges(`[target="${e.id()}"]`)[0].source()
const wing = (parent.data('children') === 1) ? 0 : parent.data('subtreeWidth') / 2 + 50
const lastOffset = parent.data('lastChild')
const step = wing * 2 / (parent.data('children') - 1)
//e.removeData('subtreeWidth')
if (lastOffset !== undefined) {
parent.data('lastChild', lastOffset + step)
const pos = { x: 250 * e.data('layer'), y: (lastOffset + step) }
e.data('oldPos', pos)
return pos
} else {
parent.data('lastChild', parent.position().y - wing)
const pos = { x: 250 * e.data('layer'), y: (parent.position().y - wing) }
e.data('oldPos', pos)
return pos
}
}
}, // map of (node id) => (position obj); or function(node){ return somPos; }
zoom: undefined, // the zoom level to set (prob want fit = false if set)
pan: true, // the pan level to set (prob want fit = false if set)
fit: false, // whether to fit to viewport
padding: 30, // padding on fit
animate: false, // whether to transition the node positions
animationDuration: 500, // duration of animation in ms if enabled
animationEasing: undefined, // easing of animation if enabled
animateFilter: function (node, i) { return true; }, // a function that determines whether the node should be animated. All nodes animated by default on animate enabled. Non-animated nodes are positioned immediately when the layout starts
ready: readyLO, // callback on layoutready
transform: function (node, position) { return position; } // transform a given node position. Useful for changing flow direction in discrete layouts
}
useEffect(() => {
document.querySelector("#root")?.addEventListener("mouseup", cleardragQuestionContentId);
const cy = cyRef.current;
const eles = cy?.add(storeToNodes(questions))
cy.data('changed', true)
const elecs = eles.layout(lyopts).run()
cy?.on('add', () => cy.data('changed', true))
cy?.fit()
//cy?.layout().run()
return () => {
document.querySelector("#root")?.removeEventListener("mouseup", cleardragQuestionContentId);
layoutsContainer.current?.remove();
plusesContainer.current?.remove();
crossesContainer.current?.remove();
gearsContainer.current?.remove();
};
}, []);
const removeButtons = (id: string) => {
layoutsContainer.current
?.querySelector(`.popper-layout[data-id='${id}']`)
?.remove();
plusesContainer.current
?.querySelector(`.popper-plus[data-id='${id}']`)
?.remove();
crossesContainer.current
?.querySelector(`.popper-cross[data-id='${id}']`)
?.remove();
gearsContainer.current
?.querySelector(`.popper-gear[data-id='${id}']`)
?.remove();
};
const initialPopperIcons = (e) => {
const cy = e.cy
const container =
(document.body.querySelector(
".__________cytoscape_container"
) as HTMLDivElement) || null;
if (!container) {
return;
}
container.style.overflow = "hidden";
if (!plusesContainer.current) {
plusesContainer.current = document.createElement("div");
plusesContainer.current.setAttribute("id", "popper-pluses");
container.append(plusesContainer.current);
}
if (!crossesContainer.current) {
crossesContainer.current = document.createElement("div");
crossesContainer.current.setAttribute("id", "popper-crosses");
container.append(crossesContainer.current);
}
if (!gearsContainer.current) {
gearsContainer.current = document.createElement("div");
gearsContainer.current.setAttribute("id", "popper-gears");
container.append(gearsContainer.current);
}
if (!layoutsContainer.current) {
layoutsContainer.current = document.createElement("div");
layoutsContainer.current.setAttribute("id", "popper-layouts");
container.append(layoutsContainer.current);
}
const ext = cy.extent()
const nodesInView = cy.nodes().filter(n => {
const bb = n.boundingBox()
return bb.x2 > ext.x1 && bb.x1 < ext.x2 && bb.y2 > ext.y1 && bb.y1 < ext.y2
})
nodesInView
.toArray()
?.forEach((item) => {
const node = item as NodeSingularWithPopper;
const layoutsPopper = node.popper({
popper: {
placement: "left",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
const itemElement = layoutsContainer.current?.querySelector(
`.popper-layout[data-id='${itemId}']`
);
if (itemElement) {
return itemElement;
}
const layoutElement = document.createElement("div");
layoutElement.style.zIndex = "0"
layoutElement.classList.add("popper-layout");
layoutElement.setAttribute("data-id", item.id());
layoutElement.addEventListener("mouseup", () => {
//Узнаём грани, идущие от этой ноды
setModalQuestionParentContentId(item.id())
setOpenedModalQuestions(true)
});
layoutsContainer.current?.appendChild(layoutElement);
return layoutElement;
},
});
const plusesPopper = node.popper({
popper: {
placement: "right",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
const itemElement = plusesContainer.current?.querySelector(
`.popper-plus[data-id='${itemId}']`
);
if (itemElement) {
return itemElement;
}
const plusElement = document.createElement("div");
plusElement.classList.add("popper-plus");
plusElement.setAttribute("data-id", item.id());
plusElement.style.zIndex = "1"
plusElement.addEventListener("mouseup", () => {
setStartCreate(node.id());
});
plusesContainer.current?.appendChild(plusElement);
return plusElement;
},
});
const crossesPopper = node.popper({
popper: {
placement: "top-end",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
const itemElement = crossesContainer.current?.querySelector(
`.popper-cross[data-id='${itemId}']`
);
if (itemElement) {
return itemElement;
}
const crossElement = document.createElement("div");
crossElement.classList.add("popper-cross");
crossElement.setAttribute("data-id", item.id());
crossElement.style.zIndex = "2"
crossesContainer.current?.appendChild(crossElement);
crossElement.addEventListener("mouseup", () => {
setStartRemove(node.id())
}
);
return crossElement;
},
});
const gearsPopper = node.popper({
popper: {
placement: "left",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
if (item.cy().edges(`[target="${itemId}"]`).sources().length === 0) {
return;
}
const itemElement = gearsContainer.current?.querySelector(
`.popper-gear[data-id='${itemId}']`
);
if (itemElement) {
return itemElement;
}
const gearElement = document.createElement("div");
gearElement.classList.add("popper-gear");
gearElement.setAttribute("data-id", item.id());
gearElement.style.zIndex = "1"
gearsContainer.current?.appendChild(gearElement);
gearElement.addEventListener("mouseup", (e) => {
updateOpenedModalSettingsId(item.id())
});
return gearElement;
},
});
const update = async () => {
await plusesPopper.update();
await crossesPopper.update();
await gearsPopper.update();
await layoutsPopper.update();
};
const onZoom = (event: AbstractEventObject) => {
const zoom = event.cy.zoom();
update();
crossesPopper.setOptions({
modifiers: [
{ name: "flip", options: { boundary: node } },
{ name: "offset", options: { offset: [-5 * zoom, -30 * zoom] } },
],
});
layoutsPopper.setOptions({
modifiers: [
{ name: "flip", options: { boundary: node } },
{ name: "offset", options: { offset: [0, -130 * zoom] } },
],
});
layoutsContainer.current
?.querySelectorAll("#popper-layouts > .popper-layout")
.forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${130 * zoom}px`;
element.style.height = `${130 * zoom}px`;
});
plusesContainer.current
?.querySelectorAll("#popper-pluses > .popper-plus")
.forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${40 * zoom}px`;
element.style.height = `${40 * zoom}px`;
element.style.fontSize = `${40 * zoom}px`;
element.style.borderRadius = `${6 * zoom}px`;
});
crossesContainer.current
?.querySelectorAll("#popper-crosses > .popper-cross")
.forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${24 * zoom}px`;
element.style.height = `${24 * zoom}px`;
element.style.fontSize = `${24 * zoom}px`;
element.style.borderRadius = `${6 * zoom}px`;
});
gearsContainer.current
?.querySelectorAll("#popper-gears > .popper-gear")
.forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${60 * zoom}px`;
element.style.height = `${40 * zoom}px`;
});
};
node?.on("position", update);
cy?.on("pan zoom resize render", onZoom);
});
};
return (
<>
<CytoscapeComponent
wheelSensitivity={0.1}
elements={[]}
// elements={createGraphElements(tree, quiz)}
style={{ height: "480px", background: "#F2F3F7" }}
stylesheet={stylesheet}
layout={(lyopts)}
cy={(cy) => {
cyRef.current = cy;
}}
/>
{/* <button onClick={() => {
console.log("NODES____________________________")
cyRef.current?.elements().forEach((ele: any) => {
console.log(ele.data())
})
}}>nodes</button>
<button onClick={() => {
console.log("ELEMENTS____________________________")
console.log(questions)
}}>elements</button> */}
</>
);
};