// ==UserScript==
// @name Semantle Enhancements
// @namespace http://c9a.dev/
// @version 0.4.8.4
// @description Add a little pizzazz to Semantle
// @author Joel Bradshaw
// @match https://semantle.novalis.org/*
// @icon https://www.google.com/s2/favicons?domain=novalis.org
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/d3.min.js
// @grant none
// @license MIT
// ==/UserScript==
/* globals d3 */
/* global Semantle guesses guessed */
const head = document.querySelector('head');
const styles = document.createElement('style');
styles.innerHTML = `;
.cloud {
display: flex;
flex-direction: column;
}
.cloud .row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.cloud .word {
margin: 0px 5px;
}
`;
head.appendChild(styles);
let guessTable;
let chartParts = {};
let similarityValues;
let xScale, yScale;
let xAxis, yAxis;
let focusedWord = null;
// This does negatives like I want
function round(num, digits) {
const mult = 10**digits;
return Math.floor(num*mult)/mult;
}
function magRound(num) {
const magnitude = Math.floor(Math.log10(num));
return round(num, -magnitude);
}
class Cloud {
constructor(guessTable, waypoints, { scale, width }) {
this.waypoints = waypoints;
this.scale = scale;
this.width = width;
this.minSize = 12;
this.div = document.createElement('div');
this.div.setAttribute('class', 'cloud');
guessTable.parentNode.insertBefore(this.div, guessTable);
this.words = d3.select(this.div);
}
getSize(similarity) {
return this.minSize + similarity*this.scale
}
getWordsPerLine(guess) {
const guessSize = this.getSize(guess.similarity);
const estimatedWordsPerLine = this.width/(guessSize*5);
const wordsPerLine = Math.max(magRound(estimatedWordsPerLine), 10);
//console.log("Words per line: ", guessSize, estimatedWordsPerLine, wordsPerLine);
return wordsPerLine;
}
update(guesses, lastGuess) {
const [closest, top10, top1000] = this.waypoints;
guesses.sort((a, b) => b.similarity - a.similarity);
const groups = [];
// determine breaks
let nextBreak = 1000;
let curWords = [];
let wordsPerLine = this.getWordsPerLine(guesses[0]);
let breakSize = 1;
for(const guess of guesses) {
const guessSize = this.getSize(guess.similarity);
let niceBreak = false;
if(guess.closeness && guess.closeness < nextBreak) {
niceBreak = (curWords.length > wordsPerLine/3) || curWords[0] && curWords[0].closeness === 1000;
const pos = 1000-guess.closeness;
breakSize = 10**(Math.floor(Math.log10(pos)));
nextBreak = 1000 - (Math.floor(pos/breakSize)+1)*breakSize;
console.log('next break:', nextBreak)
} else if(guess.similarity < top1000 && nextBreak !== null) {
niceBreak = true;
nextBreak = null;
}
if(niceBreak || curWords.length > wordsPerLine) {
groups.push(curWords);
wordsPerLine = this.getWordsPerLine(guess);
curWords = [];
}
curWords.push(guess);
}
if(curWords.length) { groups.push(curWords); }
const self = this;
const rows = this.words
.selectAll('div.row')
.data(groups)
.join(enter => enter.append('div').attr('class', 'row'))
.selectAll('div.word')
.data(g => g, d => d.order)
.join(
enter => enter.append('div')
.attr('class', 'word')
.text(d => d.closeness === 1000 ? `🎉🏆${d.guess}🏆🎉` : d.guess)
)
.style('font-size', d => self.getSize(d.similarity) + 'px')
.style('color', d => d.order === lastGuess ? 'red' : d.closeness ? `rgb(0,${d.closeness/1000*96+31},0)` : `rgb(${d.similarity/100*255},0,0)`)
.style('font-weight', d => d.order === lastGuess ? 'bold' : 'normal');
}
}
class Chart {
constructor(guessTable, waypoints) {
this.waypoints = waypoints;
this.div = document.createElement('div');
guessTable.parentNode.insertBefore(this.div, guessTable);
this.height = 150;
this.margin = 20;
const chart = d3.select(this.div).append('svg')
.style('width', '100%')
.attr('height', this.height);
window.chart = chart;
// TODO: Not const?;
const width = this.div.querySelector('svg').clientWidth;
this.guesses = chart.append('g');
this.max = chart.append('g');
this.avg = chart.append('g');
this.labels = chart.append('g').attr('fill', 'rgba(255,255,255,0.2)');
this.xAxis = chart.append('g');
this.yAxis = chart.append('g');
yScale = d3.scaleLinear().domain([-10,100]).range([this.height-this.margin*2,this.margin]);
// TODO: Shrink as we keep guessing;
xScale = d3.scaleLinear().domain([0,200]).range([this.margin*3,width-this.margin*2]);
xAxis = d3.axisBottom(xScale);
yAxis = d3.axisLeft(yScale).ticks(5);
const addTitle = (text, rotate) => (axis) => {
const bbox = axis.node().getBBox();
const textElm = axis
.append('text')
.text(text)
.attr('text-anchor','middle')
.attr('dominant-baseline', 'hanging')
.attr('fill', 'black');
if(rotate) {
textElm
.attr('writing-mode','vertical-rl')
.attr('y',bbox.y + bbox.height/2)
.attr('x',-bbox.width);
} else {
textElm
.attr('x',bbox.x + bbox.width/2)
.attr('y',bbox.height);
}
};
this.xAxis
.attr('transform', `translate(0,${yScale.range()[0]})`)
.call(xAxis)
.call(addTitle('Guesses'));
this.yAxis
.attr('transform', `translate(${xScale.range()[0]},0)`)
.call(yAxis)
.call(addTitle('Closeness', true));
this.annotations = chart.append('g')
.selectAll('line')
.data(this.waypoints)
.join('line')
.attr('stroke', 'gray')
.attr('stroke-width', '1px')
.attr('stroke-dasharray', `${xScale(0.4) - xScale(0)} ${xScale(0.6) - xScale(0)}`)
.attr('x1', xScale.range()[0])
.attr('x2', xScale.range()[1])
.attr('y1', yScale)
.attr('y2', yScale);
}
update(guesses, lastGuess) {
guesses.sort((a, b) => a.order - b.order);
if(guesses.length > xScale.domain()[1]) {
while(guesses.length > xScale.domain()[1]) {
xScale.domain([xScale.domain()[0], xScale.domain()[1]*1.5]);
}
this.xAxis.call(xAxis);
}
this.guesses
.selectAll('circle')
.data(guesses, d => d.order)
.join(enter => enter.append('circle')
.attr('alt', d => d.guess)
)
.attr('cx', d => xScale(d.order))
.attr('cy', d => yScale(d.similarity))
.attr('r', d => d.order === lastGuess ? 4 : 2 )
.attr('fill', d => d.order === lastGuess ? 'red' : 'blue');
this.max
.selectAll('circle')
.data(guesses, d => d.order)
.join(enter => enter.append('circle')
.attr('alt', d => d.guess)
.attr('r', 1)
.attr('fill', 'green')
)
.attr('cx', d => xScale(d.order))
.attr('cy', d => yScale(d.max));
let avg = movingAveragePoints(guesses.map(g => g.similarity), 12);
this.avg
.selectAll('path')
.data([avg])
.join('path')
.attr('d', d3.line(d => xScale(d[0]), d => yScale(d[1])))
.attr('stroke', 'purple')
.attr('fill', 'none')
.attr('stroke-width', '2px');
const valueLabels = ['Closest', 'Top 10', 'Top 1000'];
const valueComment = (idx) => {
switch(idx) {
case 0:
return guesses.find(g => g.closeness === 999) ? '✔' : '';
case 1:
return `(${guesses.filter(g => g.closeness >= 990).length})`;
case 2:
return `(${guesses.filter(g => g.closeness).length})`;
}
};
this.labels
.selectAll('text')
.data(this.waypoints)
.join(enter => enter.append('text')
.attr('color', 'darkgray')
.attr('font-size', '8pt')
.attr('background', 'rgba(0,0,0,0.1)')
)
.attr('x', xScale(1))
.attr('y', d => yScale(d+2))
.text((_, i) => `${valueLabels[i]} ${valueComment(i)}`);
}
}
(async function() {
'use strict';
// For bookmarklet
let d3Ready = Promise.resolve();
if(typeof d3 === 'undefined') {
(function() {
var script = document.createElement("script");
script.async = true;
script.src = "https://cdn.jsdelivr.net/npm/[email protected]/dist/d3.min.js";
script.charset = "utf-8";
document.body.appendChild(script);
})();
d3Ready = new Promise(resolve => {
function checkReady() {
if(typeof d3 === 'undefined') {
setTimeout(checkReady, 100);
} else {
resolve();
}
}
checkReady();
});
}
await d3Ready;
similarityValues = new Promise(resolve => {
const similarityStory = document.getElementById('similarity-story');
if(similarityStory.innerText) {
return resolve(parseSimilarity());
}
const similarityUpdate = new MutationObserver(function() {
resolve(parseSimilarity());
});
similarityUpdate.observe(similarityStory, {childList: true});
});
guessTable = document.getElementById('guesses');
storeDay();
const queryParams = new URLSearchParams(window.location.search);
let loadDay = queryParams.get('day');
if(loadDay) {
let oldGuesses = window.localStorage.getItem(`guesses.${loadDay}`);
if(oldGuesses) {
console.log(`Loading day ${loadDay}`);
const guessData = JSON.parse(oldGuesses);
// We just saved it above, so we're safe to overwrite;
window.localStorage.setItem('puzzleNumber', loadDay);
window.localStorage.setItem('guesses', oldGuesses);
window.localStorage.setItem('winState', Number(guessData[0][0]) === 100 ? 1 : -1);
secret = secretWords[loadDay].toLowerCase();
try {
similarityStory = await Semantle.getSimilarityStory(secret);
document.getElementById('similarity-story').innerHTML = `;
Today is puzzle number <b>${puzzleNumber}</b>. The nearest word has a similarity of;
<b>${(similarityStory.top * 100).toFixed(2)}</b>, the tenth-nearest has a similarity of;
${(similarityStory.top10 * 100).toFixed(2)} and the one thousandth nearest word has a;
similarity of ${(similarityStory.rest * 100).toFixed(2)}.;
`;
} catch(e) {
// Whatevs;
}
guesses.length = 0;
let lastGuess = null;
for(guess of guessData) {
if(!lastGuess || lastGuess[3] < guess[3]) {
lastGuess = guess;
}
guesses.push(guess);
guessed.add(guess[1]);
}
console.log(guesses);
guessCount = guessed.size;
latestGuess = lastGuess[1];
similarityValues.then(function() {
document.getElementById('guess').value = latestGuess;
document.getElementById('guess-btn').click();
});
}
}
const waypoints = await similarityValues;
const chart = new Chart(guessTable, waypoints);
const cloud = new Cloud(guessTable, waypoints, {
scale: 1/6,
width: 800
});
function update() {
storeDay();
const [guesses, lastGuess] = parseGuesses();
chart.update(guesses, lastGuess);
cloud.update(guesses, lastGuess);
}
const tableUpdate = new MutationObserver(update);
tableUpdate.observe(guessTable, {childList: true});
update();
})();
function zipObject(keys, values) {
return Object.fromEntries(keys.map((k, i) => [k, values[i]]));
}
function parseSimilarity() {
const story = document.getElementById('similarity-story');
const storyNumbers = story.innerText.match(/\d+(\.\d+)?/g).map(parseFloat);
return storyNumbers.slice(1);
}
function storeDay() {
const guesses = window.localStorage.getItem('guesses');
const day = window.localStorage.getItem('puzzleNumber');
// comment this line out if you don't want to be storing history;
window.localStorage.setItem(`guesses.${day}`, guesses);
}
// from https://observablehq.com/@d3/moving-average;
function movingAverage(values, N) {
let i = 0;
let sum = 0;
const means = new Float64Array(values.length).fill(NaN);
for (let n = Math.min(N - 1, values.length); i < n; ++i) {
sum += values[i];
}
for (let n = values.length; i < n; ++i) {
sum += values[i];
means[i] = sum / N;
sum -= values[i - N + 1];
}
return means;
}
function movingAveragePoints(values, N) {
// TODO: Should this be floor? are we off-by-one?;
return Array.from(movingAverage(values, N))
.map((v, i) => Number.isNaN(v) ? false : [i-Math.floor(N/2), v])
.filter(v => v);
}
function parseGuesses() {
const guesses = JSON.parse(window.localStorage.getItem('guesses')).map(
row => zipObject(
['similarity','guess','closeness','order'],
row.map((v, i) => i === 1 ? v : Number(v))
)
);
let lastGuess = document.querySelector('#guesses td').innerText;
lastGuess = lastGuess ? parseInt(lastGuess) : null;
guesses.sort((a, b) => a.order - b.order);
let max = 0;
guesses.forEach(g => {
const val = Number(g.similarity);
if(val > max) { max = val }
g.max = max;
});
return [guesses, lastGuess];
}