Download visualizer/embed.js from simon-clmtd/universal-dependencies-visualizer: direct link, hf CLI and curl.
- Browser
- Download file 22.6 kB
-
https://huggingface.co/spaces/simon-clmtd/universal-dependencies-visualizer/resolve/main/visualizer/embed.js
- Command line
-
hf download hf://spaces/simon-clmtd/universal-dependencies-visualizer/visualizer/embed.js
-
curl -L -o embed.js https://huggingface.co/spaces/simon-clmtd/universal-dependencies-visualizer/resolve/main/visualizer/embed.js
22.6 kB
| /* Inserted into each isolated srcdoc by template.py. */ | |
| (async function () { | |
| const root = '__RENDERER_ROOT__'; | |
| const status = document.getElementById('status'); | |
| const diagnostics = window.visualizerDiagnostics = { | |
| parserURL: '__PARSER_URL__', stage: 'loading', messages: [] | |
| }; | |
| let manualSize = false; | |
| const frame = window.frameElement; | |
| const handle = document.getElementById('resize-handle'); | |
| function sizePanel(width, height) { | |
| if (!frame) return; | |
| manualSize = true; | |
| const available = frame.parentElement.clientWidth; | |
| frame.style.width = Math.min(available, Math.max(Math.min(320, available), width)) + 'px'; | |
| frame.style.height = Math.max(parseFloat(getComputedStyle(frame).minHeight) || 280, height) + 'px'; | |
| } | |
| let drag = null; | |
| handle.addEventListener('pointerdown', event => { | |
| if (!frame || event.button !== 0) return; | |
| event.preventDefault(); | |
| const box = frame.getBoundingClientRect(); | |
| drag = {x:event.screenX, y:event.screenY, width:box.width, height:box.height}; | |
| handle.setPointerCapture(event.pointerId); | |
| }); | |
| handle.addEventListener('pointermove', event => { | |
| if (drag) sizePanel(drag.width + event.screenX - drag.x, drag.height + event.screenY - drag.y); | |
| }); | |
| handle.addEventListener('pointerup', () => { drag = null; }); | |
| handle.addEventListener('pointercancel', () => { drag = null; }); | |
| handle.addEventListener('keydown', event => { | |
| if (!frame || !['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return; | |
| event.preventDefault(); | |
| const box = frame.getBoundingClientRect(); | |
| sizePanel(box.width + (event.key === 'ArrowRight' ? 20 : event.key === 'ArrowLeft' ? -20 : 0), | |
| box.height + (event.key === 'ArrowDown' ? 20 : event.key === 'ArrowUp' ? -20 : 0)); | |
| }); | |
| handle.addEventListener('dblclick', () => { | |
| manualSize = false; | |
| if (frame) { | |
| frame.style.width = '100%'; | |
| frame.style.height = Math.ceil(document.body.getBoundingClientRect().height + 2) + 'px'; | |
| } | |
| }); | |
| function fail(error) { | |
| diagnostics.stage = 'error'; | |
| diagnostics.error = error.message; | |
| status.textContent = 'Visualization failed: ' + error.message; | |
| document.querySelectorAll('button').forEach(button => button.disabled = true); | |
| } | |
| function loadScript(url) { | |
| return new Promise((resolve, reject) => { | |
| const script = document.createElement('script'); | |
| const timer = setTimeout(() => reject(new Error('Timed out loading ' + url)), 15000); | |
| script.src = url; | |
| script.onload = () => { clearTimeout(timer); resolve(); }; | |
| script.onerror = () => { clearTimeout(timer); reject(new Error('Could not load ' + url)); }; | |
| document.head.appendChild(script); | |
| }); | |
| } | |
| try { | |
| const paths = [ | |
| 'lib/ext/jquery.min.js', 'lib/ext/jquery.svg.min.js', | |
| 'lib/ext/jquery.svgdom.min.js', 'lib/brat/configuration.js', | |
| 'lib/brat/util.js', 'lib/brat/annotation_log.js', 'lib/ext/webfont.js', | |
| 'lib/brat/dispatcher.js', 'lib/brat/url_monitor.js', | |
| 'lib/brat/visualizer.js', 'lib/local/config.js' | |
| ]; | |
| for (const path of paths) await loadScript(root + path); | |
| await loadScript(diagnostics.parserURL); | |
| if (!window.ConllU || !window.Util || !window.Config) { | |
| throw new Error('A required library API is unavailable.'); | |
| } | |
| diagnostics.stage = 'parsing'; | |
| const doc = new window.ConllU.Document(); | |
| const logger = message => diagnostics.messages.push(message); | |
| // This pinned parser flags the second trailing newline of standard | |
| // UDPipe output as an empty sentence. Keep exactly one terminator. | |
| const input = document.getElementById('input').value.replace(/\r\n/g, '\n').replace(/\n+$/, '\n'); | |
| doc.parse(input, logger); | |
| const data = doc.toBrat(logger); | |
| diagnostics.data = data; | |
| if (doc.error || data.error) throw new Error('Invalid CoNLL-U: ' + diagnostics.messages.join('; ')); | |
| if (!data.entities.length) throw new Error('No tokens were produced.'); | |
| // WebFont custom URLs must serve CSS, not the demo's raw TTF URLs. | |
| // Embed the same font bytes in downloaded SVGs for portable PNG export. | |
| const webFontURLs = []; | |
| window.embeddedFontCSS = ''; | |
| for (const [family, filename] of [ | |
| ['PT Sans Caption', 'PT_Sans-Caption-Web-Regular.ttf'], | |
| ['Liberation Sans', 'Liberation_Sans-Regular.ttf'] | |
| ]) { | |
| const response = await fetch(root + 'static/fonts/' + filename, {signal: AbortSignal.timeout(15000)}); | |
| if (!response.ok) throw new Error('Could not load font ' + filename); | |
| const bytes = new Uint8Array(await response.arrayBuffer()); | |
| let binary = ''; | |
| for (let offset = 0; offset < bytes.length; offset += 8192) { | |
| binary += String.fromCharCode(...bytes.subarray(offset, offset + 8192)); | |
| } | |
| const css = '@font-face{font-family:"' + family + '";src:url(data:font/ttf;base64,' + btoa(binary) + ') format("truetype");}'; | |
| window.embeddedFontCSS += css; | |
| webFontURLs.push('data:text/css;charset=utf-8,' + encodeURIComponent(css)); | |
| } | |
| diagnostics.stage = 'rendering'; | |
| const dispatcher = new Dispatcher(); | |
| let commentPopup = document.getElementById('commentpopup'); | |
| if (!commentPopup) { | |
| commentPopup = document.createElement('div'); | |
| commentPopup.id = 'commentpopup'; | |
| document.body.appendChild(commentPopup); | |
| } | |
| let commentTimer = null; | |
| let isCommentVisible = false; | |
| function adjustCommentPosition(evt) { | |
| if (!commentPopup) return; | |
| const popupRect = commentPopup.getBoundingClientRect(); | |
| const offset = 12; | |
| let x = evt.clientX + offset; | |
| let y = evt.clientY + offset; | |
| if (x + popupRect.width > window.innerWidth - 8) { | |
| x = evt.clientX - popupRect.width - offset; | |
| } | |
| if (y + popupRect.height > window.innerHeight - 8) { | |
| y = evt.clientY - popupRect.height - offset; | |
| } | |
| commentPopup.style.left = Math.max(8, x) + 'px'; | |
| commentPopup.style.top = Math.max(8, y) + 'px'; | |
| } | |
| dispatcher.on('displaySpanComment', function(evt, target, spanId, spanType, mods, spanText, commentText, commentType) { | |
| clearTimeout(commentTimer); | |
| let html = '<div class="comment_text">' + Util.escapeHTML(spanText) + '</div>'; | |
| if (spanType) { | |
| html += '<div class="comment_type">' + Util.escapeHTML(spanType) + '</div>'; | |
| } | |
| if (mods && mods.length) { | |
| html += '<div class="comment_mods">'; | |
| mods.forEach(function(mod) { | |
| html += '<div class="comment_mod_item">' + Util.escapeHTML(mod) + '</div>'; | |
| }); | |
| html += '</div>'; | |
| } | |
| if (commentText) { | |
| html += '<div class="comment_notes">' + Util.escapeHTMLwithNewlines(commentText) + '</div>'; | |
| } | |
| commentPopup.innerHTML = html; | |
| commentPopup.style.display = 'block'; | |
| commentPopup.style.opacity = '0'; | |
| adjustCommentPosition(evt); | |
| commentTimer = setTimeout(function() { | |
| commentPopup.style.transition = 'opacity 0.15s ease'; | |
| commentPopup.style.opacity = '1'; | |
| isCommentVisible = true; | |
| }, 100); | |
| }); | |
| dispatcher.on('hideComment', function() { | |
| clearTimeout(commentTimer); | |
| if (isCommentVisible) { | |
| commentPopup.style.transition = 'opacity 0.15s ease'; | |
| commentPopup.style.opacity = '0'; | |
| commentTimer = setTimeout(function() { | |
| commentPopup.style.display = 'none'; | |
| isCommentVisible = false; | |
| }, 150); | |
| } else { | |
| commentPopup.style.display = 'none'; | |
| } | |
| }); | |
| window.addEventListener('mousemove', function(evt) { | |
| if (isCommentVisible) adjustCommentPosition(evt); | |
| }); | |
| await new Promise((resolve, reject) => { | |
| const timer = setTimeout(() => reject(new Error('Tree rendering timed out.')), 15000); | |
| dispatcher.on('doneRendering', function () { | |
| const svg = document.querySelector('#vis svg'); | |
| if (!svg || !svg.querySelector('text') || svg.getBBox().width <= 0) return; | |
| svg.querySelectorAll('.sentnum text').forEach((element, index) => { | |
| if (data.sentlabels[index]) element.textContent = data.sentlabels[index]; | |
| }); | |
| clearTimeout(timer); | |
| resolve(); | |
| }); | |
| dispatcher.on('renderError: Fatal', function () { | |
| clearTimeout(timer); | |
| reject(new Error('The tree renderer reported an error.')); | |
| }); | |
| const labelWidth = Math.max(20, ...data.sentlabels.map(label => label ? label.length * 10 : 0)); | |
| Util.embed('vis', Config.bratCollData, data, webFontURLs, dispatcher, {sentNumMargin: labelWidth}); | |
| }); | |
| await document.fonts.ready; | |
| diagnostics.stage = 'ready'; | |
| status.textContent = ''; | |
| document.getElementById('download-svg').addEventListener('click', downloadSVG); | |
| document.getElementById('download-png').addEventListener('click', downloadPNG); | |
| document.querySelectorAll('button').forEach(button => button.disabled = false); | |
| // SVG paint order determines layering: draw arcs before POS groups. | |
| for (const arcs of document.querySelectorAll('#vis .arcs')) { | |
| arcs.parentNode.insertBefore(arcs, arcs.parentNode.firstChild); | |
| } | |
| // Match brat's configured POS padding around word backgrounds, | |
| // including temporary backgrounds inserted by its hover handler. | |
| const padding = Configuration.visual.margin; | |
| function padWordBackgrounds() { | |
| document.querySelectorAll('#vis svg > g.highlight > rect').forEach(rect => { | |
| if (rect.dataset.wordPadded && !rect.dataset.wordChunk) return; | |
| const originalX = Number(rect.getAttribute('x')); | |
| const originalY = Number(rect.getAttribute('y')); | |
| const word = Array.from(document.querySelectorAll('#vis tspan[data-chunk-id]')).find(word => { | |
| const box = word.getBBox(); | |
| return rect.dataset.wordChunk ? word.dataset.chunkId === rect.dataset.wordChunk : | |
| Math.abs(box.x - originalX) < 1 && Math.abs(box.y - originalY) < 4; | |
| }); | |
| if (word) rect.dataset.wordChunk = word.dataset.chunkId; | |
| const box = word ? word.getBBox() : { | |
| x:originalX, y:originalY, | |
| width:Number(rect.getAttribute('width')), | |
| height:Number(rect.getAttribute('height')) | |
| }; | |
| const wordWidth = word ? word.getSubStringLength(0, word.textContent.trimEnd().length) : box.width; | |
| rect.setAttribute('x', box.x - padding.x); | |
| rect.setAttribute('y', box.y - padding.y); | |
| rect.setAttribute('width', wordWidth + padding.x * 2); | |
| rect.setAttribute('height', box.height + padding.y * 2); | |
| rect.dataset.wordPadded = 'true'; | |
| }); | |
| } | |
| padWordBackgrounds(); | |
| const wordBackgrounds = document.querySelector('#vis svg > g.highlight'); | |
| if (wordBackgrounds) { | |
| new MutationObserver(padWordBackgrounds).observe(wordBackgrounds, {childList:true}); | |
| } | |
| // Highlight the syntactic head, rather than all connected tokens. | |
| const heads = new Map(); | |
| for (const sentence of doc.sentences) { | |
| for (const element of sentence.elements) { | |
| if (element.isWord() && element.head !== '0' && element.head !== '_') { | |
| heads.set(sentence.id + '-T' + element.id, sentence.id + '-T' + element.head); | |
| } | |
| } | |
| } | |
| const vis = document.getElementById('vis'); | |
| function highlightHead(target) { | |
| vis.querySelectorAll('.hover-head, .hover-head-word').forEach(rect => rect.classList.remove('hover-head', 'hover-head-word')); | |
| if (!(target instanceof Element)) return; | |
| let token = target.closest('.span')?.querySelector('rect[data-span-id]'); | |
| const word = target.closest('tspan[data-chunk-id]'); | |
| if (!token && word) { | |
| const box = word.getBoundingClientRect(); | |
| const center = (box.left + box.right) / 2; | |
| token = Array.from(vis.querySelectorAll('rect[data-span-id]')).filter(rect => { | |
| const pos = rect.getBoundingClientRect(); | |
| return center >= pos.left - 4 && center <= pos.right + 4 && | |
| box.top >= pos.bottom && box.top - pos.bottom < box.height * 3; | |
| }).sort((a, b) => b.getBoundingClientRect().bottom - a.getBoundingClientRect().bottom)[0]; | |
| } | |
| const head = token && heads.get(token.dataset.spanId); | |
| if (head) { | |
| vis.querySelectorAll('rect[data-span-id]').forEach(rect => { | |
| if (rect.dataset.spanId !== head) return; | |
| rect.classList.add('hover-head'); | |
| const pos = rect.getBoundingClientRect(); | |
| const center = (pos.left + pos.right) / 2; | |
| // brat already draws a background behind each word. | |
| // Color that rectangle without altering the text or layout. | |
| vis.querySelectorAll('svg > g.highlight > rect').forEach(background => { | |
| const box = background.getBoundingClientRect(); | |
| if (center >= box.left - 4 && center <= box.right + 4 && | |
| box.top >= pos.bottom && box.top - pos.bottom < box.height * 3) { | |
| background.classList.add('hover-head-word'); | |
| } | |
| }); | |
| }); | |
| } | |
| } | |
| vis.addEventListener('mouseover', event => highlightHead(event.target)); | |
| vis.addEventListener('mouseout', event => highlightHead(event.relatedTarget)); | |
| vis.addEventListener('mouseleave', () => highlightHead(null)); | |
| // Each brat arc segment starts at the label and ends at the token. | |
| // Suppress the label marker when the two marker anchors crowd each | |
| // other (14 native SVG units, independent of the current view zoom). | |
| for (const path of document.querySelectorAll('#vis .arcs path')) { | |
| const style = getComputedStyle(path); | |
| if (style.markerStart === 'none' || style.markerEnd === 'none') continue; | |
| const start = path.getPointAtLength(0); | |
| const end = path.getPointAtLength(path.getTotalLength()); | |
| const gap = Math.hypot(end.x - start.x, end.y - start.y); | |
| if (gap < 14) { | |
| path.style.setProperty('marker-start', 'none', 'important'); | |
| path.style.setProperty('--inactive-marker-start', 'none'); | |
| path.dataset.labelMarkerSuppressed = 'true'; | |
| // brat offsets the label-side start by the marker width. | |
| // Restore that space as a straight segment, leaving one unit | |
| // before the label. Retain the rest of the curve unchanged. | |
| const markerMatch = style.markerStart.match(/#([^"')]+)["']?\)/); | |
| const marker = markerMatch && document.getElementById(markerMatch[1]); | |
| const markerWidth = marker ? marker.markerWidth.baseVal.value : 5; | |
| const next = path.getPointAtLength(Math.min(1, path.getTotalLength())); | |
| const direction = Math.sign(next.x - start.x); | |
| const extension = Math.max(0, markerWidth - 1); | |
| if (direction && extension) { | |
| const original = path.getAttribute('d'); | |
| const initialMove = /^\s*M\s*[-+\d.eE]+[\s,]+[-+\d.eE]+/; | |
| if (initialMove.test(original)) { | |
| const newX = start.x - direction * extension; | |
| path.setAttribute('d', 'M' + newX + ',' + start.y + | |
| ' L' + start.x + ',' + start.y + original.replace(initialMove, '')); | |
| path.dataset.labelLineExtension = String(extension); | |
| path.dataset.originalLabelX = String(start.x); | |
| path.dataset.originalTip = end.x + ',' + end.y; | |
| } | |
| } | |
| } | |
| } | |
| // brat shares markers across arcs. Give inactive arcs gray copies | |
| // rather than recoloring the shared definitions used by active arcs. | |
| const markerCopies = new Map(); | |
| for (const path of document.querySelectorAll('#vis .arcs path')) { | |
| for (const position of ['start', 'end']) { | |
| const property = 'marker-' + position; | |
| const reference = getComputedStyle(path).getPropertyValue(property); | |
| const match = reference.match(/#([^"')]+)["']?\)/); | |
| if (!match) continue; | |
| const original = document.getElementById(match[1]); | |
| if (!original) continue; | |
| let copy = markerCopies.get(original.id); | |
| if (!copy) { | |
| copy = original.cloneNode(true); | |
| copy.id = original.id + '-inactive'; | |
| original.querySelectorAll('path, polygon, polyline, circle, rect').forEach((shape, index) => { | |
| const clone = copy.querySelectorAll('path, polygon, polyline, circle, rect')[index]; | |
| const style = getComputedStyle(shape); | |
| if (style.fill !== 'none') clone.style.fill = '#c4cbd3'; | |
| if (style.stroke !== 'none') clone.style.stroke = '#c4cbd3'; | |
| }); | |
| original.parentNode.appendChild(copy); | |
| markerCopies.set(original.id, copy); | |
| } | |
| path.style.setProperty('--inactive-marker-' + position, 'url(#' + copy.id + ')'); | |
| } | |
| } | |
| // Zoom changes SVG viewport dimensions, preserving its original fonts. | |
| const svg = document.querySelector('#vis svg'); | |
| const width = svg.width.baseVal.value, height = svg.height.baseVal.value + padding.y * 2 + 2; | |
| svg.dataset.nativeWidth = width; | |
| svg.dataset.nativeHeight = height; | |
| // Measure tree marks and words on every rendered row, including wrapped | |
| // lines and later sentences, so zoom and exports retain all text. | |
| const bounds = svg.getBoundingClientRect(); | |
| const marks = Array.from(svg.querySelectorAll('.span rect, .span path, .span text, .arcs path, .arcs text, svg > g.text > text tspan, .sentnum path, .sentnum text')); | |
| const contentWidth = Math.min(width, Math.max(...marks.map(mark => mark.getBoundingClientRect().right - bounds.left)) + 12); | |
| svg.dataset.contentWidth = contentWidth; | |
| svg.dataset.contentHeight = height; | |
| svg.setAttribute('viewBox', '0 0 ' + contentWidth + ' ' + height); | |
| const resize = () => { | |
| if (window.frameElement && !manualSize && !document.fullscreenElement) { | |
| window.frameElement.style.height = Math.ceil(document.body.getBoundingClientRect().height + 2) + 'px'; | |
| } | |
| }; | |
| let zoom = 1.4; | |
| const out = document.getElementById('zoom-out'); | |
| const into = document.getElementById('zoom-in'); | |
| const reset = document.getElementById('zoom-reset'); | |
| function setZoom(value) { | |
| zoom = Math.max(.5, Math.min(3, Math.round(value * 100) / 100)); | |
| svg.style.width = contentWidth * zoom + 'px'; | |
| svg.style.height = height * zoom + 'px'; | |
| padWordBackgrounds(); | |
| diagnostics.zoom = zoom; | |
| out.disabled = zoom <= .5; | |
| into.disabled = zoom >= 3; | |
| reset.disabled = zoom === 1.4; | |
| reset.title = 'Reset zoom to 140% (current: ' + Math.round(zoom * 100) + '%)'; | |
| resize(); | |
| } | |
| out.addEventListener('click', () => setZoom(zoom - .2)); | |
| into.addEventListener('click', () => setZoom(zoom + .2)); | |
| reset.addEventListener('click', () => setZoom(1.4)); | |
| setZoom(1.4); | |
| const toggle = document.getElementById('toggle-view-controls'); | |
| const controls = document.getElementById('zoom-controls'); | |
| toggle.addEventListener('click', () => { | |
| controls.hidden = !controls.hidden; | |
| toggle.setAttribute('aria-expanded', String(!controls.hidden)); | |
| const label = controls.hidden ? 'Show view controls' : 'Hide view controls'; | |
| toggle.setAttribute('aria-label', label); | |
| toggle.title = label; | |
| resize(); | |
| }); | |
| const fullscreen = document.getElementById('fullscreen'); | |
| fullscreen.disabled = !document.fullscreenEnabled; | |
| if (!document.fullscreenEnabled) fullscreen.title = 'Fullscreen unavailable in this embedding'; | |
| fullscreen.addEventListener('click', async () => { | |
| try { | |
| if (document.fullscreenElement) await document.exitFullscreen(); | |
| else await document.getElementById('tree-view').requestFullscreen(); | |
| } catch (error) { status.textContent = 'Fullscreen unavailable: ' + error.message; } | |
| }); | |
| document.addEventListener('fullscreenchange', () => { | |
| const label = document.fullscreenElement ? 'Exit fullscreen' : 'Enter fullscreen'; | |
| fullscreen.setAttribute('aria-label', label); | |
| fullscreen.title = label; | |
| resize(); | |
| }); | |
| resize(); | |
| new ResizeObserver(resize).observe(document.body); | |
| } catch (error) { | |
| fail(error); | |
| } | |
| })(); | |