shikiTestLIB

Lib для франшиз

Този скрипт не може да бъде инсталиран директно. Това е библиотека за други скриптове и може да бъде използвана с мета-директива // @require https://update.greasyfork.org/scripts/552715/1678839/shikiTestLIB.js

// ==UserScript==
// @name         ShikiTreeLibwewqsdaxedwscxz 
// @namespace    https://shikimori.one/
// @version      1.0.0.0.0.0
// @description  Lib для графиков
// @license      MIT
// @require      https://cdn.jsdelivr.net/npm/[email protected]/lodash.min.js
// ==/UserScript==

; (function () {
    'use strict';
 $.tools = $.tools || {version: '1.2.5'};

  $.tools.tooltip = {
    conf: {
      // default effect variables
      effect: 'toggle',
      fadeOutSpeed: "fast",
      predelay: 0,
      delay: 30,
      opacity: 1,
      tip: 0,

      // 'top', 'bottom', 'right', 'left', 'center'
      position: ['top', 'center'],
      offset: [0, 0, 0],
      relative: false,
      cancelDefault: true,

      // type to event mapping
      events: {
        def:       "mouseenter,mouseleave",
        input:     "focus,blur",
        widget:    "focus mouseenter,blur mouseleave",
        tooltip:    "mouseenter,mouseleave"
      },

      // 1.2
      layout: '<div/>',
      tipClass: 'tooltip',

      ignoreSelector: ''
    },

    addEffect: function(name, loadFn, hideFn) {
      effects[name] = [loadFn, hideFn];
    }
  };


  var effects = {
    toggle: [
      function(done) {
        var conf = this.getConf(), tip = this.getTip(), o = conf.opacity;
        if (o < 1) { tip.css({opacity: o}); }
        tip.show();
        done.call();
      },

      function(done) {
        this.getTip().hide();
        done.call();
      }
    ],

    fade: [
      function(done) {
        var conf = this.getConf();
        this.getTip().fadeTo(conf.fadeInSpeed, conf.opacity, done);
      },
      function(done) {
        this.getTip().fadeOut(this.getConf().fadeOutSpeed, done);
      }
    ]
  };

  function Tooltip(trigger, conf) {
    var self = this,
       fire = trigger.add(self),
       tip,
       timer = 0,
       pretimer = 0,
       title = trigger.data("do-not-use-title") ? null : trigger.attr("title"),
       tipAttr = trigger.attr("data-tooltip"),
       effect = effects[conf.effect],
       shown,

       // get show/hide configuration
       isInput = trigger.is(":input"),
       isWidget = isInput && trigger.is(":checkbox, :radio, select, :button, :submit"),
       type = trigger.attr("type"),
       evt = conf.events[type] || conf.events[isInput ? (isWidget ? 'widget' : 'input') : 'def'];


    // check that configuration is sane
    if (!effect) { throw "Nonexistent effect \"" + conf.effect + "\""; }

    evt = evt.split(/,\s*/);
    if (evt.length != 2) { throw "Tooltip: bad events configuration for " + type; }


    // trigger --> show
    trigger.on(evt[0], function(e) {
      clearTimeout(pretimer);

      var predelay = trigger.data('predelay') || conf.predelay;
      if (predelay) {
        pretimer = setTimeout(function() { self.show(e); }, predelay);
      } else {
        self.show(e);
      }
    // trigger --> hide
    }).on(evt[1], function(e)  {
      clearTimeout(pretimer);

      var delay = trigger.data('delay') || conf.delay;
      if (delay)  {
        timer = setTimeout(function() { self.hide(e); }, delay);
      } else {
        self.hide(e);
      }
    });

    if (conf.ignoreSelector) {
      trigger
        .on('mouseover', conf.ignoreSelector, function(e) {
          // event with delay because this event is triggered before same event for 'trigger'
          delay().then(function() {
            clearTimeout(pretimer);
            self.hide(e);
          });
        })
        .on('mouseout', conf.ignoreSelector, function(e) {
          // event without delay because this event must be triggered before same event for 'trigger'
          if (e.target == trigger[0] || $(e.target).closest(trigger)) {
            trigger.trigger('mouseover', [{target: trigger[0]}]);
          }
        }
      )
    }

    // remove default title
    if (title && conf.cancelDefault) {
      trigger.removeAttr("title");
      trigger.data("title", title);
    }

    $.extend(self, {
      show: function(e) {
        // для устройств с тачскрином и узких экранов тултипы отключаем
        if (!e.target.classList.contains('mobile') && (
            ('ontouchstart' in window) ||
            (navigator.MaxTouchPoints > 0) ||
            (navigator.msMaxTouchPoints > 0) ||
            isMobile())) {
          return;
        }
        // tip not initialized yet
        if (!tip) {
          // data-tooltip
          if (tipAttr) {
            tip = $(tipAttr);

          // single tip element for all
          } else if (conf.tip) {
            tip = $(conf.tip).eq(0);

          // remote tooltip
          } else if ((trigger.data('tooltip_url') || trigger.data('href') || trigger.attr('href')) && !trigger.data('local-tooltip')) {
            tip = $(conf.defaultTemplate)
              .addClass(conf.tipClass)
              .css('z-index', parseInt(trigger.parents('.tooltip').css('z-index')) || 1)
              .hide();

            if (trigger.data('insert-tooltip-after')) {
              tip.insertAfter(trigger);
            } else {
              tip.appendTo(document.body);
            }

            // why delay?
            // delay(100).then(function() {
              e = e || $.Event();
              e.type = "onBeforeFetch";
              fire.trigger(e, []);

              const tooltip_url = trigger.data('tooltip_url') ||
                trigger.data('href') ||
                trigger.attr('href').replace(/(\?|$)/, '/tooltip$1');

              tip.find('.tooltip-details').load(tooltip_url, function() {
                // если есть только картинка, то ставим класс tooltip-image
                var $this = $(this);
                var $desc = $this.find('.tooltip-desc');
                if ($desc.length && $desc.html() == '' && $this.find('img').length) {
                  $this.parents('.tooltip').addClass('tooltip-image');
                }
                $this.process();
                // после подгрузки надо тултип перересовать, если он видимый
                delay(50).then(function() {
                  if (tip.css('display') == 'none') {
                    return;
                  }

                  var top = parseInt(tip.css('top'));
                  var left = parseInt(tip.css('left'));

                  var pos = getPosition(trigger, tip, conf, true);
                  if (Math.abs(top - pos.top) > 20 || Math.abs(left - pos.left) > 20) {
                    tip.stop(true, true).css({top: pos.top, left: pos.left});
                  }
                })
              });
            // })
            trigger.attr('data-remote', null);
            trigger.attr('tooltip', null);
          // autogenerated tooltip
          //} else if (title) {
            //tip = $(conf.layout).addClass(conf.tipClass).appendTo(document.body)
              //.hide().append(title);

          // manual tooltip
          } else {
            tip = trigger.next();
            if (!tip.length) { tip = trigger.parent().next(); }
          }

          if (!tip.length) { throw "Cannot find tooltip for " + trigger;  }
        }

        if (self.isShown()) { return self; }

         // stop previous animation
        tip.stop(true, true);

        // get position
        var pos = getPosition(trigger, tip, conf);

        // restore title for single tooltip element
        if (conf.tip) {
          tip.html(trigger.data("title"));
        }

        // onBeforeShow
        e = e || $.Event();
        e.type = "onBeforeShow";
        fire.trigger(e, [pos]);
        //if (e.isDefaultPrevented()) { return self; }

        // onBeforeShow may have altered the configuration
        pos = getPosition(trigger, tip, conf);

        // set position
        tip.css({position:'absolute', top: pos.top, left: pos.left});

        shown = true;

        // invoke effect
        effect[0].call(self, function() {
          e.type = "onShow";
          shown = 'full';
          fire.trigger(e);
        });


        // tooltip events
        var event = conf.events.tooltip.split(/,\s*/);

        if (!tip.data("__set")) {

          tip.on(event[0], function() {
            clearTimeout(timer);
            clearTimeout(pretimer);
          });

          if (event[1] && !trigger.is("input:not(:checkbox, :radio), textarea")) {
            tip.on(event[1], function(e) {

              // being moved to the trigger element
              if (e.relatedTarget != trigger[0]) {
                trigger.trigger(evt[1].split(" ")[0]);
              }
            });
          }

          tip.data("__set", true);
        }

        return self;
      },
      hide: function(e) {
        if (!tip || !self.isShown()) { return self; }

        // onBeforeHide
        e = e || $.Event();
        e.type = "onBeforeHide";
        fire.trigger(e);
        //if (e.isDefaultPrevented()) { return; }

        shown = false;

        effects[conf.effect][1].call(self, function() {
          e.type = "onHide";
          fire.trigger(e);
        });

        return self;
      },

      isShown: function(fully) {
        return fully ? shown == 'full' : shown;
      },

      getConf: function() {
        return conf;
      },

      getTip: function() {
        return tip;
      },

      getTrigger: function() {
        return trigger;
      }

    });

    // callbacks
    $.each("onBeforeFetch,onHide,onBeforeShow,onShow,onBeforeHide".split(","), function(i, name) {

      // configuration
      if ($.isFunction(conf[name])) {
        $(self).on(name, conf[name]);
      }

      // API
      self[name] = function(fn) {
        if (fn) { $(self).on(name, fn); }
        return self;
      };
    });

  }



    // --- ShikiMath class ---
    /* eslint camelcase:0 */
    class ShikiMath {
        // detecting whether point is above or below a line
        // x,y - point
        // x1,y1 - point 1 of line
        // x2,y2 - point 2 of line
        static is_above(x, y, x1, y1, x2, y2) {
            const dx = x2 - x1;
            const dy = y2 - y1;

            return ((((dy * x) - (dx * y)) + (dx * y1)) - (dy * x1)) <= 0;
        }

        // detecting in which "sector" point x2,y2 is located accordingly to
        // rectangular node with center in x1,y1 and width=rx*2 and height=ry*2
        static sector(x1, y1, x2, y2, rx, ry) {
            // left_bottom to right_top
            const lb_to_rt = this.is_above(x2, y2, x1 - rx, y1 - ry, x1, y1);
            // left_top to right_bottom
            const lt_to_rb = this.is_above(x2, y2, x1 - rx, y1 + ry, x1, y1);

            if (lb_to_rt && lt_to_rb) {
                return 'top';
            } if (!lb_to_rt && lt_to_rb) {
                return 'right';
            } if (!lb_to_rt && !lt_to_rb) {
                return 'bottom';
            }
            return 'left';
        }

        // math for obtaining coords for link between two rectangular nodes
        // with center in xN,yN and width=rxN*2 and height=ryN*2
        static square_cutted_line(x1, y1, x2, y2, rx1, ry1, rx2, ry2) {
            let f_x1;
            let f_x2;
            let f_y1;
            let f_y2;
            const dx = x2 - x1;
            const dy = y2 - y1;

            const y = x => (((dy * x) + (dx * y1)) - (dy * x1)) / dx;
            const x = y => (((dx * y) - (dx * y1)) + (dy * x1)) / dy;

            const target_sector = this.sector(x1, y1, x2, y2, rx1, ry1);

            if (target_sector === 'right') {
                f_x1 = x1 + rx1;
                f_y1 = y(f_x1);

                f_x2 = x2 - rx2;
                f_y2 = y(f_x2);
            } else if (target_sector === 'left') {
                f_x1 = x1 - rx1;
                f_y1 = y(f_x1);

                f_x2 = x2 + rx2;
                f_y2 = y(f_x2);
            }

            if (target_sector === 'top') {
                f_y1 = y1 + ry1;
                f_x1 = x(f_y1);

                f_y2 = y2 - ry2;
                f_x2 = x(f_y2);
            }

            if (target_sector === 'bottom') {
                f_y1 = y1 - ry1;
                f_x1 = x(f_y1);

                f_y2 = y2 + ry2;
                f_x2 = x(f_y2);
            }

            return {
                x1: f_x1,
                y1: f_y1,
                x2: f_x2,
                y2: f_y2,
                sector: target_sector
            };
        }

        // tests for math
        static rspec() {
            // is_above
            this._assert(true, this.is_above(-1, 2, -1, -1, 1, 1));
            this._assert(true, this.is_above(0, 2, -1, -1, 1, 1));
            this._assert(true, this.is_above(0, 0, -1, -1, 1, 1));
            this._assert(true, this.is_above(1, 2, -1, -1, 1, 1));
            this._assert(false, this.is_above(2, 1, -1, -1, 1, 1));
            this._assert(false, this.is_above(-1, -2, -1, -1, 1, 1));

            // sector test
            this._assert('top', this.sector(0, 0, 0, 10, 1, 1));
            this._assert('top', this.sector(0, 0, 10, 10, 1, 1));
            this._assert('right', this.sector(0, 0, 10, 0, 1, 1));
            this._assert('right', this.sector(0, 0, 10, -10, 1, 1));
            this._assert('bottom', this.sector(0, 0, 0, -10, 1, 1));
            this._assert('left', this.sector(0, 0, -10, 0, 1, 1));

            // square_cutted_line
            this._assert(
                { x1: -9, y1: 0, x2: 9, y2: 0, sector: 'right' },
                this.square_cutted_line(-10, 0, 10, 0, 1, 1, 1, 1)
            );
            this._assert(
                { x1: 5, y1: 0, x2: -5, y2: 0, sector: 'left' },
                this.square_cutted_line(10, 0, -10, 0, 5, 1, 5, 1)
            );
            this._assert(
                { x1: 0, y1: 5, x2: 0, y2: -5, sector: 'bottom' },
                this.square_cutted_line(0, 10, 0, -10, 1, 5, 1, 5)
            );
            this._assert(
                { x1: 0, y1: -5, x2: 0, y2: 5, sector: 'top' },
                this.square_cutted_line(0, -10, 0, 10, 1, 5, 1, 5)
            );

            this._assert(
                { x1: 5, y1: 5, x2: -5, y2: -5, sector: 'left' },
                this.square_cutted_line(10, 10, -10, -10, 5, 5, 5, 5)
            );
            this._assert(
                { x1: 0.5, y1: 1, x2: 1.5, y2: 3, sector: 'top' },
                this.square_cutted_line(0, 0, 2, 4, 1, 1, 1, 1)
            );
        }

        static _assert(left, right) {
            if (JSON.stringify(left) !== JSON.stringify(right)) {
                throw `math error: expected ${JSON.stringify(left)}, got ${JSON.stringify(right)}`;
            }
        }
    }


    // --- FranchiseGraph class ---

    const START_MARKERS = ['prequel'];
    const END_MARKERS = ['sequel'];
    class FranchiseGraph {
        constructor(data) {
            // image sizes
            this._bound_x = this._bound_x.bind(this);
            this._bound_y = this._bound_y.bind(this);
            this._y_by_date = this._y_by_date.bind(this);
            this._node_selected = this._node_selected.bind(this);
            this._tick = this._tick.bind(this);
            this._link_truncated = this._link_truncated.bind(this);
            this._collide = this._collide.bind(this);
            this.image_w = 64;
            this.image_h = 64;

            this.links_data = data.links;
            this.nodes_data = data.nodes.map(node => (
                new FranchiseNode(node, this.image_w, this.image_h, node.id === data.current_id)
            ));

            this._prepare_data();
            this._position_nodes();
            this._prepare_force();
            this._check_non_symmetrical_links();
        }

        _prepare_data() {
            let original_size;
            this.links_data.forEach(link => {
                link.source = this.nodes_data.find(n => n.id === link.source_id);
                link.target = this.nodes_data.find(n => n.id === link.target_id);

                if (!link.source || !link.target) {
                    console.warn(`Link skipped: ${link.source_id} -> ${link.target_id}`);
                }
            });
            this.max_weight = _.max(this.links_data.map(v => v.weight)) * 1.0;
            this.size = (original_size = this.nodes_data.length);
            // console.log "nodes: #{@size}, max_weight: #{@max_weight}"

            // screen sizes
            this.screen_width =
                this.size < 30 ?
                    this._scale(this.size, {
                        from_min: 0,
                        from_max: 30,
                        to_min: 480,
                        to_max: 1300
                    }
                    ) :
                    this._scale(this.size, {
                        from_min: 0,
                        from_max: 100,
                        to_min: 1600,
                        to_max: 2461
                    }
                    );

            this.screen_height = this.screen_width;

            // dates for positioning on Y axis
            const min_date = _.max(this.nodes_data.map(v => v.date));
            const max_date = _.max(this.nodes_data.map(v => v.date));

            // do not use min/max dates if they belong to multiple entries
            if (this.nodes_data.filter(v => v.date === min_date).length === 1) {
                this.min_date = min_date * 1.0;
            }

            if (this.nodes_data.filter(v => v.date === max_date).length === 1) {
                return this.max_date = max_date * 1.0;
            }
        }

        // initial nodes positioning
        _position_nodes() {
            // return unless @min_date && @max_date
            return this.nodes_data.forEach(d => {
                d.y = this._y_by_date(d.date);
                d.x = (this.screen_width / 2.0) - d.rx;

                if (d.date === this.min_date) {
                    d.fixed = true;
                    // move it proportionally to its relations count
                    d.y += this._scale(d.weight, { from_min: 4, from_max: 20, to_min: 0, to_max: 700 });
                }

                if (d.date === this.max_date) {
                    d.fixed = true;
                    d.y -= 20;
                    // move it proportionally to its relations count
                    return d.y -= this._scale(d.weight, { from_min: 4, from_max: 9, to_min: 0, to_max: 150 });
                }
            });
        }

        // configure d3 force object
        _prepare_force() {
            return window.d3_force = (this.d3_force = d3.layout.force()
                .charge(function (d) {
                    if (d.selected) {
                        if (d.weight > 100) {
                            return -9000;
                        } else {
                            return -5000;
                        }
                    } else if (d.weight > 100) {
                        return -7000;
                    } else if (d.weight > 20) {
                        return -4000;
                    } else if (d.weight > 7) {
                        return -3000;
                    } else {
                        return -2000;
                    }
                }).friction(0.9)
                .linkDistance(d => {
                    const max_width =
                        this.max_weight < 3 ?
                            this._scale(this.size, { from_min: 2, from_max: 6, to_min: 100, to_max: 300 }) :
                            this.max_weight > 100 ?
                                this._scale(this.max_weight, { from_min: 30, from_max: 80, to_min: 300, to_max: 1000 }) :
                                this._scale(this.max_weight, { from_min: 30, from_max: 80, to_min: 300, to_max: 1500 });

                    return this._scale(300 * (d.weight / this.max_weight), {
                        from_min: 0,
                        from_max: 300,
                        to_min: 150,
                        to_max: max_width
                    }
                    );
                }).size([this.screen_width, this.screen_height])
                .nodes(this.nodes_data)
                .links(this.links_data));
        }

        _check_non_symmetrical_links() {
            return this.links_data.forEach(entry_1 => {
                const symmetrical_link = this.links_data
                    .find(entry_2 => (entry_2.source_id === entry_1.target_id) && (entry_2.target_id === entry_1.source_id));

                if (!symmetrical_link) {
                    console.warn(`non symmetical link [${entry_1.source_id}, ${entry_1.target_id}]`, entry_1);
                }
            });
        }

        // scale X which expected to be in [from_min..from_max] to new value in [to_min...to_max]
        _scale(x, opt) {
            let percent = (x - opt.from_min) / (opt.from_max - opt.from_min);
            percent = Math.min(1, Math.max(percent, 0));
            return opt.to_min + ((opt.to_max - opt.to_min) * percent);
        }

        // bound X coord to be within screen area
        _bound_x(d, x = d.x) {
            const min = d.rx + 5;
            const max = this.screen_width - d.rx - 5;
            return Math.max(min, Math.min(max, x));
        }

        // bound Y coord to be within screen area
        _bound_y(d, y = d.y) {
            const min = d.ry + 5;
            const max = this.screen_width - d.ry - 5;
            return Math.max(min, Math.min(max, y));
        }

        // determine Y coord by date (oldest to top, newest to bottom)
        _y_by_date(date) {
            return this._scale(date, {
                from_min: this.min_date,
                from_max: this.max_date,
                to_min: this.image_h / 2.0,
                to_max: this.screen_height - (this.image_h / 2.0)
            }
            );
        }

        render_to(target) {
            this._append_svg(target);
            this._append_markers();
            this._append_links();
            this._append_nodes();

            this.d3_force.start().on('tick', this._tick);
            for (let i = 0, end = this.size * this.size, asc = end >= 0; asc ? i <= end : i >= end; asc ? i++ : i--) { this.d3_force.tick(); }
            return this.d3_force.stop();
        }

        // handler for node selection
        _node_selected(d) {
            if (this.selected_node) {
                this.selected_node.deselect(this._bound_x, this._bound_y, this._tick);

                if (this.selected_node === d) {
                    this.selected_node = null;
                    return;
                }
            }

            this.selected_node = d;
            return this.selected_node.select(this._bound_x, this._bound_y, this._tick);
        }

        // svg tag
        _append_svg(target) {
            return this.d3_svg = d3.select(target)
                .append('svg')
                .attr({ width: this.screen_width, height: this.screen_height });
        }

        // lines between nodes
        _append_links() {
            return this.d3_link = this.d3_svg.append('svg:g').selectAll('.link')
                .data(this.links_data)
                .enter().append('svg:path')
                .attr({
                    class(d) { return `${d.source_id}-${d.target_id} link ${d.relation}`; },
                    'marker-start'(d) {
                        if (START_MARKERS.find(v => v === d.relation)) { return `url(#${d.relation})`; }
                    },
                    'marker-end'(d) {
                        if (END_MARKERS.find(v => v === d.relation)) { return `url(#${d.relation})`; }
                    },
                    'marker-mid'(d) { return `url(#${d.relation}_label)`; }
                });
        }

        // nodes (images + borders + year)
        _append_nodes() {
            this.d3_node = this.d3_svg.append('.svg:g').selectAll('.node')
                .data(this.nodes_data)
                .enter().append('svg:g')
                .attr({
                    class: 'node',
                    id(d) { return d.id; }
                }).call(this.d3_force.drag())
                .on('click', d => {
                    if (d3.event != null ? d3.event.defaultPrevented : undefined) { return; }
                    return this._node_selected(d);
                });
            // .call(@d3_force.drag().on('dragstart', -> $(@).children('text').hide()).on('dragend', -> $(@).children('text').show()))
            // .on 'mouseover', (d) ->
            // $(@).children('text').show()
            // .on 'mouseleave', (d) ->
            // $(@).children('text').hide()

            this.d3_node.append('svg:path').attr({ class: 'border_outer', d: '' });
            this.d3_image_container = this.d3_node.append('svg:g').attr({ class: 'image-container' });

            this.d3_image_container.append('svg:image')
                .attr({
                    width(d) { return d.width; },
                    height(d) { return d.height; },
                    'xlink:href'(d) { return d.image_url; }
                });

            this.d3_image_container.append('svg:path')
                .attr({
                    class: 'border_inner',
                    d(d) { return `M 0,0 ${d.width},0 ${d.width},${d.height} 0,${d.height} 0,0`; }
                });

            // year
            this.d3_image_container.append('svg:text')
                .attr({
                    x(d) { return d.yearX(); },
                    y(d) { return d.yearY(); },
                    class: 'year shadow'
                }).text(d => d.year);
            return this.d3_image_container.append('svg:text')
                .attr({
                    x(d) { return d.yearX(); },
                    y(d) { return d.yearY(); },
                    class: 'year'
                }).text(d => d.year);
        }

        // kind
        // @d3_image_container.append('svg:text')
        // .attr x: @image_w - 2, y: 0 , class: 'kind shadow'
        // .text (d) -> d.kind
        // @d3_image_container.append('svg:text')
        // .attr x: @image_w - 2, y: 0, class: 'kind'
        // .text (d) -> d.kind

        // markers for links between nodes
        _append_markers() {
            this.d3_defs = this.d3_svg.append('svg:defs');

            // arrow size
            const aw = 8;
            this.d3_defs.append('svg:marker')
                .attr({
                    id: 'sequel',
                    orient: 'auto',
                    refX: aw,
                    refY: aw / 2,
                    markerWidth: aw,
                    markerHeight: aw,
                    stroke: '#123',
                    fill: '#333'
                }).append('svg:polyline')
                .attr({ points: `0,0 ${aw},${aw / 2} 0,${aw} ${aw / 4},${aw / 2} 0,0` });
            return this.d3_defs.append('svg:marker')
                .attr({
                    id: 'prequel',
                    orient: 'auto',
                    refX: 0,
                    refY: aw / 2,
                    markerWidth: aw,
                    markerHeight: aw,
                    stroke: '#123',
                    fill: '#333'
                }).append('svg:polyline')
                .attr({ points: `${aw},${aw} 0,${aw / 2} ${aw},0 ${(aw * 3) / 4},${aw / 2} ${aw},${aw}` });
        }

        // @d3_svg.append('svg:defs').selectAll('marker')
        // .data(['sequel', 'prequel'])
        // .enter().append('svg:marker')
        // .attr
        // refX: 10, refY: 0
        // id: String,
        // markerWidth: 6, markerHeight: 6, orient: 'auto'
        // stroke: '#123', fill: '#123'
        // viewBox: '0 -5 10 10'
        // .append('svg:path')
        // .attr
        // d: (d) ->
        // if START_MARKERS.find(d)
        // "M10,-5L0,0L10,5"
        // else
        // "M0,-5L10,0L0,5"

        // move nodes and links accordingly to coords calculated by d3.force
        _tick() {
            this.d3_node.attr({
                transform: d => `translate(${this._bound_x(d) - d.rx}, ${this._bound_y(d) - d.ry})`
            });

            this.d3_link.attr({
                d: this._link_truncated
            });

            // collistion detection between nodes
            return this.d3_node.forEach(this._collide(0.5));
        }

        // math for obtaining coords for links between rectangular nodes
        _link_truncated(d) {
            if (!d.source || !d.target) return '';  // защита от undefined

            // if (!location.href.endsWith('?test')) {
            // if (d.source.id >= d.target.id) { return ''; }
            // }

            const rx1 = d.source.rx;
            const ry1 = d.source.ry;
            const rx2 = d.target.rx;
            const ry2 = d.target.ry;

            const x1 = this._bound_x(d.source);
            const y1 = this._bound_y(d.source);
            const x2 = this._bound_x(d.target);
            const y2 = this._bound_y(d.target);

            const coords = ShikiMath.square_cutted_line(x1, y1, x2, y2, rx1, ry1, rx2, ry2);

            if (!Number.isNaN(coords.x1) && !Number.isNaN(coords.y1) &&
                !Number.isNaN(coords.x2) && !Number.isNaN(coords.y2)) {
                return `M${coords.x1},${coords.y1} L${coords.x2},${coords.y2}`;
            } else {
                return `M${x1},${y1} L${x2},${y2}`;
            }
        }

        // math for collision detection. originally it was designed for circle
        // nodes so it is not absolutely accurate for rectangular nodes
        _collide(alpha) {
            const quadtree = d3.geom.quadtree(this.nodes_data);

            return d => {
                const nx1 = d.x - d.width;
                const nx2 = d.x + d.width;

                const ny1 = d.y - d.height;
                const ny2 = d.y + d.height;

                return quadtree.visit((quad, x1, y1, x2, y2) => {
                    if (quad.point && (quad.point !== d)) {
                        const rb = Math.max(d.rx + quad.point.rx, d.ry + quad.point.ry) * 1.15;

                        let x = d.x - quad.point.x;
                        let y = d.y - quad.point.y;
                        let l = Math.sqrt((x * x) + (y * y));

                        if ((l < rb) && (l !== 0)) {
                            l = ((l - rb) / l) * alpha;

                            x *= l;
                            y *= l;

                            d.x = this._bound_x(d, d.x - x);
                            d.y = this._bound_y(d, d.y - y);
                            quad.point.x = this._bound_x(quad.point, quad.point.x + x);
                            quad.point.y = this._bound_y(quad.point, quad.point.y + y);
                        }
                    }

                    return (x1 > nx2) || (x2 < nx1) || (y1 > ny2) || (y2 < ny1);
                });
            };
        }
    }

    const SELECT_SCALE = 2;
    const BORDER_OFFSET = 3;
    class FranchiseNode {
        constructor(data, width, height, isCurrent) {
            this.width = width;
            this.height = height;
            this.isCurrent = isCurrent;

            Object.assign(this, data);

            this.selected = false;
            this.fixed = false;

            if (this.isCurrent) {
                this.width = Math.ceil(this.width * 1.3);
                this.height = Math.ceil(this.height * 1.3);
            }

            this.initialWidth = this.width;
            this.initialHeight = this.height;
            this._calcRs();
            this._tooltip = null;
        }

        get d3Node() {
            if (!this._d3Node) this._d3Node = d3.select($(`.node#${this.id}`)[0]);
            return this._d3Node;
        }

        get d3ImageContainer() {
            if (!this._d3ImageContainer) this._d3ImageContainer = this.d3Node.selectAll('.image-container');
            return this._d3ImageContainer;
        }

        get d3Image() {
            if (!this._d3Image) this._d3Image = this.d3Node.selectAll('image');
            return this._d3Image;
        }

        get d3Year() {
            if (!this._d3Year) this._d3Year = this.d3Node.selectAll('.year');
            return this._d3Year;
        }

        get d3OuterBorder() {
            if (!this._d3OuterBorder) this._d3OuterBorder = this.d3Node.selectAll('path.border_outer');
            return this._d3OuterBorder;
        }

        get d3InnerBorder() {
            if (!this._d3InnerBorder) this._d3InnerBorder = this.d3Node.selectAll('path.border_inner');
            return this._d3InnerBorder;
        }

    deselect(boundX, boundY, tick) {
        this.selected = false;
        this.fixed = this.pfixed;

        this._hideTooltip();
        this._animate(this.initialWidth, this.initialHeight, boundX, boundY, tick);
    }

    select(boundX, boundY, tick) {
        this.selected = true;
        this.pfixed = this.fixed;
        this.fixed = true;

        this._loadTooltip();
        this._animate(
            this.initialWidth * SELECT_SCALE,
            this.initialHeight * SELECT_SCALE,
            boundX,
            boundY,
            tick
        );
    }

    _initTooltip() {
        if (!this._tooltip) {
            const trigger = $(this.d3Node.node());
            const conf = {
                effect: 'fade',
                tipClass: 'sticky-tooltip',
                defaultTemplate: '<div class="sticky-tooltip"><div class="tooltip-details"></div></div>'
            };
            this._tooltip = new Tooltip(trigger, conf);
        }
    }
        yearX(w = this.width) {
            return w - 2;
        }

        yearY(h = this.height) {
            return h - 2;
        }

        _calcRs() {
            this.rx = this.width / 2.0;
            return this.ry = this.height / 2.0;
        }

        _animate(newWidth, newHeight, boundX, boundY, tick) {
            let ih;
            let io;
            let iw;

            if (this.selected) {
                io = d3.interpolate(0, BORDER_OFFSET);
                iw = d3.interpolate(this.width, newWidth);
                ih = d3.interpolate(this.height, newHeight);

                this.d3Node.attr({ class: 'node selected' });
            } else {
                io = d3.interpolate(BORDER_OFFSET, 0);
                iw = d3.interpolate(this.width - (BORDER_OFFSET * 2), newWidth);
                ih = d3.interpolate(this.height - (BORDER_OFFSET * 2), newHeight);

                this.d3Node.attr({ class: 'node' });
            }

            return this.d3Node
                .transition()
                .duration(500)
                .tween('animation', () => t => {
                    // t = 1
                    const o = io(t);
                    const o2 = o * 2;
                    const w = iw(t);
                    const h = ih(t);

                    const widthIncrement = (w + o2) - this.width;
                    const heightIncrement = (h + o2) - this.height;

                    this.width += widthIncrement;
                    this.height += heightIncrement;

                    this._calcRs();

                    const outerBorderPath = `M 0,0 ${w + o2},0 ${w + o2},${h + o2} 0,${h + o2} 0,0`;

                    this.d3Node.attr({
                        transform: `translate(${boundX(this) - this.rx}, ${boundY(this) - this.ry})`
                    });
                    this.d3OuterBorder.attr({ d: outerBorderPath });
                    this.d3ImageContainer.attr({ transform: `translate(${o}, ${o})` });
                    this.d3InnerBorder.attr({ d: `M 0,0 ${w},0 ${w},${h} 0,${h} 0,0` });

                    this.d3Image.attr({ width: w, height: h });
                    this.d3Year.attr({ x: this.yearX(w), y: this.yearY(h) });

                    return tick();
                });
        }

    _hideTooltip() {
        if (this._tooltip) {
            this._tooltip.hide();
        }
    }

    async _loadTooltip() {
        this._initTooltip();
        const tip = this._tooltip.getTip();
        if (!tip) return;

        tip.addClass('b-ajax');
        this._tooltip.show();

        try {
            const { data } = await axios.get(`https://shikimori.one/api/comments/${this.id}/tooltip`);
            tip.removeClass('b-ajax');
            tip.find('.tooltip-details').html(data.html_body);
        } catch (err) {
            console.error('Tooltip:', err);
        }
    }

    }





    window.ShikiMath = ShikiMath;
    window.FranchiseGraph = FranchiseGraph;
    window.FranchiseNode = FranchiseNode;
})();