import { isFunction, isNumber, isObject, isString } from '../core/util'; import { createEl, addDomEvent, removeDomEvent, stopPropagation, preventDefault } from '../core/util/dom'; import Coordinate from '../geo/Coordinate'; import Point from '../geo/Point'; import Size from '../geo/Size'; import { Geometry, Marker, MultiPoint, LineString, MultiLineString } from '../geometry'; import type { Map } from '../map'; import { MapEventDataType } from '../map/Map.DomEvents'; import UIComponent, { UIComponentAlignOptionsType, UIComponentOptionsType } from './UIComponent'; const PROPERTY_PATTERN = /\{ *([\w_]+) *\}/g; /** * @property {Object} options * @property {Boolean} [options.autoPan=true] - set it to false if you don't want the map to do panning animation to fit the opened window. * @property {String} [options.autoCloseOn=null] - Auto close infowindow on map's events, e.g. "click contextmenu" will close infowindow with click or right click on map. * @property {String} [options.autoOpenOn='click'] - Auto open infowindow on owner's events, e.g. "click" will open infowindow with click or right click on window's owner. * @property {Number} [options.width=auto] - default width * @property {Number} [options.minHeight=120] - minimun height * @property {Boolean} [options.custom=false] - set it to true if you want a customized infowindow, customized html codes or a HTMLElement is set to content. * @property {String} [options.title=null] - title of the infowindow. * @property {String|HTMLElement} options.content - content of the infowindow. * @property {Boolean} [options.enableTemplate=false] - whether open template . such as content:`homepage:{url},company name:{name}`. * @memberOf ui.InfoWindow * @instance */ const options: InfoWindowOptionsType = { 'containerClass': 'maptalks-msgBox', 'autoPan': true, 'autoCloseOn': null, 'autoOpenOn': 'click', 'width': 'auto', 'minHeight': 120, 'custom': false, 'title': null, 'content': null, 'enableTemplate': false, 'horizontalAlignment': 'middle', 'verticalAlignment': 'top' }; const EMPTY_SIZE = new Size(0, 0); /** * @classdesc * Class for info window, a popup on the map to display any useful infomation you wanted. * @category ui * @extends ui.UIComponent * @param {Object} options - options defined in [InfoWindow]{@link InfoWindow#options} * @memberOf ui */ class InfoWindow extends UIComponent { options: InfoWindowOptionsType; //@internal _onCloseBtnClick: (event: MouseEvent | TouchEvent) => void; // TODO: obtain class in super //@internal _getClassName() { return 'InfoWindow'; } /** * Adds the UI Component to a geometry or a map * @param {Geometry|Map} owner - geometry or map to addto. * @returns {UIComponent} this * @fires UIComponent#add */ addTo(owner: Geometry | Map) { if (owner instanceof Geometry) { if (owner.getInfoWindow() && owner.getInfoWindow() !== this) { owner.removeInfoWindow(); } owner._infoWindow = this; } return super.addTo(owner); } /** * Set the content of the infowindow. * @param {String|HTMLElement} content - content of the infowindow. * return {InfoWindow} this * @fires InfoWindow#contentchange */ setContent(content: string | HTMLElement) { const old = this.options['content']; this.options['content'] = content; /** * contentchange event. * * @event InfoWindow#contentchange * @type {Object} * @property {String} type - contentchange * @property {InfoWindow} target - InfoWindow * @property {String|HTMLElement} old - old content * @property {String|HTMLElement} new - new content */ this.fire('contentchange', { 'old': old, 'new': content }); if (this.isVisible()) { this.show(this._coordinate); } return this; } /** * Get content of the infowindow. * @return {String|HTMLElement} - content of the infowindow */ getContent(): string | HTMLElement { return this.options['content']; } /** * Set the title of the infowindow. * @param {String|HTMLElement} title - title of the infowindow. * return {InfoWindow} this * @fires InfoWindow#titlechange */ setTitle(title: string) { const old = title; this.options['title'] = title; /** * titlechange event. * * @event InfoWindow#titlechange * @type {Object} * @property {String} type - titlechange * @property {InfoWindow} target - InfoWindow * @property {String} old - old content * @property {String} new - new content */ this.fire('contentchange', { 'old': old, 'new': title }); if (this.isVisible()) { this.show(this._coordinate); } return this; } /** * Get title of the infowindow. * @return {String|HTMLElement} - content of the infowindow */ getTitle() { return this.options['title']; } buildOn(): HTMLElement { const isFunc = isFunction(this.options['content']); const isStr = isString(this.options['content']); if (this.options['custom']) { const oldDom = this.getDOM(); let newDom; this._bindDomEvents(oldDom, 'off'); if (isStr || isFunc) { const dom = createEl('div'); if (isStr) { dom.innerHTML = this.options['content'] as string; this._replaceTemplate(dom); } else { //dymatic render dom content (this.options['content'] as any).bind(this)(dom); } newDom = dom; } else { this._replaceTemplate(this.options['content'] as HTMLElement); newDom = this.options['content']; } this._bindDomEvents(newDom, 'on'); this._appendCustomClass(newDom); return newDom; } this._bindDomEvents(this.getDOM(), 'off'); const dom = createEl('div'); if (this.options['containerClass']) { dom.className = this.options['containerClass']; } const width = this._getWindowWidth(); dom.style.width = isNumber(width) ? width + 'px' : 'auto'; dom.style.bottom = '0px'; // fix #657 let content = ''; if (this.options['title']) { content += '

' + this.options['title'] + '

'; } content += '×
'; dom.innerHTML = content; //reslove title this._replaceTemplate(dom); const msgContent = dom.querySelector('.maptalks-msgContent'); if (isStr || isFunc) { if (isStr) { msgContent.innerHTML = this.options['content'] as string; } else { //dymatic render dom content (this.options['content'] as any).bind(this)(msgContent); } } else { msgContent.appendChild(this.options['content'] as HTMLElement); } this._onCloseBtnClick = (event) => { if (!this.options.eventsPropagation) { preventDefault(event); stopPropagation(event); } this.hide(); } const closeBtn = dom.querySelector('.maptalks-close'); addDomEvent(closeBtn as HTMLElement, 'click touchend', this._onCloseBtnClick); //reslove content if (!isFunc) { this._replaceTemplate(msgContent); } this._bindDomEvents(dom, 'on'); this._appendCustomClass(dom); return dom; } //@internal _replaceTemplate(dom: Element) { const geo = this._owner as Geometry; if (this.options['enableTemplate'] && geo && geo.getProperties && dom && dom.innerHTML) { const properties = geo.getProperties() || {}; if (isObject(properties)) { const html = dom.innerHTML; dom.innerHTML = html.replace(PROPERTY_PATTERN, function (str, key) { return properties[key]; }); } } return this; } /** * Gets InfoWindow's transform origin for animation transform * @protected * @return {Point} transform origin */ getTransformOrigin() { const size = this.getSize(); return size.width / 2 + 'px bottom'; } getOffset() { const size = this.getSize(); let offsetX = -size.width / 2, offsetY = size.height / 2; const { horizontalAlignment, verticalAlignment, custom } = this.options; if (!custom) { const dom = this.getDOM(); if (dom) { //Dynamically add classes based on horizontalAlignment/verticalAlignment const icoDom = dom.querySelector('.maptalks-ico'); if (icoDom && icoDom.classList) { const classList = icoDom.classList; const className = `maptalks-ico-${horizontalAlignment}-${verticalAlignment}`; const needRemove = []; classList.forEach(item => { if (item !== className && item.indexOf('maptalks-ico-') > -1) { needRemove.push(item); } }); if (needRemove.length) { needRemove.forEach(item => { classList.remove(item); }) } if (!classList.contains(className)) { classList.add(className); } } } } //cal offsetx/offsety let isTop = false; if (horizontalAlignment === 'left') { offsetX = -size.width; } else if (horizontalAlignment === 'right') { offsetX = 0; } if (verticalAlignment === 'top') { offsetY = 0; isTop = true; } else if (verticalAlignment === 'bottom') { offsetY = size.height; } const o = new Point(offsetX, offsetY); // const o = new Point(-size['width'] / 2, 0); if (!custom) { o._sub(4, isTop ? 12 : 0); } else { o._sub(0, size['height']); } const owner = this.getOwner(); if (owner instanceof Marker || owner instanceof MultiPoint) { let painter, markerSize; if (owner instanceof Marker) { painter = owner._getPainter(); markerSize = owner.getSize(); } else { const children = owner.getGeometries(); if (!children || !children.length) { return o; } painter = children[0]._getPainter(); markerSize = children[0].getSize(); } if (!markerSize) { markerSize = EMPTY_SIZE; } if (painter) { const fixExtent = painter.getFixedExtent(); let translateY = fixExtent.ymin; if (verticalAlignment === 'bottom') { translateY = fixExtent.ymax; } let translateX = 0; if (verticalAlignment === 'middle') { if (horizontalAlignment === 'left') { translateX = -markerSize.width / 2; } if (horizontalAlignment === 'right') { translateX = markerSize.width / 2; } } o._add(fixExtent.xmax - markerSize.width / 2 + translateX, translateY); } else { o._add(0, -markerSize.height); } } return o; } show(coordinate: Coordinate) { if (!this.getMap()) { return this; } if (!this.getMap().options['enableInfoWindow']) { return this; } return super.show(coordinate); } getEvents() { if (!this.options['autoCloseOn']) { return null; } const events = {}; events[this.options['autoCloseOn']] = this.hide; return events; } getOwnerEvents() { const owner = this.getOwner(); if (!this.options['autoOpenOn'] || !owner) { return null; } const events = {}; events[this.options['autoOpenOn']] = this._onAutoOpen; return events; } onRemove() { this._onDomMouseout(); this.onDomRemove(); } onDomRemove() { if (this._onCloseBtnClick) { const dom = this.getDOM(); const closeBtn = dom.childNodes[2]; removeDomEvent(closeBtn as HTMLElement, 'click touchend', this._onCloseBtnClick); delete this._onCloseBtnClick; } } //@internal _onAutoOpen(e: MapEventDataType) { const owner = this.getOwner(); setTimeout(() => { if (owner instanceof Marker || owner instanceof UIComponent) { this.show((owner as Marker).getCoordinates()); } else if (owner instanceof MultiPoint) { this.show(owner.findClosest(e.coordinate)); } else if ((owner instanceof LineString) || (owner instanceof MultiLineString)) { if (this.getMap().getScale() >= 8) { e.coordinate = this._rectifyMouseCoordinte(owner, e.coordinate); } this.show(e.coordinate); } else { this.show(e.coordinate); } }, 1); } //@internal _rectifyMouseCoordinte(owner: Geometry | Map, mouseCoordinate: Coordinate): Coordinate { if (owner instanceof LineString) { return this._rectifyLineStringMouseCoordinate(owner, mouseCoordinate).coordinate; } else if (owner instanceof MultiLineString) { return owner.getGeometries().map(lineString => { return this._rectifyLineStringMouseCoordinate(lineString as LineString, mouseCoordinate); }).sort((a, b) => { return a.dis - b.dis; })[0].coordinate; } // others return mouseCoordinate; } //@internal _rectifyLineStringMouseCoordinate(lineString: LineString, mouseCoordinate: Coordinate) { const map = this.getMap(); const coordinates = lineString.getCoordinates() || []; const glRes = map.getGLRes(); //coordinates to containerpoints const pts = coordinates.map(coordinate => { const renderPoints = map.coordToPointAtRes(coordinate, glRes); const altitude = coordinate.z || 0; return map._pointAtResToContainerPoint(renderPoints, glRes, altitude); }); const mousePt = map.coordToContainerPoint(mouseCoordinate); let minDis = Infinity, coordinateIndex = -1; // Find the point with the shortest distance for (let i = 0, len = pts.length; i < len; i++) { const pt = pts[i]; const dis = mousePt.distanceTo(pt); if (dis < minDis) { minDis = dis; coordinateIndex = i; } } const indexs = [coordinateIndex - 1, coordinateIndex, coordinateIndex + 1].filter(index => { return index >= 0 && index <= pts.length - 1; }); const filterPts = indexs.map(index => { return pts[index]; }); const xys = []; const { width, height } = map.getSize(); //Calculate all pixels in the field of view for (let i = 0, len = filterPts.length - 1; i < len; i++) { const coordinateIndex = i; const pt1 = filterPts[i], pt2 = filterPts[i + 1]; // Vertical line if (pt1.x === pt2.x) { const miny = Math.max(0, Math.min(pt1.y, pt2.y)); const maxy = Math.min(height, Math.max(pt1.y, pt2.y)); for (let y = miny; y <= maxy; y++) { xys.push({ point: new Point(pt1.x, y), coordinateIndex }); } } else { const k = (pt2.y - pt1.y) / (pt2.x - pt1.x); // y-y0=k(x-x0) // y-pt1.y=k(x-pt1.x) const minx = Math.max(0, Math.min(pt1.x, pt2.x)); const maxx = Math.min(width, Math.max(pt1.x, pt2.x)); for (let x = minx; x <= maxx; x++) { const y = k * (x - pt1.x) + pt1.y; xys.push({ point: new Point(x, y), coordinateIndex }); } } } let minPtDis = Infinity, ptIndex = -1, index = -1, containerPoint; // Find the point with the shortest distance for (let i = 0, len = xys.length; i < len; i++) { const { point, coordinateIndex } = xys[i]; const dis = mousePt.distanceTo(point); if (dis < minPtDis) { minPtDis = dis; ptIndex = i; index = coordinateIndex; containerPoint = point; } } if (ptIndex < 0) { return { dis: minPtDis, coordinate: mouseCoordinate }; } // const coordinate = map.containerPointToCoord(containerPoint); const p1 = filterPts[index], p2 = filterPts[index + 1]; const distance = p1.distanceTo(p2); const d = containerPoint.distanceTo(p1); const percent = d / distance; const filterCoordinates = indexs.map(index => { return coordinates[index]; }); const c1 = filterCoordinates[index], c2 = filterCoordinates[index + 1]; const x1 = c1.x, y1 = c1.y, z1 = c1.z || 0; const x2 = c2.x, y2 = c2.y, z2 = c2.z || 0; const dx = x2 - x1, dy = y2 - y1, dz = z2 - z1; const x = x1 + dx * percent, y = y1 + dy * percent, z = z1 + dz * percent; return { dis: minPtDis, coordinate: new Coordinate(x, y, z) }; } //@internal _getWindowWidth() { const defaultWidth = options.width; let width = this.options['width']; if (!width) { width = defaultWidth; } return width; } } InfoWindow.mergeOptions(options); export default InfoWindow; export type InfoWindowOptionsType = { containerClass?: string; autoPan?: boolean; autoCloseOn?: string; autoOpenOn?: string; width?: string; minHeight?: number; custom?: boolean; title?: string; content?: string | HTMLElement; enableTemplate?: boolean; } & UIComponentOptionsType & UIComponentAlignOptionsType;