diff options
| -rw-r--r-- | src/Link.mjs | 366 | ||||
| -rw-r--r-- | src/MenuItem.mjs | 6 | ||||
| -rw-r--r-- | src/dumbyUtils.mjs | 2 | ||||
| -rw-r--r-- | src/dumbymap.mjs | 10 |
4 files changed, 188 insertions, 196 deletions
diff --git a/src/Link.mjs b/src/Link.mjs index 9b58fb3..031b125 100644 --- a/src/Link.mjs +++ b/src/Link.mjs | |||
| @@ -2,212 +2,204 @@ import LeaderLine from 'leader-line' | |||
| 2 | import { insideWindow, insideParent } from './utils' | 2 | import { insideWindow, insideParent } from './utils' |
| 3 | import * as markers from './marker.mjs' | 3 | import * as markers from './marker.mjs' |
| 4 | 4 | ||
| 5 | /** | ||
| 6 | * GeoLink: anchor element with geo scheme and properties about maps | ||
| 7 | * @typedef {Object} GeoLink | ||
| 8 | * @extends HTMLAnchorElement | ||
| 9 | * @property {string[]} targets - ids of target map elements | ||
| 10 | * @property {LeaderLine[]} lines | ||
| 11 | * @property {Object} dataset | ||
| 12 | * @property {string} dataset.lon - longitude string of geo scheme | ||
| 13 | * @property {string} dataset.lat - latitude string of geo scheme | ||
| 14 | * @property {string} dataset.crs - short name of CRS in EPSG/ESRI format | ||
| 15 | */ | ||
| 16 | |||
| 17 | /** | ||
| 18 | * DocLink: anchor element which points to DOM node by filter | ||
| 19 | * @typedef {Object} DocLink | ||
| 20 | * @extends HTMLAnchorElement | ||
| 21 | * @property {LeaderLine[]} lines | ||
| 22 | */ | ||
| 23 | |||
| 5 | /** VAR: pattern for coodinates */ | 24 | /** VAR: pattern for coodinates */ |
| 6 | export const coordPattern = /^geo:([-]?[0-9.]+),([-]?[0-9.]+)/ | 25 | export const coordPattern = /^geo:([-]?[0-9.]+),([-]?[0-9.]+)/ |
| 7 | 26 | ||
| 8 | /** | 27 | /** |
| 9 | * Class: GeoLink - link for maps | 28 | * GeoLink: append GeoLink features onto anchor element |
| 10 | * | 29 | * @param {HTMLAnchorElement} link |
| 11 | * @extends {window.HTMLAnchorElement} | 30 | * @return {GeoLink} |
| 12 | */ | 31 | */ |
| 13 | export class GeoLink extends window.HTMLAnchorElement { | 32 | export const GeoLink = (link) => { |
| 14 | static replaceWith = (link) => | 33 | const url = new URL(link.href) |
| 15 | link.replaceWith(new GeoLink(link)) | 34 | const params = new URLSearchParams(link.search) |
| 16 | 35 | const xyInParams = params.get('xy')?.split(',')?.map(Number) | |
| 17 | /** | 36 | const [lon, lat] = url.href |
| 18 | * Creates a new GeoLink instance | 37 | ?.match(coordPattern) |
| 19 | * | 38 | ?.slice(1) |
| 20 | * @param {HTMLAnchorElement} link | 39 | ?.reverse() |
| 21 | */ | 40 | ?.map(Number) |
| 22 | constructor (link) { | 41 | const xy = xyInParams ?? [lon, lat] |
| 23 | super() | 42 | |
| 24 | this.innerHTML = link.innerHTML | 43 | if (!xy || isNaN(xy[0]) || isNaN(xy[1])) return false |
| 25 | this.href = link.href | 44 | |
| 26 | 45 | // Geo information in link | |
| 27 | const url = new URL(link.href) | 46 | link.dataset.lon = lon |
| 28 | const params = new URLSearchParams(link.search) | 47 | link.dataset.lat = lat |
| 29 | const xyInParams = params.get('xy')?.split(',')?.map(Number) | 48 | link.dataset.crs = params.get('crs') |
| 30 | const [lon, lat] = url.href | 49 | link.classList.add('with-leader-line', 'geolink') |
| 31 | ?.match(coordPattern) | 50 | link.classList.remove('not-geolink') |
| 32 | ?.slice(1) | 51 | // TODO refactor as data attribute |
| 33 | ?.reverse() | 52 | link.title = 'Left-Click to move Camera, Middle-Click to clean anchor' |
| 34 | ?.map(Number) | 53 | link.targets = params.get('id')?.split(',') ?? null |
| 35 | const xy = xyInParams ?? [lon, lat] | 54 | link.lines = [] |
| 36 | |||
| 37 | if (!xy || isNaN(xy[0]) || isNaN(xy[1])) return false | ||
| 38 | |||
| 39 | // Geo information in link | ||
| 40 | this.dataset.lon = lon | ||
| 41 | this.dataset.lat = lat | ||
| 42 | this.dataset.crs = params.get('crs') | ||
| 43 | this.classList.add('with-leader-line', 'geolink') | ||
| 44 | this.classList.remove('not-geolink') | ||
| 45 | // TODO refactor as data attribute | ||
| 46 | this.targets = params.get('id')?.split(',') ?? null | ||
| 47 | this.title = 'Left-Click to move Camera, Middle-Click to clean anchor' | ||
| 48 | this.lines = [] | ||
| 49 | |||
| 50 | // Hover link for LeaderLine | ||
| 51 | this.onmouseover = () => this.getMarkersFromMaps() | ||
| 52 | .filter(isAnchorVisible) | ||
| 53 | .forEach(anchor => { | ||
| 54 | const labelText = new URL(this).searchParams.get('text') ?? this.textContent | ||
| 55 | const line = new LeaderLine({ | ||
| 56 | start: this, | ||
| 57 | end: anchor, | ||
| 58 | hide: true, | ||
| 59 | middleLabel: labelText, | ||
| 60 | path: 'magnet', | ||
| 61 | }) | ||
| 62 | line.show('draw', { duration: 300 }) | ||
| 63 | |||
| 64 | this.lines.push(line) | ||
| 65 | }) | ||
| 66 | 55 | ||
| 67 | this.onmouseout = () => removeLeaderLines(this) | 56 | // Hover link for LeaderLine |
| 68 | 57 | link.onmouseover = () => getMarkersFromMaps(link) | |
| 69 | // Click to move camera | 58 | .filter(isAnchorVisible) |
| 70 | this.onclick = (event) => { | 59 | .forEach(anchor => { |
| 71 | event.preventDefault() | 60 | const labelText = new URL(link).searchParams.get('text') ?? link.textContent |
| 72 | removeLeaderLines(this) | 61 | const line = new LeaderLine({ |
| 73 | this.getMarkersFromMaps().forEach(marker => { | 62 | start: link, |
| 74 | const map = marker.closest('.mapclay') | 63 | end: anchor, |
| 75 | map.scrollIntoView({ behavior: 'smooth' }) | 64 | hide: true, |
| 76 | updateMapCameraByMarker([ | 65 | middleLabel: labelText, |
| 77 | Number(this.dataset.lon), | 66 | path: 'magnet', |
| 78 | Number(this.dataset.lat), | ||
| 79 | ])(marker) | ||
| 80 | }) | 67 | }) |
| 81 | } | 68 | line.show('draw', { duration: 300 }) |
| 82 | 69 | ||
| 83 | // Use middle click to remove markers | 70 | link.lines.push(line) |
| 84 | this.onauxclick = (e) => { | 71 | }) |
| 85 | if (e.which !== 2) return | 72 | |
| 86 | e.preventDefault() | 73 | link.onmouseout = () => removeLeaderLines(link) |
| 87 | removeLeaderLines(this) | 74 | |
| 88 | this.getMarkersFromMaps() | 75 | // Click to move camera |
| 89 | .forEach(marker => marker.remove()) | 76 | link.onclick = (event) => { |
| 90 | } | 77 | event.preventDefault() |
| 78 | removeLeaderLines(link) | ||
| 79 | getMarkersFromMaps(link).forEach(marker => { | ||
| 80 | const map = marker.closest('.mapclay') | ||
| 81 | map.scrollIntoView({ behavior: 'smooth' }) | ||
| 82 | updateMapCameraByMarker([ | ||
| 83 | Number(link.dataset.lon), | ||
| 84 | Number(link.dataset.lat), | ||
| 85 | ])(marker) | ||
| 86 | }) | ||
| 91 | } | 87 | } |
| 92 | 88 | ||
| 93 | /** | 89 | // Use middle click to remove markers |
| 94 | * getMarkersFromMaps. Get marker elements by GeoLink | 90 | link.onauxclick = (e) => { |
| 95 | * | 91 | if (e.which !== 2) return |
| 96 | * @param {HTMLAnchorElement} link | 92 | e.preventDefault() |
| 97 | * @return {HTMLElement[]} markers | 93 | removeLeaderLines(link) |
| 98 | */ | 94 | getMarkersFromMaps(link) |
| 99 | getMarkersFromMaps () { | 95 | .forEach(marker => marker.remove()) |
| 100 | const params = new URLSearchParams(this.search) | ||
| 101 | const maps = Array.from( | ||
| 102 | this.closest('.Dumby') | ||
| 103 | .querySelectorAll('.mapclay[data-render="fulfilled"]'), | ||
| 104 | ) | ||
| 105 | return maps | ||
| 106 | .filter(map => this.targets ? this.targets.includes(map.id) : true) | ||
| 107 | .map(map => { | ||
| 108 | const renderer = map.renderer | ||
| 109 | const lonLat = [Number(this.dataset.lon), Number(this.dataset.lat)] | ||
| 110 | const type = params.get('type') ?? 'pin' | ||
| 111 | const svg = markers[type] | ||
| 112 | const element = document.createElement('div') | ||
| 113 | element.style.cssText = `width: ${svg.size[0]}px; height: ${svg.size[1]}px;` | ||
| 114 | element.innerHTML = svg.html | ||
| 115 | |||
| 116 | const marker = map.querySelector(`.marker[data-xy="${lonLat}"]`) ?? | ||
| 117 | renderer.addMarker({ | ||
| 118 | xy: lonLat, | ||
| 119 | element, | ||
| 120 | type, | ||
| 121 | anchor: svg.anchor, | ||
| 122 | size: svg.size, | ||
| 123 | }) | ||
| 124 | marker.dataset.xy = lonLat | ||
| 125 | marker.title = new URLSearchParams(this.search).get('xy') ?? lonLat | ||
| 126 | const crs = this.dataset.crs | ||
| 127 | if (crs && crs !== 'EPSG:4326') { | ||
| 128 | marker.title += '@' + this.dataset.crs | ||
| 129 | } | ||
| 130 | |||
| 131 | return marker | ||
| 132 | }) | ||
| 133 | } | 96 | } |
| 134 | } | 97 | |
| 135 | if (!window.customElements.get('dumby-geolink')) { | 98 | return link |
| 136 | window.customElements.define('dumby-geolink', GeoLink, { extends: 'a' }) | ||
| 137 | } | 99 | } |
| 138 | 100 | ||
| 139 | /** | 101 | /** |
| 140 | * Class: DocLink - link for DOM | 102 | * GeoLink: getMarkersFromMaps. Get marker elements by GeoLink |
| 141 | * | 103 | * |
| 142 | * @extends {window.HTMLAnchorElement} | 104 | * @param {GeoLink} link |
| 105 | * @return {HTMLElement[]} markers | ||
| 143 | */ | 106 | */ |
| 144 | export class DocLink extends window.HTMLAnchorElement { | 107 | export const getMarkersFromMaps = (link) => { |
| 145 | static replaceWith = (link) => | 108 | const params = new URLSearchParams(link.search) |
| 146 | link.replaceWith(new DocLink(link)) | 109 | const maps = Array.from( |
| 147 | 110 | link.closest('.Dumby') | |
| 148 | /** | 111 | .querySelectorAll('.mapclay[data-render="fulfilled"]'), |
| 149 | * Creates a new DocLink instance | 112 | ) |
| 150 | * | 113 | return maps |
| 151 | * @param {HTMLAnchorElement} link | 114 | .filter(map => link.targets ? link.targets.includes(map.id) : true) |
| 152 | */ | 115 | .map(map => { |
| 153 | constructor (link) { | 116 | const renderer = map.renderer |
| 154 | super() | 117 | const lonLat = [Number(link.dataset.lon), Number(link.dataset.lat)] |
| 155 | this.innerHTML = link.innerHTML | 118 | const type = params.get('type') ?? 'pin' |
| 156 | this.href = link.href | 119 | const svg = markers[type] |
| 157 | 120 | const element = document.createElement('div') | |
| 158 | const label = decodeURIComponent(link.href.split('#')[1]) | 121 | element.style.cssText = `width: ${svg.size[0]}px; height: ${svg.size[1]}px;` |
| 159 | const selector = link.title.split('=>')[1] ?? (label ? '#' + label : null) | 122 | element.innerHTML = svg.html |
| 160 | if (!selector) return false | 123 | |
| 161 | 124 | const marker = map.querySelector(`.marker[data-xy="${lonLat}"]`) ?? | |
| 162 | this.classList.add('with-leader-line', 'doclink') | 125 | renderer.addMarker({ |
| 163 | this.lines = [] | 126 | xy: lonLat, |
| 164 | 127 | element, | |
| 165 | this.onmouseover = () => { | 128 | // FIXME In conten script, leaflet cannot render marker with HTMLElement |
| 166 | const targets = document.querySelectorAll(selector) | 129 | // so pass html string here |
| 167 | 130 | html: svg.html, | |
| 168 | targets.forEach(target => { | 131 | type, |
| 169 | if (!target?.checkVisibility()) return | 132 | anchor: svg.anchor, |
| 170 | 133 | size: svg.size, | |
| 171 | // highlight selected target | ||
| 172 | target.dataset.style = target.style.cssText | ||
| 173 | const rect = target.getBoundingClientRect() | ||
| 174 | const isTiny = rect.width < 100 || rect.height < 100 | ||
| 175 | if (isTiny) { | ||
| 176 | target.style.background = 'lightPink' | ||
| 177 | } else { | ||
| 178 | target.style.outline = 'lightPink 6px dashed' | ||
| 179 | } | ||
| 180 | |||
| 181 | // point to selected target | ||
| 182 | const line = new LeaderLine({ | ||
| 183 | start: this, | ||
| 184 | end: target, | ||
| 185 | middleLabel: LeaderLine.pathLabel({ | ||
| 186 | text: label, | ||
| 187 | fontWeight: 'bold', | ||
| 188 | }), | ||
| 189 | hide: true, | ||
| 190 | path: 'magnet', | ||
| 191 | }) | 134 | }) |
| 192 | this.lines.push(line) | 135 | marker.dataset.xy = lonLat |
| 193 | line.show('draw', { duration: 300 }) | 136 | marker.title = new URLSearchParams(link.search).get('xy') ?? lonLat |
| 194 | }) | 137 | const crs = link.dataset.crs |
| 195 | } | 138 | if (crs && crs !== 'EPSG:4326') { |
| 139 | marker.title += '@' + link.dataset.crs | ||
| 140 | } | ||
| 141 | |||
| 142 | return marker | ||
| 143 | }) | ||
| 144 | } | ||
| 145 | |||
| 146 | /** | ||
| 147 | * DocLink: append DocLink features onto anchor element | ||
| 148 | * @param {HTMLAnchorElement} link | ||
| 149 | * @return {DocLink} | ||
| 150 | */ | ||
| 151 | export const DocLink = (link) => { | ||
| 152 | const label = decodeURIComponent(link.href.split('#')[1]) | ||
| 153 | const selector = link.title.split('=>')[1] ?? (label ? '#' + label : null) | ||
| 154 | if (!selector) return false | ||
| 196 | 155 | ||
| 197 | this.onmouseout = () => { | 156 | link.classList.add('with-leader-line', 'doclink') |
| 198 | removeLeaderLines(this) | 157 | link.lines = [] |
| 199 | 158 | ||
| 200 | // resume targets from highlight | 159 | link.onmouseover = () => { |
| 201 | const targets = document.querySelectorAll(selector) | 160 | const targets = document.querySelectorAll(selector) |
| 202 | targets.forEach(target => { | 161 | |
| 203 | target.style.cssText = target.dataset.style | 162 | targets.forEach(target => { |
| 204 | delete target.dataset.style | 163 | if (!target?.checkVisibility()) return |
| 164 | |||
| 165 | // highlight selected target | ||
| 166 | target.dataset.style = target.style.cssText | ||
| 167 | const rect = target.getBoundingClientRect() | ||
| 168 | const isTiny = rect.width < 100 || rect.height < 100 | ||
| 169 | if (isTiny) { | ||
| 170 | target.style.background = 'lightPink' | ||
| 171 | } else { | ||
| 172 | target.style.outline = 'lightPink 6px dashed' | ||
| 173 | } | ||
| 174 | |||
| 175 | // point to selected target | ||
| 176 | const line = new LeaderLine({ | ||
| 177 | start: link, | ||
| 178 | end: target, | ||
| 179 | middleLabel: LeaderLine.pathLabel({ | ||
| 180 | text: label, | ||
| 181 | fontWeight: 'bold', | ||
| 182 | }), | ||
| 183 | hide: true, | ||
| 184 | path: 'magnet', | ||
| 205 | }) | 185 | }) |
| 206 | } | 186 | link.lines.push(line) |
| 187 | line.show('draw', { duration: 300 }) | ||
| 188 | }) | ||
| 207 | } | 189 | } |
| 208 | } | 190 | |
| 209 | if (!window.customElements.get('dumby-doclink')) { | 191 | link.onmouseout = () => { |
| 210 | window.customElements.define('dumby-doclink', DocLink, { extends: 'a' }) | 192 | removeLeaderLines(link) |
| 193 | |||
| 194 | // resume targets from highlight | ||
| 195 | const targets = document.querySelectorAll(selector) | ||
| 196 | targets.forEach(target => { | ||
| 197 | target.style.cssText = target.dataset.style | ||
| 198 | delete target.dataset.style | ||
| 199 | }) | ||
| 200 | } | ||
| 201 | |||
| 202 | return link | ||
| 211 | } | 203 | } |
| 212 | 204 | ||
| 213 | /** | 205 | /** |
| @@ -234,7 +226,7 @@ const updateMapCameraByMarker = lonLat => marker => { | |||
| 234 | /** | 226 | /** |
| 235 | * removeLeaderLines. clean lines start from link | 227 | * removeLeaderLines. clean lines start from link |
| 236 | * | 228 | * |
| 237 | * @param {HTMLAnchorElement} link | 229 | * @param {GeoLink} link |
| 238 | */ | 230 | */ |
| 239 | export const removeLeaderLines = link => { | 231 | export const removeLeaderLines = link => { |
| 240 | if (!link.lines) return | 232 | if (!link.lines) return |
diff --git a/src/MenuItem.mjs b/src/MenuItem.mjs index 7b1f41e..4769817 100644 --- a/src/MenuItem.mjs +++ b/src/MenuItem.mjs | |||
| @@ -1,6 +1,6 @@ | |||
| 1 | import { shiftByWindow } from './utils.mjs' | 1 | import { shiftByWindow } from './utils.mjs' |
| 2 | /* eslint-disable no-unused-vars */ | 2 | /* eslint-disable no-unused-vars */ |
| 3 | import { GeoLink, removeLeaderLines } from './Link.mjs' | 3 | import { GeoLink, getMarkersFromMaps, removeLeaderLines } from './Link.mjs' |
| 4 | /* eslint-enable */ | 4 | /* eslint-enable */ |
| 5 | import * as markers from './marker.mjs' | 5 | import * as markers from './marker.mjs' |
| 6 | 6 | ||
| @@ -447,9 +447,9 @@ export const setGeoLinkTypeItem = ({ link, type, ...others }) => { | |||
| 447 | params.set('type', type) | 447 | params.set('type', type) |
| 448 | link.search = params | 448 | link.search = params |
| 449 | removeLeaderLines(link) | 449 | removeLeaderLines(link) |
| 450 | link.getMarkersFromMaps() | 450 | getMarkersFromMaps(link) |
| 451 | .forEach(marker => marker.remove()) | 451 | .forEach(marker => marker.remove()) |
| 452 | link.getMarkersFromMaps() | 452 | getMarkersFromMaps(link) |
| 453 | }, | 453 | }, |
| 454 | }) | 454 | }) |
| 455 | } | 455 | } |
diff --git a/src/dumbyUtils.mjs b/src/dumbyUtils.mjs index 0c05973..02cabca 100644 --- a/src/dumbyUtils.mjs +++ b/src/dumbyUtils.mjs | |||
| @@ -238,6 +238,6 @@ export const addGeoLinkByDrag = (container, range, endOfLeaderLine) => { | |||
| 238 | } | 238 | } |
| 239 | 239 | ||
| 240 | link.href = `geo:${marker.dataset.xy.split(',').reverse()}` | 240 | link.href = `geo:${marker.dataset.xy.split(',').reverse()}` |
| 241 | GeoLink.replaceWith(link) | 241 | GeoLink(link) |
| 242 | } | 242 | } |
| 243 | } | 243 | } |
diff --git a/src/dumbymap.mjs b/src/dumbymap.mjs index 3200a05..f1e971b 100644 --- a/src/dumbymap.mjs +++ b/src/dumbymap.mjs | |||
| @@ -6,7 +6,7 @@ import MarkdownItInjectLinenumbers from 'markdown-it-inject-linenumbers' | |||
| 6 | import * as mapclay from 'mapclay' | 6 | import * as mapclay from 'mapclay' |
| 7 | import { onRemove, animateRectTransition, throttle, debounce, shiftByWindow } from './utils' | 7 | import { onRemove, animateRectTransition, throttle, debounce, shiftByWindow } from './utils' |
| 8 | import { Layout, SideBySide, Overlay, Sticky } from './Layout' | 8 | import { Layout, SideBySide, Overlay, Sticky } from './Layout' |
| 9 | import { GeoLink, DocLink } from './Link.mjs' | 9 | import { GeoLink, DocLink, getMarkersFromMaps } from './Link.mjs' |
| 10 | import * as utils from './dumbyUtils' | 10 | import * as utils from './dumbyUtils' |
| 11 | import * as menuItem from './MenuItem' | 11 | import * as menuItem from './MenuItem' |
| 12 | import PlainModal from 'plain-modal' | 12 | import PlainModal from 'plain-modal' |
| @@ -246,9 +246,9 @@ export const generateMaps = (container, { | |||
| 246 | 246 | ||
| 247 | // Add GeoLinks/DocLinks by pattern | 247 | // Add GeoLinks/DocLinks by pattern |
| 248 | target.querySelectorAll(geoLinkSelector) | 248 | target.querySelectorAll(geoLinkSelector) |
| 249 | .forEach(GeoLink.replaceWith) | 249 | .forEach(GeoLink) |
| 250 | target.querySelectorAll(docLinkSelector) | 250 | target.querySelectorAll(docLinkSelector) |
| 251 | .forEach(DocLink.replaceWith) | 251 | .forEach(DocLink) |
| 252 | 252 | ||
| 253 | // Add GeoLinks from text nodes | 253 | // Add GeoLinks from text nodes |
| 254 | // const addedNodes = Array.from(mutation.addedNodes) | 254 | // const addedNodes = Array.from(mutation.addedNodes) |
| @@ -319,7 +319,7 @@ export const generateMaps = (container, { | |||
| 319 | values.at(-1) | 319 | values.at(-1) |
| 320 | .map(utils.setGeoSchemeByCRS(crsString)) | 320 | .map(utils.setGeoSchemeByCRS(crsString)) |
| 321 | .filter(link => link) | 321 | .filter(link => link) |
| 322 | .forEach(GeoLink.replaceWith) | 322 | .forEach(GeoLink) |
| 323 | }) | 323 | }) |
| 324 | } | 324 | } |
| 325 | 325 | ||
| @@ -557,7 +557,7 @@ export const generateMaps = (container, { | |||
| 557 | menu.appendChild(new menuItem.Item({ | 557 | menu.appendChild(new menuItem.Item({ |
| 558 | text: 'Delete', | 558 | text: 'Delete', |
| 559 | onclick: () => { | 559 | onclick: () => { |
| 560 | geoLink.getMarkersFromMaps() | 560 | getMarkersFromMaps(geoLink) |
| 561 | .forEach(m => m.remove()) | 561 | .forEach(m => m.remove()) |
| 562 | geoLink.replaceWith( | 562 | geoLink.replaceWith( |
| 563 | document.createTextNode(geoLink.textContent), | 563 | document.createTextNode(geoLink.textContent), |