phpmyadmin/resources/js/table/gis_visualization.ts
Maximilian Krög ecf75a991d
Transmit the wkt data, without processing for ol
OpenLayers comes with its very own WKT parser.
This reduces the size of transmitted data.

Signed-off-by: Maximilian Krög <maxi_kroeg@web.de>
2026-02-27 22:58:33 +01:00

739 lines
22 KiB
TypeScript

import $ from 'jquery';
import { AJAX } from '../modules/ajax.ts';
import { escapeHtml } from '../modules/functions/escape.ts';
import Feature from 'ol/Feature';
import Map from 'ol/Map';
import View from 'ol/View';
import Attribution from 'ol/control/Attribution';
import MousePosition from 'ol/control/MousePosition';
import Zoom from 'ol/control/Zoom';
import { createStringXY } from 'ol/coordinate';
import { isEmpty } from 'ol/extent';
import WKT from 'ol/format/WKT';
import Geometry from 'ol/geom/Geometry';
import GeometryCollection from 'ol/geom/GeometryCollection';
import Tile from 'ol/layer/Tile';
import VectorLayer from 'ol/layer/Vector';
import OSM from 'ol/source/OSM';
import VectorSource from 'ol/source/Vector';
import Circle from 'ol/style/Circle';
import Fill from 'ol/style/Fill';
import Stroke from 'ol/style/Stroke';
import Style from 'ol/style/Style';
import Text from 'ol/style/Text';
import { get as getProjection } from 'ol/proj';
/**
* @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 = Number(this.svgEl.getAttribute('width'));
this.originalHeight = Number(this.svgEl.getAttribute('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() || $(this.svgEl).width());
const visHeight = Math.ceil($(this.target).height() || $(this.svgEl).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('data-label');
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();
}
}
interface OlGeometryData {
wkt: string;
srid?: number;
label?: string;
}
export interface OlData {
geometries: OlGeometryData[];
colors: [number, number, number][];
}
class OlVisualization extends GisVisualization {
private olMap: any = undefined;
private data: OlData;
constructor (target: HTMLElement, data: OlData) {
super(target);
this.data = data;
}
getFeaturesFromData (geometries: OlGeometryData[]): Feature[] {
let features: Feature[] = [];
const addFeature = (index: number, data: OlGeometryData, geometry: Geometry) => {
const feature = new Feature(geometry);
feature.set('index', index, true);
if (data.label) {
feature.set('label', data.label, true);
}
features.push(feature);
};
const projections = {};
const reader = new WKT();
for (let i = 0, ii = geometries.length; i < ii; ++i) {
const data = geometries[i];
const dataProjection = 'EPSG:' + (data.srid ?? 4326);
if (!(dataProjection in projections)) {
projections[dataProjection] = getProjection(dataProjection);
}
// Skip features which use an unknown projection
if (!projections[dataProjection]) {
continue;
}
const geometry = reader.readGeometry(data.wkt, {
dataProjection,
featureProjection: 'EPSG:3857',
});
if (geometry.getType() === 'GeometryCollection') {
const geometries = (geometry as GeometryCollection).getGeometriesArrayRecursive();
geometries.forEach((geometry) => {
addFeature(i, data, geometry);
});
} else {
addFeature(i, data, geometry);
}
}
return features;
}
drawOpenLayers () {
if (! document.querySelector('script[src*="js/vendor/openlayers/openlayers.js"]')) {
return undefined;
}
const olCss = 'js/vendor/openlayers/openlayers.css';
if (! document.querySelector('link[rel="stylesheet"][href="' + olCss + '"]')) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = olCss;
document.head.appendChild(link);
}
const styleFactory = {
Point: (color: [number, number, number]) => new Style({
image: new Circle({
radius: 3,
fill: new Fill({ color: 'white' }),
stroke: new Stroke({ width: 2, color }),
}),
text: new Text({
stroke: new Stroke({ width: 2, color: 'white' }),
}),
}),
LineString: (color: [number, number, number]) => new Style({
stroke: new Stroke({ width: 2, color: color }),
text: new Text({
stroke: new Stroke({ width: 2, color: 'white' }),
}),
}),
Polygon: (color: [number, number, number]) => new Style({
fill: new Fill({ color: [...color, 0.8] }),
stroke: new Stroke({ width: .5, color: 'black' }),
text: new Text({
stroke: new Stroke({ width: 2, color: 'white' }),
}),
}),
};
const vectorSource = new VectorSource({
features: this.getFeaturesFromData(this.data.geometries),
});
let currentFeatureIndex: number|undefined;
const colors = this.data.colors;
const cache: Record<string, Style> = {};
const vectorLayer = new VectorLayer({
source: vectorSource,
style: function (feature) {
const index = feature.get('index');
const type = feature.getGeometry().getType().replace('Multi', '');
const selected = index === currentFeatureIndex;
const key = type + (selected ? '+' : '-') + (index % colors.length);
let style = cache[key];
if (!style) {
let color = colors[index % colors.length];
if (selected) {
color = color.map((c) => c * 1.2) as [number, number, number];
}
style = styleFactory[type](color);
cache[key] = style;
}
style.getText().setText(feature.get('label'));
return style;
},
});
const map = new Map({
target: this.target,
layers: [
new Tile({ source: new OSM() }),
vectorLayer,
],
view: new View({ center: [0, 0], zoom: 4 }),
controls: [
new MousePosition({
coordinateFormat: createStringXY(4),
projection: 'EPSG:4326'
}),
new Zoom(),
new Attribution(),
]
});
map.on('pointermove', (evt) => {
const newFeatureIndex = (map.forEachFeatureAtPixel(evt.pixel, (f) => f))?.get('index') as number;
if (newFeatureIndex !== currentFeatureIndex) {
currentFeatureIndex = newFeatureIndex;
vectorLayer.changed();
}
});
const extent = vectorSource.getExtent();
if (! isEmpty(extent)) {
map.getView().fit(extent, { padding: [20, 20, 20, 20] });
}
return map;
}
show () {
super.show();
if (this.olMap) {
this.olMap.updateSize();
} else {
this.olMap = this.drawOpenLayers();
}
}
dispose () {
if (this.olMap) {
// Removes ol.Map's resize listener from window
this.olMap.setTarget(null);
this.olMap = undefined;
}
super.dispose();
}
}
class GisVisualizationController {
private svgVis: SvgVisualization|undefined = undefined;
private olVis: OlVisualization|undefined = undefined;
private boundOnChoiceChange: any;
private olData: OlData;
constructor (olData: OlData) {
this.boundOnChoiceChange = this.onChoiceChange.bind(this);
this.olData = olData;
$(document).on('click', '#useOsmAsBaseLayerSwitch', this.boundOnChoiceChange);
if (! document.querySelector('script[src*="js/vendor/openlayers/openlayers.js"]')) {
$('#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),
this.olData,
);
}
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();
}
}
}
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(
JSON.parse($('#visualization-placeholder').attr('data-ol-data'))
);
}
});