phpmyadmin/phpmyadmin

View on GitHub
resources/js/src/table/gis_visualization.ts

Summary

Maintainability
F
1 wk
Test Coverage
import $ from 'jquery';
import { AJAX } from '../modules/ajax.ts';
import { escapeHtml } from '../modules/functions/escape.ts';

let openLayersData: any[]|null = null;

/**
 * @fileoverview    functions used for visualizing GIS data
 *
 * @requires    jquery
 * @requires    jQueryUI
 */

class GisVisualization {
    protected target: HTMLElement;

    constructor (target: HTMLElement) {
        this.target = target;
    }

    /**
     * Make this visualization visible
     */
    public show () {
        $(this.target).show();
    }

    /**
     * Hide this visualization
     */
    public hide () {
        $(this.target).hide();
    }

    /**
     * Do cleanup when it is no longer needed
     */
    public dispose () {
        $(this.target).empty();
    }
}

interface UI {
    /**  The jQuery object representing the helper that's being dragged. */
    helper: JQuery,
    /**
     * Current CSS position of the helper. The values may be changed to modify
     * where the element will be positioned. This is useful for custom containment,
     * snapping, etc.
     */
    position: {top: number, left: number},
    /** Current offset position of the helper. */
    offset: {top: number, left: number},
}

const DEFAULT_SCALE = 1.0;
const ZOOM_FACTOR = 1.5;

class SvgVisualization extends GisVisualization {
    private svgEl: SVGSVGElement;

    private originalWidth: number;

    private originalHeight: number;

    private x = 0;

    private y = 0;

    private scale = DEFAULT_SCALE;

    private dragX = 0;

    private dragY = 0;

    private width: number;

    private height: number;

    private boundOnMouseWheel: any;

    private boundOnDragStart: any;

    private boundOnDrag: any;

    private boundOnPlotDblClick: any;

    private boundOnZoomInClick: any;

    private boundOnZoomWorldClick: any;

    private boundOnZoomOutClick: any;

    private boundOnLeftArrowClick: any;

    private boundOnRightArrowClick: any;

    private boundOnUpArrowClick: any;

    private boundOnDownArrowClick: any;

    private boundOnMouseMove: any;

    private boundOnMouseLeave: any;

    private boundOnResize: any;

    private boundOnButtonDragStart: any;

    /**
     * @param {HTMLElement} target
     */
    constructor (target) {
        super(target);

        this.svgEl = $(this.target).find('svg').get(0);
        this.originalWidth = $(this.svgEl).width();
        this.originalHeight = $(this.svgEl).height();
        this.width = this.originalWidth;
        this.height = this.originalHeight;

        this.boundOnMouseWheel = this.onMouseWheel.bind(this);
        this.boundOnDragStart = this.onDragStart.bind(this);
        this.boundOnDrag = this.onDrag.bind(this);
        this.boundOnPlotDblClick = this.onPlotDblClick.bind(this);
        this.boundOnZoomInClick = this.onZoomInClick.bind(this);
        this.boundOnZoomWorldClick = this.onZoomWorldClick.bind(this);
        this.boundOnZoomOutClick = this.onZoomOutClick.bind(this);
        this.boundOnLeftArrowClick = this.onLeftArrowClick.bind(this);
        this.boundOnRightArrowClick = this.onRightArrowClick.bind(this);
        this.boundOnUpArrowClick = this.onUpArrowClick.bind(this);
        this.boundOnDownArrowClick = this.onDownArrowClick.bind(this);
        this.boundOnMouseMove = this.onMouseMove.bind(this);
        this.boundOnMouseLeave = this.onMouseLeave.bind(this);
        this.boundOnResize = this.onResize.bind(this);
        this.boundOnButtonDragStart = () => false;

        this.addControls();
        this.bindEvents();
    }

