blob: c64d662aff2b23b8bcd8f78f4337177751b5222c [file] [log] [blame]
/* 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; });
};
}());