import MarkdownIt from 'markdown-it'
import MarkdownItAnchor from 'markdown-it-anchor'
import MarkdownItFootnote from 'markdown-it-footnote'
import MarkdownItFrontMatter from 'markdown-it-front-matter'
import MarkdownItInjectLinenumbers from 'markdown-it-inject-linenumbers'
import * as mapclay from 'mapclay'
import { onRemove, animateRectTransition, throttle, debounce, shiftByWindow } from './utils'
import { Layout, SideBySide, Overlay, Sticky } from './Layout'
import { GeoLink, DocLink, getMarkersFromMaps } from './Link.mjs'
import * as utils from './dumbyUtils'
import * as menuItem from './MenuItem'
import PlainModal from 'plain-modal'
import proj4 from 'proj4'
import { register, fromEPSGCode } from 'ol/proj/proj4'
/** VAR: CSS Selector for main components */
const mapBlockSelector = 'pre:has(code[class*=map]), .mapclay-container'
const docLinkSelector = 'a[href^="#"][title^="=>"]:not(.doclink)'
const geoLinkSelector = 'a[href^="geo:"]:not(.geolink)'
/** VAR: Default Layouts */
const defaultLayouts = [
new Layout({ name: 'normal' }),
new SideBySide({ name: 'side-by-side' }),
new Overlay({ name: 'overlay' }),
new Sticky({ name: 'sticky' }),
]
/** VAR: Cache across every dumbymap generation */
const mapCache = {}
/**
* Converts Markdown content to HTML and prepares it for DumbyMap rendering
*
* @param {HTMLElement} container - Target Element to include generated HTML contents
* @param {string} mdContent - Texts in Markdown format
* @returns {Object} An object representing the DumbyMap instance
*/
export const markdown2HTML = (container, mdContent) => {
/** Prepare Elements for Container */
container.querySelector('.SemanticHtml')?.remove()
/** Prepare MarkdownIt Instance */
const md = MarkdownIt({
html: true,
breaks: true,
linkify: true,
})
.use(MarkdownItAnchor, {
permalink: MarkdownItAnchor.permalink.linkInsideHeader({
placement: 'before',
}),
})
.use(MarkdownItFootnote)
.use(MarkdownItFrontMatter)
.use(MarkdownItInjectLinenumbers)
/** Custom rule for Blocks in DumbyMap */
// FIXME A better way to generate blocks
md.renderer.rules.dumby_block_open = () => '<article class="dumby-block">'
md.renderer.rules.dumby_block_close = () => '</article>'
md.core.ruler.before('block', 'dumby_block', state => {
state.tokens.push(new state.Token('dumby_block_open', '', 1))
})
// Add close tag for block with more than 2 empty lines
md.block.ruler.before('table', 'dumby_block', (state, startLine) => {
if (
state.src[state.bMarks[startLine - 1]] === '\n' &&
state.src[state.bMarks[startLine - 2]] === '\n' &&
state.tokens.at(-1).type !== 'list_item_open' // Quick hack for not adding tag after "::marker" for <li>
) {
state.push('dumby_block_close', '', -1)
state.push('dumby_block_open', '', 1)
}
})
md.core.ruler.after('block', 'dumby_block', state => {
state.tokens.push(new state.Token('dumby_block_close', '', -1))
})
/** Render HTML */
const htmlHolder = document.createElement('div')
htmlHolder.className = 'SemanticHtml'
htmlHolder.innerHTML = md.render(mdContent)
container.appendChild(htmlHolder)
return container
}
/**
* updateAttributeByStep.
* @description Update data attribute by steps of map render
* @param {Object} - renderer which is running steps
*/
const updateAttributeByStep = ({ results, target, steps }) => {
let passNum = results.filter(
r => r.type === 'render' && r.state.match(/success|skip/),
).length
const total = steps.length
passNum += `/${total}`
const final = results.filter(r => r.type === 'render').length === total
// FIXME HACK use MutationObserver for animation
if (!target.animations) target.animations = Promise.resolve()
target.animations = target.animations.then(async () => {
await new Promise(resolve => setTimeout(resolve, 100))
if (final) passNum += '\x20'
target.dataset.report = passNum
if (final) setTimeout(() => delete target.dataset.report, 100)
})
}
/** Get default render method by converter */
const defaultRender = mapclay.renderWith(config => ({
use: config.use ?? 'Leaflet',
width: '100%',
...config,
aliases: {
...mapclay.defaultAliases,
...(config.aliases ?? {}),
},
stepCallback: updateAttributeByStep,
}))
/**
* Generates maps based on the provided configuration
*
* @param {HTMLElement} container - The container element for the maps
* @param {Object} options
* @param {String} options.contentSelector - CSS selector for Semantic HTML
* @param {string} options.crs - CRS in EPSG/ESRI code, see epsg.io
* @param {string} options.initialLayout
* @param {number} [options.delay=1000] mapDelay - Delay before rendering maps (in milliseconds)
* @param {Function} options.render - Render function for maps
* @param {Function} options.renderCallback - Callback function to be called after map rendering
* @param {String | null} options.defaultApply
*/
export const generateMaps = (container, {
contentSelector,
crs = 'EPSG:4326',
initialLayout,
layouts = [],
mapDelay = 1000,
render = defaultRender,
renderCallback = () => null,
defaultApply = 'https://outdoorsafetylab.github.io/dumbymap/assets/default.yml',
} = {}) => {
/** Prepare: Contaner */
if (container.classList.contains('Dumby')) return
container.classList.add('Dumby')
delete container.dataset.layout
container.dataset.crs = crs
container.dataset.layout = initialLayout ?? defaultLayouts.at(0).name
register(proj4)
/** Prepare: Semantic HTML part and blocks of contents inside */
const htmlHolder = container.querySelector(contentSelector) ??
container.querySelector('.SemanticHtml, main, :scope > article') ??
Array.from(container.children).find(e => e.id?.match(/main|content/) || e.className?.match?.(/main|content/)) ??
Array.from(container.children).sort((a, b) => a.textContent.length < b.textContent.length).at(0)
htmlHolder.classList.add('SemanticHtml')
/** Prepare: Showcase */
const showcase = document.createElement('div')
container.appendChild(showcase)
showcase.classList.add('Showcase')
/** Prepare: Other Variables */
const modalContent = document.createElement('div')
container.appendChild(modalContent)
const modal = new PlainModal(modalContent)
/** VAR: dumbymap Object */
const dumbymap = {
layouts: [...defaultLayouts, ...layouts.map(l => typeof l === 'object' ? l : { name: l })],
container,
get htmlHolder () { return container.querySelector('.SemanticHtml') },
get showcase () { return container.querySelector('.Showcase') },
get blocks () { return Array.from(container.querySelectorAll('.dumby-block')) },
modal,
modalContent,
aliases: {},
utils: {
...utils,
renderedMaps: () =>
Array.from(
container.querySelectorAll('.mapclay[data-render=fulfilled]'),
).sort((a, b) => a.style.order > b.style.order),
setContextMenu: (menuCallback) => {
const originalCallback = container.oncontextmenu
container.oncontextmenu = (e) => {
const menu = originalCallback(e)
if (!menu) return
menuCallback(e, menu)
menu.style.transform = ''
shiftByWindow(menu)
}
},
focusNextMap: throttle(utils.focusNextMap, utils.focusDelay),
switchToNextLayout: throttle(utils.switchToNextLayout, 300),
},
}
Object.entries(dumbymap.utils).forEach(([util, value]) => {
if (typeof value === 'function') {
dumbymap.utils[util] = value.bind(dumbymap)
}
})
/** WATCH: text content of Semantic HTML */
new window.MutationObserver((mutations) => {
for (const mutation of mutations) {
const node = mutation.target
if (node.matches?.('.mapclay') || node.closest?.('.mapclay')) return
// Add GeoLinks from plain texts
utils.addGeoSchemeByText(node)
// Render Map
const mapTarget = node.parentElement?.closest(mapBlockSelector)
if (mapTarget) {
renderMap(mapTarget)
}
}
}).observe(container, {
characterData: true,
subtree: true,
})
/** WATCH: children of Semantic HTML */
new window.MutationObserver((mutations) => {
for (const mutation of mutations) {
const target = mutation.target
if (target.matches?.('.mapclay') || target.closest?.('.mapclay')) continue
// In case observer triggered by data attribute
if (mutation.type === 'attribute') {
delete target.dataset.initDumby
}
// Update dumby block
const dumbyBlockChanges = target.querySelectorAll('.dumby-block')
if (dumbyBlockChanges) {
const blocks = container.querySelectorAll('.dumby-block')
blocks.forEach(b => {
b.dataset.total = blocks.length
})
}
// Add GeoLinks/DocLinks by pattern
target.querySelectorAll(geoLinkSelector)
.forEach(GeoLink)
target.querySelectorAll(docLinkSelector)
.forEach(DocLink)
// Add GeoLinks from text nodes
// const addedNodes = Array.from(mutation.addedNodes)
if (mutation.type === 'attributes') {
addGeoLinksByText(target)
}
// Render code blocks for maps
const mapTargets = [
...target.querySelectorAll(mapBlockSelector),
target.closest(mapBlockSelector),
].filter(t => t)
mapTargets.forEach(renderMap)
}
}).observe(container, {
attributes: true,
attributeFilter: ['data-init-dumby'],
childList: true,
subtree: true,
})
container.dataset.initDumby = 'true'
/** WATCH: Layout changes */
new window.MutationObserver(mutations => {
const mutation = mutations.at(-1)
const oldLayout = mutation.oldValue
const newLayout = container.dataset.layout
// Apply handler for leaving/entering layouts
if (oldLayout) {
dumbymap.layouts
.find(l => l.name === oldLayout)
?.leaveHandler?.call(this, dumbymap)
}
Object.values(dumbymap)
.filter(ele => ele instanceof window.HTMLElement)
.forEach(ele => { ele.style.cssText = '' })
if (newLayout) {
dumbymap.layouts
.find(l => l.name === newLayout)
?.enterHandler?.call(this, dumbymap)
}
// Since layout change may show/hide showcase, the current focused map may need to go into/outside showcase
// Reset attribute triggers MutationObserver which is observing it
const focusMap =
container.querySelector('.mapclay.focus') ??
container.querySelector('.mapclay')
focusMap?.classList?.add('focus')
}).observe(container, {
attributes: true,
attributeFilter: ['data-layout'],
attributeOldValue: true,
})
/**
* LINKS: addGeoLinksByText.
*
* @param {Node} node
*/
function addGeoLinksByText (node) {
const addGeoScheme = utils.addGeoSchemeByText(node)
const crsString = container.dataset.crs
Promise.all([fromEPSGCode(crsString), addGeoScheme]).then((values) => {
values.at(-1)
.map(utils.updateGeoSchemeByCRS(crsString))
.filter(link => link)
.forEach(GeoLink)
})
}
/**
* MAP: mapFocusObserver. observe for map focus
* @return {MutationObserver} observer
*/
const mapClassObserver = () =>
new window.MutationObserver(mutations => {
const mutation = mutations.at(-1)
const target = mutation.target
const focus = target.classList.contains('focus')
const shouldBeInShowcase = focus && showcase.checkVisibility()
if (focus) {
dumbymap.utils
.renderedMaps()
.filter(map => map.id !== target.id)
.forEach(map => map.classList.remove('focus'))
if (target.classList.contains('focus-manual')) {
setTimeout(
() => target.classList.remove('focus-manual'),
2000,
)
}
}
if (shouldBeInShowcase) {
if (showcase.contains(target)) return
// Placeholder for map in Showcase, it should has the same DOMRect
const placeholder = target.cloneNode(true)
delete placeholder.id
placeholder.className = ''
const parent = target.parentElement
parent.replaceChild(placeholder, target)
onRemove(placeholder, () => {
parent.appendChild(target)
})
// FIXME Maybe use @start-style for CSS
// Trigger CSS transition, if placeholde is the olny child element in block,
// reduce its height to zero.
// To make sure the original height of placeholder is applied, DOM changes seems needed
// then set data-attribute for CSS selector to change height to 0
placeholder.getBoundingClientRect()
placeholder.dataset.placeholder = target.id
// To fit showcase, remove all inline style
target.style.cssText = ''
target.style.order = placeholder.style.order
showcase.appendChild(target)
// Resume rect from Semantic HTML to Showcase, with animation
animateRectTransition(target, placeholder.getBoundingClientRect(), {
duration: 300,
resume: true,
})
} else if (showcase.contains(target)) {
// Check placeholder is inside Semantic HTML
const placeholder = dumbymap.htmlHolder.querySelector(
`[data-placeholder="${target.id}"]`,
)
if (!placeholder) { throw Error(`Cannot find placeholder for map "${target.id}"`) }
// Consider animation may fail, write callback
const afterAnimation = () => {
target.style = placeholder.style.cssText
placeholder.remove()
}
// animation from Showcase to placeholder
animateRectTransition(target, placeholder.getBoundingClientRect(), {
duration: 300,
}).finished.finally(afterAnimation)
}
})
/**
* MAP: afterMapRendered. callback of each map rendered
*
* @param {Object} renderer
*/
const afterMapRendered = renderer => {
const mapElement = renderer.target
// FIXME
mapElement.renderer = renderer
// Make map not focusable by tab key
mapElement.tabindex = -1
// Cache if render is fulfilled
if (mapElement.dataset.render === 'fulfilled') {
mapCache[mapElement.id] = renderer
} else {
return
}
// Simple callback by caller
renderCallback?.(renderer)
// Watch change of class
const observer = mapClassObserver()
observer.observe(mapElement, {
attributes: true,
attributeFilter: ['class'],
attributeOldValue: true,
})
// Focus current map is no map is focused
if (
!dumbymap.utils.renderedMaps().find(map => map.classList.contains('focus')) ||
container.querySelectorAll('.mapclay.focus').length === 0
) {
mapElement.classList.add('focus')
}
}
// Set unique ID for map container
function assignMapId (config) {
const mapIdList = Array.from(document.querySelectorAll('.mapclay'))
.map(map => map.id)
.filter(id => id)
let mapId = config.id?.replaceAll('\x20', '_')
if (!mapId) {
mapId = config.use?.split('/')?.at(-1)
let counter = 1
while (!mapId || mapIdList.includes(mapId)) {
mapId = `${config.use ?? 'unnamed'}-${counter}`
counter++
}
}
config.id = mapId
mapIdList.push(mapId)
return config
}
//
// if (autoMap && elementsWithMapConfig.length === 0) {
// const mapContainer = document.createElement('pre')
// mapContainer.className = 'mapclay-container'
// mapContainer.textContent = '#Created by DumbyMap'
// mapContainer.style.cssText = 'display: none;'
// htmlHolder.insertBefore(mapContainer, htmlHolder.firstElementChild)
// elementsWithMapConfig.push(mapContainer)
// }
//
/**
* MAP: Render each taget element for maps by text content in YAML
*
* @param {HTMLElement} target
*/
function renderMap (target) {
if (!target.isConnected) return
target.classList.add('map-container')
// Get text in code block starts with markdown text '```map'
const configText = target
.textContent // BE CAREFUL!!! 0xa0 char is "non-breaking spaces" in HTML text content
// replace it by normal space
.replace(/\u00A0/g, '\u0020')
let configList = []
try {
configList = mapclay.parseConfigsFromYaml(configText).map(assignMapId)
} catch (_) {
console.warn('Fail to parse yaml config for element', target)
return
}
// If map in cache has the same ID, just put it into target
// So user won't feel anything changes when editing markdown
configList.forEach(config => {
const cachedRenderer = mapCache[config.id]
if (!cachedRenderer) return
target.appendChild(cachedRenderer.target)
config.target = cachedRenderer.target
})
// trivial: if map cache is applied, do not show yaml text
if (target.querySelector('.mapclay')) {
target
.querySelectorAll(':scope > :not([data-render=fulfilled])')
.forEach(e => e.remove())
}
if (!target.renderMap || target.dataset.render === 'no-delay') {
target.renderMap = debounce(
(configList) => {
// Render maps
render(target, configList).forEach(renderPromise => {
renderPromise.then(afterMapRendered)
})
Array.from(target.children).forEach(e => {
if (e.dataset.render === 'fulfilled') {
afterMapRendered(e.renderer)
}
})
}, target.dataset.render === 'no-delay' ? 0 : mapDelay,
)
}
target.renderMap(configList)
}
/** MENU: Menu Items for Context Menu */
container.oncontextmenu = e => {
container.querySelectorAll('.dumby-menu').forEach(m => m.remove())
const map = e.target.closest('.mapclay')
const block = e.target.closest('.dumby-block')
const linkWithLine = e.target.closest('.with-leader-line')
if (!block && !map && !linkWithLine) return
e.preventDefault()
/** Add HTMLElement for menu */
const menu = document.createElement('div')
menu.classList.add('menu', 'dumby-menu')
menu.onclick = (e) => {
if (e.target.closest('.keep-menu')) return
menu.remove()
}
container.appendChild(menu)
const containerRect = container.getBoundingClientRect()
new window.MutationObserver(() => {
menu.style.display = 'block'
menu.style.left = (e.pageX - containerRect.left + 10) + 'px'
menu.style.top = (e.pageY - containerRect.top + 5) + 'px'
clearTimeout(menu.timer)
}).observe(menu, { childList: true })
menu.timer = setTimeout(() => menu.remove(), 100)
/** Menu Item for editing map */
const mapEditor = e.target.closest('.edit-map')
if (mapEditor) {
menu.appendChild(menuItem.Item({
text: 'Finish Editig',
onclick: () => mapEditor.blur(),
}))
return
}
/** Menu Items for Links */
const geoLink = e.target.closest('.geolink')
if (geoLink) {
if (geoLink.classList.contains('from-text')) {
menu.appendChild(menuItem.Item({
innerHTML: '<strong style="color: red;">DELETE</strong>',
onclick: () => {
getMarkersFromMaps(geoLink)
.forEach(m => m.remove())
geoLink.replaceWith(
document.createTextNode(geoLink.textContent),
)
},
}))
}
menu.appendChild(menuItem.setGeoLinkType(geoLink))
}
if (linkWithLine) {
menu.appendChild(menuItem.setLeaderLineType(linkWithLine))
return
}
/** Menu Items for map */
if (map) {
const rect = map.getBoundingClientRect()
const [x, y] = [e.x - rect.left, e.y - rect.top]
menu.appendChild(menuItem.simplePlaceholder(`MAP ID: ${map.id}`))
menu.appendChild(menuItem.editMap(map, dumbymap))
menu.appendChild(menuItem.renderResults(dumbymap, map))
if (map.dataset.render === 'fulfilled') {
menu.appendChild(menuItem.toggleMapFocus(map))
menu.appendChild(menuItem.Folder({
text: 'Actions',
items: [
menuItem.getCoordinatesByPixels(map, [x, y]),
menuItem.restoreCamera(map),
menuItem.addMarker({
point: [e.pageX, e.pageY],
map,
}),
],
}))
}
} else {
/** Toggle block focus */
if (block) {
menu.appendChild(menuItem.toggleBlockFocus(block))
}
}
/** Menu Items for picking map/block/layout */
if (!map || map.closest('.Showcase')) {
if (dumbymap.utils.renderedMaps().length > 0) {
menu.appendChild(menuItem.pickMapItem(dumbymap))
}
menu.appendChild(menuItem.pickBlockItem(dumbymap))
menu.appendChild(menuItem.pickLayoutItem(dumbymap))
}
shiftByWindow(menu)
return menu
}
/** MENU: Event Handler when clicking outside of Context Manu */
const actionOutsideMenu = e => {
const menu = container.querySelector('.dumby-menu')
if (!menu) return
const keepMenu = e.target.closest('.keep-menu') || e.target.classList.contains('.keep-menu')
if (keepMenu) return
const rect = menu.getBoundingClientRect()
if (
e.clientX < rect.left ||
e.clientX > rect.left + rect.width ||
e.clientY < rect.top ||
e.clientY > rect.top + rect.height
) {
menu.remove()
}
}
document.addEventListener('click', actionOutsideMenu)
onRemove(container, () =>
document.removeEventListener('click', actionOutsideMenu),
)
/** MOUSE: Drag/Drop on map for new GeoLink */
container.ondragstart = () => false
container.onmousedown = (e) => {
// Check should start drag event for GeoLink
const selection = document.getSelection()
if (e.which !== 1 || selection.type !== 'Range') return
// Check if click is inside selection
const range = selection.getRangeAt(0)
const rect = range.getBoundingClientRect()
const mouseInRange = e.clientX < rect.right && e.clientX > rect.left && e.clientY < rect.bottom && e.clientY > rect.top
if (!mouseInRange) return
const pointByArrow = document.createElement('div')
pointByArrow.className = 'point-by-arrow'
container.appendChild(pointByArrow)
const timer = setTimeout(() => {
utils.addGeoLinkByDrag(container, range, pointByArrow)
}, 300)
// Update leader-line with mouse move
container.onmousemove = (event) => {
const rect = container.getBoundingClientRect()
pointByArrow.style.left = `${event.clientX - rect.left}px`
pointByArrow.style.top = `${event.clientY - rect.top}px`
// TODO Scroll dumbymap.htmlHolder when cursor is at upper/lower side
}
container.onmousemove(e)
container.onmouseup = () => {
clearTimeout(timer)
pointByArrow.remove()
container.onmouseup = null
container.onmousemove = null
}
}
/** Get default applied config */
if (defaultApply) {
fetch(defaultApply)
.then(res => res.text())
.then(rawText => {
const config = mapclay.parseConfigsFromYaml(rawText)?.at(0)
Object.entries(config.aliases)
.forEach(([option, aliases]) => {
dumbymap.aliases[option] = aliases
})
})
.catch(err => console.warn(`Fail to get aliases from ${defaultApply}`, err))
}
/** Return Object for utils */
return Object.seal(dumbymap)
}