    /**
     * Adds controls for zooming and panning.
     */
    private addControls () {
        $(this.target).append(
            // pan arrows
            '<img class="button left_arrow" src="' + window.themeImagePath + 'west-mini.png">',
            '<img class="button right_arrow" src="' + window.themeImagePath + 'east-mini.png">',
            '<img class="button up_arrow" src="' + window.themeImagePath + 'north-mini.png">',
            '<img class="button down_arrow" src="' + window.themeImagePath + 'south-mini.png">',
            // zoom controls
            '<img class="button zoom_in" src="' + window.themeImagePath + 'zoom-plus-mini.png">',
            '<img class="button zoom_world" src="' + window.themeImagePath + 'zoom-world-mini.png">',
            '<img class="button zoom_out" src="' + window.themeImagePath + 'zoom-minus-mini.png">'
        );
    }

    /**
     * Zooms and pans the visualization.
     */
    private zoomAndPan () {
        $('g', this.svgEl)
            .first()
            .attr('transform', 'translate(' + this.x + ', ' + this.y + ') scale(' + this.scale + ')');

        $('circle.vector', this.svgEl)
            .attr('r', 3 / this.scale)
            .attr('stroke-width', 2 / this.scale);

        $('polyline.vector', this.svgEl).attr('stroke-width', 2 / this.scale);
        $('path.vector', this.svgEl).attr('stroke-width', 0.5 / this.scale);
    }

    /**
     * Resizes the GIS visualization to fit into the space available.
     */
    private resize () {
        const visWidth = Math.ceil($(this.target).width());
        const visHeight = Math.ceil($(this.target).height());

        this.x += (visWidth - this.width) / 2;
        this.y += (visHeight - this.height) / 2;
        this.width = visWidth;
        this.height = visHeight;
        this.svgEl.setAttribute('width', String(visWidth));
        this.svgEl.setAttribute('height', String(visHeight));

        this.zoomAndPan();
    }

    private reset () {
        this.scale = DEFAULT_SCALE;
        this.x = 0;
        this.y = 0;
        this.width = this.originalWidth;
        this.height = this.originalHeight;

        this.resize();
    }

    private getRelativeCoords (event: JQuery.TriggeredEvent|MouseEvent): {x: number, y: number} {
        const position = $(this.target).offset();

        return {
            x: event.pageX - position.left,
            y: event.pageY - position.top
        };
    }

    /**
     * @param {WheelEvent} event
     */
    private onMouseWheel (event) {
        if (event.deltaY === 0) {
            return;
        }

        event.preventDefault();

        const relCoords = this.getRelativeCoords(event);
        const factor = event.deltaY > 0 ? 1 / ZOOM_FACTOR : ZOOM_FACTOR;
        // zoom
        this.scale *= factor;
        // zooming keeping the position under mouse pointer unmoved.
        this.x = relCoords.x - (relCoords.x - this.x) * factor;
        this.y = relCoords.y - (relCoords.y - this.y) * factor;

        this.zoomAndPan();
    }

    show () {
        super.show();

        this.resize();
    }

    dispose () {
        this.unbindEvents();

        super.dispose();
    }

    private bindEvents () {
        $(this.svgEl)
            .on('dblclick', this.boundOnPlotDblClick)
            .on('dragstart', this.boundOnDragStart)
            .on('drag', this.boundOnDrag)
            .on('mousemove', '.vector', this.boundOnMouseMove)
            .on('mouseleave', '.vector', this.boundOnMouseLeave)
            .draggable({
                cursor: 'move',
                // Give a fake element to be used for dragging display
                helper: () => document.createElement('div'),
            });

        this.svgEl.addEventListener('wheel', this.boundOnMouseWheel, { passive: false });

        $(this.target)
            .on('dragstart', '.button', this.boundOnButtonDragStart)
            .on('click', '.zoom_in', this.boundOnZoomInClick)
            .on('click', '.zoom_world', this.boundOnZoomWorldClick)
            .on('click', '.zoom_out', this.boundOnZoomOutClick)
            .on('click', '.left_arrow', this.boundOnLeftArrowClick)
            .on('click', '.right_arrow', this.boundOnRightArrowClick)
            .on('click', '.up_arrow', this.boundOnUpArrowClick)
            .on('click', '.down_arrow', this.boundOnDownArrowClick);

        $(window).on('resize', this.boundOnResize);
    }

