/* 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 = '
' + Util.escapeHTML(spanText) + '
'; if (spanType) { html += '
' + Util.escapeHTML(spanType) + '
'; } if (mods && mods.length) { html += '
'; mods.forEach(function(mod) { html += '
' + Util.escapeHTML(mod) + '
'; }); html += '
'; } if (commentText) { html += '
' + Util.escapeHTMLwithNewlines(commentText) + '
'; } 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); } })();