aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/dumbymap.mjs
diff options
context:
space:
mode:
authorHsieh Chin Fan <pham@topo.tw>2024-08-13 23:58:38 +0800
committerHsieh Chin Fan <pham@topo.tw>2024-09-07 17:02:01 +0800
commit15a939d234910016d36d4297ec14de51c96168ce (patch)
tree4b3fe24f842485242949c19e3b2d0c078cae7797 /src/dumbymap.mjs
Initial Commit
Diffstat (limited to 'src/dumbymap.mjs')
-rw-r--r--src/dumbymap.mjs357
1 files changed, 357 insertions, 0 deletions
diff --git a/src/dumbymap.mjs b/src/dumbymap.mjs
new file mode 100644
index 0000000..1cc0f07
--- /dev/null
+++ b/src/dumbymap.mjs
@@ -0,0 +1,357 @@
1// vim:foldmethod
2import MarkdownIt from 'markdown-it'
3import MarkdownItAnchor from 'markdown-it-anchor'
4import MarkdownItFootnote from 'markdown-it-footnote'
5import MarkdownItFrontMatter from 'markdown-it-front-matter'
6import MarkdownItTocDoneRight from 'markdown-it-toc-done-right'
7import LeaderLine from 'leader-line'
8import PlainDraggable from 'plain-draggable'
9import { render, parseConfigsFromText } from 'mapclay'
10
11const observers = new Map()
12
13export const markdown2HTML = async (container, mdContent) => {
14 // Render: Markdown -> HTML {{{
15
16 container.innerHTML = `
17 <div id="map"></div>
18 <div id="markdown"></div>
19 `
20
21 const md = MarkdownIt({ html: true })
22 .use(MarkdownItAnchor, {
23 permalink: MarkdownItAnchor.permalink.linkInsideHeader({ placement: 'before' })
24 })
25 .use(MarkdownItFootnote)
26 .use(MarkdownItFrontMatter)
27 .use(MarkdownItTocDoneRight)
28
29 // FIXME A better way to generate draggable code block
30 md.renderer.rules.draggable_block_open = () => '<div>'
31 md.renderer.rules.draggable_block_close = () => '</div>'
32
33 md.core.ruler.before('block', 'draggable_block', (state) => {
34 state.tokens.push(new state.Token('draggable_block_open', '', 1))
35 })
36
37 // Add close tag for block with more than 2 empty lines
38 md.block.ruler.before('table', 'draggable_block', (state, startLine) => {
39 if (state.src[state.bMarks[startLine - 1]] === '\n' && state.src[state.bMarks[startLine - 2]] === '\n') {
40 state.push('draggable_block_close', '', -1);
41 state.push('draggable_block_open', '', 1);
42 }
43 })
44
45 md.core.ruler.after('block', 'draggable_block', (state) => {
46 state.tokens.push(new state.Token('draggable_block_close', '', -1))
47 })
48
49 const markdown = container.querySelector('#markdown')
50 const contentWithToc = '${toc}\n\n\n' + mdContent
51 markdown.innerHTML = md.render(contentWithToc);
52 markdown.querySelectorAll('*> div:not(:has(nav))')
53 .forEach(b => b.classList.add('draggable-block'))
54
55
56 // TODO Improve it!
57 const docLinks = Array.from(container.querySelectorAll('#markdown a[href^="#"][title^="doc"]'))
58 docLinks.forEach(link => {
59 link.classList.add('with-leader-line', 'doclink')
60 link.lines = []
61
62 link.onmouseover = () => {
63 const target = document.querySelector(link.getAttribute('href'))
64 if (!target?.checkVisibility()) return
65
66 const line = new LeaderLine({
67 start: link,
68 end: target,
69 hide: true,
70 path: "magnet"
71 })
72 link.lines.push(line)
73 line.show('draw', { duration: 300, })
74 }
75 link.onmouseout = () => {
76 link.lines.forEach(line => line.remove())
77 link.lines.length = 0
78 }
79 })
80 //}}}
81}
82
83export const generateMaps = async (container) => {
84 // LeaderLine {{{
85
86 // Get anchors with "geo:" scheme
87 const markdown = container.querySelector('#markdown')
88 markdown.anchors = []
89
90 // Set focusArea
91 const focusArea = container.querySelector('#map')
92 const mapPlaceholder = document.createElement('div')
93 mapPlaceholder.id = 'mapPlaceholder'
94 focusArea.appendChild(mapPlaceholder)
95
96 // Links points to map by geo schema and id
97 const geoLinks = Array.from(container.querySelectorAll('#markdown a[href^="geo:"]'))
98 .filter(link => {
99 const url = new URL(link.href)
100 const xy = url?.href?.match(/^geo:([0-9.,]+)/)?.at(1)?.split(',')?.reverse()?.map(Number)
101
102 if (!xy || isNaN(xy[0]) || isNaN(xy[1])) return false
103
104 // Geo information in link
105 link.url = url
106 link.xy = xy
107 link.classList.add('with-leader-line', 'geolink')
108 link.targets = link.url.searchParams.get('id')?.split(',') ?? null
109
110 // LeaderLine
111 link.lines = []
112 link.onmouseover = () => addLeaderLines(link)
113 link.onmouseout = () => removeLeaderLines(link)
114 link.onclick = (event) => {
115 event.preventDefault()
116 markdown.anchors
117 .filter(isAnchorPointedBy(link))
118 .forEach(updateMapByMarker(xy))
119 // TODO Just hide leader line and show it again
120 removeLeaderLines(link)
121 }
122
123 return true
124 })
125
126 const isAnchorPointedBy = (link) => (anchor) => {
127 const mapContainer = anchor.closest('.map-container')
128 const isTarget = !link.targets || link.targets.includes(mapContainer.id)
129 return anchor.title === link.url.pathname && isTarget
130 }
131
132 const isAnchorVisible = (anchor) => {
133 const mapContainer = anchor.closest('.map-container')
134 return insideWindow(anchor) && insideParent(anchor, mapContainer)
135 }
136
137 const drawLeaderLine = (link) => (anchor) => {
138 const line = new LeaderLine({
139 start: link,
140 end: anchor,
141 hide: true,
142 middleLabel: link.url.searchParams.get('text'),
143 path: "magnet",
144 })
145 line.show('draw', { duration: 300, })
146 return line
147 }
148
149 const addLeaderLines = (link) => {
150 link.lines = markdown.anchors
151 .filter(isAnchorPointedBy(link))
152 .filter(isAnchorVisible)
153 .map(drawLeaderLine(link))
154 }
155
156 const removeLeaderLines = (link) => {
157 if (!link.lines) return
158 link.lines.forEach(line => line.remove())
159 link.lines = []
160 }
161
162 const updateMapByMarker = (xy) => (marker) => {
163 const renderer = marker.closest('.map-container')?.renderer
164 renderer.updateCamera({ center: xy }, true)
165 }
166
167 const insideWindow = (element) => {
168 const rect = element.getBoundingClientRect()
169 return rect.left > 0 &&
170 rect.right < window.innerWidth + rect.width &&
171 rect.top > 0 &&
172 rect.bottom < window.innerHeight + rect.height
173 }
174
175 const insideParent = (childElement, parentElement) => {
176 const childRect = childElement.getBoundingClientRect();
177 const parentRect = parentElement.getBoundingClientRect();
178 const offset = 20
179
180 return childRect.left > parentRect.left + offset &&
181 childRect.right < parentRect.right - offset &&
182 childRect.top > parentRect.top + offset &&
183 childRect.bottom < parentRect.bottom - offset
184 }
185 //}}}
186 // Render Maps {{{
187
188 const afterEachMapLoaded = (mapContainer) => {
189 mapContainer.querySelectorAll('.marker')
190 .forEach(marker => markdown.anchors.push(marker))
191
192 const focusClickedMap = () => {
193 if (container.getAttribute('data-layout') !== 'none') return
194
195 container.querySelectorAll('.map-container')
196 .forEach(c => c.classList.remove('focus'))
197 mapContainer.classList.add('focus')
198 }
199 mapContainer.onclick = focusClickedMap
200 }
201
202 // Set unique ID for map container
203 const mapIdList = []
204 const assignMapId = (config) => {
205 let mapId = config.id
206 if (!mapId) {
207 mapId = config.use?.split('/')?.at(-1)
208 let counter = 2
209 while (mapIdList.includes(mapId)) {
210 mapId = `${config.use}.${counter}`
211 counter++
212 }
213 config.id = mapId
214 }
215 mapIdList.push(mapId)
216 }
217
218 const markerOptions = geoLinks.map(link => {
219 return {
220 targets: link.targets,
221 xy: link.xy,
222 title: link.url.pathname
223 }
224 })
225
226
227 // Render each code block with "language-map" class
228 const renderTargets = Array.from(container.querySelectorAll('pre:has(.language-map)'))
229 const renderAllTargets = renderTargets.map(async (target) => {
230 // Get text in code block starts with '```map'
231 // BE CAREFUL!!! 0xa0 char is "non-breaking spaces" in HTML text content
232 // replace it by normal space
233 const configText = target.querySelector('.language-map').textContent.replace(/\u00A0/g, '\u0020')
234
235 let configList = []
236 try {
237 configList = parseConfigsFromText(configText).map(result => {
238 assignMapId(result)
239 const markersFromLinks = markerOptions.filter(marker =>
240 !marker.targets || marker.targets.includes(result.id)
241 )
242 Object.assign(result, { markers: markersFromLinks })
243 return result
244 })
245 } catch (err) {
246 console.error('Fail to parse yaml config for element', target, err)
247 }
248
249 // Render maps
250 return render(target, configList)
251 .then(results => {
252 results.forEach((mapByConfig) => {
253 if (mapByConfig.status === 'fulfilled') {
254 afterEachMapLoaded(mapByConfig.value)
255 return mapByConfig.value
256 } else {
257 console.error('Fail to render target element', mapByConfig.value)
258 }
259 })
260 })
261 })
262 const renderInfo = await Promise.all(renderAllTargets).then(() => 'Finish Rendering')
263 console.info(renderInfo)
264
265 //}}}
266 // CSS observer {{{
267 if (!observers.get(container)) {
268 observers.set(container, [])
269 }
270 const obs = observers.get(container)
271 if (obs.length) {
272 obs.forEach(o => o.disconnect())
273 obs.length = 0
274 }
275 // Layout{{{
276
277 // press key to switch layout
278 const layouts = ['none', 'side', 'overlay']
279 container.setAttribute("data-layout", layouts[0])
280 document.onkeydown = (event) => {
281 if (event.key === 'x' && container.querySelector('.map-container')) {
282 let currentLayout = container.getAttribute('data-layout')
283 currentLayout = currentLayout ? currentLayout : 'none'
284 const nextLayout = layouts[(layouts.indexOf(currentLayout) + 1) % layouts.length]
285
286 container.setAttribute("data-layout", nextLayout)
287 }
288 }
289
290 // Add draggable part for blocks
291 markdown.blocks = Array.from(markdown.querySelectorAll('.draggable-block'))
292 markdown.blocks.forEach(block => {
293 const draggablePart = document.createElement('div');
294 draggablePart.classList.add('draggable')
295 draggablePart.textContent = '☰'
296
297 // TODO Better way to close block
298 draggablePart.onmouseup = (e) => {
299 if (e.button === 1) block.style.display = "none";
300 }
301 block.insertBefore(draggablePart, block.firstChild)
302 })
303
304 // observe layout change
305 const layoutObserver = new MutationObserver(() => {
306 const layout = container.getAttribute('data-layout')
307 markdown.blocks.forEach(b => b.style.display = "block")
308
309 if (layout === 'none') {
310 mapPlaceholder.innerHTML = ""
311 const map = focusArea.querySelector('.map-container')
312 // Swap focused map and palceholder in markdown
313 if (map) {
314 mapPlaceholder.parentElement?.replaceChild(map, mapPlaceholder)
315 focusArea.append(mapPlaceholder)
316 }
317 } else {
318 // If paceholder is not set, create one and put map into focusArea
319 if (focusArea.contains(mapPlaceholder)) {
320 const mapContainer = container.querySelector('.map-container.focus') ?? container.querySelector('.map-container')
321 mapPlaceholder.innerHTML = `<div>Placeholder</div>`
322 // TODO
323 // mapPlaceholder.src = map.map.getCanvas().toDataURL()
324 mapContainer.parentElement?.replaceChild(mapPlaceholder, mapContainer)
325 focusArea.appendChild(mapContainer)
326 }
327 }
328
329 if (layout === 'overlay') {
330 markdown.blocks.forEach(block => {
331 block.draggableInstance = new PlainDraggable(block, { handle: block.querySelector('.draggable') })
332 block.draggableInstance.snap = { x: { step: 20 }, y: { step: 20 } }
333 // block.draggableInstance.onDragEnd = () => {
334 // links(block).forEach(link => link.line.position())
335 // }
336 })
337 } else {
338 markdown.blocks.forEach(block => {
339 try {
340 block.style.transform = 'none'
341 block.draggableInstance.remove()
342 } catch (err) {
343 console.warn('Fail to remove draggable instance', err)
344 }
345 })
346 }
347 });
348 layoutObserver.observe(container, {
349 attributes: true,
350 attributeFilter: ["data-layout"],
351 attributeOldValue: true
352 });
353 obs.push(layoutObserver)
354 //}}}
355 //}}}
356 return container
357}