    private unbindEvents () {
        $(this.svgEl)
            .off('dblclick', this.boundOnPlotDblClick)
            .off('dragstart', this.boundOnDragStart)
            .off('drag', this.boundOnDrag)
            .off('mousemove', '.vector', this.boundOnMouseMove)
            .off('mouseleave', '.vector', this.boundOnMouseLeave)
            .draggable('destroy');

        this.svgEl.removeEventListener('wheel', this.boundOnMouseWheel);

        $(this.target)
            .off('dragstart', '.button', this.boundOnButtonDragStart)
            .off('click', '.zoom_in', this.boundOnZoomInClick)
            .off('click', '.zoom_world', this.boundOnZoomWorldClick)
            .off('click', '.zoom_out', this.boundOnZoomOutClick)
            .off('click', '.left_arrow', this.boundOnLeftArrowClick)
            .off('click', '.right_arrow', this.boundOnRightArrowClick)
            .off('click', '.up_arrow', this.boundOnUpArrowClick)
            .off('click', '.down_arrow'), this.boundOnDownArrowClick;

        $(window).off('resize', this.boundOnResize);
    }

    private onDragStart (event: JQuery.TriggeredEvent, dd: UI) {
        this.dragX = dd.offset.left;
        this.dragY = dd.offset.top;
    }

    private onDrag (event: JQuery.TriggeredEvent, dd: UI) {
        this.x += Math.round(dd.offset.left - this.dragX);
        this.dragX = dd.offset.left;

        this.y += Math.round(dd.offset.top - this.dragY);
        this.dragY = dd.offset.top;

        this.zoomAndPan();
    }

    private onPlotDblClick (event: JQuery.TriggeredEvent) {
        this.scale *= ZOOM_FACTOR;
        // zooming in keeping the position under mouse pointer unmoved.
        const relCoords = this.getRelativeCoords(event);
        this.x = relCoords.x - (relCoords.x - this.x) * ZOOM_FACTOR;
        this.y = relCoords.y - (relCoords.y - this.y) * ZOOM_FACTOR;

        this.zoomAndPan();
    }

    private onZoomInClick (event: JQuery.TriggeredEvent) {
        event.preventDefault();

        // zoom in
        this.scale *= ZOOM_FACTOR;

        // zooming in keeping the center unmoved.
        this.x = this.width / 2 - (this.width / 2 - this.x) * ZOOM_FACTOR;
        this.y = this.height / 2 - (this.height / 2 - this.y) * ZOOM_FACTOR;

        this.zoomAndPan();
    }

    private onZoomWorldClick (event: JQuery.TriggeredEvent) {
        event.preventDefault();

        this.reset();
    }

    private onZoomOutClick (event: JQuery.TriggeredEvent) {
        event.preventDefault();

        // zoom out
        this.scale /= ZOOM_FACTOR;

        // zooming out keeping the center unmoved.
        this.x = this.width / 2 - (this.width / 2 - this.x) / ZOOM_FACTOR;
        this.y = this.height / 2 - (this.height / 2 - this.y) / ZOOM_FACTOR;

        this.zoomAndPan();
    }

    private onLeftArrowClick (event: JQuery.TriggeredEvent) {
        event.preventDefault();

        this.x += 100;

        this.zoomAndPan();
    }

    private onRightArrowClick (event: JQuery.TriggeredEvent) {
        event.preventDefault();

        this.x -= 100;

        this.zoomAndPan();
    }

    private onUpArrowClick (event: JQuery.TriggeredEvent) {
        event.preventDefault();

        this.y += 100;

        this.zoomAndPan();
    }

    private onDownArrowClick (event) {
        event.preventDefault();

        this.y -= 100;

        this.zoomAndPan();
    }

