diff --git a/package.json b/package.json index 7711b153..416ec309 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@geolonia/mbgl-geolonia-control": "^0.4.2", "@geolonia/mbgl-gesture-handling": "^1.0.15", "@mapbox/geojson-extent": "^0.3.2", + "@mapbox/point-geometry": "^0.1.0", "@turf/center": "^6.0.1", "core-js": "^3.6.5", "intersection-observer": "^0.5.1", diff --git a/src/lib/CustomAttributionControl.js b/src/lib/CustomAttributionControl.js new file mode 100644 index 00000000..6f6211c2 --- /dev/null +++ b/src/lib/CustomAttributionControl.js @@ -0,0 +1,317 @@ +import { DOM, bindAll } from './maplibre-util'; + +/** + * This class is a copy of maplibre-gl-js's AttributionControl class and rewrite by shadow DOM. + * https://github.com/maplibre/maplibre-gl-js/blob/main/src/ui/control/attribution_control.ts + */ + +class CustomAttributionControl { + + constructor(options = {}) { + this.options = options; + this._map; + this._compact; + this._container; + this._shadowContainer; + this._innerContainer; + this._compactButton; + this._editLink; + this._attribHTML; + this.styleId; + this.styleOwner; + + bindAll([ + '_toggleAttribution', + '_updateData', + '_updateCompact', + '_updateCompactMinimize', + ], this); + } + + getDefaultPosition() { + return 'bottom-right'; + } + + onAdd(map) { + this._map = map; + this._compact = this.options && this.options.compact; + this._container = DOM.create('div'); + + const shadow = this._container.attachShadow({mode: 'open'}); + + this._shadowContainer = DOM.create('details', 'maplibregl-ctrl maplibregl-ctrl-attrib'); + this._compactButton = DOM.create('summary', 'maplibregl-ctrl-attrib-button', this._shadowContainer); + this._compactButton.addEventListener('click', this._toggleAttribution); + this._setElementTitle(this._compactButton, 'ToggleAttribution'); + this._innerContainer = DOM.create('div', 'maplibregl-ctrl-attrib-inner', this._shadowContainer); + + const style = document.createElement('style'); + style.textContent = ` + .maplibregl-ctrl { + clear: both; + pointer-events: auto; + transform: translate(0); + } + .maplibregl-ctrl-attrib-button:focus,.maplibregl-ctrl-group button:focus { + box-shadow: 0 0 2px 2px #0096ff + } + .maplibregl-ctrl.maplibregl-ctrl-attrib { + background-color: hsla(0,0%,100%,.5); + margin: 0; + padding: 0 5px + } + + @media screen { + .maplibregl-ctrl-attrib.maplibregl-compact { + background-color: #fff; + border-radius: 12px; + box-sizing: content-box; + margin: 10px; + min-height: 20px; + padding: 2px 24px 2px 0; + position: relative + } + + .maplibregl-ctrl-attrib.maplibregl-compact-show { + padding: 2px 28px 2px 8px; + visibility: visible + } + + .maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact-show,.maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact-show { + border-radius: 12px; + padding: 2px 8px 2px 28px + } + + .maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-inner { + display: none + } + + .maplibregl-ctrl-attrib-button { + background-color: hsla(0,0%,100%,.5); + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E"); + border: 0; + border-radius: 12px; + box-sizing: border-box; + cursor: pointer; + display: none; + height: 24px; + outline: none; + position: absolute; + right: 0; + top: 0; + width: 24px + } + + .maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button { + appearance: none; + list-style: none + } + + .maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button::-webkit-details-marker { + display: none + } + + .maplibregl-ctrl-bottom-left .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-top-left .maplibregl-ctrl-attrib-button { + left: 0 + } + + .maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-inner { + display: block + } + + .maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-button { + background-color: rgb(0 0 0/5%) + } + + .maplibregl-ctrl-bottom-right>.maplibregl-ctrl-attrib.maplibregl-compact:after { + bottom: 0; + right: 0 + } + + .maplibregl-ctrl-top-right>.maplibregl-ctrl-attrib.maplibregl-compact:after { + right: 0; + top: 0 + } + + .maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact:after { + left: 0; + top: 0 + } + + .maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact:after { + bottom: 0; + left: 0 + } + } + + @media screen and (-ms-high-contrast:active) { + .maplibregl-ctrl-attrib.maplibregl-compact:after { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd' fill='%23fff'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E") + } + } + + @media screen and (-ms-high-contrast:black-on-white) { + .maplibregl-ctrl-attrib.maplibregl-compact:after { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E") + } + } + + .maplibregl-ctrl-attrib a { + color: rgba(0,0,0,.75); + text-decoration: none; + white-space: nowrap; + } + + .maplibregl-ctrl-attrib a:hover { + color: inherit; + text-decoration: underline + } + + .maplibregl-attrib-empty { + display: none + } + `; + + this._updateAttributions(); + this._updateCompact(); + + this._map.on('styledata', this._updateData); + this._map.on('sourcedata', this._updateData); + this._map.on('terrain', this._updateData); + this._map.on('resize', this._updateCompact); + this._map.on('drag', this._updateCompactMinimize); + + shadow.appendChild(style); + shadow.appendChild(this._shadowContainer); + + return this._container; + } + + onRemove() { + DOM.remove(this._container); + + this._map.off('styledata', this._updateData); + this._map.off('sourcedata', this._updateData); + this._map.off('terrain', this._updateData); + this._map.off('resize', this._updateCompact); + this._map.off('drag', this._updateCompactMinimize); + + this._map = undefined; + this._compact = undefined; + this._attribHTML = undefined; + } + + _setElementTitle(element, title) { + const str = this._map._getUIString(`AttributionControl.${title}`); + element.title = str; + element.setAttribute('aria-label', str); + } + + _toggleAttribution() { + if (this._shadowContainer.classList.contains('maplibregl-compact')) { + if (this._shadowContainer.classList.contains('maplibregl-compact-show')) { + this._shadowContainer.setAttribute('open', ''); + this._shadowContainer.classList.remove('maplibregl-compact-show'); + } else { + this._shadowContainer.classList.add('maplibregl-compact-show'); + this._shadowContainer.removeAttribute('open'); + } + } + } + + _updateData(e) { + if (e && (e.sourceDataType === 'metadata' || e.sourceDataType === 'visibility' || e.dataType === 'style' || e.type === 'terrain')) { + this._updateAttributions(); + } + } + + _updateAttributions() { + if (!this._map.style) return; + let attributions = []; + if (this.options.customAttribution) { + if (Array.isArray(this.options.customAttribution)) { + attributions = attributions.concat( + this.options.customAttribution.map((attribution) => { + if (typeof attribution !== 'string') return ''; + return attribution; + }), + ); + } else if (typeof this.options.customAttribution === 'string') { + attributions.push(this.options.customAttribution); + } + } + + if (this._map.style.stylesheet) { + const stylesheet = this._map.style.stylesheet; + this.styleOwner = stylesheet.owner; + this.styleId = stylesheet.id; + } + + const sourceCaches = this._map.style.sourceCaches; + for (const id in sourceCaches) { + const sourceCache = sourceCaches[id]; + if (sourceCache.used || sourceCache.usedForTerrain) { + const source = sourceCache.getSource(); + if (source.attribution && attributions.indexOf(source.attribution) < 0) { + attributions.push(source.attribution); + } + } + } + + // remove any entries that are whitespace + attributions = attributions.filter((e) => String(e).trim()); + + // remove any entries that are substrings of another entry. + // first sort by length so that substrings come first + attributions.sort((a, b) => a.length - b.length); + attributions = attributions.filter((attrib, i) => { + for (let j = i + 1; j < attributions.length; j++) { + if (attributions[j].indexOf(attrib) >= 0) { return false; } + } + return true; + }); + + // check if attribution string is different to minimize DOM changes + const attribHTML = attributions.join(' | '); + if (attribHTML === this._attribHTML) return; + + this._attribHTML = attribHTML; + + if (attributions.length) { + this._innerContainer.innerHTML = attribHTML; + this._shadowContainer.classList.remove('maplibregl-attrib-empty'); + } else { + this._shadowContainer.classList.add('maplibregl-attrib-empty'); + } + this._updateCompact(); + // remove old DOM node from _editLink + this._editLink = null; + } + + _updateCompact() { + if (this._map.getCanvasContainer().offsetWidth <= 640 || this._compact) { + if (this._compact === false) { + this._shadowContainer.setAttribute('open', ''); + } else if (!this._shadowContainer.classList.contains('maplibregl-compact') && !this._shadowContainer.classList.contains('maplibregl-attrib-empty')) { + this._shadowContainer.setAttribute('open', ''); + this._shadowContainer.classList.add('maplibregl-compact', 'maplibregl-compact-show'); + } + } else { + this._shadowContainer.setAttribute('open', ''); + if (this._shadowContainer.classList.contains('maplibregl-compact')) { + this._shadowContainer.classList.remove('maplibregl-compact', 'maplibregl-compact-show'); + } + } + } + + _updateCompactMinimize() { + if (this._shadowContainer.classList.contains('maplibregl-compact')) { + if (this._shadowContainer.classList.contains('maplibregl-compact-show')) { + this._shadowContainer.classList.remove('maplibregl-compact-show'); + } + } + } + +} + +export default CustomAttributionControl; diff --git a/src/lib/geolonia-map.js b/src/lib/geolonia-map.js index 4c829c72..f33e9836 100644 --- a/src/lib/geolonia-map.js +++ b/src/lib/geolonia-map.js @@ -2,6 +2,7 @@ import 'whatwg-fetch'; import 'promise-polyfill/src/polyfill'; import maplibregl from 'maplibre-gl'; import GeoloniaControl from '@geolonia/mbgl-geolonia-control'; +import CustomAttributionControl from './CustomAttributionControl'; import GestureHandling from '@geolonia/mbgl-gesture-handling'; import parseAtts from './parse-atts'; @@ -128,6 +129,8 @@ export default class GeoloniaMap extends maplibregl.Map { const { position: geoloniaControlPosition } = util.parseControlOption(atts.geoloniaControl); map.addControl(new GeoloniaControl(), geoloniaControlPosition); + map.addControl(new CustomAttributionControl(), 'bottom-right'); + const { enabled: fullscreenControlEnabled, position: fullscreenControlPosition } = util.parseControlOption(atts.fullscreenControl); if (fullscreenControlEnabled) { // IE patch for fullscreen mode diff --git a/src/lib/maplibre-util.js b/src/lib/maplibre-util.js new file mode 100644 index 00000000..890e99e4 --- /dev/null +++ b/src/lib/maplibre-util.js @@ -0,0 +1,125 @@ +import Point from '@mapbox/point-geometry'; + +/** + * This class is a copy of maplibre-gl-js's util DOM class and rewrite it to JavaScript. + * https://github.com/maplibre/maplibre-gl-js/blob/main/src/util/dom.ts + * */ +export class DOM { + static #docStyle = typeof window !== 'undefined' && window.document && window.document.documentElement.style; + + static #userSelect; + + static #selectProp = DOM.testProp(['userSelect', 'MozUserSelect', 'WebkitUserSelect', 'msUserSelect']); + + static #transformProp = DOM.testProp(['transform', 'WebkitTransform']); + + static testProp(props) { + if (!DOM['#docStyle']) return props[0]; + for (let i = 0; i < props.length; i++) { + if (props[i] in DOM['#docStyle']) { + return props[i]; + } + } + return props[0]; + } + + static create(tagName, className, container) { + const el = window.document.createElement(tagName); + if (className !== undefined) el.className = className; + if (container) container.appendChild(el); + return el; + } + + static createNS(namespaceURI, tagName) { + const el = window.document.createElementNS(namespaceURI, tagName); + return el; + } + + static disableDrag() { + if (DOM['#docStyle'] && DOM['#selectProp']) { + DOM['#userSelect'] = DOM['#docStyle'][DOM['#selectProp']]; + DOM['#docStyle'][DOM['#selectProp']] = 'none'; + } + } + + static enableDrag() { + if (DOM['#docStyle'] && DOM['#selectProp']) { + DOM['#docStyle'][DOM['#selectProp']] = DOM['#userSelect']; + } + } + + static setTransform(el, value) { + el.style[DOM['#transformProp']] = value; + } + + static addEventListener(target, type, callback, options) { + if ('passive' in options) { + target.addEventListener(type, callback, options); + } else { + target.addEventListener(type, callback, options.capture); + } + } + + static removeEventListener(target, type, callback, options) { + if ('passive' in options) { + target.removeEventListener(type, callback, options); + } else { + target.removeEventListener(type, callback, options.capture); + } + } + + // Suppress the next click, but only if it's immediate. + static #suppressClickInternal(e) { + e.preventDefault(); + e.stopPropagation(); + window.removeEventListener('click', DOM['#transformProp'], true); + } + + static suppressClick() { + window.addEventListener('click', DOM['#transformProp'], true); + window.setTimeout(() => { + window.removeEventListener('click', DOM['#transformProp'], true); + }, 0); + } + + static mousePos(el, e) { + const rect = el.getBoundingClientRect(); + return new Point( + e.clientX - rect.left - el.clientLeft, + e.clientY - rect.top - el.clientTop, + ); + } + + static touchPos(el, touches) { + const rect = el.getBoundingClientRect(); + const points = []; + for (let i = 0; i < touches.length; i++) { + points.push(new Point( + touches[i].clientX - rect.left - el.clientLeft, + touches[i].clientY - rect.top - el.clientTop, + )); + } + return points; + } + + static mouseButton(e) { + return e.button; + } + + static remove(node) { + if (node.parentNode) { + node.parentNode.removeChild(node); + } + } +} + +/** + * This function is a copy of maplibre-gl-js's util function and rewrite it to JavaScript. + * https://github.com/maplibre/maplibre-gl-js/blob/main/src/util/util.ts#L223-L228 + * */ +export function bindAll(fns, context) { + fns.forEach((fn) => { + if (!context[fn]) { return; } + context[fn] = context[fn].bind(context); + }); +} diff --git a/src/lib/util.js b/src/lib/util.js index c823b9c9..afe5c0f8 100644 --- a/src/lib/util.js +++ b/src/lib/util.js @@ -201,7 +201,7 @@ export function getOptions(container, params, atts) { zoom: parseFloat(atts.zoom), hash: (atts.hash === 'on'), localIdeographFontFamily: 'sans-serif', - attributionControl: true, + attributionControl: false, }; if (atts.minZoom !== '' && (Number(atts.minZoom) === 0 || Number(atts.minZoom))) {