dnd5-scripts/timeline.js
2026-06-17 22:32:48 +03:00

578 lines
22 KiB
JavaScript
Raw Permalink 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.

await dv.view("scripts/locations");
const path = require("path");
// В начале файла добавить:
window.lastGeneratedMermaidCode = ""; // Глобальная переменная для хранения последнего сгенерированного кода
// Функция, переводящая дату "ГГГГ.ММ.ДД" (с 30 днями в месяце) в число.
function customDateToNumber(str) {
let [y, m, d] = str.split("/");
y = parseInt(y, 10);
m = parseInt(m, 10);
d = parseInt(d, 10);
return (y * 12 + (m - 1)) * 30 + (d - 1);
}
function charactersMatches(dv, charactersList, pCharacters) {
if (pCharacters !== null) {
if (!Array.isArray(pCharacters)) {
pCharacters = [pCharacters];
}
return pCharacters.some((pCharacter) => {
if (typeof pCharacter === "object" && pCharacter.hasOwnProperty("path")) {
const fileNameWithoutExt = path.basename(
pCharacter.path,
path.extname(pCharacter.path)
);
return charactersList.includes(fileNameWithoutExt);
} else {
return charactersList.includes(pCharacter);
}
});
} else {
return false;
}
}
function pageWithCorrectFields(dv, p, locations, characters) {
if (!dv.locations.regionMatches(dv, locations, p.regions)) return false;
if (
!characters ||
!characters.length ||
charactersMatches(dv, characters, p.participants)
)
return true;
return false;
}
// Функция проверки, попадает ли дата в заданный диапазон.
function isInRange(p, startStr, endStr) {
const dateStr = p.date;
if (!dateStr) return false;
if (p.file.folder === "Templates") return false;
if (typeof dateStr !== "string") return false;
let dateNum = customDateToNumber(dateStr);
let startNum = customDateToNumber(startStr);
let endNum = customDateToNumber(endStr);
return dateNum >= startNum && dateNum <= endNum;
}
/**
* Позволяет «тащить» (drag) содержимое контейнера .mermaid-timeline
* при зажатой клавише Ctrl + ЛКМ.
* Реализовано через прокрутку (scroll) контейнера.
*/
function enableDiagramDragging(dv) {
const container = dv.container.querySelector(".mermaid-timeline");
if (!container) return;
// Настраиваем стили контейнера:
container.style.position = "relative";
container.style.width = "100%"; // Можно зафиксировать ширину, если нужно
container.style.height = "660px"; // или любую другую высоту
container.style.overflow = "auto"; // Показываем полосы прокрутки
container.style.border = "1px solid #ccc"; // Для наглядности
container.style.cursor = "grab"; // Курсов при наведении
// container.style.backgroundColor = "green"; // Курсов при наведении
let isDragging = false;
let startX, startY; // Координаты точки, где нажали мышь
let scrollLeft, scrollTop; // Текущие значения прокрутки в момент начала «захвата»
// Когда нажимаем мышь + Ctrl на контейнере
container.addEventListener("mousedown", (e) => {
// Проверяем, что нажата ЛКМ (button === 0)
if (e.button === 2) {
isDragging = true;
container.style.cursor = "grabbing";
// Запоминаем начальные координаты
// (pageX/Y - абсолютные координаты курсора на странице)
// offsetLeft / offsetTop нужны, если контейнер имеет отступы
startX = e.pageX - container.offsetLeft;
startY = e.pageY - container.offsetTop;
// Запоминаем текущую прокрутку
scrollLeft = container.scrollLeft;
scrollTop = container.scrollTop;
e.preventDefault(); // Чтобы не выделялся текст
}
});
// Если мышь уходит за пределы контейнера — перестаём «тянуть»
container.addEventListener("mouseleave", () => {
isDragging = false;
container.style.cursor = "grab";
});
// Когда отпускаем кнопку мыши — перестаём «тянуть»
container.addEventListener("mouseup", () => {
isDragging = false;
container.style.cursor = "grab";
});
// При движении мыши — меняем scroll контейнера
container.addEventListener("mousemove", (e) => {
if (!isDragging) return;
e.preventDefault();
// Текущее положение мыши относительно контейнера
const x = e.pageX - container.offsetLeft;
const y = e.pageY - container.offsetTop;
// Смещение, на которое «увели» мышь
const walkX = x - startX;
const walkY = y - startY;
// Двигаем прокрутку «вверх/вниз» и «влево/вправо» (с учётом смещения)
container.scrollLeft = scrollLeft - walkX;
container.scrollTop = scrollTop - walkY;
});
}
const zoomStep = 0.1; // Шаг изменения масштаба // 0.1
// Добавляем обработчик для масштабирования при зажатом Ctrl + колесико мыши
function enableZoomControl(timelineElement) {
if (timelineElement) {
timelineElement.addEventListener("wheel", (event) => {
if (event.ctrlKey) {
event.preventDefault(); // Предотвращаем прокрутку страницы
let scale = timelineElement.dataset.scale
? parseFloat(timelineElement.dataset.scale)
: 1;
if (event.deltaY < 0) {
// Увеличение масштаба
scale += zoomStep;
} else {
// Уменьшение масштаба
scale = Math.max(scale - zoomStep, 0.1); // Минимальный масштаб: 0.1
}
timelineElement.dataset.scale = scale; // Сохраняем текущий масштаб
timelineElement.style.transform = `scale(${scale})`; // Применяем масштабирование через CSS
console.log(`Масштаб изменен: ${scale}`);
}
});
} else {
console.error('Элемент с ID "timeline" не найден.');
}
}
// Добавляем обработчик контекстного меню для нодов
function setupContextMenu(dv) {
// Если ссылки находятся в определенном контейнере
const nodes = dv.container.querySelectorAll(
".mermaid-timeline a.internal-link"
);
nodes.forEach((node) => {
node.addEventListener("contextmenu", handleContextMenu);
});
}
async function generateUniqueFileName(basePath) {
let counter = 0;
let filePath = `${basePath}.md`;
// Проверяем существование файла
while (await app.vault.adapter.exists(filePath)) {
counter++;
filePath = `${basePath} - ${counter}.md`;
}
return filePath;
}
const handleContextMenu = async (e) => {
e.preventDefault();
const node = e.currentTarget;
const nodeName = node.getAttribute("href");
// Проверяем, что нода находится внутри mermaid диаграммы
const isMermaidNode = node.closest(".mermaid") !== null;
if (!isMermaidNode) return;
const originalPage = dv
.pages("#event")
.where((p) => p.file.name === nodeName)
.first();
if (!originalPage) return;
// Создаем одноразовый обработчик события
const menuHandler = (menu, file) => {
// Проверяем, что это меню для нашего файла
if (!file || file.path !== originalPage.file.path) return;
menu.addSeparator();
menu.addItem((item) => {
item
.setTitle("Create New Event")
.onClick(() => createNewEvent(originalPage, "new"));
});
menu.addItem((item) => {
item
.setTitle("Create Previous Event")
.onClick(() => createNewEvent(originalPage, "previous"));
});
menu.addItem((item) => {
item
.setTitle("Create Next Event")
.onClick(() => createNewEvent(originalPage, "next"));
});
// Удаляем обработчик после использования
app.workspace.off("file-menu", menuHandler);
};
// Регистрируем временный обработчик
app.workspace.on("file-menu", menuHandler);
// Вызываем меню
// TODO: провоцирует ошибки, в т.ч. `app.js:1 Uncaught TypeError: e.addItem is not a function`
// и app.js:1 Uncaught TypeError: Cannot read properties of undefined (reading 'path'), но справиться с этим не удалось
app.workspace.trigger("file-menu", {
file: originalPage.file,
node: node,
x: e.pageX,
y: e.pageY,
});
};
async function createNewEvent(originalPage, type) {
// Получаем путь к папке оригинального файла
const folderPath = originalPage.file.folder;
const date = originalPage.date;
const regions = originalPage.regions;
const [year, month, day] = date.split("/");
const formattedDate = `${year}.${month.padStart(2, "0")}.${day.padStart(
2,
"0"
)}`;
// Создаем имя нового файла
const newFileName = `${formattedDate} Новое событие`;
const fullPath = folderPath ? `${folderPath}/${newFileName}` : newFileName;
try {
// Получаем содержимое шаблона
const templateContent = await app.vault.adapter.read(
"Templates/Preparation/Fields for Event.md"
);
// Генерируем уникальное имя файла
const uniqueFilePath = await generateUniqueFileName(fullPath);
// Создаем новый файл
let file = await app.vault.create(uniqueFilePath, templateContent);
await app.fileManager.processFrontMatter(file, (fm) => {
fm["date"] = date;
if (!fm["regions"]) {
fm["regions"] = [];
}
let filePath = "";
if (Array.isArray(regions)) {
regions.forEach((region) => {
if (typeof region === "object") {
filePath = region.path;
} else {
filePath = region;
}
const fileNameWithoutExt = path.basename(
filePath,
path.extname(filePath)
);
fm["regions"].push(`[[${fileNameWithoutExt}]]`);
});
} else {
if (typeof regions === "object") {
filePath = regions.path;
} else {
filePath = regions;
}
const fileNameWithoutExt = path.basename(
filePath,
path.extname(filePath)
);
fm["regions"].push(`[[${fileNameWithoutExt}]]`);
}
if (!fm["nextEvents"]) {
fm["nextEvents"] = [];
}
if (type === "previous") {
fm["nextEvents"].push(`[[${originalPage.file.name}]]`);
}
});
// Получаем только имя файла без пути и расширения
const uniqueFileName = uniqueFilePath.split("/").pop().replace(".md", "");
// Показываем уведомление об успехе
new Notice(`Создано новое событие: ${uniqueFileName}`);
// Если это следующее событие, обновляем nextEvents в текущем файле
if (type === "next") {
const currentFile = app.vault.getAbstractFileByPath(
`${originalPage.file.path}`
);
if (currentFile) {
await app.fileManager.processFrontMatter(currentFile, (fm) => {
if (!fm["nextEvents"]) {
fm["nextEvents"] = [];
}
fm["nextEvents"].push(`[[${file.basename}]]`);
});
}
}
// Открываем новый файл в новой вкладке
const leaf = app.workspace.getLeaf("tab"); // создаем новую вкладку
await leaf.openFile(file, { active: false }); // открываем файл, не переключаясь на него
// Обновлять таймлайн не надо, в случае создания нового файла самостоятельно заметка обновится
// updateTimeline();
} catch (error) {
console.error("Ошибка при создании файла:", error);
new Notice("Ошибка при создании события");
}
}
const monthsHarpotos = {
1: "Хаммер",
2: "Альтуриак",
3: "Чес",
4: "Тарсах",
5: "Миртул",
6: "Киторн",
7: "Флеймрул",
8: "Элесиас",
9: "Элейнт",
10: "Марпенот",
11: "Уктар",
12: "Найтол",
};
function showTimeline(
dv,
locations,
characters,
currentDate,
startDate,
endDate,
pcsParticipationOnly,
includeRegionsParticipants,
fullWidth = false
) {
// Выбираем страницы с полем date, попадающим в диапазон:
let pages = dv
.pages("#event")
.filter((p) => pageWithCorrectFields(dv, p, locations, characters))
.where(
(p) =>
isInRange(p, startDate, endDate) &&
(!pcsParticipationOnly || p.pcsParticipation)
);
if (pages.length == 0) {
dv.el("div", "Not found", { cls: "mermaid-timeline" });
window.lastGeneratedMermaidCode = "";
return;
}
// Группируем по "год.месяц", используя существующую функцию
// Не оптимально, надо потом упростить, попробовать уйти от displayKey ниже
let groups = pages
.groupBy((p) => {
let dateNum = customDateToNumber(p.date);
let [y, m, d] = p.date.split("/");
return {
numericKey: Math.floor(dateNum / 30), // убираем дни, оставляем только год и месяц
displayKey: `${y}.${m}`, // для отображения
};
})
.sort((g) => g.key.numericKey);
// Начинаем собирать строку для mermaid-диаграммы:
let result = "```mermaid\n";
result += `%% ниже представлены события в текущей НРИ кампании, текущая дата: ${currentDate}.\n\n`;
// для foreignObject и foreignObject div свойства в т.ч., чтобы были переносы для нодов с длинными названиями, иначе одной строкой выводит
result +=
`%%{init: {'themeCSS':
'.nodes rect { fill: none; stroke: none; }; ` +
`.cluster rect { fill: var(--background-primary-alt); stroke: var(--background-modifier-border); }` +
`.node:not(.pointNode) foreignObject { width: 100px !important; height: auto !important; overflow: visible !important; }; ` +
`.node:not(.pointNode) foreignObject div { display: block; min-width: fit-content; width: auto !important; height: auto; white-space: normal !important; padding: 4px 6px; background-color: var(--background-secondary-alt); border: 1px solid var(--background-modifier-border); }` +
`.node.pcs-participation:not(.pointNode) foreignObject div { background-color: #d7fafa; border: 1px solid var(--background-modifier-border); }` +
`.node.future-events:not(.pointNode) foreignObject div { background-color:rgb(216, 244, 177); border: 1px solid var(--background-modifier-border); }` +
`.context-menu div:hover { background-color: var(--background-modifier-hover); }` + // стили для контекстного меню
`.context-menu div { transition: background-color 0.1s ease; }` +
`'}}%%\n\n`;
result += "graph LR\n\n";
// Соберём связи и классы в массивы:
let connections = [];
let pathToNodeName = {};
let subgraphLinks = [];
let nodeClasses = []; // Для определения классов узлов
let customStyleNodeClasses = []; // Для определения классов узлов
// 1. Генерируем сабграфы
let idx = 1;
for (let group of groups) {
let monthNumber = parseInt(group.key.displayKey.split(".")[1], 10);
let monthName = monthsHarpotos[monthNumber];
result += ` subgraph Layer${idx} [${group.key.displayKey} ${monthName}]\n`;
let tNodeName = `T${idx}`;
result += ` ${tNodeName}(( ))\n`; // Добавляем узел T<i> в начало сабграфа
nodeClasses.push(` class ${tNodeName} pointNode;`);
for (let item of group.rows) {
// Уберём пробелы из названия файла, чтобы Mermaid корректно рендерил:
let nodeName = "event_" + item.file.name.replace(/[\s.,()[\]{}]+/g, "_"); // чтобы получить корректное название нода
let nodeLabel = item.file.name; // Отображаемое имя (с пробелами)
if (item.file.path) pathToNodeName[item.file.path] = nodeName;
// Добавляем ноду в сабграф с отображаемым именем
if (item.summary) result += ` %% summary: ${item.summary}\n`;
if (includeRegionsParticipants)
{
if (item.regions)
result += ` %% regions: ${item.regions}\n`;
if (item.participants)
result += ` %% participants: ${item.participants}\n`;
}
result += ` ${nodeName}[${nodeLabel}]\n`;
// Добавляем класс для ноды
nodeClasses.push(` class ${nodeName} internal-link;`);
if (customDateToNumber(item.date) > customDateToNumber(currentDate))
customStyleNodeClasses.push(` class ${nodeName} future-events;`);
else if (item.pcsParticipation)
customStyleNodeClasses.push(` class ${nodeName} pcs-participation;`);
if (item.nextEvents) {
let nextEvents = Array.isArray(item.nextEvents)
? item.nextEvents
: [item.nextEvents];
for (let nextEvent of nextEvents) {
if (nextEvent && nextEvent.path) {
connections.push([nodeName, nextEvent.path]);
}
}
}
}
result += " end\n\n";
if (idx > 1) {
// Добавляем связь между сабграфами: T<i-1> --> T<i>
subgraphLinks.push(` T${idx - 1} --> T${idx}:::customLink;`);
// A-->|Текст связи|B:::customLink;
}
idx++;
}
// 2. Добавляем блок со связями между сабграфами
if (subgraphLinks.length > 0) {
result += " %% Связи между сабграфами\n";
result += subgraphLinks.join("\n") + "\n\n";
}
// 3. Вставляем блок со связями между нодами, если они есть
if (connections.length > 0) {
result += " %% Связи\n";
for (let connection of connections) {
let fromNodeName = connection[0];
let nextEventPath = connection[1];
let nextEventNodeName = pathToNodeName[nextEventPath];
if (nextEventNodeName)
result += ` ${fromNodeName} --> ${nextEventNodeName}\n`;
}
}
result += " %% Классы узлов и связей\n";
result +=
" classDef pointNode fill:lightgray,stroke:none,opacity:1,radius:2px;\n";
result += " %% Объявляем класс для ссылок\n";
result +=
" classDef customLink stroke:lightgray,stroke-width:0px,stroke-dasharray:5 5;\n";
result += " classDef pcs-participation color:inherit;\n"; // зададим через css выше, т.к. здесь свойства для g-контейнера div'а
result += " classDef future-events color:inherit;\n"; // зададим через css выше, т.к. здесь свойства для g-контейнера div'а
// 4. Вставляем блок для классов узлов
if (nodeClasses.length > 0) {
result += " %% Классы узлов\n";
result += nodeClasses.join("\n") + "\n";
result += customStyleNodeClasses.join("\n") + "\n";
}
// Закрываем кодовый блок с мермейдом
result += "```";
// Создаём div с конкретным классом
let mermaidTimelineDiv = dv.el("div", result, { cls: "mermaid-timeline" });
// Сохраняем сгенерированный код
window.lastGeneratedMermaidCode = result;
// Активируем drag-перемещение
enableDiagramDragging(dv);
// Вызываем функцию после рендеринга диаграммы
setTimeout(() => {
setupContextMenu(dv);
enableZoomControl(mermaidTimelineDiv.querySelector(".mermaid"));
}, 1000); // Даем время на рендеринг Mermaid, передаём dv через лямбду
}
async function run_show_timeline(...args) {
if (!args[0] || !args[0].arg5) return; // не имеет свойства length
let dv = args[0].arg1;
let locations = args[0].arg2; // Предполагается, что это массив целевых локаций
let characters = args[0].arg3;
let defaultCurrentDate = args[0].arg4;
let startDate = args[0].arg5;
let endDate = args[0].arg6;
let pcsParticipationOnly = args[0].arg7;
let includeRegionsParticipants = args[0].arg8;
showTimeline(
dv,
locations,
characters,
defaultCurrentDate,
startDate,
endDate,
pcsParticipationOnly,
includeRegionsParticipants
);
}
await run_show_timeline(input);
dv.timeline = {
showTimeline,
};