    /**
     * Detect the mousemove event and show tooltips.
     */
    private onMouseMove (event: JQuery.TriggeredEvent) {
        $('#tooltip').remove();

        const target = event.target as SVGElement;
        const contents = target.getAttribute('name').trim();
        if (contents === '') {
            return;
        }

        $('<div id="tooltip">' + escapeHtml(contents) + '</div>')
            .css({
                top: event.pageY + 10,
                left: event.pageX + 10,
            })
            .appendTo('body')
            .fadeIn(200);
    }

    /**
     * Detect the mouseout event and hide tooltips.
     */
    private onMouseLeave () {
        $('#tooltip').remove();
    }

    private onResize () {
        this.resize();
    }
}

class OlVisualization extends GisVisualization {
    private olMap: any = undefined;

    private initFn: (HTMLElement) => any;

    /**
     * @param {function(HTMLElement): ol.Map} initFn
     */
    constructor (target: HTMLElement, initFn) {
        super(target);

        this.initFn = initFn;
    }

    show () {
        super.show();

        if (this.olMap) {
            this.olMap.updateSize();
        } else {
            const initFn = this.initFn;
            this.olMap = initFn(this.target);
        }
    }

    dispose () {
        if (this.olMap) {
            // Removes ol.Map's resize listener from window
            this.olMap.setTarget(null);
            this.olMap = undefined;
        }

        super.dispose();
    }
}

function getFeaturesFromOpenLayersData (geometries: any[]): any[] {
    let features = [];
    for (const geometry of geometries) {
        if (geometry.isCollection) {
            features = features.concat(getFeaturesFromOpenLayersData(geometry.geometries));

            continue;
        }

        let olGeometry: any = null;
        const style: any = {};
        if (geometry.geometry.type === 'LineString') {
            olGeometry = new window.ol.geom.LineString(geometry.geometry.coordinates);
            style.stroke = new window.ol.style.Stroke(geometry.style.stroke);
        } else if (geometry.geometry.type === 'MultiLineString') {
            olGeometry = new window.ol.geom.MultiLineString(geometry.geometry.coordinates);
            style.stroke = new window.ol.style.Stroke(geometry.style.stroke);
        } else if (geometry.geometry.type === 'MultiPoint') {
            olGeometry = new window.ol.geom.MultiPoint(geometry.geometry.coordinates);
            style.image = new window.ol.style.Circle({
                fill: new window.ol.style.Fill(geometry.style.circle.fill),
                stroke: new window.ol.style.Stroke(geometry.style.circle.stroke),
                radius: geometry.style.circle.radius,
            });
        } else if (geometry.geometry.type === 'MultiPolygon') {
            olGeometry = new window.ol.geom.MultiPolygon(geometry.geometry.coordinates);
            style.fill = new window.ol.style.Fill(geometry.style.fill);
            style.stroke = new window.ol.style.Stroke(geometry.style.stroke);
        } else if (geometry.geometry.type === 'Point') {
            olGeometry = new window.ol.geom.Point(geometry.geometry.coordinates);
            style.image = new window.ol.style.Circle({
                fill: new window.ol.style.Fill(geometry.style.circle.fill),
                stroke: new window.ol.style.Stroke(geometry.style.circle.stroke),
                radius: geometry.style.circle.radius,
            });
        } else if (geometry.geometry.type === 'Polygon') {
            olGeometry = new window.ol.geom.Polygon(geometry.geometry.coordinates);
            style.fill = new window.ol.style.Fill(geometry.style.fill);
            style.stroke = new window.ol.style.Stroke(geometry.style.stroke);
        } else {
            throw new Error();
        }

        if (geometry.geometry.srid !== 3857) {
            olGeometry = olGeometry.transform(
                'EPSG:' + (geometry.geometry.srid !== 0 ? geometry.geometry.srid : 4326),
                'EPSG:3857'
            );
        }

        if (geometry.style.text) {
            style.text = new window.ol.style.Text(geometry.style.text);
        }

        const feature = new window.ol.Feature(olGeometry);
        feature.setStyle(new window.ol.style.Style(style));
        features.push(feature);
    }

    return features;
}

