moodle_local_treestudyplan/amd/src/simpleline.js
2023-07-18 11:57:58 +02:00

447 lines
15 KiB
JavaScript

/*eslint no-console: "off"*/
/*eslint no-trailing-spaces: "off"*/
import {calc} from "./css-calc";
/**
* Copies defined properties in to, over from the from object. Does so recursively
* Used to copy user defined parameters onto a pre-existing object with defaults preset.
* @param {Object} to The object to copy to
* @param {Object} from The object tp copy from (but only the properties already named in "to")
*/
const specsCopy = (to, from) => {
for(const ix in to){
if(from.hasOwnProperty(ix)){
if( typeof to[ix] == "object"
&& typeof from[ix] == "object")
{
if(Array.isArray(to[ix])){
if(Array.isArray(from[ix])){
to[ix] = Array.from(from[ix]);
} // else, skip...
} else {
specsCopy(to[ix], from[ix]); // recursive copy
}
}
else {
to[ix] = from[ix];
}
}
}
};
const debounce = (func, delay) => {
let timer;
return function(...args) {
const context = this;
clearTimeout(timer);
timer = setTimeout(() => {
func.apply(context, args);
}, delay);
};
};
/**
* Get the position of an element relative to another
* @param {HTMLElement} el The element whose position to determine
* @param {HTMLElement} reference Relative to this element
* @returns {object} Position object with {x: ..., y:...}
*/
const getElementPosition = (el, reference) => {
if(!el || !(el instanceof HTMLElement)){
// Always return 0,0 if the element is invalid
return {x: 0, y: 0};
}
if (!reference || !(reference instanceof HTMLElement)){
// Take the document body as reference if the reference is invalud
reference = document.querySelector("body");
}
if( el.offsetParent === reference){
// easily done if the reference element is also the offsetParent..
return {x: el.offsetLeft, y: el.offsetTop};
} else {
const elR = el.getBoundingClientRect();
const refR = reference.getBoundingClientRect();
return {x: elR.left - refR.left,
y: elR.top - refR.top};
}
};
export class SimpleLine {
static idCounter = 0;
/**
* Create a new line object
*
* @param {HTMLElement|string} start The element the line starts from
* @param {HTMLElement|string} end The element the line ends at
* @param {object} config Configuration for the
* @returns {SimpleLine} object
*/
constructor(start, end, config){
this.svg = null;
this.id = SimpleLine.idCounter++;
this.setConfig(config);
// Validate start element
if(start instanceof HTMLElement) {
this.start = start;
} else if (typeof start === 'string' || this.start instanceof String) {
this.startSelector = start;
this.start = document.querySelector(start);
if(!(this.start instanceof HTMLElement)){
console.error("Cannot find start element:",start);
return;
}
} else {
console.error("Start element not string or dom element",start);
return;
}
// Validate end element
if(end instanceof HTMLElement) {
this.end = end;
} else if (typeof end === 'string' || this.end instanceof String) {
this.endSelector = end;
this.end = document.querySelector(end);
if(!(this.end instanceof HTMLElement)){
console.error("Cannot find end element:",end);
return;
}
} else {
console.error("End element not string or dom element",start);
return;
}
// create observers for both elements
this.resizeObserver = new ResizeObserver(debounce(() => {
this.update();
},20));
this.resizeObserver.observe(this.start);
this.resizeObserver.observe(this.end);
// Setup the mutationobserver so we can remove the line if it's start or end is removed.
this.mutationObserver = new MutationObserver(function(mutations_list) {
mutations_list.forEach(function(mutation) {
mutation.removedNodes.forEach(function(removed_node) {
if(removed_node == this.start || removed_node == this.end) {
console.warning("Element removed",removed_node);
this.remove();
}
});
});
});
this.mutationObserver.observe(this.start.parentElement, { subtree: false, childList: true });
this.mutationObserver.observe(this.end.parentElement, { subtree: false, childList: true });
// Setup the position checker
this.positionCheck(); // Initialize refresh
if(this.specs.autorefresh > 0){
this.refreshTimer = setInterval(()=>{this.positionCheck();},this.specs.autorefresh);
}
this.active = true;
this.update(); // fist draw
}
/**
* Change the simpleline config
* @param {object} config The object containing the specs
*/
setConfig(config){
// setup defaults
if(!(this.specs)){
this.specs = {
autorefresh: 10,
class: "",
color: "", // invalid propery makes it inherit from parent containers
anchors: {
// top, middle, bottom
// left, center, right
start: ["middle","right"],
end: ["middle", "left"],
},
gravity: {
start: 1,
end: 1,
},
stroke: "4px",
};
}
if(config && typeof config == "object"){
specsCopy(this.specs,config);
}
if(this.svg){
// Re-initialize the refresh timer
clearInterval(this.refreshTimer);
if(this.specs.autorefresh > 0){
this.refreshTimer = setInterval(()=>{this.positionCheck();},this.specs.autorefresh);
}
// Update the svg image
this.update();
}
}
/**
* Get the css class of the line
*/
get cssClass(){
return this.specs.class;
}
/**
* Set the css class of the line
*/
set cssClass(cssClass)
{
this.specs.class = cssClass;
this.update();
}
/**
* Check for an element positino change and update accordingly
*/
positionCheck(){
const startPos = {x: this.start.offsetLeft, y: this.start.offsetTop};
const endPos = {x: this.end.offsetLeft, y: this.end.offsetTop};
let needUpdate = false;
if(this.startPos){
needUpdate = needUpdate || (this.startPos.x != startPos.x || this.startPos.y != startPos.y);
//console.info("Offset position changed for",this.start);
}
this.startPos = startPos;
if(this.endPos){
needUpdate = needUpdate || (this.endPos.x != endPos.x || this.endPos.y != endPos.y);
//console.info("Offset position changed for",this.end);
}
this.endPos = endPos;
if(needUpdate){
this.update();
}
}
getContainer(){
// Validate or determine container
let container = this.start.offsetParent;
if(!container) {
if(getComputedStyle(this.start).position == "fixed"){
container = document.querySelector("body");
} else {
console.error("Start element has no offsetParent. likely ");
}
}
return container;
}
getAnchorPoint(anchor){
let el = this.start;
if(anchor != "start"){
anchor = "end";
el = this.end;
}
let x, dirX;
let y, dirY;
// determine start coordinates
if(this.specs.anchors[anchor].includes("left")){
x = 0;
dirX = -1;
} else if (this.specs.anchors[anchor].includes("right")) {
x = el.offsetWidth -1;
dirX = 1;
} else { // center
x = el.offsetWidth / 2;
dirX = 0;
}
if(this.specs.anchors[anchor].includes("top")){
y = 0;
dirY = -1;
} else if (this.specs.anchors[anchor].includes("bottom")) {
y = el.offsetHeight -1;
dirY = 1;
} else { // middle
y = el.offsetHeight / 2;
dirY = 0;
}
return { x: x, y: y, dir: {x: dirX, y: dirY}};
}
/**
* Generates the svg defs parts including the marker
* @returns {Object}
*/
svgDefs(){
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
marker.setAttribute("id",`simpleline-${this.id}-arrow`);
marker.setAttribute("markerUnits",`strokeWidth`);
marker.setAttribute("viewBox",`-8 -8 16 16`);
marker.setAttribute("orient",`auto`);
marker.setAttribute("markerWidth",`4`);
marker.setAttribute("markerHeight",`4`);
defs.appendChild(marker);
const polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
polygon.setAttribute("fill",`currentColor`);
polygon.setAttribute("points",`-8,-8 8,0 -8,8 -5,0`);
marker.append(polygon);
return defs;
}
/**
* (Re)Paint the arrow
*
*/
update(){
if(!this.active){ return;} // don't do this if we are no longer active
const container = this.getContainer();
if (!container) { return; } // Do not create any svg if container is empty
if(this.svg && (this.svg instanceof SVGElement)){
if(container !== this.svg.offsetParent){
// update the svg's parent if the container was changed
container.appendChild(this.svg);
}
} else {
this.svg = document.createElementNS('http://www.w3.org/2000/svg','svg');
this.svg.setAttribute("id",`simpleline-${this.id}`);
this.svg.setAttribute("class",`simpleline ${this.specs.class}`);
this.svg.style.position = "absolute";
this.svg.style.pointerEvents = 'none';
container.appendChild(this.svg);
this.svg.appendChild(this.svgDefs()); // create a new defs element and add it to the svg
// Add the marker definitions
}
// determine proper x, y ,h and w
const startAnchor = this.getAnchorPoint("start");
const endAnchor = this.getAnchorPoint("end");
// make it a little shorter at the end to compensate for the size of the arrow
const strokeWpx = calc(this.specs.stroke);
endAnchor.x += endAnchor.dir.x * strokeWpx *1.5;
endAnchor.y += endAnchor.dir.y * strokeWpx *1.5;
const elStartPos = getElementPosition(this.start,container);
const elEndPos = getElementPosition(this.end,container);
//console.info("startAnchor",startAnchor);
//console.info("endAnchor",endAnchor);
//console.info("elStartPos",elStartPos);
//console.info("elEndPos",elEndPos);
// Determine basic h/w between start and end anchor to help determine desired control point length
const w = Math.max(elStartPos.x + startAnchor.x,elEndPos.x + endAnchor.x)
- Math.min(elStartPos.x + startAnchor.x,elEndPos.x + endAnchor.x);
const h = Math.max(elStartPos.y + startAnchor.y,elEndPos.y + endAnchor.y)
- Math.min(elStartPos.y + startAnchor.y,elEndPos.y + endAnchor.y);
const weight = Math.sqrt(h*h+w*w)/2;
// Determine start positions and end positions relative to container
const cStartPos = {
x: elStartPos.x + startAnchor.x,
y: elStartPos.y + startAnchor.y,
dirx: elStartPos.x + startAnchor.x + startAnchor.dir.x * this.specs.gravity.start * weight,
diry: elStartPos.y + startAnchor.y + startAnchor.dir.y * this.specs.gravity.start * weight,
};
const cEndPos = {
x: elEndPos.x + endAnchor.x,
y: elEndPos.y + endAnchor.y,
dirx: elEndPos.x + endAnchor.x + endAnchor.dir.x * this.specs.gravity.end * weight,
diry: elEndPos.y + endAnchor.y + endAnchor.dir.y * this.specs.gravity.end * weight,
};
// determine the bounding rectangle of the
//console.info("cStartPos",cStartPos);
//console.info("cEndPos",cEndPos);
const margin = (!isNaN(this.specs.stroke)?5*this.specs.stroke:25);
const bounds = {
x: Math.min(cStartPos.x,cEndPos.x,cStartPos.dirx,cEndPos.dirx) - margin,
y: Math.min(cStartPos.y,cEndPos.y,cStartPos.diry,cEndPos.diry) - margin,
w: Math.max(cStartPos.x,cEndPos.x,cStartPos.dirx,cEndPos.dirx)
- Math.min(cStartPos.x,cEndPos.x,cStartPos.dirx,cEndPos.dirx) + 2*margin,
h: Math.max(cStartPos.y,cEndPos.y,cStartPos.diry,cEndPos.diry)
- Math.min(cStartPos.y,cEndPos.y,cStartPos.diry,cEndPos.diry) + 2*margin,
};
// Now convert the coordinates to the svg space
const startPos = {
x: cStartPos.x - bounds.x,
y: cStartPos.y - bounds.y,
dirx: cStartPos.dirx - bounds.x,
diry: cStartPos.diry - bounds.y,
};
const endPos = {
x: cEndPos.x - bounds.x,
y: cEndPos.y - bounds.y,
dirx: cEndPos.dirx - bounds.x,
diry: cEndPos.diry - bounds.y,
};
//console.info("Bounds",bounds);
//console.info("startPos",startPos);
//console.info("endPos",endPos);
// Update the svg attributes
this.svg.setAttribute("viewBox",`0 0 ${bounds.w}, ${bounds.h}`);
this.svg.setAttribute("width",`${bounds.w}px`);
this.svg.setAttribute("height",`${bounds.h}px`);
this.svg.style.color = this.specs.color;
// Reposition the SVG relative to the container
this.svg.style.left = `${bounds.x}px`;
this.svg.style.top = `${bounds.y}px`;
this.svg.style.width = `${bounds.w}px`;
this.svg.style.height = `${bounds.h}px`;
// Draw the line
if(!this.line){
this.line = document.createElementNS("http://www.w3.org/2000/svg", "path");
this.line.setAttribute("marker-end",`url(#simpleline-${this.id}-arrow)`);
this.svg.appendChild(this.line);
}
let strokeWidth = this.specs.stroke;
if(!isNaN(strokeWidth) && strokeWidth != 0){
strokeWidth = `${strokeWidth}px`;
}
this.line.style.stroke = "currentColor";
this.line.style.fill = "none";
this.line.style.strokeWidth= strokeWidth;
this.line.setAttribute('d',
`M ${startPos.x} ${startPos.y}
C ${startPos.dirx} ${startPos.diry}, ${endPos.dirx} ${endPos.diry}, ${endPos.x} ${endPos.y}`);
}
/**
* Remove the line element from the dom and invalidate it.
*/
remove(){
// remove the line
this.svg.remove();
this.svg = null;
this.line = null;
// clear the refresh timer
clearInterval(this.refreshTimer);
// stop the observers
this.resizeObserver.disconnect();
this.mutationObserver.disconnect();
this.active = false;
}
}