574 lines
12 KiB
HTML
574 lines
12 KiB
HTML
{% load static i18n %}
|
|
|
|
<style>
|
|
.genealogy-tree ul {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: stretch;
|
|
padding-top: 20px;
|
|
padding-left: 0px;
|
|
position: relative;
|
|
}
|
|
|
|
.genealogy-tree li {
|
|
text-align: center;
|
|
list-style-type: none;
|
|
position: relative;
|
|
padding: 20px 5px 0 5px;
|
|
}
|
|
|
|
.genealogy-tree li::before,
|
|
.genealogy-tree li::after {
|
|
content: "";
|
|
position: absolute;
|
|
top: 0;
|
|
right: 50%;
|
|
border-top: 2px solid #ccc;
|
|
width: 50%;
|
|
height: 18px;
|
|
}
|
|
|
|
.genealogy-tree li::after {
|
|
right: auto;
|
|
left: 50%;
|
|
border-left: 2px solid #ccc;
|
|
}
|
|
|
|
.genealogy-tree li:only-child::after,
|
|
.genealogy-tree li:only-child::before {
|
|
display: none;
|
|
}
|
|
|
|
.genealogy-tree li:only-child {
|
|
padding-top: 0;
|
|
}
|
|
|
|
.genealogy-tree li:first-child::before,
|
|
.genealogy-tree li:last-child::after {
|
|
border: 0 none;
|
|
}
|
|
|
|
.genealogy-tree li:last-child::before {
|
|
border-right: 2px solid #ccc;
|
|
border-radius: 0 5px 0 0;
|
|
-webkit-border-radius: 0 5px 0 0;
|
|
-moz-border-radius: 0 5px 0 0;
|
|
}
|
|
|
|
.genealogy-tree li:first-child::after {
|
|
border-radius: 5px 0 0 0;
|
|
-webkit-border-radius: 5px 0 0 0;
|
|
-moz-border-radius: 5px 0 0 0;
|
|
}
|
|
|
|
.genealogy-tree ul ul::before {
|
|
content: "";
|
|
position: absolute;
|
|
top: 0;
|
|
left: 50%;
|
|
border-left: 2px solid #ccc;
|
|
width: 0;
|
|
height: 20px;
|
|
}
|
|
|
|
.genealogy-tree li a {
|
|
text-decoration: none;
|
|
color: #666;
|
|
font-size: 13px;
|
|
display: inline-block;
|
|
border-radius: 10px;
|
|
transition: 0.3s;
|
|
min-width: 180px;
|
|
min-height: 120px;
|
|
}
|
|
|
|
.member-view-box {
|
|
padding: 10px 10px;
|
|
text-align: center;
|
|
border-radius: 10px;
|
|
border: 1px;
|
|
border-color: #ffb6ab;
|
|
border-style: solid;
|
|
position: relative;
|
|
background-color: #fff6e0;
|
|
display: flex;
|
|
flex-flow: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.genealogy-tree li > a .member-view-box span {
|
|
display: none;
|
|
}
|
|
|
|
.genealogy-tree li.has-children > a .member-view-box span {
|
|
display: block;
|
|
}
|
|
|
|
.has-children > a .member-view-box {
|
|
background-color: #ffe4e0;
|
|
}
|
|
|
|
.member-image {
|
|
padding: 10px;
|
|
width: 120px;
|
|
position: relative;
|
|
}
|
|
|
|
.member-image img {
|
|
width: 100px;
|
|
height: 100px;
|
|
border-radius: 6px;
|
|
background-color: #fff;
|
|
z-index: 1;
|
|
}
|
|
|
|
.member-header {
|
|
color: #333;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.member-footer {
|
|
text-align: center;
|
|
}
|
|
|
|
.member-footer div.name {
|
|
color: #666;
|
|
font-size: 13px;
|
|
margin-bottom: 15px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.canvas-wrap {
|
|
position: relative;
|
|
overflow-y: auto;
|
|
background: #f7f7f7;
|
|
border-radius: 10px;
|
|
overflow-x: auto;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: center;
|
|
height: calc(100vh - 230px);
|
|
}
|
|
|
|
.canvas {
|
|
position: relative;
|
|
transform: translate(var(--tx), var(--ty)) scale(var(--scale));
|
|
transform-origin: 0 0;
|
|
transition: transform 0s;
|
|
}
|
|
|
|
.node {
|
|
position: absolute;
|
|
min-width: 140px;
|
|
min-height: 72px;
|
|
padding: 12px 14px;
|
|
background: #fff;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 10px;
|
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
|
|
cursor: move;
|
|
user-select: none;
|
|
}
|
|
|
|
.canvas-wrap.hand {
|
|
cursor: grab;
|
|
}
|
|
|
|
.canvas-wrap.hand.dragging {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.upArrow {
|
|
transform: rotate(180deg);
|
|
}
|
|
.buttons {
|
|
position: absolute;
|
|
right: 2%;
|
|
top: 210px;
|
|
}
|
|
|
|
.buttons .zoomIn, .buttons .zoomOut {
|
|
padding: 7px;
|
|
margin-bottom: 5px;
|
|
background-color: #444;
|
|
color: #fff;
|
|
border-style: none;
|
|
border: 1px solid #000;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.direction-controls {
|
|
width: 74px;
|
|
height: 74px;
|
|
background: #444;
|
|
border: 1px solid #dee2e6;
|
|
border-radius: 8px;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.dir-btn {
|
|
position: absolute;
|
|
background: transparent;
|
|
border: none;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: bold;
|
|
color: #495057;
|
|
transition: all 0.15s ease;
|
|
font-family: monospace;
|
|
color: #fff;
|
|
|
|
}
|
|
|
|
.dir-btn:hover {
|
|
background: rgba(233, 236, 239, 0.7);
|
|
color: #212529;
|
|
}
|
|
|
|
.dir-btn:active {
|
|
background: rgba(222, 226, 230, 0.8);
|
|
}
|
|
|
|
.dir-btn.up {
|
|
top: 0;
|
|
left: 25px;
|
|
width: 24px;
|
|
height: 25px;
|
|
}
|
|
|
|
.dir-btn.left {
|
|
top: 25px;
|
|
left: 0;
|
|
width: 25px;
|
|
height: 24px;
|
|
}
|
|
|
|
.dir-btn.right {
|
|
top: 25px;
|
|
right: 0;
|
|
width: 25px;
|
|
height: 24px;
|
|
}
|
|
|
|
.dir-btn.down {
|
|
bottom: 0;
|
|
left: 25px;
|
|
width: 24px;
|
|
height: 25px;
|
|
}
|
|
</style>
|
|
|
|
<div class="canvas-wrap" id="viewport">
|
|
<div class="canvas" id="canvas" style="--tx: 0px; --ty: 0px; --scale: 1">
|
|
<div class="genealogy-body genealogjy-scroll">
|
|
<div class="genealogy-tree" id="org-tree"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="buttons">
|
|
<button class="zoomOut cursor-zoom-out"><i class="fa fa-search-minus" aria-hidden="true"></i></button>
|
|
<button class="zoomIn cursor-zoom-in"><i class="fa fa-search-plus" aria-hidden="true"></i></button>
|
|
|
|
<div class="direction-controls text-2xl">
|
|
<button class="dir-btn up" ><ion-icon name="chevron-up-outline"></ion-icon></button>
|
|
<button class="dir-btn left" ><ion-icon name="chevron-back-outline"></ion-icon></button>
|
|
<button class="dir-btn right" ><ion-icon name="chevron-forward-outline"></ion-icon></button>
|
|
<button class="dir-btn down" ><ion-icon name="chevron-down-outline"></ion-icon></button>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
{{ act_datasource|json_script:"chart-data" }}
|
|
|
|
<script>
|
|
var orgData = JSON.parse($("#chart-data").text());
|
|
|
|
// Function to create a tree node
|
|
function createNode(person) {
|
|
const hasChildren = person.children && person.children.length > 0;
|
|
|
|
return `
|
|
<li class="flex-1 flex flex-col items-center justify-start ${
|
|
hasChildren ? "has-children" : ""
|
|
}">
|
|
<a class="block" href="#" onclick="event.preventDefault()">
|
|
<div class="member-view-box relative h-full">
|
|
<div class="member-header">${person.name}</div>
|
|
<div class="member-footer">
|
|
<div class="name">${person.title}</div>
|
|
</div>
|
|
<span class="absolute bottom-[-10px] left-0 right-0 text-xl"><ion-icon name="chevron-down-outline"></ion-icon></span>
|
|
</div>
|
|
</a>
|
|
${hasChildren ? createChildrenList(person.children) : ""}
|
|
</li>
|
|
`;
|
|
}
|
|
|
|
// Function to create children list
|
|
function createChildrenList(children) {
|
|
if (!children || children.length === 0) return "";
|
|
|
|
return `
|
|
<ul>
|
|
${children.map((child) => createNode(child)).join("")}
|
|
</ul>
|
|
`;
|
|
}
|
|
|
|
// Function to build the complete tree
|
|
function buildOrgTree(data) {
|
|
return `
|
|
<ul>
|
|
${createNode(data)}
|
|
</ul>
|
|
`;
|
|
}
|
|
|
|
// Initialize the tree
|
|
function initializeTree() {
|
|
const treeContainer = document.getElementById("org-tree");
|
|
treeContainer.innerHTML = buildOrgTree(orgData);
|
|
setupTreeBehavior();
|
|
}
|
|
|
|
// Setup tree expand/collapse behavior
|
|
function setupTreeBehavior() {
|
|
$(".genealogy-tree ul").hide();
|
|
$(".genealogy-tree > ul").show();
|
|
$(".genealogy-tree ul.active").show();
|
|
|
|
$(".genealogy-tree li").each(function () {
|
|
if ($(this).children("ul").length) {
|
|
$(this).addClass("has-children");
|
|
}
|
|
});
|
|
|
|
$(".genealogy-tree li .member-view-box")
|
|
.off("click")
|
|
.on("click", function (e) {
|
|
const $children = $(this).closest("li").children("ul");
|
|
$(this).find("span").toggleClass("upArrow");
|
|
|
|
if ($children.length) {
|
|
if ($children.is(":visible")) {
|
|
$children
|
|
.slideUp("fast")
|
|
.removeClass("active")
|
|
.find("ul")
|
|
.slideUp("fast")
|
|
.removeClass("active");
|
|
} else {
|
|
$children.slideDown("fast").addClass("active");
|
|
}
|
|
}
|
|
|
|
e.stopPropagation();
|
|
});
|
|
}
|
|
|
|
// Pan and zoom functionality
|
|
function initializePanAndZoom(
|
|
viewportId = "viewport",
|
|
canvasId = "canvas",
|
|
) {
|
|
const wrap = document.getElementById(viewportId);
|
|
const plane = document.getElementById(canvasId);
|
|
|
|
let isSpace = false,
|
|
isPanning = false,
|
|
start = { x: 0, y: 0 },
|
|
base = { tx: 0, ty: 0 };
|
|
let scale = 1;
|
|
|
|
const MIN = 0.2,
|
|
MAX = 3,
|
|
ZOOM_STEP = 0.1; // 10% per step
|
|
PAN_STEP = 50;
|
|
|
|
// Apply transform to plane
|
|
const setVars = () => {
|
|
plane.style.transform = `translate(${base.tx}px, ${base.ty}px) scale(${scale})`;
|
|
};
|
|
|
|
// --- Keyboard Space Panning ---
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.code === "Space" && !isSpace) {
|
|
const target = e.target;
|
|
const isEditable =
|
|
target.tagName === "INPUT" ||
|
|
target.tagName === "TEXTAREA" ||
|
|
target.isContentEditable;
|
|
|
|
if (isEditable) return; // allow space in inputs
|
|
|
|
isSpace = true;
|
|
wrap.classList.add("hand");
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
|
|
document.addEventListener("keyup", (e) => {
|
|
if (e.code === "Space") {
|
|
isSpace = false;
|
|
wrap.classList.remove("hand", "dragging");
|
|
}
|
|
});
|
|
|
|
// --- Mouse Panning ---
|
|
wrap.addEventListener("mousedown", (e) => {
|
|
if (!isSpace) return;
|
|
isPanning = true;
|
|
wrap.classList.add("dragging");
|
|
start.x = e.clientX;
|
|
start.y = e.clientY;
|
|
e.preventDefault();
|
|
});
|
|
|
|
document.addEventListener("mousemove", (e) => {
|
|
if (!isPanning) return;
|
|
base.tx += e.clientX - start.x;
|
|
base.ty += e.clientY - start.y;
|
|
start.x = e.clientX;
|
|
start.y = e.clientY;
|
|
setVars();
|
|
});
|
|
|
|
document.addEventListener("mouseup", () => {
|
|
if (isPanning) {
|
|
isPanning = false;
|
|
wrap.classList.remove("dragging");
|
|
}
|
|
});
|
|
|
|
// Prevent clicks after drag
|
|
plane.addEventListener(
|
|
"click",
|
|
(e) => {
|
|
if (isPanning || isSpace) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
}
|
|
},
|
|
true
|
|
);
|
|
|
|
// --- Wheel Zoom ---
|
|
wrap.addEventListener(
|
|
"wheel",
|
|
(e) => {
|
|
if (!e.ctrlKey) return;
|
|
e.preventDefault();
|
|
|
|
const rect = plane.getBoundingClientRect();
|
|
const cx = (e.clientX - rect.left) / scale;
|
|
const cy = (e.clientY - rect.top) / scale;
|
|
|
|
const old = scale;
|
|
const dir = Math.sign(e.deltaY); // 1 or -1
|
|
scale = Math.min(
|
|
MAX,
|
|
Math.max(MIN, old * (1 - dir * ZOOM_STEP))
|
|
);
|
|
|
|
base.tx -= cx * scale - cx * old;
|
|
base.ty -= cy * scale - cy * old;
|
|
setVars();
|
|
},
|
|
{ passive: false }
|
|
);
|
|
|
|
// --- Zoom Buttons ---
|
|
$(".zoomIn").on("click", () => {
|
|
scale = Math.min(MAX, scale * (1 + ZOOM_STEP));
|
|
setVars();
|
|
});
|
|
$(".zoomOut").on("click", () => {
|
|
scale = Math.max(MIN, scale * (1 - ZOOM_STEP));
|
|
setVars();
|
|
});
|
|
|
|
// --- Direction Buttons ---
|
|
$(".dir-btn.up").on("click", () => {
|
|
base.ty += PAN_STEP;
|
|
setVars();
|
|
});
|
|
$(".dir-btn.down").on("click", () => {
|
|
base.ty -= PAN_STEP;
|
|
setVars();
|
|
});
|
|
$(".dir-btn.left").on("click", () => {
|
|
base.tx += PAN_STEP;
|
|
setVars();
|
|
});
|
|
$(".dir-btn.right").on("click", () => {
|
|
base.tx -= PAN_STEP;
|
|
setVars();
|
|
});
|
|
|
|
wrap.addEventListener("dblclick", (e) => {
|
|
if (e.target.closest(".buttons")) return;
|
|
|
|
base = { tx: 0, ty: 0 };
|
|
scale = 1;
|
|
setVars();
|
|
});
|
|
|
|
setVars();
|
|
}
|
|
|
|
function filterMembers(keyword) {
|
|
var $tree = $("#org-tree");
|
|
|
|
$tree.find(".matched, .retained").removeClass("matched retained");
|
|
$tree.find("li").removeClass("hidden");
|
|
|
|
if (!keyword.trim()) {
|
|
return;
|
|
}
|
|
|
|
var $matched = $tree.find(".member-view-box").filter(function () {
|
|
return (
|
|
$(this).text().toLowerCase().indexOf(keyword.toLowerCase()) > -1
|
|
);
|
|
});
|
|
|
|
$matched.addClass("matched");
|
|
|
|
$matched
|
|
.parents("li")
|
|
.addClass("retained")
|
|
.each(function () {
|
|
$(this).parents("ul").addClass("active");
|
|
$(this).parents("ul").slideDown("fast").addClass("active");
|
|
});
|
|
|
|
$tree
|
|
.find("li")
|
|
.not(".retained")
|
|
.not($matched.closest("li"))
|
|
.addClass("hidden");
|
|
}
|
|
|
|
$(document).ready(function () {
|
|
let searchTimer;
|
|
const searchDelay = 500;
|
|
initializeTree();
|
|
initializePanAndZoom();
|
|
|
|
$("#searchInput").on("keyup", function () {
|
|
clearTimeout(searchTimer);
|
|
const search = $(this).val();
|
|
searchTimer = setTimeout(function () {
|
|
filterMembers(search);
|
|
}, searchDelay);
|
|
});
|
|
});
|
|
</script>
|