shikiTestLIB

Lib для франшиз

Dieses Skript sollte nicht direkt installiert werden. Es handelt sich hier um eine Bibliothek für andere Skripte, welche über folgenden Befehl in den Metadaten eines Skriptes eingebunden wird // @require https://update.greasyfork.org/scripts/552715/1683718/shikiTestLIB.js

// ==UserScript==
// @name         ShikiTreeLib
// @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';
    // --- 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();
        }

        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; // prior fixed
            this.fixed = true;

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

        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() {
            $('.sticky-tooltip').hide();
        }

        async _loadTooltip() {
            $('.sticky-tooltip').show().addClass('b-ajax');

            const { data } = await axios.get(`https://shikimori.one/comments/${this.id}/tooltip`);
            const $temp = $('<div>').html(data);

            $temp.find('.l-top_menu-v2, .l-footer').remove();

            $('.sticky-tooltip').removeClass('b-ajax');
            $('.sticky-tooltip > .inner').html($temp.html()).process();
        }

    }


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