mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2025-12-17 01:41:22 +01:00
409 lines
13 KiB
JavaScript
409 lines
13 KiB
JavaScript
appendStyleSheet();
|
|
insertHtml();
|
|
addListeners();
|
|
|
|
const MARGINS = {top: 10, right: 10, bottom: -5, left: 10};
|
|
|
|
const handleZoom = () => viewbox.attr("transform", d3.event.transform);
|
|
const zoom = d3.zoom().scaleExtent([0.2, 1.5]).on("zoom", handleZoom);
|
|
|
|
// define svg elements
|
|
const svg = d3.select("#hierarchyTree > svg").call(zoom);
|
|
const viewbox = svg.select("g#hierarchyTree_viewbox");
|
|
const primaryLinks = viewbox.select("g#hierarchyTree_linksPrimary");
|
|
const secondaryLinks = viewbox.select("g#hierarchyTree_linksSecondary");
|
|
const nodes = viewbox.select("g#hierarchyTree_nodes");
|
|
const dragLine = viewbox.select("path#hierarchyTree_dragLine");
|
|
|
|
// properties
|
|
let dataElements; // {i, name, type, origins}[], e.g. path.religions
|
|
let validElements; // not-removed dataElements
|
|
let onNodeEnter; // d3Data => void
|
|
let onNodeLeave; // d3Data => void
|
|
let getDescription; // dataElement => string
|
|
let getShape; // dataElement => string;
|
|
|
|
export function open(props) {
|
|
closeDialogs(".stable");
|
|
|
|
dataElements = props.data;
|
|
dataElements[0].origins = [null];
|
|
validElements = dataElements.filter(r => !r.removed);
|
|
if (validElements.length < 3) return tip(`Not enough ${props.type} to show hierarchy`, false, "error");
|
|
|
|
onNodeEnter = props.onNodeEnter;
|
|
onNodeLeave = props.onNodeLeave;
|
|
getDescription = props.getDescription;
|
|
getShape = props.getShape;
|
|
|
|
const root = getRoot();
|
|
const treeWidth = root.leaves().length * 50;
|
|
const treeHeight = root.height * 50;
|
|
|
|
const w = treeWidth - MARGINS.left - MARGINS.right;
|
|
const h = treeHeight + 30 - MARGINS.top - MARGINS.bottom;
|
|
const treeLayout = d3.tree().size([w, h]);
|
|
|
|
const width = minmax(treeWidth, 300, innerWidth * 0.75);
|
|
const height = minmax(treeHeight, 200, innerHeight * 0.75);
|
|
|
|
zoom.extent([Array(2).fill(0), [width, height]]);
|
|
svg.attr("viewBox", `0, 0, ${width}, ${height}`);
|
|
|
|
$("#hierarchyTree").dialog({
|
|
title: `${capitalize(props.type)} tree`,
|
|
position: {my: "left center", at: "left+10 center", of: "svg"},
|
|
width
|
|
});
|
|
|
|
renderTree(root, treeLayout);
|
|
}
|
|
|
|
function appendStyleSheet() {
|
|
const styles = /* css */ `
|
|
#hierarchyTree_selectedOrigins > button {
|
|
margin: 0 2px;
|
|
}
|
|
|
|
.hierarchyTree_selectedButton {
|
|
border: 1px solid #aaa;
|
|
background: none;
|
|
padding: 1px 4px;
|
|
}
|
|
|
|
.hierarchyTree_selectedButton:hover {
|
|
border: 1px solid #333;
|
|
}
|
|
|
|
.hierarchyTree_selectedOrigin::after {
|
|
content: "✕";
|
|
margin-left: 8px;
|
|
color: #999;
|
|
}
|
|
|
|
.hierarchyTree_selectedOrigin:hover:after {
|
|
color: #333;
|
|
}
|
|
|
|
#hierarchyTree_originSelector > form > div {
|
|
padding: 0.3em;
|
|
margin: 1px 0;
|
|
border-radius: 1em;
|
|
}
|
|
|
|
#hierarchyTree_originSelector > form > div:hover {
|
|
background-color: #ddd;
|
|
}
|
|
|
|
#hierarchyTree_originSelector > form > div[checked] {
|
|
background-color: #c6d6d6;
|
|
}
|
|
|
|
#hierarchyTree_nodes > g > text {
|
|
pointer-events: none;
|
|
stroke: none;
|
|
font-size: 11px;
|
|
}
|
|
|
|
#hierarchyTree_nodes > g.selected {
|
|
stroke: #c13119;
|
|
stroke-width: 1;
|
|
cursor: move;
|
|
}
|
|
|
|
#hierarchyTree_dragLine {
|
|
marker-end: url(#end-arrow);
|
|
stroke: #333333;
|
|
stroke-dasharray: 5;
|
|
stroke-dashoffset: 1000;
|
|
animation: dash 80s linear backwards;
|
|
}
|
|
`;
|
|
|
|
const style = document.createElement("style");
|
|
style.appendChild(document.createTextNode(styles));
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
function insertHtml() {
|
|
const html = /* html */ `<div id="hierarchyTree" style="overflow: hidden;">
|
|
<svg>
|
|
<g id="hierarchyTree_viewbox" style="text-anchor: middle; dominant-baseline: central">
|
|
<g transform="translate(10, -45)">
|
|
<g id="hierarchyTree_links" fill="none" stroke="#aaa">
|
|
<g id="hierarchyTree_linksPrimary"></g>
|
|
<g id="hierarchyTree_linksSecondary" stroke-dasharray="1"></g>
|
|
</g>
|
|
<g id="hierarchyTree_nodes"></g>
|
|
<path id="hierarchyTree_dragLine" path='' />
|
|
</g>
|
|
</g>
|
|
</svg>
|
|
|
|
<div id="hierarchyTree_details" class='chartInfo'>
|
|
<div id='hierarchyTree_infoLine' style="display: block">‍</div>
|
|
<div id='hierarchyTree_selected' style="display: none">
|
|
<span><span id='hierarchyTree_selectedName'></span>. </span>
|
|
<span data-name="Type short name (abbreviation)">Abbreviation: <input id='hierarchyTree_selectedCode' type='text' maxlength='3' size='3' /></span>
|
|
<span>Origins: <span id='hierarchyTree_selectedOrigins'></span></span>
|
|
<button data-tip='Add origin' class="hierarchyTree_selectedButton" id='hierarchyTree_selectedSelectButton'>Select</button>
|
|
<button data-tip='Exit edit mode' class="hierarchyTree_selectedButton" id='hierarchyTree_selectedCloseButton'>Exit</button>
|
|
</div>
|
|
</div>
|
|
<div id="hierarchyTree_originSelector"></div>
|
|
</div>`;
|
|
|
|
byId("dialogs").insertAdjacentHTML("beforeend", html);
|
|
}
|
|
|
|
function addListeners() {}
|
|
|
|
function getRoot() {
|
|
return d3
|
|
.stratify()
|
|
.id(d => d.i)
|
|
.parentId(d => d.origins[0])(validElements);
|
|
}
|
|
|
|
function getLinkPath(d) {
|
|
const {
|
|
source: {x: sx, y: sy},
|
|
target: {x: tx, y: ty}
|
|
} = d;
|
|
return `M${sx},${sy} C${sx},${(sy * 3 + ty) / 4} ${tx},${(sy * 2 + ty) / 3} ${tx},${ty}`;
|
|
}
|
|
|
|
function getSecondaryLinks(root) {
|
|
const nodes = root.descendants();
|
|
const links = [];
|
|
|
|
for (const node of nodes) {
|
|
const origins = node.data.origins;
|
|
|
|
for (let i = 1; i < origins.length; i++) {
|
|
const source = nodes.find(n => n.data.i === origins[i]);
|
|
if (source) links.push({source, target: node});
|
|
}
|
|
}
|
|
|
|
return links;
|
|
}
|
|
|
|
const shapesMap = {
|
|
undefined: "M5,0A5,5,0,1,1,-5,0A5,5,0,1,1,5,0", // small circle
|
|
circle: "M11.3,0A11.3,11.3,0,1,1,-11.3,0A11.3,11.3,0,1,1,11.3,0",
|
|
square: "M-11,-11h22v22h-22Z",
|
|
hexagon: "M-6.5,-11.26l13,0l6.5,11.26l-6.5,11.26l-13,0l-6.5,-11.26Z",
|
|
diamond: "M0,-14L14,0L0,14L-14,0Z",
|
|
concave: "M-11,-11l11,2l11,-2l-2,11l2,11l-11,-2l-11,2l2,-11Z",
|
|
octagon: "M-4.97,-12.01 l9.95,0 l7.04,7.04 l0,9.95 l-7.04,7.04 l-9.95,0 l-7.04,-7.04 l0,-9.95Z",
|
|
pentagon: "M0,-14l14,11l-6,14h-16l-6,-14Z"
|
|
};
|
|
|
|
const getSortIndex = node => {
|
|
const descendants = node.descendants();
|
|
const secondaryOrigins = descendants.map(({data}) => data.origins.slice(1)).flat();
|
|
|
|
if (secondaryOrigins.length === 0) return node.data.i;
|
|
return d3.mean(secondaryOrigins);
|
|
};
|
|
|
|
function renderTree(root, treeLayout) {
|
|
treeLayout(root.sort((a, b) => getSortIndex(a) - getSortIndex(b)));
|
|
|
|
primaryLinks.selectAll("path").data(root.links()).enter().append("path").attr("d", getLinkPath);
|
|
secondaryLinks.selectAll("path").data(getSecondaryLinks(root)).enter().append("path").attr("d", getLinkPath);
|
|
|
|
const node = nodes
|
|
.selectAll("g")
|
|
.data(root.descendants())
|
|
.enter()
|
|
.append("g")
|
|
.attr("data-id", d => d.data.i)
|
|
.attr("stroke", "#333")
|
|
.attr("transform", d => `translate(${d.x}, ${d.y})`)
|
|
.on("mouseenter", handleNoteEnter)
|
|
.on("mouseleave", handleNodeExit)
|
|
.on("click", selectElement)
|
|
.call(d3.drag().on("start", dragToReorigin));
|
|
|
|
node
|
|
.append("path")
|
|
.attr("d", ({data}) => shapesMap[getShape(data)])
|
|
.attr("fill", d => d.data.color || "#ffffff")
|
|
.attr("stroke-dasharray", d => (d.data.cells ? "none" : "1"));
|
|
|
|
node.append("text").text(d => d.data.code || "");
|
|
}
|
|
|
|
function rerenderTree() {
|
|
nodes.selectAll("*").remove();
|
|
primaryLinks.selectAll("*").remove();
|
|
secondaryLinks.selectAll("*").remove();
|
|
|
|
const root = getRoot();
|
|
const treeWidth = root.leaves().length * 50;
|
|
const treeHeight = root.height * 50;
|
|
|
|
const w = treeWidth - MARGINS.left - MARGINS.right;
|
|
const h = treeHeight + 30 - MARGINS.top - MARGINS.bottom;
|
|
const treeLayout = d3.tree().size([w, h]);
|
|
|
|
renderTree(root, treeLayout);
|
|
}
|
|
|
|
function selectElement(d) {
|
|
const dataElement = d.data;
|
|
|
|
nodes.selectAll("g").style("outline", "none");
|
|
this.style.outline = "1px solid #c13119";
|
|
byId("hierarchyTree_selected").style.display = "block";
|
|
byId("hierarchyTree_infoLine").style.display = "none";
|
|
|
|
byId("hierarchyTree_selectedName").innerText = dataElement.name;
|
|
byId("hierarchyTree_selectedCode").value = dataElement.code;
|
|
|
|
byId("hierarchyTree_selectedCode").onchange = function () {
|
|
if (this.value.length > 3) return tip("Abbreviation must be 3 characters or less", false, "error", 3000);
|
|
if (!this.value.length) return tip("Abbreviation cannot be empty", false, "error", 3000);
|
|
nodes.select(`g[data-id="${d.id}"] > text`).text(this.value);
|
|
dataElement.code = this.value;
|
|
};
|
|
|
|
const createOriginButtons = () => {
|
|
byId("hierarchyTree_selectedOrigins").innerHTML = dataElement.origins
|
|
.filter(origin => origin)
|
|
.map((origin, index) => {
|
|
const {name, code} = validElements.find(r => r.i === origin) || {};
|
|
const type = index ? "Secondary" : "Primary";
|
|
const tip = `${type} origin: ${name}. Click to remove link to that origin`;
|
|
return `<button data-id="${origin}" class="hierarchyTree_selectedButton hierarchyTree_selectedOrigin" data-tip="${tip}">${code}</button>`;
|
|
})
|
|
.join("");
|
|
|
|
byId("hierarchyTree_selectedOrigins").onclick = event => {
|
|
const target = event.target;
|
|
if (target.tagName !== "BUTTON") return;
|
|
const origin = Number(target.dataset.id);
|
|
const filtered = dataElement.origins.filter(elementOrigin => elementOrigin !== origin);
|
|
dataElement.origins = filtered.length ? filtered : [0];
|
|
target.remove();
|
|
rerenderTree();
|
|
};
|
|
};
|
|
|
|
createOriginButtons();
|
|
|
|
byId("hierarchyTree_selectedSelectButton").onclick = () => {
|
|
const origins = dataElement.origins;
|
|
|
|
const descendants = d.descendants().map(d => d.data.i);
|
|
const selectableElements = validElements.filter(({i}) => !descendants.includes(i));
|
|
|
|
const selectableElementsHtml = selectableElements.map(({i, name, code, color}) => {
|
|
const isPrimary = origins[0] === i ? "checked" : "";
|
|
const isChecked = origins.includes(i) ? "checked" : "";
|
|
|
|
if (i === 0) {
|
|
return /*html*/ `
|
|
<div ${isChecked}>
|
|
<input data-tip="Set as primary origin" type="radio" name="primary" value="${i}" ${isPrimary} />
|
|
Top level
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return /*html*/ `
|
|
<div ${isChecked}>
|
|
<input data-tip="Set as primary origin" type="radio" name="primary" value="${i}" ${isPrimary} />
|
|
<input data-id="${i}" id="selectElementOrigin${i}" class="checkbox" type="checkbox" ${isChecked} />
|
|
<label data-tip="Check to set as a secondary origin" for="selectElementOrigin${i}" class="checkbox-label">
|
|
<fill-box fill="${color}" size=".8em" disabled></fill-box>
|
|
${code}: ${name}
|
|
</label>
|
|
</div>
|
|
`;
|
|
});
|
|
byId("hierarchyTree_originSelector").innerHTML = /*html*/ `
|
|
<form style="max-height: 35vh">
|
|
${selectableElementsHtml.join("")}
|
|
</form>
|
|
`;
|
|
|
|
$("#hierarchyTree_originSelector").dialog({
|
|
title: "Select origins",
|
|
position: {my: "center", at: "center", of: "svg"},
|
|
buttons: {
|
|
Select: () => {
|
|
$("#hierarchyTree_originSelector").dialog("close");
|
|
const $selector = byId("hierarchyTree_originSelector");
|
|
const selectedRadio = $selector.querySelector("input[type='radio']:checked");
|
|
const selectedCheckboxes = $selector.querySelectorAll("input[type='checkbox']:checked");
|
|
|
|
const primary = selectedRadio ? Number(selectedRadio.value) : 0;
|
|
const secondary = Array.from(selectedCheckboxes)
|
|
.map(input => Number(input.dataset.id))
|
|
.filter(origin => origin !== primary);
|
|
|
|
dataElement.origins = [primary, ...secondary];
|
|
|
|
rerenderTree();
|
|
createOriginButtons();
|
|
},
|
|
Cancel: () => {
|
|
$("#hierarchyTree_originSelector").dialog("close");
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
byId("hierarchyTree_selectedCloseButton").onclick = () => {
|
|
this.style.outline = "none";
|
|
byId("hierarchyTree_selected").style.display = "none";
|
|
byId("hierarchyTree_infoLine").style.display = "block";
|
|
};
|
|
}
|
|
|
|
function handleNoteEnter(d) {
|
|
this.classList.add("selected");
|
|
onNodeEnter(d);
|
|
|
|
byId("hierarchyTree_infoLine").innerText = getDescription(d.data);
|
|
tip("Drag to other node to add parent, click to edit");
|
|
}
|
|
|
|
function handleNodeExit(d) {
|
|
this.classList.remove("selected");
|
|
onNodeLeave(d);
|
|
|
|
byId("hierarchyTree_infoLine").innerHTML = "‍";
|
|
tip("");
|
|
}
|
|
|
|
function dragToReorigin(from) {
|
|
dragLine.attr("d", `M${from.x},${from.y}L${from.x},${from.y}`);
|
|
|
|
d3.event.on("drag", () => {
|
|
dragLine.attr("d", `M${from.x},${from.y}L${d3.event.x},${d3.event.y}`);
|
|
});
|
|
|
|
d3.event.on("end", function () {
|
|
dragLine.attr("d", "");
|
|
const selected = nodes.select("g.selected");
|
|
if (!selected.size()) return;
|
|
|
|
const elementId = from.data.i;
|
|
const newOrigin = selected.datum().data.i;
|
|
if (elementId === newOrigin) return; // dragged to itself
|
|
if (from.data.origins.includes(newOrigin)) return; // already a child of the selected node
|
|
if (from.descendants().some(node => node.data.i === newOrigin)) return; // cannot be a child of its own child
|
|
|
|
const element = dataElements.find(({i}) => i === elementId);
|
|
if (!element) return;
|
|
|
|
if (element.origins[0] === 0) element.origins = [];
|
|
element.origins.push(newOrigin);
|
|
|
|
rerenderTree();
|
|
});
|
|
}
|