frontPanel/src/pages/Questions/BranchingMap/helper.ts
2024-04-26 17:41:36 +03:00

374 lines
11 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 { QuestionType } from "@model/question/question";
import { QuizQuestionResult } from "@model/questionTypes/result";
import {
AnyTypedQuizQuestion,
QuestionBranchingRule,
QuestionBranchingRuleMain,
UntypedQuizQuestion,
} from "@model/questionTypes/shared";
import {
createResult,
getQuestionByContentId,
updateQuestion,
} from "@root/questions/actions";
import { useQuestionsStore } from "@root/questions/store";
import { useQuizStore } from "@root/quizes/store";
import { updateOpenedModalSettingsId } from "@root/uiTools/actions";
import { useUiTools } from "@root/uiTools/store";
import { NodeSingular, PresetLayoutOptions } from "cytoscape";
import { enqueueSnackbar } from "notistack";
export interface Node {
data: {
isRoot: boolean;
id: string;
label: string;
qtype: string;
type: string;
parent?: string;
children: number;
};
classes: string;
}
export interface Edge {
data: {
source: string;
target: string;
};
}
export function isElementANode(element: Node | Edge): element is Node {
return !("source" in element.data && "target" in element.data);
}
export function isNodeInViewport(node: NodeSingular, padding: number = 0) {
const extent = node.cy().extent();
const bb = node.boundingBox();
return (
bb.x2 > extent.x1 - padding &&
bb.x1 < extent.x2 + padding &&
bb.y2 > extent.y1 - padding &&
bb.y1 < extent.y2 + padding
);
}
export const storeToNodes = (questions: AnyTypedQuizQuestion[]) => {
const nodes: Node[] = [];
const edges: Edge[] = [];
questions.forEach((question) => {
if (question.content.rule.parentId) {
const parentQuestion = {
...getQuestionByContentId(question.content.rule.parentId),
} as AnyTypedQuizQuestion;
let label =
question.title === "" || question.title === " "
? "noname"
: question.title;
if (label.length > 10) label = label.slice(0, 10).toLowerCase() + "…";
nodes.push({
data: {
isRoot: question.content.rule.parentId === "root",
id: question.content.id,
label,
qtype:
question.content.rule.parentId === "root"
? "root"
: parentQuestion.type,
type: question.type,
children: question.content.rule.children.length,
},
classes: "multiline-auto",
});
if (question.content.rule.parentId !== "root")
edges.push({
data: {
source: question.content.rule.parentId,
target: question.content.id,
},
});
}
});
return [...nodes, ...edges];
};
export const layoutOptions: PresetLayoutOptions = {
name: "preset",
positions: calcNodePosition,
zoom: undefined,
pan: 1,
fit: false,
padding: 30,
animate: false,
animationDuration: 500,
animationEasing: undefined,
animateFilter: () => false,
ready: (event) => {
if (event.cy.data("firstNode") === "nonroot") {
event.cy.data("firstNode", "root");
event.cy.nodes().sort((a, b) => (a.data("root") ? 1 : -1));
} else {
event.cy.removeData("firstNode");
}
},
transform: (_, p) => p,
};
export function clearDataAfterAddNode({
parentNodeContentId,
targetQuestion,
}: {
parentNodeContentId: string;
targetQuestion: AnyTypedQuizQuestion;
}) {
const parentQuestion = {
...getQuestionByContentId(parentNodeContentId),
} as AnyTypedQuizQuestion;
//смотрим не добавлен ли родителю result. Если да - делаем его неактивным. Веточкам result не нужен
useQuestionsStore
.getState()
.questions.filter(
(question): question is QuizQuestionResult => question.type === "result",
)
.forEach((targetQuestion) => {
if (targetQuestion.content.rule.parentId === parentQuestion.content.id) {
updateQuestion<QuizQuestionResult>(
targetQuestion.id,
(q) => (q.content.usage = false),
);
}
});
//предупреждаем добавленный вопрос о том, кто его родитель
updateQuestion(targetQuestion.content.id, (question) => {
question.content.rule.parentId = parentNodeContentId;
question.content.rule.main = [];
//Это листик. Сбросим ему на всякий случай не листиковые поля
question.content.rule.children = [];
question.content.rule.default = "";
});
const noChild = parentQuestion.content.rule.children.length === 0;
//предупреждаем родителя о новом потомке (если он ещё не знает о нём)
if (!parentQuestion.content.rule.children.includes(targetQuestion.content.id))
updateQuestion(parentNodeContentId, (question) => {
question.content.rule.children = [
...question.content.rule.children,
targetQuestion.content.id,
];
//единственному ребёнку даём дефолт по-умолчанию
question.content.rule.default = noChild
? targetQuestion.content.id
: question.content.rule.default;
});
if (!noChild) {
//детей больше 1
//- предупреждаем стор вопросов об открытии модалки ветвления
updateOpenedModalSettingsId(targetQuestion.content.id);
}
}
export function clearDataAfterRemoveNode({
trashQuestions,
targetQuestionContentId,
parentQuestionContentId,
}: {
trashQuestions: (AnyTypedQuizQuestion | UntypedQuizQuestion)[];
targetQuestionContentId: string;
parentQuestionContentId: string;
}) {
updateQuestion(targetQuestionContentId, (question) => {
question.content.rule.parentId = "";
question.content.rule.children = [];
question.content.rule.main = [];
question.content.rule.default = "";
});
//Ищём родителя
const parentQuestion = getQuestionByContentId(parentQuestionContentId);
//Делаем результат родителя активным
const parentResult = trashQuestions.find(
(q): q is QuizQuestionResult =>
q.type === "result" &&
q.content.rule.parentId === parentQuestionContentId,
);
if (parentResult) {
updateQuestion<QuizQuestionResult>(parentResult.content.id, (q) => {
q.content.usage = true;
});
} else {
createResult(useQuizStore.getState().editQuizId, parentQuestionContentId);
}
//чистим rule родителя
if (!parentQuestion?.type) {
return;
}
const newChildren = [...parentQuestion.content.rule.children];
newChildren.splice(
parentQuestion.content.rule.children.indexOf(targetQuestionContentId),
1,
);
const newRule: QuestionBranchingRule = {
children: newChildren,
default:
parentQuestion.content.rule.default === targetQuestionContentId
? ""
: parentQuestion.content.rule.default,
//удаляем условия перехода от родителя к этому вопросу,
main: parentQuestion.content.rule.main.filter(
(data: QuestionBranchingRuleMain) =>
data.next !== targetQuestionContentId,
),
parentId: parentQuestion.content.rule.parentId,
};
updateQuestion(parentQuestionContentId, (PQ) => {
PQ.content.rule = newRule;
});
}
export function calcNodePosition(node: any) {
const id = node.id();
const incomming = node.cy().edges(`[target="${id}"]`);
const layer = 0;
node.removeData("lastChild");
if (incomming.length === 0) {
if (node.cy().data("firstNode") === undefined)
node.cy().data("firstNode", "root");
node.data("root", true);
const children = node.cy().edges(`[source="${id}"]`).targets();
node.data("layer", layer);
node.data("children", children.length);
const queue: any[] = [];
children.forEach((n: any) => {
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 = node
.cy()
.edges(`[source="${task.task.id()}"]`)
.targets();
task.task.data("children", children.length);
if (children.length !== 0) {
children.forEach((n: any) =>
queue.push({ task: n, layer: task.layer + 1 }),
);
}
}
queue.push({ parent: node, children: children });
while (queue.length) {
const task = queue.pop();
if (task.children.length === 0) {
task.parent.data("subtreeWidth", task.parent.height() + 50);
continue;
}
const unprocessed = task?.children.filter((node: any) => {
return node.data("subtreeWidth") === undefined;
});
if (unprocessed.length !== 0) {
queue.push(task);
unprocessed.forEach((t: any) => {
queue.push({
parent: t,
children: t.cy().edges(`[source="${t.id()}"]`).targets(),
});
});
continue;
}
task?.parent.data(
"subtreeWidth",
task.children.reduce((p: any, n: any) => p + n.data("subtreeWidth"), 0),
);
}
const pos = { x: 0, y: 0 };
node.data("oldPos", pos);
queue.push({ task: children, parent: node });
while (queue.length) {
const task = queue.pop();
const oldPos = task.parent.data("oldPos");
let yoffset = oldPos.y - task.parent.data("subtreeWidth") / 2;
task.task.forEach((n: any) => {
const width = n.data("subtreeWidth");
n.data("oldPos", {
x: 250 * n.data("layer"),
y: yoffset + width / 2,
});
yoffset += width;
queue.push({
task: n.cy().edges(`[source="${n.id()}"]`).targets(),
parent: n,
});
});
}
return pos;
} else {
const opos = node.data("oldPos");
if (opos) {
return opos;
}
}
}
export const addNode = ({
parentNodeContentId,
targetNodeContentId,
}: {
parentNodeContentId: string;
targetNodeContentId?: string;
}) => {
//запрещаем работу родителя-ребенка если это один и тот же вопрос
if (parentNodeContentId === targetNodeContentId) return;
//У 4 типов вопросов не может быть больше 1 потомка
const parentQuestion = {
...getQuestionByContentId(parentNodeContentId),
} as AnyTypedQuizQuestion;
if (
parentQuestion.type !== undefined &&
isQuestionProhibited(parentQuestion.type) &&
parentQuestion.content.rule.children.length > 0
) {
enqueueSnackbar("у вопроса этого типа может быть только 1 потомок");
return;
}
//если есть инфо о выбранном вопросе из модалки - берём родителя из инфо модалки. Иначе из значения дропа
const targetQuestion = {
...getQuestionByContentId(
targetNodeContentId || useUiTools.getState().dragQuestionContentId,
),
} as AnyTypedQuizQuestion;
if (Object.keys(targetQuestion).length !== 0 && parentNodeContentId) {
clearDataAfterAddNode({ parentNodeContentId, targetQuestion });
createResult(useQuizStore.getState().editQuizId, targetQuestion.content.id);
} else {
enqueueSnackbar(
"Добавляемый вопрос не найден. Перетащите вопрос из списка",
);
}
};
export const isQuestionProhibited = (parentQType: string) =>
parentQType === "text" ||
parentQType === "date" ||
parentQType === "number" ||
parentQType === "page";