Browse Source

Add category name editing

main
indigane 4 years ago committed by GitHub
parent
commit
2458ccbbff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 202
      analysis.html
  2. 32
      common-scripts.js

202
analysis.html

@ -12,6 +12,9 @@
font-family: sans-serif; font-family: sans-serif;
color: #000; color: #000;
} }
body {
padding-bottom: 60vh;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@ -31,7 +34,6 @@
} }
/* Category refinement */ /* Category refinement */
.category-refinement { .category-refinement {
grid-area: bottom;
position: relative; position: relative;
} }
.category-refinement .title-bar { .category-refinement .title-bar {
@ -46,7 +48,9 @@
.category-refinement .title-bar h2 { .category-refinement .title-bar h2 {
margin: 0; margin: 0;
} }
.category-refinement .title-bar button { .category-refinement .title-bar button,
.category-refinement .title-bar input[type="checkbox"],
.category-refinement .title-bar label {
cursor: pointer; cursor: pointer;
} }
.category-refinement .title-bar button[disabled] { .category-refinement .title-bar button[disabled] {
@ -72,6 +76,11 @@
} }
.category-refinement .category-name { .category-refinement .category-name {
grid-area: topleft; grid-area: topleft;
white-space: pre-wrap;
outline-offset: 3px;
}
.category-refinement .category-name:hover:not(:focus):not(:active) {
outline: 2px solid #ddd;
} }
.category-refinement .category-merge-tool { .category-refinement .category-merge-tool {
grid-area: bottomleft; grid-area: bottomleft;
@ -99,13 +108,6 @@
.category-refinement .category-merge-button-start:focus { .category-refinement .category-merge-button-start:focus {
opacity: 1; opacity: 1;
} }
/*
@media (hover: none) {
.category-refinement .category-merge-button-start {
opacity: 1;
}
}
*/
.category-refinement .categories:not(.is-merge-active) .category-merge-button-end, .category-refinement .categories:not(.is-merge-active) .category-merge-button-end,
.category-refinement .categories.is-merge-active .is-merge-source .category-merge-button-end, .category-refinement .categories.is-merge-active .is-merge-source .category-merge-button-end,
.category-refinement .categories.is-merge-active .category-merge-button-start, .category-refinement .categories.is-merge-active .category-merge-button-start,
@ -159,6 +161,9 @@
border: 1px solid rgba(102, 102, 255, 0); border: 1px solid rgba(102, 102, 255, 0);
border-color: rgba(102, 102, 255, var(--more-weight)); border-color: rgba(102, 102, 255, var(--more-weight));
border-radius: 5px; border-radius: 5px;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0);
--similarity-highlight: 0 0 0 2px rgba(0, 0, 0, 1);
transition: box-shadow .1s ease-in;
} }
.category-refinement .card:not(:last-child) { .category-refinement .card:not(:last-child) {
margin-right: 10px; margin-right: 10px;
@ -172,12 +177,14 @@
} }
/* Association table */ /* Association table */
.associations { .associations {
position: relative;
padding: 15px; padding: 15px;
} }
.associations .title-bar { .associations .title-bar {
position: sticky; position: sticky;
top: 0; top: 0;
padding: 15px 0; padding: 15px 0;
width: fit-content;
background: #fff; background: #fff;
} }
.associations .title-bar h2 { .associations .title-bar h2 {
@ -187,6 +194,14 @@
border-collapse: collapse; border-collapse: collapse;
--border-styles: 1px solid #eee; --border-styles: 1px solid #eee;
} }
.association-table tr:first-of-type th:first-of-type {
text-align: right;
vertical-align: bottom;
padding: 15px;
}
.association-table .associations-button-swap-axes {
margin: 15px 0 0 15px;
}
.association-table th { .association-table th {
text-align: inherit; text-align: inherit;
font-weight: inherit; font-weight: inherit;
@ -196,12 +211,12 @@
border: var(--border-styles); border: var(--border-styles);
padding: 0; padding: 0;
} }
.association-table .card-text-cell { .association-table .x-axis-head-cell {
width: 30px; width: 30px;
height: 200px; height: 200px;
vertical-align: bottom; vertical-align: bottom;
} }
.association-table .card-text { .association-table .x-axis-head-text {
position: absolute; position: absolute;
width: 260px; width: 260px;
transform-origin: left bottom; transform-origin: left bottom;
@ -210,7 +225,7 @@
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.association-table .category-name { .association-table .y-axis-head-text {
padding-right: 0.25em; padding-right: 0.25em;
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
@ -241,10 +256,15 @@
<textarea id="results-input"></textarea> <textarea id="results-input"></textarea>
</div> </div>
<div class="category-refinement"> <div class="category-refinement">
<style class="similarity-hover-effect-stylesheet"></style>
<div class="title-bar"> <div class="title-bar">
<h2>Category refinement</h2> <h2>Category refinement</h2>
<button>Auto-merge</button> <button>Auto-merge</button>
<button class="button-recalculate" disabled>Apply merged categories</button> <button class="button-recalculate" disabled>Apply changes</button>
<label for="similarity-hover-effect-toggle">
<input type="checkbox" id="similarity-hover-effect-toggle" />
Similarity highlight on hover
</label>
</div> </div>
<div class="categories"></div> <div class="categories"></div>
</div> </div>
@ -255,7 +275,7 @@
<table class="association-table"></table> <table class="association-table"></table>
</div> </div>
<template class="category-template"> <template class="category-template">
<div class="category"> <div class="category" spellcheck="false">
<div class="category-name"></div> <div class="category-name"></div>
<div class="category-cards"></div> <div class="category-cards"></div>
<div class="merged-categories"></div> <div class="merged-categories"></div>
@ -283,6 +303,7 @@
<script> <script>
const resultsInput = document.querySelector('#results-input'); const resultsInput = document.querySelector('#results-input');
const mergedCategories = {}; const mergedCategories = {};
const renamedCategories = {};
let loadedResults = []; let loadedResults = [];
resultsInput.addEventListener('input', handleResultsInput); resultsInput.addEventListener('input', handleResultsInput);
const jsonSplitRegex = /\}[^{}]*\{/; const jsonSplitRegex = /\}[^{}]*\{/;
@ -493,12 +514,28 @@
recalculateButton.addEventListener('click', () => calculateAndRender()); recalculateButton.addEventListener('click', () => calculateAndRender());
const similarityHoverEffectStyleElement = document.querySelector('.similarity-hover-effect-stylesheet');
const similarityHoverEffectToggleInput = document.querySelector('#similarity-hover-effect-toggle');
let isSimilarityHoverEffectEnabled = similarityHoverEffectToggleInput.checked;
similarityHoverEffectToggleInput.addEventListener('change', () => isSimilarityHoverEffectEnabled = similarityHoverEffectToggleInput.checked);
let isSimilarityHoverEffectApplied = false;
document.body.addEventListener('mouseover', function handleMouseOver() {
if ( ! isSimilarityHoverEffectApplied) {
return;
}
similarityHoverEffectStyleElement.innerHTML = '';
isSimilarityHoverEffectApplied = false;
});
function renderCategoryRefinement(data) { function renderCategoryRefinement(data) {
const categoryElements = []; const categoryElements = [];
for (const category of data.categories) { for (const category of data.categories) {
const categoryElement = document.importNode(categoryTemplate.content.children[0], true); const categoryElement = document.importNode(categoryTemplate.content.children[0], true);
const similarityHoverSelector = [];
const cardElements = []; const cardElements = [];
for (const [cardText, cardCount] of Object.entries(data.cardCountsByCategory[category.name])) { const cardEntries = Object.entries(data.cardCountsByCategory[category.name]);
cardEntries.sort(([textA], [textB]) => textA.localeCompare(textB));
for (const [cardText, cardCount] of cardEntries) {
const cardElement = document.importNode(cardTemplate.content.children[0], true); const cardElement = document.importNode(cardTemplate.content.children[0], true);
const cardPercentage = Math.round((cardCount / data.resultCount) * 100.0); const cardPercentage = Math.round((cardCount / data.resultCount) * 100.0);
const weight = (cardPercentage / 100.0) / 2.0; const weight = (cardPercentage / 100.0) / 2.0;
@ -507,10 +544,29 @@
cardElement.querySelector('.card-correlation').textContent = `${cardPercentage}%`; cardElement.querySelector('.card-correlation').textContent = `${cardPercentage}%`;
cardElement.querySelector('.card-correlation').title = `${cardCount}/${data.resultCount} participants`; cardElement.querySelector('.card-correlation').title = `${cardCount}/${data.resultCount} participants`;
cardElements.push(cardElement); cardElements.push(cardElement);
// Similarity hover effect
const textBase64 = btoa(cardText);
cardElement.setAttribute('data-text-b64', textBase64);
similarityHoverSelector.push(`.category-refinement .card[data-text-b64="${textBase64}"]`);
cardElement.addEventListener('mouseover', function handleMouseOver(event) {
if ( ! isSimilarityHoverEffectEnabled) {
return;
}
event.stopPropagation();
isSimilarityHoverEffectApplied = true;
similarityHoverEffectStyleElement.innerHTML = `.category-refinement .card[data-text-b64="${textBase64}"] { box-shadow: var(--similarity-highlight); }`;
});
} }
category.element = categoryElement; category.element = categoryElement;
categoryElement._category = category; categoryElement._category = category;
categoryElement.querySelector('.category-name').textContent = category.name; const categoryNameElement = categoryElement.querySelector('.category-name');
categoryNameElement.textContent = renamedCategories[category.name] || category.name;
categoryNameElement.addEventListener('input', () => {
renamedCategories[category.name] = categoryNameElement.textContent;
categoryNameElement.classList.add('dirty');
updateRecalculateButton();
});
setContentEditablePlaintext(categoryNameElement);
categoryElement.querySelector('.category-cards').replaceChildren(...cardElements); categoryElement.querySelector('.category-cards').replaceChildren(...cardElements);
categoryElement.querySelector('.category-merge-button-start').addEventListener('click', handleMergeStart); categoryElement.querySelector('.category-merge-button-start').addEventListener('click', handleMergeStart);
categoryElement.querySelector('.category-merge-button-end').addEventListener('click', handleMergeEnd); categoryElement.querySelector('.category-merge-button-end').addEventListener('click', handleMergeEnd);
@ -527,6 +583,16 @@
renderMergeToolMergedCategory(categoryElement, {name: sourceCategoryName}); renderMergeToolMergedCategory(categoryElement, {name: sourceCategoryName});
} }
} }
// Similarity hover effect
const similarityStyleText = `${similarityHoverSelector.join(', ')} { box-shadow: var(--similarity-highlight); }`;
categoryElement.addEventListener('mouseover', function handleMouseOver() {
if ( ! isSimilarityHoverEffectEnabled) {
return;
}
event.stopPropagation();
isSimilarityHoverEffectApplied = true;
similarityHoverEffectStyleElement.innerHTML = similarityStyleText;
});
} }
categoriesContainer.replaceChildren(...categoryElements); categoriesContainer.replaceChildren(...categoryElements);
updateRecalculateButton(); updateRecalculateButton();
@ -578,7 +644,7 @@
updateRecalculateButton(); updateRecalculateButton();
} }
function updateRecalculateButton() { function updateRecalculateButton() {
if (document.querySelector('.is-merge-pending, .is-undo-pending')) { if (categoriesContainer.querySelector('.is-merge-pending, .is-undo-pending, .dirty')) {
recalculateButton.disabled = false; recalculateButton.disabled = false;
} }
else { else {
@ -599,43 +665,117 @@
// Card-category association table // Card-category association table
const associationTable = document.querySelector('.association-table'); const associationTable = document.querySelector('.association-table');
associationTable.addEventListener('click', function handleClick(event) {
if (event.target.matches('.associations-button-swap-axes')) {
associationTableSwapAxes(event.target);
}
});
function renderAssociationTable(data) { function renderAssociationTable(data) {
let tableHtml = ''; let tableHtml = '';
// Categories on X-axis, Cards on Y-axis
// Card texts row tableHtml += '<tbody class="x-categories-y-cards">';
tableHtml += '<tr>';
tableHtml += `
<th>
Categories →
<br />
Cards ↓
<button class="associations-button-swap-axes" data-target="x-cards-y-categories">Swap axes</button>
</th>
`;
for (const category of data.categories) {
tableHtml += `
<th class="x-axis-head-cell">
<div class="x-axis-head-text">${asTextContent(renamedCategories[category.name] || category.name)}</div>
</th>
`;
}
tableHtml += '</tr>';
for (const card of data.cards) {
tableHtml += `<tr>`;
tableHtml += `<th class="y-axis-head-text">${asTextContent(card.text)}</th>`;
for (const category of data.categories) {
const cardCount = data.cardCountsByCategory[category.name][card.text];
const cardPercentage = cardCount ? Math.round((cardCount / data.resultCount) * 100.0) : 0;
const weight = cardPercentage / 100.0;
const fontColor = weight > 0.5 ? '--light' : '--dark';
if (cardCount > 0) {
tableHtml += `
<td>
<div
class="card-correlation"
style="--weight: ${weight}; color: var(${fontColor});"
title="${cardCount}/${data.resultCount} participants\n\nCard: ${asTextContent(card.text)}\n\nCategory: ${asTextContent(renamedCategories[category.name] || category.name)}"
>${cardPercentage}</div>
</td>
`;
}
else {
tableHtml += `<td><div class="card-correlation"></div></td>`;
}
}
tableHtml += '</tr>';
}
tableHtml += '</tbody>';
// Cards on X-axis, Categories on Y-axis
tableHtml += '<tbody class="x-cards-y-categories" style="display: none;">';
tableHtml += '<tr>'; tableHtml += '<tr>';
tableHtml += '<th></th>'; tableHtml += `
<th>
Cards →
<br />
Categories ↓
<button class="associations-button-swap-axes" data-target="x-categories-y-cards">Swap axes</button>
</th>
`;
for (const card of data.cards) { for (const card of data.cards) {
tableHtml += ` tableHtml += `
<th class="card-text-cell"> <th class="x-axis-head-cell">
<div class="card-text">${asTextContent(card.text)}</div> <div class="x-axis-head-text">${asTextContent(card.text)}</div>
</th> </th>
`; `;
} }
tableHtml += '</tr>'; tableHtml += '</tr>';
for (const category of data.categories) { for (const category of data.categories) {
tableHtml += '<tr>'; tableHtml += `<tr>`;
tableHtml += `<th class="category-name">${asTextContent(category.name)}</th>`; tableHtml += `<th class="y-axis-head-text">${asTextContent(renamedCategories[category.name] || category.name)}</th>`;
for (const card of data.cards) { for (const card of data.cards) {
const cardCount = data.cardCountsByCategory[category.name][card.text]; const cardCount = data.cardCountsByCategory[category.name][card.text];
const cardPercentage = cardCount ? Math.round((cardCount / data.resultCount) * 100.0) : 0; const cardPercentage = cardCount ? Math.round((cardCount / data.resultCount) * 100.0) : 0;
const weight = cardPercentage / 100.0; const weight = cardPercentage / 100.0;
const fontColor = weight > 0.5 ? '--light' : '--dark'; const fontColor = weight > 0.5 ? '--light' : '--dark';
if (cardCount > 0) { if (cardCount > 0) {
tableHtml += `<td><div class="card-correlation" style="--weight: ${weight}; color: var(${fontColor});" title="${cardCount}/${data.resultCount} participants">${cardPercentage}</div></td>`; tableHtml += `
<td>
<div
class="card-correlation"
style="--weight: ${weight}; color: var(${fontColor});"
title="${cardCount}/${data.resultCount} participants\n\nCard: ${asTextContent(card.text)}\n\nCategory: ${asTextContent(renamedCategories[category.name] || category.name)}"
>${cardPercentage}</div>
</td>
`;
} }
else { else {
tableHtml += '<td><div class="card-correlation"></div></td>'; tableHtml += `<td><div class="card-correlation"></div></td>`;
} }
} }
tableHtml += '</tr>'; tableHtml += '</tr>';
} }
performance.mark('innerhtml-start'); tableHtml += '</tbody>';
associationTable.innerHTML = tableHtml; associationTable.innerHTML = tableHtml;
performance.mark('innerhtml-end'); }
performance.measure("innerhtml-start to end", 'innerhtml-start', 'innerhtml-end');
console.log(performance.getEntriesByType("measure")); function associationTableSwapAxes(button) {
const target = button.getAttribute('data-target');
if (target == 'x-categories-y-cards') {
document.querySelector('.association-table .x-cards-y-categories').style.display = 'none';
document.querySelector('.association-table .x-categories-y-cards').style.display = '';
}
else {
document.querySelector('.association-table .x-categories-y-cards').style.display = 'none';
document.querySelector('.association-table .x-cards-y-categories').style.display = '';
}
} }
</script> </script>
</body> </body>

32
common-scripts.js

@ -105,3 +105,35 @@ function asTextContent(input) {
tempElement.textContent = input; tempElement.textContent = input;
return tempElement.innerHTML; return tempElement.innerHTML;
} }
function setContentEditablePlaintext(element) {
element.setAttribute('contenteditable', 'PLAINTEXT-ONLY');
const isPlaintextSupported = element.contentEditable === 'plaintext-only';
element.addEventListener('keydown', function(event) {
if (event.key === 'Enter') {
element.blur();
event.preventDefault();
}
});
element.addEventListener('blur', function(event) {
// Combine text nodes into one text node
element.textContent = element.textContent;
});
if (isPlaintextSupported) {
return;
}
// Polyfill for contenteditable="plaintext-only"
element.contentEditable = true;
const observer = new MutationObserver(plaintextOnlyMutationHandler);
observer.observe(element, {childList: true, subtree: true});
}
function plaintextOnlyMutationHandler(mutationList, observer) {
for (const mutation of mutationList) {
for (const addedNode of mutation.addedNodes) {
if (addedNode.nodeType == Node.ELEMENT_NODE) {
addedNode.replaceWith(addedNode.textContent);
}
}
}
}
Loading…
Cancel
Save