import { Diagram, DiagramType, diagram_combine, empty } from './diagram.js'; import { str_to_mathematical_italic } from './unicode_utils.js' import { Vector2, V2 } from './vector.js'; import { get_color, tab_color } from './color_palette.js'; import { f_draw_to_svg, calculate_text_scale } from './draw_svg.js'; import { rectangle, rectangle_corner } from './shapes.js'; import { size } from './shapes/shapes_geometry.js'; import { HorizontalAlignment, VerticalAlignment, distribute_horizontal_and_align, distribute_variable_row, distribute_vertical_and_align } from './alignment.js'; import { expand_directional_value, range } from './utils.js'; type BBox = [Vector2, Vector2] const FOCUS_RECT_CLASSNAME = "diagramatics-focusrect" const FOCUS_NO_OUTLINE_CLASSNAME = "diagramatics-focusable-no-outline" function format_number(val : number, prec : number) { let fixed = val.toFixed(prec); // remove trailing zeros // and if the last character is a dot, remove it return fixed.replace(/\.?0+$/, ""); } export type formatFunction = (name : string, value : any, prec? : number) => string; const defaultFormat_f : formatFunction = (name : string, val : any, prec? : number) => { let val_str = (typeof val == 'number' && prec != undefined) ? format_number(val,prec) : val.toString(); return `${str_to_mathematical_italic(name)} = ${val_str}`; } type setter_function_t = (_ : any) => void; type inpVariables_t = {[key : string] : any}; type inpSetter_t = {[key : string] : setter_function_t }; enum control_svg_name { locator = "control_svg", dnd = "dnd_svg", custom = "custom_int_svg", button = "button_svg" } enum HTML_INT_TARGET { DOCUMENT = "document", SVG = "svg" } /** * Object that controls the interactivity of the diagram */ export class Interactive { public inp_variables : inpVariables_t = {}; public inp_setter : inpSetter_t = {}; public display_mode : "svg" | "canvas" = "svg"; public diagram_svg : SVGSVGElement | undefined = undefined; public locator_svg : SVGSVGElement | undefined = undefined; public dnd_svg : SVGSVGElement | undefined = undefined; public custom_svg : SVGSVGElement | undefined = undefined; public button_svg : SVGSVGElement | undefined = undefined; private locatorHandler? : LocatorHandler = undefined; private dragAndDropHandler? : DragAndDropHandler = undefined; private buttonHandler? : ButtonHandler = undefined; // no support for canvas yet private focus_padding : number = 1; private global_scale_factor = 1; public draw_function : (inp_object : inpVariables_t, setter_object? : inpSetter_t) => any = (_) => {}; public display_precision : undefined | number = 5; intervals : {[key : string] : any} = {}; public registeredEventListenerRemoveFunctions : (() => void)[] = []; public single_int_mode: boolean = false; /** * @param control_container_div the div that contains the control elements * @param diagram_outer_svg the svg element that contains the diagram * \* _only needed if you want to use the locator_ * @param inp_object_ the object that contains the variables * \* _only needed if you want to use custom input object_ */ constructor( public control_container_div : HTMLElement, public diagram_outer_svg? : SVGSVGElement, inp_object_? : {[key : string] : any}, public event_target: HTML_INT_TARGET = HTML_INT_TARGET.SVG, ){ if (inp_object_ != undefined){ this.inp_variables = inp_object_; } } public draw() : void { this.draw_function(this.inp_variables, this.inp_setter); this.locatorHandler?.setViewBox(); this.dragAndDropHandler?.setViewBox(); set_viewbox(this.custom_svg, this.diagram_svg); set_viewbox(this.button_svg, this.diagram_svg); // TODO: also do this for the other control_svg } public set(variable_name : string, val : any) : void { this.inp_setter[variable_name](val); } public get(variable_name : string) : any { return this.inp_variables[variable_name]; } public label(variable_name : string, value : any, display_format_func : formatFunction = defaultFormat_f){ let labeldiv = document.createElement('div'); labeldiv.classList.add("diagramatics-label"); labeldiv.innerHTML = display_format_func(variable_name, value, this.display_precision); this.inp_variables[variable_name] = value; // setter ========================== const setter = (val : any) => { this.inp_variables[variable_name] = val; labeldiv.innerHTML = display_format_func(variable_name, val, this.display_precision); } this.inp_setter[variable_name] = setter; // ============================== // add components to div // //
//
//
let container = document.createElement('div'); container.classList.add("diagramatics-label-container"); container.appendChild(labeldiv); this.control_container_div.appendChild(container); } /** * WARNING: deprecated * use `locator_initial_draw` instead */ public locator_draw(){ this.locatorHandler?.setViewBox(); } public locator_initial_draw(){ // TODO: generate the svg here this.locatorHandler?.setViewBox(); } /** * alias for `dnd_initial_draw` */ public drag_and_drop_initial_draw(){ this.dnd_initial_draw(); } public dnd_initial_draw() { this.dragAndDropHandler?.setViewBox(); this.dragAndDropHandler?.drawSvg(); } private registerEventListener( element: EventTarget, type: keyof GlobalEventHandlersEventMap, callback: EventListenerOrEventListenerObject | null, options? : boolean | AddEventListenerOptions, ) { element.addEventListener(type, callback, options); const removeFunction = () => element.removeEventListener(type, callback); this.registeredEventListenerRemoveFunctions.push(removeFunction); } public removeRegisteredEventListener() { this.registeredEventListenerRemoveFunctions.forEach(f => f()); this.registeredEventListenerRemoveFunctions = []; } get_svg_element(metaname: string, force_recreate: boolean = false) : SVGSVGElement { if (this.diagram_outer_svg == undefined) throw Error("diagram_outer_svg in Interactive class is undefined"); let diagram_svg : SVGSVGElement | undefined = undefined; // check if this.diagram_outer_svg has a child with meta=control_svg // if not, create one let svg_element : SVGSVGElement | undefined = undefined; for (let i in this.diagram_outer_svg.children) { let child = this.diagram_outer_svg.children[i]; if (child instanceof SVGSVGElement && child.getAttribute("meta") == metaname) { svg_element = child; } } if (this.single_int_mode && force_recreate && svg_element != undefined) { svg_element.remove?.(); svg_element = undefined; } if (svg_element == undefined) { svg_element = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg_element.setAttribute("meta", metaname); svg_element.setAttribute("width", "100%"); svg_element.setAttribute("height", "100%"); if (this.isTargetingDocument()) svg_element.style.overflow = "visible"; this.diagram_outer_svg.appendChild(svg_element); } return svg_element; } get_diagram_svg() : SVGSVGElement { let diagram_svg = this.get_svg_element("diagram_svg"); this.diagram_svg = diagram_svg; return diagram_svg; } isTargetingDocument() : boolean { return this.event_target == HTML_INT_TARGET.DOCUMENT; } set_focus_padding(padding : number) { this.focus_padding = padding; if (this.dragAndDropHandler) { this.dragAndDropHandler.focus_padding = padding; } if (this.buttonHandler){ this.buttonHandler.focus_padding = padding; } } /** * Create a locator * Locator is a draggable object that contain 2D coordinate information * @param variable_name name of the variable * @param value initial value * @param radius radius of the locator draggable object * @param color color of the locator * @param track_diagram if provided, the locator will snap to the closest point on the diagram */ public locator( variable_name : string, value : Vector2, radius : number, color : string = 'blue', track_diagram? : Diagram, blink : boolean = true, callback?: (locator_name: string, position: Vector2) => any, ){ if (this.diagram_outer_svg == undefined) throw Error("diagram_outer_svg in Interactive class is undefined"); this.inp_variables[variable_name] = value; let diagram_svg = this.get_diagram_svg(); let control_svg = this.get_svg_element(control_svg_name.locator, !this.locator_svg); this.locator_svg = control_svg; // if this is the fist time this function is called, create a locatorHandler if (this.locatorHandler == undefined) { let locatorHandler = new LocatorHandler(control_svg, diagram_svg, this.global_scale_factor); this.locatorHandler = locatorHandler; const eventTarget = this.isTargetingDocument() ? document : this.diagram_outer_svg; this.registerEventListener(eventTarget, 'mousemove', (evt:any) => { locatorHandler.drag(evt)}); this.registerEventListener(eventTarget, 'mouseup', (evt:any) => { locatorHandler.endDrag(evt)}); this.registerEventListener(eventTarget, 'touchmove', (evt:any) => { locatorHandler.drag(evt)}); this.registerEventListener(eventTarget, 'touchend', (evt:any) => { locatorHandler.endDrag(evt)}); this.registerEventListener(eventTarget, 'touchcancel',(evt:any) => { locatorHandler.endDrag(evt)}); } // ============== callback const f_callback = (pos : Vector2, redraw : boolean = true) => { this.inp_variables[variable_name] = pos; if (callback && redraw) callback(variable_name, pos); if (redraw) this.draw(); } this.locatorHandler.registerCallback(variable_name, f_callback); // ============== Circle element let locator_svg = this.locatorHandler.create_locator_circle_pointer_svg(variable_name, radius, value, color, blink); if(blink){ // store the circle_outer into the LocatorHandler so that we can turn it off later let blinking_outers = locator_svg.getElementsByClassName("diagramatics-locator-blink"); for (let i = 0; i < blinking_outers.length; i++) (this.locatorHandler as LocatorHandler).addBlinkingCircleOuter(blinking_outers[i]) } this.registerEventListener(locator_svg, 'mousedown', (evt:any) => { this.locatorHandler!.startDrag(evt, variable_name, locator_svg); }); this.registerEventListener(locator_svg, 'touchstart', (evt:any) => { this.locatorHandler!.startDrag(evt, variable_name, locator_svg); }); // =============== setter let setter; if (track_diagram) { if (track_diagram.type != DiagramType.Polygon && track_diagram.type != DiagramType.Curve) throw Error('Track diagram must be a polygon or curve'); if (track_diagram.path == undefined) throw Error(`diagram {diagtam.type} must have a path`); let track = track_diagram.path.points; setter = (pos : Vector2) => { const s = this.global_scale_factor; let coord = closest_point_from_points(pos, track); locator_svg.setAttribute("transform", `translate(${coord.x * s},${-coord.y * s})`) return coord; } } else{ setter = (pos : Vector2) => { const s = this.global_scale_factor; locator_svg.setAttribute("transform", `translate(${pos.x * s},${-pos.y * s})`) return pos; } } this.locatorHandler.registerSetter(variable_name, setter); this.inp_setter[variable_name] = setter; // set initial position let init_pos = setter(value); this.locatorHandler.setPos(variable_name, init_pos); } // TODO: in the next breaking changes update, // merge this function with locator /** * Create a locator with custom diagram object * @param variable_name name of the variable * @param value initial value * @param diagram diagram of the locator * @param track_diagram if provided, the locator will snap to the closest point on the diagram * @param blink if true, the locator will blink * @param callback callback function that will be called when the locator is moved * @param callback_rightclick callback function that will be called when the locator is right clicked */ public locator_custom( variable_name : string, value : Vector2, diagram : Diagram, track_diagram? : Diagram, blink : boolean = true, callback?: (locator_name: string, position: Vector2) => any, callback_rightclick?: (locator_name: string) => any ){ if (this.diagram_outer_svg == undefined) throw Error("diagram_outer_svg in Interactive class is undefined"); this.inp_variables[variable_name] = value; let diagram_svg = this.get_diagram_svg(); let control_svg = this.get_svg_element(control_svg_name.locator, !this.locator_svg); this.locator_svg = control_svg; // if this is the fist time this function is called, create a locatorHandler if (this.locatorHandler == undefined) { let locatorHandler = new LocatorHandler(control_svg, diagram_svg, this.global_scale_factor); this.locatorHandler = locatorHandler; const eventTarget = this.isTargetingDocument() ? document : this.diagram_outer_svg; this.registerEventListener(eventTarget, 'mousemove', (evt:any) => { locatorHandler.drag(evt); }) this.registerEventListener(eventTarget, 'mouseup', (evt:any) => { locatorHandler.endDrag(evt); }) this.registerEventListener(eventTarget, 'touchmove', (evt:any) => { locatorHandler.drag(evt); }) this.registerEventListener(eventTarget, 'touchend', (evt:any) => { locatorHandler.endDrag(evt); }) this.registerEventListener(eventTarget, 'touchcancel',(evt:any) => { locatorHandler.endDrag(evt); }) } // ============== callback const f_callback = (pos : Vector2, redraw : boolean = true) => { this.inp_variables[variable_name] = pos; // don't call the callback on the initialization; if (callback && redraw) callback(variable_name, pos); if (redraw) this.draw(); } this.locatorHandler.registerCallback(variable_name, f_callback); // ============== SVG element let locator_svg = this.locatorHandler!.create_locator_diagram_svg(variable_name, diagram, blink); this.registerEventListener(locator_svg, 'mousedown', (evt:any) => { this.locatorHandler!.startDrag(evt, variable_name, locator_svg); }); this.registerEventListener(locator_svg, 'touchstart', (evt:any) => { this.locatorHandler!.startDrag(evt, variable_name, locator_svg); }); if (callback_rightclick){ this.registerEventListener(locator_svg, 'contextmenu', (evt) => { evt.preventDefault(); callback_rightclick(variable_name); }); } // =============== setter let setter; if (track_diagram) { if (track_diagram.type != DiagramType.Polygon && track_diagram.type != DiagramType.Curve) throw Error('Track diagram must be a polygon or curve'); if (track_diagram.path == undefined) throw Error(`diagram {diagtam.type} must have a path`); let track = track_diagram.path.points; setter = (pos : Vector2) => { let coord = closest_point_from_points(pos, track); const s = this.global_scale_factor; locator_svg.setAttribute("transform", `translate(${coord.x * s},${-coord.y * s})`) return coord; } } else{ setter = (pos : Vector2) => { const s = this.global_scale_factor; locator_svg.setAttribute("transform", `translate(${pos.x * s},${-pos.y * s})`) return pos; } } this.locatorHandler.registerSetter(variable_name, setter); this.inp_setter[variable_name] = setter; // set initial position let init_pos = setter(value); this.locatorHandler.setPos(variable_name, init_pos); } /** * Create a slider * @param variable_name name of the variable * @param min minimum value * @param max maximum value * @param value initial value * @param step step size * @param time time of the animation in milliseconds * @param display_format_func function to format the display of the value */ public slider(variable_name : string, min : number = 0, max : number = 100, value : number = 50, step : number = -1, time : number = 1.5, display_format_func : formatFunction = defaultFormat_f){ // if the step is -1, then it is automatically calculated if (step == -1){ step = (max - min) / 100; } // initialize the variable this.inp_variables[variable_name] = value; // =========== label ============= let labeldiv = document.createElement('div'); labeldiv.classList.add("diagramatics-label"); labeldiv.innerHTML = display_format_func(variable_name, value, this.display_precision); // =========== slider =========== // create the callback function const callback = (val : number, redraw : boolean = true) => { this.inp_variables[variable_name] = val; labeldiv.innerHTML = display_format_func(variable_name, val, this.display_precision); if (redraw) this.draw(); } let slider = create_slider(callback, min, max, value, step); // ================ setter const setter = (val : number) => { slider.value = val.toString(); callback(val, false); } this.inp_setter[variable_name] = setter; // =========== playbutton ======== let nstep = (max - min) / step; const interval_time = 1000 * time / nstep; let playbutton = document.createElement('button'); let symboldiv = document.createElement('div'); symboldiv.classList.add("diagramatics-slider-playbutton-symbol"); playbutton.appendChild(symboldiv); playbutton.classList.add("diagramatics-slider-playbutton"); playbutton.onclick = () => { if (this.intervals[variable_name] == undefined){ // if is not playing playbutton.classList.add("paused"); this.intervals[variable_name] = setInterval(() => { let val = parseFloat(slider.value); val += step; // wrap around val = ((val - min) % (max - min)) + min; slider.value = val.toString(); callback(val); }, interval_time); } else { // if is playing playbutton.classList.remove("paused"); clearInterval(this.intervals[variable_name]); this.intervals[variable_name] = undefined; } } // ============================== // add components to div // //
//
// //
//
//
// //
// let leftcontainer = document.createElement('div'); leftcontainer.classList.add("diagramatics-slider-leftcontainer"); leftcontainer.appendChild(document.createElement('br')); leftcontainer.appendChild(playbutton); let rightcontainer = document.createElement('div'); rightcontainer.classList.add("diagramatics-slider-rightcontainer"); rightcontainer.appendChild(labeldiv); rightcontainer.appendChild(slider); let container = document.createElement('div'); container.classList.add("diagramatics-slider-container"); container.appendChild(leftcontainer); container.appendChild(rightcontainer); this.control_container_div.appendChild(container); } private init_drag_and_drop() { if (this.diagram_outer_svg == undefined) throw Error("diagram_outer_svg in Interactive class is undefined"); let diagram_svg = this.get_diagram_svg(); let dnd_svg = this.get_svg_element(control_svg_name.dnd, !this.dnd_svg); this.dnd_svg = dnd_svg; // if this is the fist time this function is called, create a dragAndDropHandler if (this.dragAndDropHandler == undefined) { let dragAndDropHandler = new DragAndDropHandler(dnd_svg, diagram_svg, this.global_scale_factor); dragAndDropHandler.focus_padding = this.focus_padding; this.dragAndDropHandler = dragAndDropHandler; const eventTarget = this.isTargetingDocument() ? document : this.diagram_outer_svg; // this.registerEventListener(this.diagram_outer_svg, 'mousemove', (evt:any) => {dragAndDropHandler.drag(evt);}); this.registerEventListener(eventTarget, 'mousemove', (evt:any) => {dragAndDropHandler.drag(evt);}); this.registerEventListener(eventTarget, 'mouseup', (evt:any) => {dragAndDropHandler.endDrag(evt);}); this.registerEventListener(eventTarget, 'touchmove', (evt:any) => {dragAndDropHandler.drag(evt);}); this.registerEventListener(eventTarget, 'touchend', (evt:any) => {dragAndDropHandler.endDrag(evt);}); this.registerEventListener(eventTarget, 'touchcancel',(evt:any) => {dragAndDropHandler.endDrag(evt);}); } } set_global_scale_factor(factor: number) { this.global_scale_factor = factor; if (this.buttonHandler) this.buttonHandler.global_scale_factor = factor; if (this.locatorHandler) this.locatorHandler.global_scale_factor = factor; if (this.dragAndDropHandler) this.dragAndDropHandler.global_scale_factor = factor; } /** * Create a drag and drop container * @param name name of the container * @param diagram diagram of the container * @param capacity capacity of the container (default is 1) * @param config configuration of the container positioning * the configuration is an object with the following format: * `{type:"horizontal-uniform"}`, `{type:"vertical-uniform"}`, `{type:"grid", value:[number, number]}` * `{type:"horizontal", padding:number}`, `{type:"vertical", padding:number}` * `{type:"flex-row", padding:number, vertical_alignment:VerticalAlignment, horizontal_alignment:HorizontalAlignment}` * * you can also add custom region box for the target by adding `custom_region_box: [Vector2, Vector2]` in the config * * you can also add a sorting function for the target by adding `sorting_function: (a: string, b: string) => number` */ public dnd_container(name : string, diagram : Diagram, capacity? : number, config? : dnd_container_config) { this.init_drag_and_drop(); this.dragAndDropHandler?.add_container(name, diagram, capacity, config); } // TODO: in the next breaking changes update, // merge this function with dnd_draggable_to_container /** * Create a drag and drop draggable that is positioned into an existing container * @param name name of the draggable * @param diagram diagram of the draggable * @param container_name name of the container * @param callback callback function (called after the draggable is moved) * @param onclickstart_callback callback function (called at the start of the drag) */ public dnd_draggable_to_container( name : string, diagram : Diagram, container_name : string, callback? : (name:string, container:string) => any, onclickstart_callback? : () => any ) { this.init_drag_and_drop(); if (this.dragAndDropHandler == undefined) throw Error("dragAndDropHandler in Interactive class is undefined"); this.inp_variables[name] = diagram.origin; this.dragAndDropHandler.add_draggable_to_container(name, diagram, container_name); const dnd_callback = (pos : Vector2, redraw : boolean = true) => { this.inp_variables[name] = pos; if (callback) callback(name, container_name); if (redraw) this.draw(); } this.dragAndDropHandler.registerCallback(name, dnd_callback); if (onclickstart_callback) this.dragAndDropHandler.register_clickstart_callback(name, onclickstart_callback); } /** * Create a drag and drop draggable * @param name name of the draggable * @param diagram diagram of the draggable * @param container_diagram diagram of the container, if not provided, a container will be created automatically * @param callback callback function (called after the draggable is moved) * @param onclickstart_callback callback function (called at the start of the drag) */ public dnd_draggable( name : string, diagram : Diagram, container_diagram? : Diagram, callback? : (name:string, pos:Vector2) => any, onclickstart_callback? : () => any ) { this.init_drag_and_drop(); if (this.dragAndDropHandler == undefined) throw Error("dragAndDropHandler in Interactive class is undefined"); this.inp_variables[name] = diagram.origin; this.dragAndDropHandler.add_draggable_with_container(name, diagram, container_diagram); const dnd_callback = (pos : Vector2, redraw : boolean = true) => { this.inp_variables[name] = pos; if (callback) callback(name, pos); if (redraw) this.draw(); } this.dragAndDropHandler.registerCallback(name, dnd_callback); if (onclickstart_callback) this.dragAndDropHandler.register_clickstart_callback(name, onclickstart_callback); } /** * Register a callback function when a draggable is dropped outside of a container * @param callback callback function */ public dnd_register_drop_outside_callback(callback : (name : string) => any) { this.init_drag_and_drop(); this.dragAndDropHandler?.register_dropped_outside_callback(callback); } /** * Register a validation function when a draggable is moved to a container * If the function return false, the draggable will not be moved * @param fun validation function */ public dnd_register_move_validation_function(fun: (draggable_name: string, target_name: string) => boolean) { this.init_drag_and_drop(); this.dragAndDropHandler?.register_move_validation_function(fun); } /** * Move a draggable to a container * @param name name of the draggable * @param container_name name of the container */ public dnd_move_to_container(name : string, container_name : string) { this.dragAndDropHandler?.try_move_draggable_to_container(name, container_name); } /** * Get the data of the drag and drop objects with the format: * `{container:string, content:string[]}[]` */ public get_dnd_data() : DragAndDropData { return this.dragAndDropHandler?.getData() ?? []; } /** * Set the data of the drag and drop objects with the format: * `{container:string, content:string[]}[]` */ public set_dnd_data(data : DragAndDropData) : void { this.dragAndDropHandler?.setData(data); } /** * reorder the tabindex of the containers * @param container_names */ public dnd_reorder_tabindex(container_names: string[]){ this.dragAndDropHandler?.reorder_svg_container_tabindex(container_names); } /** * Get the content size of a container */ public get_dnd_container_content_size(container_name : string) : [number,number] { if (!this.dragAndDropHandler) return [NaN,NaN]; return this.dragAndDropHandler.get_container_content_size(container_name); } /** * Set whether the content of the container should be sorted or not */ public set_dnd_content_sort(sort_content : boolean) : void { if (!this.dragAndDropHandler) return; this.dragAndDropHandler.sort_content = sort_content; } public remove_dnd_draggable(name : string) { this.dragAndDropHandler?.remove_draggable(name); } public remove_locator(name: string) { this.locatorHandler?.remove(name); } public remove_button(name: string) { this.buttonHandler?.remove(name); } /** * @deprecated (use `Interactive.custom_object_g()` instead) * This method will be removed in the next major release * * Create a custom interactive object * @param id id of the object * @param classlist list of classes of the object * @param diagram diagram of the object * @returns the svg element of the object */ public custom_object(id : string, classlist: string[], diagram : Diagram) : SVGSVGElement { if (this.diagram_outer_svg == undefined) throw Error("diagram_outer_svg in Interactive class is undefined"); let diagram_svg = this.get_diagram_svg(); let control_svg = this.get_svg_element(control_svg_name.custom, !this.custom_svg); let svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); f_draw_to_svg(svg, svg, diagram, true, false, calculate_text_scale(diagram_svg), this.global_scale_factor); svg.setAttribute("overflow", "visible"); svg.setAttribute("class", classlist.join(" ")); svg.setAttribute("id",id); control_svg.setAttribute("viewBox", diagram_svg.getAttribute("viewBox") as string); control_svg.setAttribute("preserveAspectRatio", diagram_svg.getAttribute("preserveAspectRatio") as string); control_svg.style.overflow = "visible"; control_svg.appendChild(svg); this.custom_svg = control_svg; return svg; } /** * Create a custom interactive object * @param id id of the object * @param classlist list of classes of the object * @param diagram diagram of the object * @returns the svg element of the object */ public custom_object_g(id : string, classlist: string[], diagram : Diagram) : SVGGElement { if (this.diagram_outer_svg == undefined) throw Error("diagram_outer_svg in Interactive class is undefined"); let diagram_svg = this.get_diagram_svg(); let control_svg = this.get_svg_element(control_svg_name.custom, !this.custom_svg); let g = document.createElementNS("http://www.w3.org/2000/svg", "g"); f_draw_to_svg(control_svg, g, diagram, true, false, calculate_text_scale(diagram_svg), this.global_scale_factor); g.setAttribute("overflow", "visible"); g.setAttribute("class", classlist.join(" ")); g.setAttribute("id",id); control_svg.setAttribute("viewBox", diagram_svg.getAttribute("viewBox") as string); control_svg.setAttribute("preserveAspectRatio", diagram_svg.getAttribute("preserveAspectRatio") as string); control_svg.style.overflow = "visible"; control_svg.appendChild(g); this.custom_svg = control_svg; return g; } private init_button() { if (this.diagram_outer_svg == undefined) throw Error("diagram_outer_svg in Interactive class is undefined"); let diagram_svg = this.get_diagram_svg(); let button_svg = this.get_svg_element(control_svg_name.button, !this.button_svg); this.button_svg = button_svg; // if this is the fist time this function is called, create a dragAndDropHandler if (this.buttonHandler == undefined) { let buttonHandler = new ButtonHandler(button_svg, diagram_svg, this.global_scale_factor); buttonHandler.focus_padding = this.focus_padding; this.buttonHandler = buttonHandler; } } /** * Create a toggle button * @param name name of the button * @param diagram_on diagram of the button when it is on * @param diagram_off diagram of the button when it is off * @param state initial state of the button * @param callback callback function when the button state is changed */ public button_toggle(name : string, diagram_on : Diagram, diagram_off : Diagram, state : boolean = false, callback? : (name : string, state : boolean) => any ){ this.init_button(); if (this.buttonHandler == undefined) throw Error("buttonHandler in Interactive class is undefined"); this.inp_variables[name] = state; let main_callback; if (callback){ main_callback = (state : boolean, redraw : boolean = true) => { this.inp_variables[name] = state callback(name, state); if (redraw) this.draw(); } } else { main_callback = (state : boolean, redraw : boolean = true) => { this.inp_variables[name] = state if (redraw) this.draw(); } } let setter = this.buttonHandler.try_add_toggle(name, diagram_on, diagram_off, state, main_callback); this.inp_setter[name] = setter; } /** * Create a click button * @param name name of the button * @param diagram diagram of the button * @param diagram_pressed diagram of the button when it is pressed * @param callback callback function when the button is clicked */ public button_click(name : string, diagram : Diagram, diagram_pressed : Diagram, callback : () => any){ this.init_button(); if (this.buttonHandler == undefined) throw Error("buttonHandler in Interactive class is undefined"); let n_callback = () => { callback(); this.draw(); } this.buttonHandler.try_add_click(name, diagram, diagram_pressed, diagram, n_callback); } /** * Create a click button * @param name name of the button * @param diagram diagram of the button * @param diagram_pressed diagram of the button when it is pressed * @param diagram_hover diagram of the button when it is hovered * @param callback callback function when the button is clicked */ public button_click_hover(name : string, diagram : Diagram, diagram_pressed : Diagram, diagram_hover : Diagram, callback : () => any){ this.init_button(); if (this.buttonHandler == undefined) throw Error("buttonHandler in Interactive class is undefined"); let n_callback = () => { callback(); this.draw(); } this.buttonHandler.try_add_click(name, diagram, diagram_pressed, diagram_hover, n_callback); } } // ========== functions // function set_viewbox(taget : SVGSVGElement | undefined, source : SVGSVGElement | undefined) { if (taget == undefined) return; if (source == undefined) return; taget.setAttribute("viewBox", source.getAttribute("viewBox") as string); taget.setAttribute("preserveAspectRatio", source.getAttribute("preserveAspectRatio") as string); } function create_slider(callback : (val : number) => any, min : number = 0, max : number = 100, value : number = 50, step : number) : HTMLInputElement { // create a slider let slider = document.createElement("input"); slider.type = "range"; slider.min = min.toString(); slider.max = max.toString(); slider.value = value.toString(); slider.step = step.toString(); slider.oninput = () => { let val = slider.value; callback(parseFloat(val)); } // add class to slider slider.classList.add("diagramatics-slider"); return slider; } // function create_locator() : SVGCircleElement { // } // function closest_point_from_points(p : Vector2, points : Vector2[]) : Vector2 { if (points.length == 0) return p; let closest_d2 = Infinity; let closest_p = points[0]; for (let i = 0; i < points.length; i++) { let d2 = points[i].sub(p).length_sq(); if (d2 < closest_d2) { closest_d2 = d2; closest_p = points[i]; } } return closest_p; } // helper to calculate CTM in firefox // there's a well known bug in firefox about `getScreenCTM()` function firefox_calcCTM(svgelem : SVGSVGElement) : DOMMatrix { let ctm = svgelem.getScreenCTM() as DOMMatrix; // get screen width and height of the element let screenWidth = svgelem.width.baseVal.value; let screenHeight = svgelem.height.baseVal.value; let viewBox = svgelem.viewBox.baseVal; let scalex = screenWidth/viewBox.width; let scaley = screenHeight/viewBox.height; let scale = Math.min(scalex, scaley); // let translateX = (screenWidth/2 + ctm.e) - (viewBox.width/2 + viewBox.x) * scale; // let translateY = (screenHeight/2 + ctm.f) - (viewBox.height/2 + viewBox.y) * scale; let translateX = (screenWidth/2 ) - (viewBox.width/2 + viewBox.x) * scale; let translateY = (screenHeight/2) - (viewBox.height/2 + viewBox.y) * scale; return DOMMatrix.fromMatrix(ctm).translate(translateX, translateY).scale(scale); } type LocatorEvent = TouchEvent | Touch | MouseEvent type DnDEvent = TouchEvent | Touch | MouseEvent /** * Convert client position to SVG position * @param clientPos the client position * @param svgelem the svg element */ export function clientPos_to_svgPos(clientPos : {x : number, y : number}, svgelem : SVGSVGElement) : {x : number, y : number} { // var CTM = this.control_svg.getScreenCTM() as DOMMatrix; // NOTE: there's a well known bug in firefox about `getScreenCTM()` // check if the browser is firefox let CTM : DOMMatrix; if (navigator.userAgent.toLowerCase().indexOf('firefox') > -1) { CTM = firefox_calcCTM(svgelem); } else { CTM = svgelem.getScreenCTM() as DOMMatrix; } // console.log(CTM); return { x : (clientPos.x - CTM.e) / CTM.a, y : - (clientPos.y - CTM.f) / CTM.d } } function getMousePosition(evt : LocatorEvent, svgelem : SVGSVGElement) : {x : number, y : number} { // firefox doesn't support `TouchEvent`, we need to check for it if (window.TouchEvent && evt instanceof TouchEvent) { evt = evt.touches[0]; } let clientPos = { x : (evt as Touch | MouseEvent).clientX, y : (evt as Touch | MouseEvent).clientY } return clientPos_to_svgPos(clientPos, svgelem); } /** * Get the SVG coordinate from the event (MouseEvent or TouchEvent) * @param evt the event * @param svgelem the svg element * @returns the SVG coordinate */ export function get_SVGPos_from_event(evt : LocatorEvent, svgelem : SVGSVGElement) : {x : number, y : number} { return getMousePosition(evt, svgelem); } class LocatorHandler { selectedElement : SVGElement | null = null; selectedVariable : string | null = null; mouseOffset : Vector2 = V2(0,0); callbacks : {[key : string] : (pos : Vector2, redraw?: boolean) => any} = {}; setter : {[key : string] : (pos : Vector2) => any} = {}; // store blinking circle_outer so that we can turn it off svg_elements: {[key : string] : SVGElement} = {}; blinking_circle_outers : Element[] = []; first_touch_callback : Function | null = null; element_pos : {[key : string] : Vector2} = {}; constructor(public control_svg : SVGSVGElement, public diagram_svg : SVGSVGElement, public global_scale_factor : number){ } startDrag(evt : LocatorEvent, variable_name : string, selectedElement : SVGElement) { this.selectedElement = selectedElement; this.selectedVariable = variable_name; const s = this.global_scale_factor; if (evt instanceof MouseEvent) { evt.preventDefault(); } if (window.TouchEvent && evt instanceof TouchEvent) { evt.preventDefault(); } let coord = getMousePosition(evt, this.control_svg); let mousepos = V2(coord.x/s, coord.y/s); let elementpos = this.element_pos[variable_name]; if (elementpos){ this.mouseOffset = elementpos.sub(mousepos); } this.handleBlinking(); } drag(evt : LocatorEvent) { if (this.selectedElement == undefined) return; if (this.selectedVariable == undefined) return; if (evt instanceof MouseEvent) { evt.preventDefault(); } if (window.TouchEvent && evt instanceof TouchEvent) { evt.preventDefault(); } let coord = getMousePosition(evt, this.control_svg); const s = this.global_scale_factor; let pos = V2(coord.x/s, coord.y/s).add(this.mouseOffset); this.element_pos[this.selectedVariable] = pos; // check if setter for this.selectedVariable exists // if it does, call it if (this.setter[this.selectedVariable] != undefined) { pos = this.setter[this.selectedVariable](pos); } // check if callback for this.selectedVariable exists // if it does, call it if (this.selectedVariable == null) return; if (this.callbacks[this.selectedVariable] != undefined) { this.callbacks[this.selectedVariable](pos); } this.setViewBox(); } setViewBox() { // set viewBox and preserveAspectRatio of control_svg to be the same as diagram_svg this.control_svg.setAttribute("viewBox", this.diagram_svg.getAttribute("viewBox") as string); this.control_svg.setAttribute("preserveAspectRatio", this.diagram_svg.getAttribute("preserveAspectRatio") as string); } endDrag(_ : LocatorEvent) { this.selectedElement = null; this.selectedVariable = null; } public remove(variable_name : string) : void { if (this.selectedVariable == variable_name){ this.selectedElement = null; this.selectedVariable = null; } delete this.callbacks[variable_name]; delete this.setter[variable_name]; this.svg_elements[variable_name]?.remove(); delete this.svg_elements[variable_name]; delete this.element_pos[variable_name]; } setPos(name : string, pos : Vector2){ this.element_pos[name] = pos; this.callbacks[name](pos, false); } registerCallback(name : string, callback : (pos : Vector2) => any){ this.callbacks[name] = callback; } registerSetter(name : string, setter : (pos : Vector2) => any){ this.setter[name] = setter; } addBlinkingCircleOuter(circle_outer : Element){ this.blinking_circle_outers.push(circle_outer); } handleBlinking(){ // turn off all blinking_circle_outers after the first touch if (this.blinking_circle_outers.length == 0) return; for (let i = 0; i < this.blinking_circle_outers.length; i++) { this.blinking_circle_outers[i].classList.remove("diagramatics-locator-blink"); } this.blinking_circle_outers = []; if (this.first_touch_callback != null) this.first_touch_callback(); } create_locator_diagram_svg(name: string, diagram : Diagram, blink : boolean) : SVGGElement { let g = document.createElementNS("http://www.w3.org/2000/svg", "g"); f_draw_to_svg(this.control_svg, g, diagram.position(V2(0,0)), true, false, calculate_text_scale(this.diagram_svg), this.global_scale_factor); g.style.cursor = "pointer"; g.setAttribute("overflow", "visible"); if (blink) { g.classList.add("diagramatics-locator-blink"); this.addBlinkingCircleOuter(g); } if (this.svg_elements[name]){ this.svg_elements[name].replaceWith(g); } else { this.control_svg.appendChild(g); } this.svg_elements[name] = g; this.element_pos[name] return g; } create_locator_circle_pointer_svg(name: string, radius : number, value : Vector2, color : string, blink : boolean) : SVGGElement { let g = document.createElementNS("http://www.w3.org/2000/svg", "g"); // set svg overflow to visible g.setAttribute("overflow", "visible"); // set cursor to be pointer when hovering g.style.cursor = "pointer"; let circle_outer = document.createElementNS("http://www.w3.org/2000/svg", "circle"); let circle_inner = document.createElementNS("http://www.w3.org/2000/svg", "circle"); let inner_radius = radius * 0.4; circle_outer.setAttribute("r", radius.toString()); circle_outer.setAttribute("fill", get_color(color, tab_color)); circle_outer.setAttribute("fill-opacity", "0.3137"); circle_outer.setAttribute("stroke", "none"); circle_outer.classList.add("diagramatics-locator-outer"); if (blink) circle_outer.classList.add("diagramatics-locator-blink"); circle_inner.setAttribute("r", inner_radius.toString()); circle_inner.setAttribute("fill", get_color(color, tab_color)); circle_inner.setAttribute("stroke", "none"); circle_inner.classList.add("diagramatics-locator-inner"); const s = this.global_scale_factor; g.appendChild(circle_outer); g.appendChild(circle_inner); g.setAttribute("transform", `translate(${value.x * s},${-value.y * s})`) if (this.svg_elements[name]){ this.svg_elements[name].replaceWith(g); } else { this.control_svg.appendChild(g); } this.svg_elements[name] = g; return g; } } type DragAndDropContainerData = { name : string, position : Vector2, svgelement? : SVGElement, diagram : Diagram, content : string[], capacity : number, config : dnd_container_config, } type DragAndDropDraggableData = { name : string, position : Vector2, svgelement? : SVGElement, diagram : Diagram, diagram_size : [number, number], container : string, } type DragAndDropData = {container:string, content:string[]}[] enum dnd_type { container = "diagramatics-dnd-container", draggable = "diagramatics-dnd-draggable", ghost = "diagramatics-dnd-draggable-ghost", } //TODO: add more type dnd_container_positioning_type = {type:"horizontal-uniform"} | {type:"vertical-uniform"} | {type:"horizontal", padding:number} | {type:"vertical", padding:number} | {type:"flex-row", padding:number|[number,number], gap:number|[number,number], vertical_alignment?:VerticalAlignment, horizontal_alignment?:HorizontalAlignment} | {type:"grid", value:[number, number]} type dnd_container_config = dnd_container_positioning_type & { custom_region_box?: [Vector2, Vector2] sorting_function?: (a : string, b : string) => number } class DragAndDropHandler { containers : {[key : string] : DragAndDropContainerData} = {}; draggables : {[key : string] : DragAndDropDraggableData} = {}; callbacks : {[key : string] : (pos : Vector2) => any} = {}; onclickstart_callback : {[key : string] : () => any} = {}; hoveredContainerName : string | null = null; draggedElementName : string | null = null; draggedElementGhost : SVGElement | null = null; dropped_outside_callback : ((name : string) => any) | null = null; move_validation_function : ((draggable_name: string, target_name: string) => boolean) | null = null; sort_content : boolean = false; dom_to_id_map : WeakMap; active_draggable_name: string | null = null; // active from tap/enter focus_padding : number = 1; constructor(public dnd_svg : SVGSVGElement, public diagram_svg : SVGSVGElement, public global_scale_factor : number){ this.dom_to_id_map = new WeakMap(); } public add_container( name : string, diagram : Diagram, capacity? : number , config? : dnd_container_config, ) { if (this.containers[name] != undefined) { this.replace_container_svg(name, diagram, capacity, config); return; } this.containers[name] = { name, diagram, position : diagram.origin, content : [], config : config ?? {type:"horizontal-uniform"}, capacity : capacity ?? 1 }; } generate_position_map(bbox : BBox, config : dnd_container_config, capacity : number, content : string[]) : Vector2[] { const p_center = bbox[0].add(bbox[1]).scale(0.5); switch (config.type){ case "horizontal-uniform": { let width = bbox[1].x - bbox[0].x; let dx = width / capacity; let x0 = bbox[0].x + dx / 2; let y = p_center.y; return range(0, capacity).map(i => V2(x0 + dx*i, y)); } case "vertical-uniform": { //NOTE: top to bottom let height = bbox[1].y - bbox[0].y; let dy = height / capacity; let x = p_center.x; let y0 = bbox[1].y - dy / 2; return range(0, capacity).map(i => V2(x, y0 - dy*i)); } case "grid" : { let [nx,ny] = config.value; let height = bbox[1].y - bbox[0].y; let width = bbox[1].x - bbox[0].x; let dx = width / nx; let dy = height / ny; let x0 = bbox[0].x + dx / 2; let y0 = bbox[1].y - dy / 2; return range(0, capacity).map(i => { let x = x0 + dx * (i % nx); let y = y0 - dy * Math.floor(i / nx); return V2(x, y); }); } case "vertical" : { const p_top_center = V2(p_center.x, bbox[1].y); const sizelist = content.map((name) => this.draggables[name]?.diagram_size ?? [0,0]); const size_rects = sizelist.map(([w,h]) => rectangle(w,h).mut()); const distributed = distribute_vertical_and_align(size_rects, config.padding).mut() .move_origin('top-center').position(p_top_center) .translate(V2(0,-config.padding)); return distributed.children.map(d => d.origin); } case "horizontal" : { const p_center_left = V2(bbox[0].x, p_center.y); const sizelist = content.map((name) => this.draggables[name]?.diagram_size ?? [0,0]); const size_rects = sizelist.map(([w,h]) => rectangle(w,h).mut()); const distributed = distribute_horizontal_and_align(size_rects, config.padding).mut() .move_origin('center-left').position(p_center_left) .translate(V2(config.padding,0)); return distributed.children.map(d => d.origin); } case "flex-row" : { const pad = expand_directional_value(config.padding ?? 0); const gap = config.gap ? expand_directional_value(config.gap) : pad; const container_width = bbox[1].x - bbox[0].x - pad[1] - pad[3]; const sizelist = content.map((name) => this.draggables[name]?.diagram_size ?? [0,0]); const size_rects = sizelist.map(([w,h]) => rectangle(w,h).mut()); let distributed = distribute_variable_row( size_rects, container_width, gap[0], gap[1], config.vertical_alignment, config.horizontal_alignment ).mut() switch (config.horizontal_alignment){ case 'center' :{ distributed = distributed .move_origin('top-center').position(V2(p_center.x, bbox[1].y-pad[0])); } break; case 'right' : { distributed = distributed .move_origin('top-right').position(V2(bbox[1].x-pad[1], bbox[1].y-pad[0])); } break; case 'center': default: { distributed = distributed .move_origin('top-left').position(V2(bbox[0].x+pad[3], bbox[1].y-pad[0])); } } return distributed.children.map(d => d.origin); } default : { return []; } } } get_container_content_size(container_name : string) : [number,number] { const container = this.containers[container_name]; if (container == undefined) return [NaN, NaN]; const pad = (container.config as any).padding ?? 0; const content_diagrams = container.content.map(name => this.draggables[name]?.diagram ?? empty()); const [width, height] = size(diagram_combine(...content_diagrams)); return [width + 2*pad, height + 2*pad]; } private replace_draggable_svg(name : string, diagram : Diagram) { let draggable = this.draggables[name]; if (draggable == undefined) return; let outer_g = draggable.svgelement?.parentNode as SVGGElement; if (outer_g == undefined) return; draggable.svgelement?.remove(); draggable.diagram = diagram; draggable.diagram_size = size(diagram); this.add_draggable_svg(name, diagram, outer_g); this.reposition_container_content(draggable.container) } private replace_container_svg(name : string, diagram : Diagram, capacity? : number, config? : dnd_container_config) { let container = this.containers[name]; if (container == undefined) return; const outer_g = this.get_container_outer_g(name); if (outer_g == undefined) return; container.svgelement?.remove(); container.diagram = diagram; if (capacity) container.capacity = capacity; if (config) container.config = config; this.add_container_svg(name, diagram, outer_g); this.reposition_container_content(name); } public add_draggable_to_container(name : string, diagram : Diagram, container_name : string) { if (this.draggables[name] != undefined) { this.replace_draggable_svg(name, diagram); this.move_draggable_to_container(name, container_name, true); return; } const diagram_size = size(diagram); this.draggables[name] = {name, diagram : diagram.mut() , diagram_size, position : diagram.origin, container : container_name}; this.containers[container_name].content.push(name); } public add_draggable_with_container(name : string, diagram : Diagram, container_diagram? : Diagram) { if (this.draggables[name] != undefined) { this.replace_draggable_svg(name, diagram); return; } // add a container as initial container for the draggable let initial_container_name = `_container0_${name}`; if (container_diagram == undefined) container_diagram = this.diagram_container_from_draggable(diagram); this.add_container(initial_container_name, container_diagram); const diagram_size = size(diagram); this.containers[initial_container_name].content.push(name); this.draggables[name] = {name, diagram : diagram.mut() , diagram_size, position : diagram.origin, container : initial_container_name}; } public remove_draggable(name : string) : void { for (let container_name in this.containers) { const container = this.containers[container_name]; container.content = container.content.filter(e => e != name); } this.draggables[name].svgelement?.remove(); delete this.draggables[name]; } registerCallback(name : string, callback : (pos : Vector2) => any){ this.callbacks[name] = callback; } register_clickstart_callback(name : string, callback : () => any){ this.onclickstart_callback[name] = callback; } register_dropped_outside_callback(callback : (name : string) => any){ this.dropped_outside_callback = callback; } register_move_validation_function(fun: (draggable_name: string, target_name: string) => boolean){ this.move_validation_function = fun; } setViewBox() { // set viewBox and preserveAspectRatio of control_svg to be the same as diagram_svg this.dnd_svg.setAttribute("viewBox", this.diagram_svg.getAttribute("viewBox") as string); this.dnd_svg.setAttribute("preserveAspectRatio", this.diagram_svg.getAttribute("preserveAspectRatio") as string); } public drawSvg(){ for (let container_name in this.containers){ const container_data = this.containers[container_name]; if (container_data?.svgelement == undefined) { const outer_g = document.createElementNS("http://www.w3.org/2000/svg", "g"); this.dnd_svg.append(outer_g); this.add_container_svg(container_name, container_data.diagram, outer_g); } const outer_g = this.get_container_outer_g(container_name) if (outer_g == undefined) continue; for (let draggable_name of container_data.content){ const draggable_data = this.draggables[draggable_name]; if (draggable_data?.svgelement) continue; this.add_draggable_svg(draggable_name, draggable_data.diagram, outer_g) } } for (let name in this.containers) { this.reposition_container_content(name); this.reconfigure_container_tabindex(name); } } getData() : DragAndDropData { let data : DragAndDropData = [] for (let name in this.containers){ data.push({container : name, content : this.containers[name].content}); } return data; } setData(data : DragAndDropData) { try { for (let containerdata of data) { for (let content of containerdata.content) { this.try_move_draggable_to_container(content, containerdata.container, true); } } } catch (_e) { console.error("the data is not valid"); } } diagram_container_from_draggable(diagram : Diagram) : Diagram { let rect = rectangle_corner(...diagram.bounding_box()).move_origin(diagram.origin); return rect.strokedasharray([5]); } register_tap_enter(g: SVGElement, callback : (keyboard?: boolean) => any) { g.onclick = (e) => { callback(false); } g.onkeydown = (evt) => { if (evt.key == "Enter") callback(true); } } tap_enter_draggable(draggable_name: string, keyboard?: boolean){ if (this.active_draggable_name == null){ // select the draggable this.reset_picked_class() this.active_draggable_name = draggable_name; let draggable = this.draggables[draggable_name]; if (draggable.svgelement == undefined) return; draggable.svgelement.classList.add("picked"); if (keyboard) this.onclickstart_callback[draggable_name]?.(); } else if (draggable_name == this.active_draggable_name) { // unselect the draggable this.reset_picked_class() this.active_draggable_name = null; } else { // try to switch if possible const target_container = this.draggables[draggable_name]?.container; if (target_container) { this.try_move_draggable_to_container(this.active_draggable_name, target_container); } this.reset_picked_class() this.active_draggable_name = null; } } tap_enter_container(container_name: string){ if (this.active_draggable_name == null) return; this.try_move_draggable_to_container(this.active_draggable_name, container_name); this.active_draggable_name = null; this.reset_picked_class(); } private get_container_outer_g(container_name : string) : SVGGElement { const container_data = this.containers[container_name]; return container_data?.svgelement?.parentNode as SVGGElement; } private add_container_svg(name : string, diagram: Diagram, outer_g: SVGGElement) { let g = document.createElementNS("http://www.w3.org/2000/svg", "g"); f_draw_to_svg(this.dnd_svg, g, diagram.position(V2(0,0)), false, false, calculate_text_scale(this.diagram_svg), this.global_scale_factor, dnd_type.container); const s = this.global_scale_factor; let position = diagram.origin; g.setAttribute("transform", `translate(${position.x * s},${-position.y * s})`) g.setAttribute("class", dnd_type.container); g.setAttribute("tabindex", "0"); g.onmousedown = (e) => { e.preventDefault(); } this.register_tap_enter(g, () => { this.tap_enter_container(name); }); outer_g.prepend(g); this.containers[name].svgelement = g; this.dom_to_id_map.set(g, name); this.add_focus_rect(g, diagram) } private add_draggable_svg(name : string, diagram : Diagram, outer_g : SVGGElement) { let g = document.createElementNS("http://www.w3.org/2000/svg", "g"); f_draw_to_svg(this.dnd_svg, g, diagram.position(V2(0,0)), true, false, calculate_text_scale(this.diagram_svg), this.global_scale_factor, dnd_type.draggable); const s = this.global_scale_factor; let position = diagram.origin; g.setAttribute("transform", `translate(${position.x * s},${-position.y * s})`) g.setAttribute("class", dnd_type.draggable); g.setAttribute("draggable", "true"); g.setAttribute("tabindex", "0"); g.onmousedown = (evt) => { this.draggedElementName = name; this.startDrag(evt); } g.ontouchstart = (evt) => { this.draggedElementName = name; this.tap_enter_draggable(name) this.startDrag(evt); } this.register_tap_enter(g, (keyboard?: boolean) => { this.tap_enter_draggable(name, keyboard); }); outer_g.append(g); this.draggables[name].svgelement = g; this.dom_to_id_map.set(g, name); this.add_focus_rect(g, diagram) } private add_focus_rect(g: SVGGElement, diagram : Diagram) { const bbox = diagram.position(V2(0,0)).bounding_box(); const pad = this.focus_padding; const s = this.global_scale_factor; const width = bbox[1].x - bbox[0].x + 2*pad; const height = bbox[1].y - bbox[0].y + 2*pad; // focus rect svg element const focus_rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); focus_rect.setAttribute("width", (width * s).toString()); focus_rect.setAttribute("height", (height * s).toString()); focus_rect.setAttribute("x", ((bbox[0].x - pad) * s).toString()); focus_rect.setAttribute("y", ((-bbox[1].y - pad) * s).toString()); focus_rect.setAttribute("fill", "none"); focus_rect.setAttribute("stroke", "black"); focus_rect.setAttribute("stroke-width", "1"); focus_rect.setAttribute("vector-effect", "non-scaling-stroke"); focus_rect.setAttribute("class", FOCUS_RECT_CLASSNAME); g.appendChild(focus_rect); } private move_svg_draggable_to_container(draggable_name : string, container_name : string){ const draggable_svg = this.draggables[draggable_name]?.svgelement; if (draggable_svg == undefined) return; const container_outer_g = this.get_container_outer_g(container_name); if (container_outer_g == undefined) return; container_outer_g.appendChild(draggable_svg); } private reorder_svg_container_content(container_name : string){ const content = this.containers[container_name]?.content; const g = this.get_container_outer_g(container_name); if (content == undefined || g == undefined) return; for (let draggable_name of content) { const draggable_svg = this.draggables[draggable_name]?.svgelement; if (draggable_svg == undefined) continue; g.appendChild(draggable_svg) } } private reconfigure_container_tabindex(container_name : string) { const container = this.containers[container_name]; if (container == undefined) return; if (container.capacity == 1) { if (container.content.length == 1) { container.svgelement?.setAttribute("tabindex", "-1") if (container.svgelement == document.activeElement){ // set the focus to the content const content = container.content[0]; this.draggables[content]?.svgelement?.focus(); } } else { container.svgelement?.setAttribute("tabindex", "0") } } } public reorder_svg_container_tabindex(container_names: string[]){ for (let container_name of container_names) { const g = this.get_container_outer_g(container_name); if (g == undefined) continue; this.dnd_svg.appendChild(g); } } reposition_container_content(container_name : string){ let container = this.containers[container_name]; if (container == undefined) return; if (this.sort_content){ container.content.sort() this.reorder_svg_container_content(container_name) } else if (container.config?.sorting_function) { container.content.sort(container.config.sorting_function); this.reorder_svg_container_content(container_name) } const bbox = container.config.custom_region_box ?? container.diagram.bounding_box(); const position_map = this.generate_position_map(bbox, container.config, container.capacity, container.content); const s = this.global_scale_factor; for (let i = 0; i < container.content.length; i++) { let draggable = this.draggables[container.content[i]]; let pos = position_map[i] ?? container.diagram.origin; draggable.diagram = draggable.diagram.position(pos); draggable.position = pos; draggable.svgelement?.setAttribute("transform", `translate(${pos.x * s},${-pos.y * s})`); } } remove_draggable_from_container(draggable_name : string, container_name : string) { this.containers[container_name].content = this.containers[container_name].content.filter((name) => name != draggable_name); } private move_draggable_to_container(draggable_name : string, container_name : string, ignore_callback = false) { let draggable = this.draggables[draggable_name]; if (draggable == undefined) return; // ignore if the draggable is already in the container if (draggable.container == container_name) return; let container = this.containers[container_name]; let original_container_name = draggable.container; this.remove_draggable_from_container(draggable_name, original_container_name); draggable.container = container_name; container.content.push(draggable_name); this.move_svg_draggable_to_container(draggable_name, container_name) this.reposition_container_content(container_name); this.reposition_container_content(original_container_name); this.reconfigure_container_tabindex(container_name); this.reconfigure_container_tabindex(original_container_name); if (ignore_callback) return; let draggedElement = this.draggables[draggable_name]; this.callbacks[draggedElement.name](draggedElement.position); } try_move_draggable_to_container(draggable_name : string, container_name : string, ignore_callback = false) { if (this.move_validation_function) { const valid = this.move_validation_function(draggable_name, container_name); if (!valid) return; } let draggable = this.draggables[draggable_name]; let container = this.containers[container_name]; if (container.content.length + 1 <= container.capacity) { this.move_draggable_to_container(draggable_name, container_name, ignore_callback); } else if (container.capacity == 1){ // only swap if the container has only 1 capacity // swap let original_container_name = draggable.container; let other_draggable_name = container.content[0]; this.move_draggable_to_container(draggable_name, container_name, true); this.move_draggable_to_container(other_draggable_name, original_container_name, ignore_callback); } } startDrag(evt : DnDEvent) { if (evt instanceof MouseEvent) { evt.preventDefault(); } if (window.TouchEvent && evt instanceof TouchEvent) { evt.preventDefault(); } this.hoveredContainerName = null; // reset container hovered class this.reset_hovered_class(); // delete orphaned ghost let ghosts = this.dnd_svg.getElementsByClassName(dnd_type.ghost); for (let i = 0; i < ghosts.length; i++) ghosts[i].remove(); // create a clone of the dragged element if (this.draggedElementName == null) return; let draggable = this.draggables[this.draggedElementName]; if (draggable.svgelement == undefined) return; draggable.svgelement.classList.add("picked"); this.onclickstart_callback[this.draggedElementName]?.(); this.draggedElementGhost = draggable.svgelement.cloneNode(true) as SVGElement; // set pointer-events : none this.draggedElementGhost.style.pointerEvents = "none"; this.draggedElementGhost.setAttribute("opacity", "0.5"); this.draggedElementGhost.setAttribute("class", dnd_type.ghost); this.dnd_svg.append(this.draggedElementGhost); } get_dnd_element_data_from_evt(evt : DnDEvent) : {name : string, type : string} | null { let element : HTMLElement | null = null; if (window.TouchEvent && evt instanceof TouchEvent) { let evt_touch = evt.touches[0]; element = document.elementFromPoint(evt_touch.clientX, evt_touch.clientY) as HTMLElement; } else { const evt_ = evt as MouseEvent element = document.elementFromPoint(evt_.clientX, evt_.clientY) as HTMLElement; } if (element == null) return null; if (element.localName == "tspan") element = element.parentElement; if (element == null) return null; let dg_tag = element.getAttribute("_dg_tag"); if (dg_tag == null) return null; if (dg_tag == dnd_type.container) { let parent = element.parentElement; if (parent == null) return null; let name = this.dom_to_id_map.get(parent); if (name == null) return null; return {name, type : dnd_type.container}; } if (dg_tag == dnd_type.draggable) { let parent = element.parentElement; if (parent == null) return null; let name = this.dom_to_id_map.get(parent); if (name == null) return null; return {name, type : dnd_type.draggable}; } return null; } drag(evt : DnDEvent) { if (this.draggedElementName == null) return; if (this.draggedElementGhost == null) return; if (evt instanceof MouseEvent) { evt.preventDefault(); } if (window.TouchEvent && evt instanceof TouchEvent) { evt.preventDefault(); } this.reset_hovered_class(); let element_data = this.get_dnd_element_data_from_evt(evt); if (element_data == null) { this.hoveredContainerName = null; } else if (element_data.type == dnd_type.container) { this.hoveredContainerName = element_data.name; this.containers[element_data.name].svgelement?.classList.add("hovered"); } else if (element_data.type == dnd_type.draggable) { this.hoveredContainerName = this.draggables[element_data.name]?.container; this.draggables[element_data.name].svgelement?.classList.add("hovered"); } let coord = getMousePosition(evt, this.dnd_svg); this.draggedElementGhost.setAttribute("transform", `translate(${coord.x},${-coord.y})`); } endDrag(_evt : DnDEvent) { if (this.hoveredContainerName != null && this.draggedElementName != null){ this.try_move_draggable_to_container(this.draggedElementName, this.hoveredContainerName); } // if dropped outside of any container if (this.hoveredContainerName == null && this.draggedElementName != null && this.dropped_outside_callback != null){ this.dropped_outside_callback(this.draggedElementName); } this.draggedElementName = null; this.hoveredContainerName = null; this.reset_hovered_class(); this.reset_picked_class(); if (this.draggedElementGhost != null){ this.draggedElementGhost.remove(); this.draggedElementGhost = null; } } reset_hovered_class(){ for (let name in this.containers) { this.containers[name].svgelement?.classList.remove("hovered"); } for (let name in this.draggables) { this.draggables[name].svgelement?.classList.remove("hovered"); } } reset_picked_class(){ for (let name in this.draggables) { this.draggables[name].svgelement?.classList.remove("picked"); } } } class ButtonHandler { // callbacks : {[key : string] : (state : boolean) => any} = {}; states : {[key : string] : boolean} = {}; svg_g_element : {[key : string] : SVGGElement|undefined} = {}; touchdownName : string | null = null; focus_padding: number = 1; constructor(public button_svg : SVGSVGElement, public diagram_svg : SVGSVGElement, public global_scale_factor : number){ } remove(name : string){ delete this.states[name]; const g = this.svg_g_element[name]; g?.remove(); delete this.svg_g_element[name]; } /** add a new toggle button if it doesn't exist, otherwise, update diagrams and callback */ try_add_toggle(name : string, diagram_on : Diagram, diagram_off : Diagram, state : boolean, callback : (state : boolean, redraw? : boolean) => any) : setter_function_t { let g = this.svg_g_element[name]; if (g) { g.innerHTML = ""; } else { g = document.createElementNS("http://www.w3.org/2000/svg", "g"); this.button_svg.appendChild(g); } return this.add_toggle(name, diagram_on, diagram_off, state, g, callback); } private add_toggle( name : string, diagram_on : Diagram, diagram_off : Diagram, state : boolean, g : SVGGElement, callback : (state : boolean, redraw? : boolean) => any ) : setter_function_t { let g_off = document.createElementNS("http://www.w3.org/2000/svg", "g"); f_draw_to_svg(this.button_svg, g_off, diagram_off, true, false, calculate_text_scale(this.diagram_svg), this.global_scale_factor); g_off.setAttribute("overflow", "visible"); g_off.style.cursor = "pointer"; let g_on = document.createElementNS("http://www.w3.org/2000/svg", "g"); f_draw_to_svg(this.button_svg, g_on, diagram_on, true, false, calculate_text_scale(this.diagram_svg), this.global_scale_factor); g_on.setAttribute("overflow", "visible"); g_on.style.cursor = "pointer"; g.setAttribute("overflow", "visible"); g.setAttribute("tabindex", "0"); g.appendChild(g_on) g.appendChild(g_off) this.svg_g_element[name] = g; this.states[name] = state; const set_display = (state : boolean) => { g_on.setAttribute("display", state ? "block" : "none"); g_off.setAttribute("display", state ? "none" : "block"); } set_display(this.states[name]); const update_state = (state : boolean, redraw : boolean = true) => { this.states[name] = state; callback(this.states[name], redraw); set_display(this.states[name]); } g.onmousedown = (e) => { e.preventDefault(); } g.onclick = (e) => { e.preventDefault(); update_state(!this.states[name]); } g.onkeydown = (e) => { if (e.key == "Enter") update_state(!this.states[name]); } const setter = (state : boolean) => { update_state(state, false); } return setter; } /** add a new click button if it doesn't exist, otherwise, update diagrams and callback */ try_add_click( name : string, diagram : Diagram, diagram_pressed : Diagram, diagram_hover : Diagram, callback : () => any ){ let g = this.svg_g_element[name]; if (g) { g.innerHTML = ""; } else { g = document.createElementNS("http://www.w3.org/2000/svg", "g"); this.button_svg.appendChild(g); } this.add_click(name, diagram, diagram_pressed, diagram_hover, g, callback); } private add_click( name : string, diagram : Diagram, diagram_pressed : Diagram, diagram_hover : Diagram, g : SVGGElement, callback : () => any ){ let g_normal = document.createElementNS("http://www.w3.org/2000/svg", "g"); f_draw_to_svg(this.button_svg, g_normal, diagram, true, false, calculate_text_scale(this.diagram_svg), this.global_scale_factor); g_normal.setAttribute("overflow", "visible"); g_normal.style.cursor = "pointer"; let g_pressed = document.createElementNS("http://www.w3.org/2000/svg", "g"); f_draw_to_svg(this.button_svg, g_pressed, diagram_pressed, true, false, calculate_text_scale(this.diagram_svg), this.global_scale_factor); g_pressed.setAttribute("overflow", "visible"); g_pressed.style.cursor = "pointer"; let g_hover = document.createElementNS("http://www.w3.org/2000/svg", "g"); f_draw_to_svg(this.button_svg, g_hover, diagram_hover, true, false, calculate_text_scale(this.diagram_svg), this.global_scale_factor); g_hover.setAttribute("overflow", "visible"); g_hover.style.cursor = "pointer"; g.setAttribute("class", FOCUS_NO_OUTLINE_CLASSNAME) g.setAttribute("overflow", "visible"); g.setAttribute("tabindex", "0"); g.appendChild(g_normal); g.appendChild(g_pressed); g.appendChild(g_hover); this.add_focus_rect(g, diagram); this.svg_g_element[name] = g; const set_display = (pressed : boolean, hovered : boolean) => { g_normal.setAttribute("display", !pressed && !hovered ? "block" : "none"); g_pressed.setAttribute("display", pressed ? "block" : "none"); g_hover.setAttribute("display", hovered && !pressed ? "block" : "none"); } set_display(false, false); let pressed_state = false; let hover_state = false; const update_display = () => { set_display(pressed_state, hover_state); } g.onblur = (_e) => { hover_state = false; pressed_state = false; update_display(); } g.onmouseenter = (_e) => { hover_state = true; update_display(); } g.onmouseleave = (_e) => { hover_state = false; pressed_state = false; update_display(); } g.onmousedown = (e) => { e.preventDefault(); pressed_state = true; update_display(); } g.onmouseup = (e) => { pressed_state = false; update_display(); } g.onclick = (e) => { callback(); hover_state = false; pressed_state = false; update_display(); } g.onkeydown = (e) => { if (e.key == "Enter") { callback(); pressed_state = true; update_display(); } } g.onkeyup = (e) => { pressed_state = false; update_display(); } } add_focus_rect(g: SVGGElement, diagram : Diagram) { const bbox = diagram.bounding_box(); const pad = this.focus_padding; const width = bbox[1].x - bbox[0].x + 2*pad; const height = bbox[1].y - bbox[0].y + 2*pad; // focus rect svg element const focus_rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); focus_rect.setAttribute("width", width.toString()); focus_rect.setAttribute("height", height.toString()); focus_rect.setAttribute("x", (bbox[0].x - pad).toString()); focus_rect.setAttribute("y", (-bbox[1].y - pad).toString()); focus_rect.setAttribute("fill", "none"); focus_rect.setAttribute("stroke", "black"); focus_rect.setAttribute("stroke-width", "1"); focus_rect.setAttribute("vector-effect", "non-scaling-stroke"); focus_rect.setAttribute("class", FOCUS_RECT_CLASSNAME); g.appendChild(focus_rect); } }