Simon Clematide
Add tree resize handle, pedagogical guides, and UI/comparison enhancements
115fba1
Raw History Blame Contribute Delete
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);
}
})();