function drawOpenLayers (target: HTMLElement) {
    if (typeof window.ol === 'undefined') {
        return undefined;
    }

    $('head').append('<link rel="stylesheet" type="text/css" href="js/vendor/openlayers/theme/ol.css">');

    const vectorSource = new window.ol.source.Vector({});
    const map = new window.ol.Map({
        target: target,
        layers: [
            new window.ol.layer.Tile({ source: new window.ol.source.OSM() }),
            new window.ol.layer.Vector({ source: vectorSource })
        ],
        view: new window.ol.View({ center: [0, 0], zoom: 4 }),
        controls: [
            new window.ol.control.MousePosition({
                coordinateFormat: window.ol.coordinate.createStringXY(4),
                projection: 'EPSG:4326'
            }),
            new window.ol.control.Zoom,
            new window.ol.control.Attribution
        ]
    });

    openLayersData = openLayersData ?? JSON.parse($('#visualization-placeholder').attr('data-ol-data'));
    const features = getFeaturesFromOpenLayersData(openLayersData);
    for (const feature of features) {
        vectorSource.addFeature(feature);
    }

    const extent = vectorSource.getExtent();
    if (! window.ol.extent.isEmpty(extent)) {
        map.getView().fit(extent, { padding: [20, 20, 20, 20] });
    }

    return map;
}

class GisVisualizationController {
    private svgVis: SvgVisualization|undefined = undefined;

    private olVis: OlVisualization|undefined = undefined;

    private boundOnChoiceChange: any;

    constructor () {
        this.boundOnChoiceChange = this.onChoiceChange.bind(this);

        $(document).on('click', '#useOsmAsBaseLayerSwitch', this.boundOnChoiceChange);

        if (typeof window.ol === 'undefined') {
            $('#useOsmAsBaseLayerSwitch, #useOsmAsBaseLayerSwitchLabel').hide();
            $('#useOsmAsBaseLayerSwitch').prop('checked', false);
        }

        this.selectVisualization();
    }

    private onChoiceChange () {
        this.selectVisualization();
    }

    /**
     * Initially loads either SVG or OSM visualization based on the choice.
     */
    private selectVisualization () {
        const showOl = $('#useOsmAsBaseLayerSwitch').prop('checked') === true;
        const oldVis = showOl ? this.svgVis : this.olVis;
        if (oldVis) {
            oldVis.hide();
        }

        let newVis: GisVisualization;
        if (showOl) {
            if (!this.olVis) {
                this.olVis = new OlVisualization(
                    $('#visualization-placeholder > .visualization-target-ol').get(0),
                    drawOpenLayers
                );
            }

            newVis = this.olVis;
        } else {
            if (!this.svgVis) {
                this.svgVis = new SvgVisualization($('#visualization-placeholder > .visualization-target-svg').get(0));
            }

            newVis = this.svgVis;
        }

        newVis.show();
    }

    /**
     * Cleanup events when no longer needed
     */
    public dispose () {
        $(document).off('click', '#useOsmAsBaseLayerSwitch');

        if (this.svgVis) {
            this.svgVis.dispose();
        }

        if (this.olVis) {
            this.olVis.dispose();
        }
    }

    public setOpenLayersData (olData: any[]): void {
        openLayersData = olData;
    }
}

declare global {
    interface Window {
        GisVisualizationController: typeof GisVisualizationController;
    }
}

window.GisVisualizationController = GisVisualizationController;

let visualizationController: GisVisualizationController|undefined;

/**
 * Ajax handlers for GIS visualization page
 *
 * Actions Ajaxified here:
 * Create visualization for the gis data
 */

/**
 * Unbind all event handlers before tearing down a page
 */
AJAX.registerTeardown('table/gis_visualization.js', function () {
    if (visualizationController) {
        visualizationController.dispose();
        visualizationController = undefined;
    }
});

AJAX.registerOnload('table/gis_visualization.js', function () {
    // If we are in GIS visualization, initialize it

    if ($('#gis_div').length > 0) {
        visualizationController = new GisVisualizationController();
    }
});