diff options
Diffstat (limited to 'src/dumbymap.mjs')
-rw-r--r-- | src/dumbymap.mjs | 357 |
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 | ||
2 | import MarkdownIt from 'markdown-it' | ||
3 | import MarkdownItAnchor from 'markdown-it-anchor' | ||
4 | import MarkdownItFootnote from 'markdown-it-footnote' | ||
5 | import MarkdownItFrontMatter from 'markdown-it-front-matter' | ||
6 | import MarkdownItTocDoneRight from 'markdown-it-toc-done-right' | ||
7 | import LeaderLine from 'leader-line' | ||
8 | import PlainDraggable from 'plain-draggable' | ||
9 | import { render, parseConfigsFromText } from 'mapclay' | ||
10 | |||
11 | const observers = new Map() | ||
12 | |||
13 | export 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 | |||
83 | export 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 | } | ||