Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial Hanzi math implementation #46

Merged
merged 5 commits into from
Jul 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions public/js/modules/commands.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { evaluate as evaluateMathExpression } from "./hanzi-math";
const commands = [
{
prefix: "!random",
Expand All @@ -17,13 +18,27 @@ const commands = [
}
const minCeiled = Math.ceil(startIndex);
const maxFloored = Math.floor(endIndex);
if(minCeiled < 0 || maxFloored >= window.freqs.length) {
if (minCeiled < 0 || maxFloored >= window.freqs.length) {
return [];
}

const index = Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled);
return [window.freqs[index]];
}
},
{
prefix: "!math",
parse: segments => {
// no spaces allowed in the math operation
// so segment[0] = "!math" and segment[1] = math expression
if (segments.length !== 2) {
return [];
}
const expression = segments[1];
const result = evaluateMathExpression(expression);
// limit to 10 results
return result.filter(x => x in hanzi).sort((a, b) => hanzi[a].node.level - hanzi[b].node.level).slice(0, 10);
}
}
];
function handleCommand(value) {
Expand All @@ -38,6 +53,6 @@ function handleCommand(value) {
return command.parse(segments);
}
}
return [];
return null;
}
export { handleCommand }
184 changes: 184 additions & 0 deletions public/js/modules/hanzi-math.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
const operators = new Set(['+', '-']);
// Validate a hanzi-math expression.
// The input expression must start and end with a hanzi, and alternate between hanzi and an operator
// the plus and minus signs (+ and -) are the only supported operators for now.
// For parsing simplicity, no spaces are allowed.
//
// valid examples (though these might not find any results):
// 我+你
// 我-你
// 我-你+你
// 我-你+你+你-我
//
// invalid examples:
// 我+你+
// 我+你-
// -我+你
// +我+你
// 我_你
// 我 + 你
// 我你+你
// 你+你你
// 你+
// -你
//
// the definition of hanzi above is "exists in window.components".
// note that the components data isn't perfect, but it's enough for a start
function validate(input) {
if (input.length < 3) {
// treat single characters as invalid
// and anything with length two couldn't both start and end with a hanzi while also alternating
// between hanzi and operator.
return false;
}
if (!window.components) {
return false;
}
let nextMustBeCharacter = true;
for (const character of input) {
if (nextMustBeCharacter) {
if (!(character in window.components)) {
return false;
}
nextMustBeCharacter = false;
} else {
if (!operators.has(character)) {
return false;
}
nextMustBeCharacter = true;
}
}
return !nextMustBeCharacter;
}
// a breadth-first search for a given component of `candidate`
function containsTransitiveComponent(candidate, filterComponent) {
if (candidate === filterComponent) {
return true;
}
let componentQueue = [];
componentQueue.push(candidate);
while (componentQueue.length > 0) {
let curr = componentQueue.shift();
if (!(curr in window.components)) {
continue;
}
for (const component of window.components[curr].components) {
if (filterComponent === component) {
return true;
}
componentQueue.push(component);
}
}
return false;
}
// get components one layer deep, used in subtraction when a given
// character is known to have the item being subtracted
function findNonTransitiveComponents(characterList) {
let result = new Set();
for (const character of characterList) {
for (const component of window.components[character].components) {
result.add(component);
}
}
return result;
}
// a BFS to find all compounds containing any character in `characterList`.
// used in addition operations to find compounds containing the item being added
function findAllTransitiveCompounds(characterList) {
let compounds = new Set();
for (const character of characterList) {
let compoundQueue = [];
compoundQueue.push(character);
while (compoundQueue.length > 0) {
let curr = compoundQueue.shift();
compounds.add(curr);
if (!(curr in window.components)) {
continue;
}
for (const compound of window.components[curr].componentOf) {
compoundQueue.push(compound);
}
}
}
return compounds;
}
// TODO: adding two of the same character has unexpected results because of the components lists de-duping
// (this came from the original cjkv-ids project, if I recall correctly)...worth revisiting
//
// move left to right, evaluating the expression as we go. Return all possible results.
// note that we don't allow parentheses, and ordering could end up mattering.
function evaluate(input) {
if (!validate(input)) {
return [];
}
let leftOperandList = null;
let nextOperator = null;
for (const character of input) {
// no left side yet? set it and move on.
// note that leftOperandList can later become empty, which is then handled in the plus operation
if (leftOperandList === null) {
leftOperandList = [character];
continue;
}
// it's an operator, so note that as our next operation and move on.
if (operators.has(character)) {
nextOperator = character;
continue;
}
// if we're here, it's the right hand operand.
// evaluate what we have so far, set that to left operand, and move on
//
// note that we return all possible results at each step, so additions can also end up being filtering
// operations (i.e., we have N candidates, remove those that don't include the added operand). Subtractions
// similarly: we have N candidates, remove any that do include the added operand. TBD if we should treat
// subtractions when there aren't candidates with the operand as a no-op or a failed operation (probably the
// former).
let filtered = [];
if (nextOperator === '-') {
// when subtracting, first find which operands of leftOperandList have the component anywhere
let operandsWithComponent = [];
for (const candidate of leftOperandList) {
if (!containsTransitiveComponent(candidate, character)) {
// if it already excludes the subtracted component, keep it around
filtered.push(candidate);
} else if (candidate !== character) {
// otherwise, see if we can break it up without the component
// (as long as it's not <char>-<char>)
// see below...
operandsWithComponent.push(candidate);
}
}
let nonTransitiveComponents = findNonTransitiveComponents(operandsWithComponent);
for (const candidate of nonTransitiveComponents) {
if (!containsTransitiveComponent(candidate, character)) {
filtered.push(candidate);
}
}
} else {
// Nothing there now, due to subtraction to 0 or due to not finding results of an add?
// Add the right operand and return its compounds...
// TODO: this does mean adds of 3 or more items can be weird, with the first addition going to []
// and then the next addition adding items that probably shouldn't be there
// example:
// 我+你-->no result
// 我+你+我-->results as though it was just 我.
// Should probably only do special empty handling
// if the prior operation was a subtraction...
if (leftOperandList.length < 1) {
leftOperandList.push(character);
}
// if it's addition, find transitive compounds as the set of candidates,
// then get rid of any candidate that doesn't contain the rightOperand anywhere in
// its transitive set of components
let compounds = findAllTransitiveCompounds(leftOperandList);
for (const candidate of compounds) {
if (containsTransitiveComponent(candidate, character)) {
filtered.push(candidate);
}
}
}
leftOperandList = filtered;
}
return leftOperandList;
}
export { evaluate }
13 changes: 9 additions & 4 deletions public/js/modules/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,15 @@ function search(value, locale, mode, skipState) {
}
// first, check if this is a command.
const commandResult = handleCommand(value);
if(commandResult && commandResult.length > 0) {
notFoundElement.style.display = 'none';
document.dispatchEvent(new CustomEvent('graph-update', { detail: commandResult[0] }));
document.dispatchEvent(new CustomEvent('explore-update', { detail: { words: commandResult, mode: (mode || 'explore'), skipState: !!skipState } }));
// null is returned if the value is not supported (i.e., not actually a command)
if (commandResult !== null) {
if (commandResult.length === 0) {
notFoundElement.removeAttribute('style');
} else {
notFoundElement.style.display = 'none';
document.dispatchEvent(new CustomEvent('graph-update', { detail: commandResult[0] }));
document.dispatchEvent(new CustomEvent('explore-update', { detail: { words: commandResult, mode: (mode || 'explore'), skipState: !!skipState } }));
}
return;
}
// then, check if it's a known word or character.
Expand Down
Loading