| /* Semantic dimensions use the original word vectors, independently of t-SNE. */ |
| (function () { |
| 'use strict'; |
| const assetBase = new URL('../css/fonts/', document.currentScript.src); |
| let embeddedFonts; |
| window.initSemanticDimensions = function (lists, targets) { |
| const panel = document.getElementById('semantic-dimensions'); |
| if (!panel) return; |
| const de = panel.dataset.german === '1'; |
| const tr = (en, german) => de ? german : en; |
| const status = panel.querySelector('.semantic-status'); |
| const controls = panel.querySelector('.semantic-controls'); |
| const plot = panel.querySelector('.semantic-plot'); |
| const details = panel.querySelector('.semantic-details'); |
| const run = panel.querySelector('.semantic-run'); |
| const exportButton = document.createElement('button'); |
| exportButton.type = 'button'; |
| exportButton.className = 'semantic-export'; |
| exportButton.textContent = tr('Export SVG', 'SVG exportieren'); |
| exportButton.disabled = true; |
| run.after(exportButton); |
| let chartDefinitions, lastResult; |
| const layoutControl = document.createElement('label'); |
| layoutControl.className = 'semantic-layout'; |
| layoutControl.hidden = true; |
| layoutControl.append(document.createTextNode(tr('Layout: ', 'Darstellung: '))); |
| const layoutSelect = document.createElement('select'); |
| [['horizontal', tr('Horizontal (45° labels)', 'Horizontal (45°-Beschriftung)')], ['vertical', tr('Vertical (one word per row)', 'Vertikal (ein Wort pro Zeile)')]].forEach(([value, text]) => { |
| const option = document.createElement('option'); option.value = value; option.textContent = text; |
| layoutSelect.append(option); |
| }); |
| layoutSelect.value = 'horizontal'; |
| layoutControl.append(layoutSelect); exportButton.after(layoutControl); |
| layoutSelect.addEventListener('change', () => { syncURL(); if (lastResult) draw(lastResult); }); |
| async function fontCSS() { |
| if (!embeddedFonts) embeddedFonts = Promise.all(['Regular', 'Bold'].map(async (weight, i) => { |
| const response = await fetch(new URL('FiraSans-' + weight + '.woff2', assetBase)); |
| if (!response.ok) throw new Error(tr('Could not load export fonts.', 'Export-Schriften konnten nicht geladen werden.')); |
| const data = await response.blob(); |
| const url = await new Promise((resolve, reject) => { |
| const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(data); |
| }); |
| return '@font-face {font-family: "DeReKoVecs Fira Sans";font-weight:' + (i ? 700 : 400) + ';src:url("' + url + '") format("woff2");}'; |
| })).then(fonts => fonts.join('\n')).catch(error => { embeddedFonts = null; throw error; }); |
| return embeddedFonts; |
| } |
| exportButton.addEventListener('click', async () => { |
| const source = plot.querySelector('svg'); |
| if (!source) return; |
| const clone = source.cloneNode(true); |
| const definitions = chartDefinitions; |
| const properties = ['font-family','font-size','font-weight','font-style','fill','stroke','stroke-width','stroke-dasharray','opacity','text-anchor','shape-rendering']; |
| // Inline computed styles so the file is independent of application CSS. |
| const originals = [source, ...source.querySelectorAll('*')]; |
| [clone, ...clone.querySelectorAll('*')].forEach((node, i) => { |
| const computed = getComputedStyle(originals[i]); |
| properties.forEach(property => node.style.setProperty(property, computed.getPropertyValue(property))); |
| }); |
| exportButton.disabled = true; |
| try { |
| const fonts = await fontCSS(); |
| const ns = 'http://www.w3.org/2000/svg'; |
| clone.setAttribute('xmlns', ns); |
| const box = source.viewBox.baseVal; |
| clone.setAttribute('width', box.width); clone.setAttribute('height', box.height); |
| clone.style.background = 'white'; |
| const style = document.createElementNS(ns, 'style'); style.textContent = fonts; clone.prepend(style); |
| const metadata = document.createElementNS(ns, 'metadata'); |
| metadata.textContent = JSON.stringify({query: targets, dimensions: definitions}); clone.prepend(metadata); |
| clone.querySelectorAll('a').forEach(a => a.replaceWith(...a.childNodes)); |
| const blob = new Blob([new XMLSerializer().serializeToString(clone)], {type:'image/svg+xml;charset=utf-8'}); |
| const url = URL.createObjectURL(blob); |
| const link = document.createElement('a'); link.href = url; link.download = 'derekovecs-semantic-dimensions.svg'; |
| document.body.appendChild(link); link.click(); link.remove(); |
| setTimeout(() => URL.revokeObjectURL(url), 10000); |
| } catch (error) { status.textContent = tr('SVG export failed: ', 'SVG-Export fehlgeschlagen: ') + error.message; } |
| finally { exportButton.disabled = !plot.querySelector('svg'); } |
| }); |
| const endpoint = location.pathname.replace(/\/?$/, '/') + 'semanticDimensions'; |
| let presets, selected, revision = 0, applied = false; |
| const rows = []; |
| const targetSet = new Set((targets || '').split(/\s+/)); |
| const points = []; |
| const seen = new Set(); |
| lists.forEach((list, group) => (list || []).forEach(p => { |
| if (!seen.has(p.rank)) { points.push(Object.assign({group}, p)); seen.add(p.rank); } |
| })); |
| function el(tag, parent, text) { |
| const e = document.createElement(tag); |
| if (text !== undefined) e.textContent = text; |
| parent.appendChild(e); |
| return e; |
| } |
| function syncURL() { |
| if (rows.length !== 3) return; |
| const url = window.semanticDimensionsURL.write(new URL(location.href), rows.map(r => ({choice:r.select.value,name:r.name.value,pairs:r.pairs.value})), layoutSelect.value, presets); |
| history.replaceState(null, '', url); |
| } |
| function changed() { |
| syncURL(); |
| revision++; |
| lastResult = null; |
| exportButton.disabled = true; |
| plot.replaceChildren(); details.replaceChildren(); |
| status.textContent = tr('Settings changed. Apply to update the plot.', 'Einstellungen geändert. Zum Aktualisieren anwenden.'); |
| try { sessionStorage.setItem('semanticDimensions', JSON.stringify(rows.map(r => ({choice:r.select.value, name:r.name.value, pairs:r.pairs.value})))); } catch (_) { /* Storage is optional. */ } |
| } |
| function dimensionRow(role, initial, optional, saved) { |
| const box = el('fieldset', controls); |
| el('legend', box, role); |
| const select = el('select', box); |
| select.setAttribute('aria-label', role); |
| if (optional) el('option', select, tr('None', 'Keine')).value = ''; |
| Object.keys(presets).forEach(name => { el('option', select, name).value = name; }); |
| el('option', select, tr('Custom dimension', 'Eigene Dimension')).value = '__custom'; |
| const name = el('input', box); |
| name.maxLength = 100; |
| name.setAttribute('aria-label', tr('Dimension name', 'Name der Dimension')); |
| const pairs = el('textarea', box); |
| pairs.rows = 5; |
| pairs.setAttribute('aria-label', tr('Antonym pairs', 'Antonympaare')); |
| el('small', box, tr('One pair per line: positive; negative', 'Ein Paar pro Zeile: positiv; negativ')); |
| function load() { |
| name.hidden = pairs.hidden = !select.value; |
| name.disabled = pairs.disabled = !select.value; |
| name.value = select.value === '__custom' ? '' : select.value; |
| pairs.value = presets[select.value] ? presets[select.value].map(p => p.join('; ')).join('\n') : ''; |
| } |
| select.value = saved && [...select.options].some(o => o.value === saved.choice) ? saved.choice : initial; |
| load(); |
| if (saved) { name.value = saved.name; pairs.value = saved.pairs; } |
| select.addEventListener('change', () => { load(); changed(); }); |
| name.addEventListener('input', changed); |
| pairs.addEventListener('input', changed); |
| // Keep the application's global Enter handler from submitting a search. |
| name.addEventListener('keydown', e => e.stopPropagation()); |
| const row = {select, name, pairs}; rows.push(row); |
| return row; |
| } |
| function readDimensions() { |
| return rows.filter(r => r.select.value).map(r => { |
| const pairs = r.pairs.value.split(/\r?\n/).filter(s => s.trim()).map(line => { |
| const pair = line.split(';').map(s => s.trim()); |
| if (pair.length !== 2 || pair.some(w => !w || /[\r\n\x00]/.test(w))) throw new Error(tr('Use two words separated by a semicolon on every line.', 'Je Zeile zwei Wörter, getrennt durch ein Semikolon, eingeben.')); |
| return pair; |
| }); |
| if (!r.name.value.trim() || !pairs.length || pairs.length > 2000) throw new Error(tr('Each dimension needs a name and 1–2000 pairs.', 'Jede Dimension benötigt einen Namen und 1–2000 Paare.')); |
| return {name:r.name.value.trim(), pairs}; |
| }); |
| } |
| function draw(result) { |
| plot.replaceChildren(); details.replaceChildren(); |
| exportButton.disabled = true; |
| const hasY = !!rows[1].select.value; |
| layoutControl.hidden = hasY; |
| const vertical = !hasY && layoutSelect.value === 'vertical'; |
| plot.classList.toggle('semantic-gradient', !hasY && !vertical); |
| plot.classList.toggle('semantic-vertical', vertical); |
| const hasColor = !!rows[2].select.value; |
| const colorIndex = hasY ? 2 : 1; |
| let invalid = false; |
| const needed = new Set(points.map(p => result.mergedEnd && p.rank >= result.mergedEnd ? 1 : 0)); |
| result.models.forEach((dims, model) => { |
| if (!needed.has(model)) return; |
| dims.forEach(d => { |
| const block = el('details', details); |
| el('summary', block, d.name + (result.mergedEnd ? ' [' + (model + 1) + ']' : '') + ': ' + d.used.length + '/' + (d.used.length + d.skipped.length) + tr(' pairs used', ' Paare verwendet')); |
| el('p', block, tr('Used: ', 'Verwendet: ') + d.used.map(p => p.join(' − ')).join(', ')); |
| if (d.skipped.length) el('p', block, tr('Missing from vocabulary: ', 'Nicht im Vokabular: ') + d.skipped.map(p => p.join(' − ')).join(', ')); |
| if (d.error) { |
| invalid = true; |
| el('p', block, d.error === 'no_pairs' ? tr('No complete pair is in this model.', 'Kein vollständiges Paar ist in diesem Modell vorhanden.') : tr('The mean difference is zero; choose different pairs.', 'Die mittlere Differenz ist null; andere Paare wählen.')); |
| block.open = true; |
| } |
| }); |
| }); |
| if (invalid) { status.textContent = tr('Some dimensions cannot be calculated. See pair details below.', 'Einige Dimensionen können nicht berechnet werden. Details siehe unten.'); return; } |
| const projected = points.map(p => { |
| const dims = result.models[result.mergedEnd && p.rank >= result.mergedEnd ? 1 : 0]; |
| const norm = Math.sqrt(p.vector.reduce((s,v) => s+v*v, 0)); |
| return Object.assign({}, p, {scores: dims.map(d => d.vector.reduce((s,v,i) => s+v*p.vector[i], 0)/norm)}); |
| }).filter(p => p.scores.every(Number.isFinite)); |
| if (!projected.length) { status.textContent = tr('No neighbors to plot.', 'Keine Nachbarn zum Darstellen.'); return; } |
| const width = 1000, left = 85, right = 155, top = 45; |
| const rowHeight = 18; |
| const height = hasY ? 680 : vertical ? Math.max(340, projected.length*rowHeight+120) : 480; |
| const bottom = height - 75; |
| if (!hasY) projected.sort((a,b) => b.scores[0]-a.scores[0]); |
| const svg = d3.select(plot).append('svg').attr('viewBox', '0 0 '+width+' '+height).attr('role','img').attr('aria-label', selected.map(d => d.name).join(' / ')); |
| function scale(index, range) { |
| const extent = d3.extent(projected, p => p.scores[index]); |
| const padding = Math.max(0.02, (extent[1]-extent[0])*0.15); |
| return d3.scale.linear().domain([Math.max(-1,Math.min(0,extent[0]-padding)),Math.min(1,Math.max(0,extent[1]+padding))]).range(range).nice(); |
| } |
| const x = scale(0, [left,width-right]); |
| if (vertical) svg.append('g').attr('class','axis').attr('transform','translate(0,'+(top-18)+')').call(d3.svg.axis().scale(x).orient('top').ticks(7)); |
| const y = hasY ? scale(1, [bottom,top]) : null; |
| const colorMax = hasColor ? Math.max(0.01, ...projected.map(p => Math.abs(p.scores[colorIndex]))) : 1; |
| const color = d3.scale.linear().domain([-colorMax,0,colorMax]).range(['#b2182b','#f7f7f7','#2166ac']).clamp(true); |
| const categorical = d3.scale.category10(); |
| svg.append('g').attr('class','axis').attr('transform','translate(0,'+bottom+')').call(d3.svg.axis().scale(x).orient('bottom').ticks(7)); |
| svg.append('text').attr('x',(left+width-right)/2).attr('y',height-25).attr('text-anchor','middle').text(selected[0].name + tr(' (cosine similarity)', ' (Kosinusähnlichkeit)')); |
| svg.append('line').attr('class','semantic-zero').attr('x1',x(0)).attr('x2',x(0)).attr('y1',top).attr('y2',bottom); |
| if (hasY) { |
| svg.append('g').attr('class','axis').attr('transform','translate('+left+',0)').call(d3.svg.axis().scale(y).orient('left').ticks(7)); |
| svg.append('text').attr('transform','translate(22,'+(top+bottom)/2+') rotate(-90)').attr('text-anchor','middle').text(selected[1].name + tr(' (cosine similarity)', ' (Kosinusähnlichkeit)')); |
| svg.append('line').attr('class','semantic-zero').attr('x1',left).attr('x2',width-right).attr('y1',y(0)).attr('y2',y(0)); |
| } |
| const occupied = []; |
| const anchors = []; |
| projected.forEach((p,i) => { |
| const px = x(p.scores[0]); |
| let py = hasY ? y(p.scores[1]) : vertical ? top+i*rowHeight : (top+bottom)/2; |
| if (!hasY && !vertical) { |
| const mid = py; |
| for (let lane = 0; lane < Math.floor((bottom-top-30)/12); lane++) { |
| py = mid + Math.ceil(lane/2)*12*(lane%2 ? 1 : -1); |
| if (!anchors.some(a => Math.abs(a.x-px) < 12 && Math.abs(a.y-py) < 11)) break; |
| } |
| } |
| anchors.push({x:px,y:py}); |
| }); |
| const stems = svg.append('g'), dots = svg.append('g'), labels = svg.append('g'); |
| // Test oriented rectangles rather than their much larger axis-aligned boxes. |
| function intersects(a, b) { |
| for (const polygon of [a,b]) { |
| for (let i=0; i<polygon.length; i++) { |
| const p = polygon[i], q = polygon[(i+1)%polygon.length]; |
| const axis = {x:p.y-q.y, y:q.x-p.x}; |
| const aa = a.map(v => v.x*axis.x+v.y*axis.y), bb = b.map(v => v.x*axis.x+v.y*axis.y); |
| if (Math.max(...aa) <= Math.min(...bb) || Math.max(...bb) <= Math.min(...aa)) return false; |
| } |
| } |
| return true; |
| } |
| const dotBoxes = anchors.map(a => [{x:a.x-7,y:a.y-7},{x:a.x+7,y:a.y-7},{x:a.x+7,y:a.y+7},{x:a.x-7,y:a.y+7}]); |
| const angle = hasY || vertical ? 0 : -45; |
| const radians = angle*Math.PI/180, cos = Math.cos(radians), sin = Math.sin(radians); |
| projected.forEach((p,i) => { |
| const {x:px,y:py} = anchors[i]; |
| const circle = dots.append('circle').attr('cx',px).attr('cy',py).attr('r',4.5) |
| .style('fill',hasColor ? color(p.scores[colorIndex]) : categorical(p.group)).style('stroke','#555'); |
| const params = new URLSearchParams(location.search); params.set('word',p.word); |
| const label = labels.append('a').attr('xlink:href','?'+params+location.hash).append('text') |
| .attr('text-anchor','start').style('font-weight',targetSet.has(p.word) ? 'bold' : 'normal').text(p.word); |
| const title = p.word + '\n' + selected.map((d,j) => d.name+': '+p.scores[j].toFixed(5)).join('\n'); |
| circle.append('title').text(title); |
| label.append('title').text(title); |
| const w = label.node().getComputedTextLength()+4; |
| if (vertical) { |
| // One sorted row per word needs neither displaced labels nor leader lines. |
| const fitsRight = px+9+w < width-right; |
| label.attr('x',px+(fitsRight ? 9 : -9)).attr('y',py+4).attr('text-anchor',fitsRight ? 'start' : 'end'); |
| return; |
| } |
| const shape = [{x:-2,y:-12},{x:w,y:-12},{x:w,y:4},{x:-2,y:4}] |
| .map(v => ({x:v.x*cos-v.y*sin,y:v.x*sin+v.y*cos})); |
| let best, bestCost = Infinity; |
| for (let dy = -330; dy <= 330; dy += 12) { |
| for (const dx of [9,-9-w*cos,30,-30-w*cos,60,-60-w*cos,100,-100-w*cos]) { |
| const lx = px+dx, ly = py+dy; |
| const polygon = shape.map(v => ({x:lx+v.x,y:ly+v.y})); |
| if (polygon.some(v => v.x < left+2 || v.x > width-right || v.y < top || v.y > bottom-12)) continue; |
| // Reserve every point before placing any label, including later points. |
| if (dotBoxes.some(box => intersects(polygon,box))) continue; |
| const overlaps = occupied.reduce((n,box) => n + (intersects(polygon,box) ? 1 : 0), 0); |
| const cost = overlaps*100000 + Math.abs(dx) + Math.abs(dy)*1.2; |
| if (cost < bestCost) { best = {x:lx,y:ly,polygon}; bestCost = cost; } |
| } |
| } |
| if (best) { |
| occupied.push(best.polygon); |
| label.attr('transform','translate('+best.x+','+best.y+') rotate('+angle+')'); |
| // Connect to the closest point on the label rectangle, not through its text. |
| let end, distance = Infinity; |
| best.polygon.forEach((a,j) => { |
| const b = best.polygon[(j+1)%4], vx=b.x-a.x, vy=b.y-a.y; |
| const t=Math.max(0,Math.min(1,((px-a.x)*vx+(py-a.y)*vy)/(vx*vx+vy*vy))); |
| const point={x:a.x+t*vx,y:a.y+t*vy}, d=(point.x-px)**2+(point.y-py)**2; |
| if(d<distance) {end=point;distance=d;} |
| }); |
| stems.append('line').attr('class','semantic-stem').attr('x1',px).attr('y1',py).attr('x2',end.x).attr('y2',end.y); |
| } else { |
| // Extremely dense plots retain the word in the point tooltip. |
| label.remove(); |
| } |
| }); |
| if (hasColor) { |
| const gx = width-100; |
| for(let i=0; i<100; i++) svg.append('rect').attr('x',gx).attr('y',top+i*2).attr('width',16).attr('height',2.1).style('fill',color(colorMax*(1-i/49.5))); |
| svg.append('text').attr('x',gx-15).attr('y',top-15).text(selected[colorIndex].name); |
| [colorMax,0,-colorMax].forEach((v,i) => svg.append('text').attr('x',gx+22).attr('y',top+i*100+4).text(v.toFixed(3))); |
| } |
| exportButton.disabled = false; |
| status.textContent = projected.length + tr(' words. Positive scores point toward the first word in each pair.', ' Wörter. Positive Werte weisen zum ersten Wort jedes Paares.'); |
| if (vertical) status.textContent += tr(' Rows are ordered by score; vertical spacing has no semantic meaning.', ' Die Zeilen sind nach dem Wert sortiert; der vertikale Abstand hat keine semantische Bedeutung.'); |
| else if (!hasY) status.textContent += tr(' Vertical spacing only separates nearby words; it has no semantic meaning.', ' Die vertikale Anordnung trennt benachbarte Wörter; sie hat keine semantische Bedeutung.'); |
| } |
| async function apply() { |
| applied = true; |
| const requestRevision = ++revision; |
| run.disabled = true; exportButton.disabled = true; plot.replaceChildren(); details.replaceChildren(); |
| status.textContent = tr('Calculating dimensions…', 'Dimensionen werden berechnet…'); |
| try { |
| selected = readDimensions(); |
| syncURL(); |
| const response = await fetch(endpoint, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(selected)}); |
| const result = await response.json(); |
| if (!response.ok) throw new Error(result.error || response.statusText); |
| if (requestRevision !== revision) return; |
| await document.fonts.load('12px "DeReKoVecs Fira Sans"'); |
| await document.fonts.load('bold 12px "DeReKoVecs Fira Sans"'); |
| if (requestRevision !== revision) return; |
| lastResult = result; |
| draw(result); |
| chartDefinitions = selected; |
| exportButton.disabled = !plot.querySelector('svg'); |
| } catch (error) { |
| if (requestRevision === revision) status.textContent = tr('Could not calculate dimensions: ', 'Dimensionen konnten nicht berechnet werden: ') + error.message; |
| } finally { run.disabled = false; } |
| } |
| const shareHint = document.createElement('small'); |
| shareHint.className = 'semantic-share-hint'; |
| shareHint.textContent = tr('Share this analysis by copying the browser URL. Apply updates the chart.', 'Zum Teilen dieser Analyse die Browser-Adresse kopieren. Anwenden aktualisiert das Diagramm.'); |
| layoutControl.after(shareHint); |
| run.addEventListener('click', apply); |
| $('#tabs').on('tabsactivate', function (event, ui) { |
| if (ui.newPanel.attr('id') === panel.id && presets && !applied) apply(); |
| }); |
| fetch(endpoint).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }).then(data => { |
| presets = data; |
| let saved; |
| try { saved = JSON.parse(sessionStorage.getItem('semanticDimensions')); } catch (_) { /* Optional. */ } |
| if (!Array.isArray(saved)) saved = []; |
| // Shared settings are authoritative; never merge them with this browser's preferences. |
| const shared = window.semanticDimensionsURL.read(new URL(location.href), presets); |
| if (shared) { saved = shared.rows; layoutSelect.value = shared.layout; } |
| dimensionRow(tr('X axis', 'X-Achse'), 'Wohlstand', false, saved[0]); |
| dimensionRow(tr('Y axis', 'Y-Achse'), presets.Valenz ? 'Valenz' : '', true, saved[1]); |
| dimensionRow(tr('Color', 'Farbe'), 'Gender', true, saved[2]); |
| run.disabled = false; |
| status.textContent = tr('Choose dimensions and apply. Clear the Y axis for a one-dimensional gradient.', 'Dimensionen wählen und anwenden. Ohne Y-Achse wird ein eindimensionaler Gradient dargestellt.'); |
| if (panel.getAttribute('aria-hidden') === 'false' && !applied) apply(); |
| }).catch(error => { status.textContent = tr('Could not load presets: ', 'Voreinstellungen konnten nicht geladen werden: ') + error.message; }); |
| }; |
| }()); |