638 lines
18 KiB
JavaScript
638 lines
18 KiB
JavaScript
(function () {
|
||
"use strict";
|
||
|
||
let config = {
|
||
getAlphabets: () => null,
|
||
getGematriaDb: () => null,
|
||
getGematriaElements: () => ({
|
||
cipherEl: null,
|
||
inputEl: null,
|
||
resultEl: null,
|
||
breakdownEl: null,
|
||
modeToggleEl: null,
|
||
matchesEl: null,
|
||
inputLabelEl: null,
|
||
cipherLabelEl: null
|
||
})
|
||
};
|
||
|
||
const state = {
|
||
loadingPromise: null,
|
||
db: null,
|
||
listenersBound: false,
|
||
activeCipherId: "",
|
||
forwardInputText: "",
|
||
reverseInputText: "",
|
||
activeMode: "forward",
|
||
scriptCharMap: new Map(),
|
||
reverseLookupCache: new Map(),
|
||
reverseRequestId: 0
|
||
};
|
||
|
||
function getAlphabets() {
|
||
return config.getAlphabets?.() || null;
|
||
}
|
||
|
||
function getConfiguredGematriaDb() {
|
||
return config.getGematriaDb?.() || null;
|
||
}
|
||
|
||
function getElements() {
|
||
return config.getGematriaElements?.() || {
|
||
cipherEl: null,
|
||
inputEl: null,
|
||
resultEl: null,
|
||
breakdownEl: null,
|
||
modeToggleEl: null,
|
||
matchesEl: null,
|
||
inputLabelEl: null,
|
||
cipherLabelEl: null
|
||
};
|
||
}
|
||
|
||
function isReverseMode() {
|
||
return state.activeMode === "reverse";
|
||
}
|
||
|
||
function getCurrentInputText() {
|
||
return isReverseMode()
|
||
? state.reverseInputText
|
||
: state.forwardInputText;
|
||
}
|
||
|
||
function formatCount(value) {
|
||
const numericValue = Number(value);
|
||
if (!Number.isFinite(numericValue)) {
|
||
return "0";
|
||
}
|
||
return numericValue.toLocaleString();
|
||
}
|
||
|
||
function getFallbackGematriaDb() {
|
||
return {
|
||
baseAlphabet: "abcdefghijklmnopqrstuvwxyz",
|
||
ciphers: [
|
||
{
|
||
id: "simple-ordinal",
|
||
name: "Simple Ordinal",
|
||
description: "A=1 ... Z=26",
|
||
values: Array.from({ length: 26 }, (_, index) => index + 1)
|
||
},
|
||
{
|
||
id: "decadic-cipher",
|
||
name: "Decadic Cipher",
|
||
description: "A=1 ... I=9, J=10 ... R=90, S=100 ... Z=800",
|
||
values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 200, 300, 400, 500, 600, 700, 800]
|
||
}
|
||
]
|
||
};
|
||
}
|
||
|
||
function normalizeGematriaText(value) {
|
||
return String(value || "")
|
||
.normalize("NFD")
|
||
.replace(/[\u0300-\u036f]/g, "")
|
||
.toLowerCase();
|
||
}
|
||
|
||
function transliterationToBaseLetters(transliteration, baseAlphabet) {
|
||
const normalized = normalizeGematriaText(transliteration);
|
||
if (!normalized) {
|
||
return "";
|
||
}
|
||
|
||
const primaryVariant = normalized.split(/[\/,;|]/)[0] || normalized;
|
||
const primaryLetters = [...primaryVariant].filter((char) => baseAlphabet.includes(char));
|
||
if (primaryLetters.length) {
|
||
return primaryLetters[0];
|
||
}
|
||
|
||
const allLetters = [...normalized].filter((char) => baseAlphabet.includes(char));
|
||
return allLetters[0] || "";
|
||
}
|
||
|
||
function addScriptCharMapEntry(map, scriptChar, mappedLetters) {
|
||
const key = String(scriptChar || "").trim();
|
||
const value = String(mappedLetters || "").trim();
|
||
if (!key || !value) {
|
||
return;
|
||
}
|
||
|
||
map.set(key, value);
|
||
}
|
||
|
||
function buildGematriaScriptMap(baseAlphabet) {
|
||
const map = new Map();
|
||
const alphabets = getAlphabets() || {};
|
||
const hebrewLetters = Array.isArray(alphabets.hebrew) ? alphabets.hebrew : [];
|
||
const greekLetters = Array.isArray(alphabets.greek) ? alphabets.greek : [];
|
||
|
||
hebrewLetters.forEach((entry) => {
|
||
const mapped = transliterationToBaseLetters(entry?.transliteration, baseAlphabet);
|
||
addScriptCharMapEntry(map, entry?.char, mapped);
|
||
});
|
||
|
||
greekLetters.forEach((entry) => {
|
||
const mapped = transliterationToBaseLetters(entry?.transliteration, baseAlphabet);
|
||
addScriptCharMapEntry(map, entry?.char, mapped);
|
||
addScriptCharMapEntry(map, entry?.charLower, mapped);
|
||
addScriptCharMapEntry(map, entry?.charFinal, mapped);
|
||
});
|
||
|
||
const hebrewFinalForms = {
|
||
ך: "k",
|
||
ם: "m",
|
||
ן: "n",
|
||
ף: "p",
|
||
ץ: "t"
|
||
};
|
||
|
||
Object.entries(hebrewFinalForms).forEach(([char, mapped]) => {
|
||
if (!map.has(char) && baseAlphabet.includes(mapped)) {
|
||
addScriptCharMapEntry(map, char, mapped);
|
||
}
|
||
});
|
||
|
||
if (!map.has("ς") && baseAlphabet.includes("s")) {
|
||
addScriptCharMapEntry(map, "ς", "s");
|
||
}
|
||
|
||
return map;
|
||
}
|
||
|
||
function refreshScriptMap(baseAlphabetOverride = "") {
|
||
const db = state.db || getFallbackGematriaDb();
|
||
const baseAlphabet = String(baseAlphabetOverride || db.baseAlphabet || "abcdefghijklmnopqrstuvwxyz").toLowerCase();
|
||
state.scriptCharMap = buildGematriaScriptMap(baseAlphabet);
|
||
}
|
||
|
||
function sanitizeGematriaDb(db) {
|
||
const baseAlphabet = String(db?.baseAlphabet || "abcdefghijklmnopqrstuvwxyz").toLowerCase();
|
||
const ciphers = Array.isArray(db?.ciphers)
|
||
? db.ciphers
|
||
.map((cipher) => {
|
||
const id = String(cipher?.id || "").trim();
|
||
const name = String(cipher?.name || "").trim();
|
||
const values = Array.isArray(cipher?.values)
|
||
? cipher.values.map((value) => Number(value))
|
||
: [];
|
||
|
||
if (!id || !name || values.length !== baseAlphabet.length || values.some((value) => !Number.isFinite(value))) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
id,
|
||
name,
|
||
description: String(cipher?.description || "").trim(),
|
||
values
|
||
};
|
||
})
|
||
.filter(Boolean)
|
||
: [];
|
||
|
||
if (!ciphers.length) {
|
||
return getFallbackGematriaDb();
|
||
}
|
||
|
||
return {
|
||
baseAlphabet,
|
||
ciphers
|
||
};
|
||
}
|
||
|
||
async function loadGematriaDb() {
|
||
if (state.db) {
|
||
return state.db;
|
||
}
|
||
|
||
if (state.loadingPromise) {
|
||
return state.loadingPromise;
|
||
}
|
||
|
||
state.loadingPromise = Promise.resolve()
|
||
.then(async () => {
|
||
const configuredDb = getConfiguredGematriaDb();
|
||
if (configuredDb) {
|
||
return configuredDb;
|
||
}
|
||
|
||
const referenceData = await window.TarotDataService?.loadReferenceData?.();
|
||
return referenceData?.gematriaCiphers || null;
|
||
})
|
||
.then((db) => {
|
||
if (!db) {
|
||
throw new Error("Gematria cipher data unavailable from API.");
|
||
}
|
||
state.db = sanitizeGematriaDb(db);
|
||
return state.db;
|
||
})
|
||
.catch(() => {
|
||
state.db = getFallbackGematriaDb();
|
||
return state.db;
|
||
})
|
||
.finally(() => {
|
||
state.loadingPromise = null;
|
||
});
|
||
|
||
return state.loadingPromise;
|
||
}
|
||
|
||
function getActiveGematriaCipher() {
|
||
const db = state.db || getFallbackGematriaDb();
|
||
const ciphers = Array.isArray(db.ciphers) ? db.ciphers : [];
|
||
if (!ciphers.length) {
|
||
return null;
|
||
}
|
||
|
||
const selectedId = state.activeCipherId || ciphers[0].id;
|
||
return ciphers.find((cipher) => cipher.id === selectedId) || ciphers[0];
|
||
}
|
||
|
||
function renderGematriaCipherOptions() {
|
||
const { cipherEl } = getElements();
|
||
if (!cipherEl) {
|
||
return;
|
||
}
|
||
|
||
const db = state.db || getFallbackGematriaDb();
|
||
const ciphers = Array.isArray(db.ciphers) ? db.ciphers : [];
|
||
|
||
cipherEl.innerHTML = "";
|
||
ciphers.forEach((cipher) => {
|
||
const option = document.createElement("option");
|
||
option.value = cipher.id;
|
||
option.textContent = cipher.name;
|
||
if (cipher.description) {
|
||
option.title = cipher.description;
|
||
}
|
||
cipherEl.appendChild(option);
|
||
});
|
||
|
||
const activeCipher = getActiveGematriaCipher();
|
||
state.activeCipherId = activeCipher?.id || "";
|
||
cipherEl.value = state.activeCipherId;
|
||
}
|
||
|
||
function setMatchesMessage(matchesEl, message) {
|
||
if (!matchesEl) {
|
||
return;
|
||
}
|
||
|
||
matchesEl.replaceChildren();
|
||
const emptyEl = document.createElement("div");
|
||
emptyEl.className = "alpha-gematria-match-empty";
|
||
emptyEl.textContent = message;
|
||
matchesEl.appendChild(emptyEl);
|
||
}
|
||
|
||
function clearReverseMatches(matchesEl) {
|
||
if (!matchesEl) {
|
||
return;
|
||
}
|
||
|
||
matchesEl.replaceChildren();
|
||
matchesEl.hidden = true;
|
||
}
|
||
|
||
function updateModeUi() {
|
||
const {
|
||
cipherEl,
|
||
inputEl,
|
||
modeToggleEl,
|
||
matchesEl,
|
||
inputLabelEl,
|
||
cipherLabelEl
|
||
} = getElements();
|
||
|
||
const reverseMode = isReverseMode();
|
||
|
||
if (modeToggleEl) {
|
||
modeToggleEl.checked = reverseMode;
|
||
}
|
||
|
||
if (inputLabelEl) {
|
||
inputLabelEl.textContent = reverseMode ? "Value" : "Text";
|
||
}
|
||
|
||
if (cipherLabelEl) {
|
||
cipherLabelEl.textContent = reverseMode ? "Cipher (disabled in reverse mode)" : "Cipher";
|
||
}
|
||
|
||
if (cipherEl) {
|
||
cipherEl.disabled = reverseMode;
|
||
cipherEl.closest(".alpha-gematria-field")?.classList.toggle("is-disabled", reverseMode);
|
||
}
|
||
|
||
if (inputEl) {
|
||
inputEl.placeholder = reverseMode ? "Enter a whole number, e.g. 33" : "Type or paste text";
|
||
inputEl.inputMode = reverseMode ? "numeric" : "text";
|
||
inputEl.spellcheck = !reverseMode;
|
||
|
||
const nextValue = getCurrentInputText();
|
||
if (inputEl.value !== nextValue) {
|
||
inputEl.value = nextValue;
|
||
}
|
||
}
|
||
|
||
if (!reverseMode) {
|
||
clearReverseMatches(matchesEl);
|
||
}
|
||
}
|
||
|
||
function parseReverseLookupValue(rawValue) {
|
||
const normalizedValue = String(rawValue || "").trim();
|
||
if (!normalizedValue) {
|
||
return null;
|
||
}
|
||
|
||
if (!/^\d+$/.test(normalizedValue)) {
|
||
return Number.NaN;
|
||
}
|
||
|
||
const numericValue = Number(normalizedValue);
|
||
if (!Number.isSafeInteger(numericValue)) {
|
||
return Number.NaN;
|
||
}
|
||
|
||
return numericValue;
|
||
}
|
||
|
||
async function loadReverseLookup(value) {
|
||
const cacheKey = String(value);
|
||
if (state.reverseLookupCache.has(cacheKey)) {
|
||
return state.reverseLookupCache.get(cacheKey);
|
||
}
|
||
|
||
const payload = await window.TarotDataService?.loadGematriaWordsByValue?.(value);
|
||
state.reverseLookupCache.set(cacheKey, payload);
|
||
return payload;
|
||
}
|
||
|
||
function renderReverseLookupMatches(payload, numericValue) {
|
||
const { resultEl, breakdownEl, matchesEl } = getElements();
|
||
if (!resultEl || !breakdownEl || !matchesEl) {
|
||
return;
|
||
}
|
||
|
||
const matches = Array.isArray(payload?.matches) ? payload.matches : [];
|
||
const count = Number(payload?.count);
|
||
const displayCount = Number.isFinite(count) ? count : matches.length;
|
||
const ciphers = Array.isArray(payload?.ciphers) ? payload.ciphers : [];
|
||
const cipherCount = Number(payload?.cipherCount);
|
||
const displayCipherCount = Number.isFinite(cipherCount) ? cipherCount : ciphers.length;
|
||
const visibleMatches = matches.slice(0, 120);
|
||
|
||
resultEl.textContent = `Value: ${formatCount(numericValue)}`;
|
||
|
||
if (!displayCount) {
|
||
breakdownEl.textContent = "No words matched this reverse gematria value.";
|
||
matchesEl.hidden = false;
|
||
setMatchesMessage(matchesEl, "No matches found in the reverse gematria index.");
|
||
return;
|
||
}
|
||
|
||
const topCipherSummary = ciphers
|
||
.slice(0, 6)
|
||
.map((cipher) => `${String(cipher?.name || cipher?.id || "Unknown")} ${formatCount(cipher?.count)}`)
|
||
.join(" · ");
|
||
|
||
breakdownEl.textContent = `Found ${formatCount(displayCount)} matches across ${formatCount(displayCipherCount)} ciphers.${topCipherSummary ? ` Top ciphers: ${topCipherSummary}.` : ""}${displayCount > visibleMatches.length ? ` Showing first ${formatCount(visibleMatches.length)}.` : ""}`;
|
||
|
||
const fragment = document.createDocumentFragment();
|
||
visibleMatches.forEach((match) => {
|
||
const cardEl = document.createElement("article");
|
||
cardEl.className = "alpha-gematria-match";
|
||
|
||
const wordEl = document.createElement("div");
|
||
wordEl.className = "alpha-gematria-match-word";
|
||
wordEl.textContent = String(match?.word || "--");
|
||
cardEl.appendChild(wordEl);
|
||
|
||
const definition = String(match?.definition || "").trim();
|
||
if (definition) {
|
||
const definitionEl = document.createElement("div");
|
||
definitionEl.className = "alpha-gematria-match-definition";
|
||
definitionEl.textContent = definition;
|
||
cardEl.appendChild(definitionEl);
|
||
}
|
||
|
||
const ciphersEl = document.createElement("div");
|
||
ciphersEl.className = "alpha-gematria-match-ciphers";
|
||
const matchCiphers = Array.isArray(match?.ciphers) ? match.ciphers : [];
|
||
matchCiphers.slice(0, 6).forEach((cipher) => {
|
||
const chipEl = document.createElement("span");
|
||
chipEl.className = "alpha-gematria-match-cipher";
|
||
chipEl.textContent = String(cipher?.name || cipher?.id || "Unknown");
|
||
ciphersEl.appendChild(chipEl);
|
||
});
|
||
if (matchCiphers.length > 6) {
|
||
const extraEl = document.createElement("span");
|
||
extraEl.className = "alpha-gematria-match-cipher";
|
||
extraEl.textContent = `+${matchCiphers.length - 6} more`;
|
||
ciphersEl.appendChild(extraEl);
|
||
}
|
||
cardEl.appendChild(ciphersEl);
|
||
|
||
fragment.appendChild(cardEl);
|
||
});
|
||
|
||
matchesEl.replaceChildren(fragment);
|
||
matchesEl.hidden = false;
|
||
}
|
||
|
||
async function renderReverseLookupResult() {
|
||
const { resultEl, breakdownEl, matchesEl } = getElements();
|
||
if (!resultEl || !breakdownEl || !matchesEl) {
|
||
return;
|
||
}
|
||
|
||
const rawValue = state.reverseInputText;
|
||
if (!String(rawValue || "").trim()) {
|
||
resultEl.textContent = "Value: --";
|
||
breakdownEl.textContent = "Enter a whole number to find words across all available ciphers.";
|
||
matchesEl.hidden = false;
|
||
setMatchesMessage(matchesEl, "Reverse lookup searches the API-backed gematria word index.");
|
||
return;
|
||
}
|
||
|
||
const numericValue = parseReverseLookupValue(rawValue);
|
||
if (!Number.isFinite(numericValue)) {
|
||
resultEl.textContent = "Value: --";
|
||
breakdownEl.textContent = "Enter digits only to search the reverse gematria index.";
|
||
matchesEl.hidden = false;
|
||
setMatchesMessage(matchesEl, "Use a whole number such as 33 or 418.");
|
||
return;
|
||
}
|
||
|
||
const requestId = state.reverseRequestId + 1;
|
||
state.reverseRequestId = requestId;
|
||
resultEl.textContent = `Value: ${formatCount(numericValue)}`;
|
||
breakdownEl.textContent = "Searching reverse gematria index...";
|
||
matchesEl.hidden = false;
|
||
setMatchesMessage(matchesEl, "Loading matching words...");
|
||
|
||
try {
|
||
const payload = await loadReverseLookup(numericValue);
|
||
if (requestId !== state.reverseRequestId || !isReverseMode()) {
|
||
return;
|
||
}
|
||
renderReverseLookupMatches(payload, numericValue);
|
||
} catch {
|
||
if (requestId !== state.reverseRequestId || !isReverseMode()) {
|
||
return;
|
||
}
|
||
resultEl.textContent = `Value: ${formatCount(numericValue)}`;
|
||
breakdownEl.textContent = "Reverse lookup is unavailable right now.";
|
||
matchesEl.hidden = false;
|
||
setMatchesMessage(matchesEl, "Unable to load reverse gematria words from the API.");
|
||
}
|
||
}
|
||
|
||
function computeGematria(text, cipher, baseAlphabet) {
|
||
const normalizedInput = normalizeGematriaText(text);
|
||
const scriptMap = state.scriptCharMap instanceof Map
|
||
? state.scriptCharMap
|
||
: new Map();
|
||
|
||
const letterParts = [];
|
||
let total = 0;
|
||
let count = 0;
|
||
|
||
[...normalizedInput].forEach((char) => {
|
||
const mappedLetters = baseAlphabet.includes(char)
|
||
? char
|
||
: (scriptMap.get(char) || "");
|
||
|
||
if (!mappedLetters) {
|
||
return;
|
||
}
|
||
|
||
[...mappedLetters].forEach((mappedChar) => {
|
||
const index = baseAlphabet.indexOf(mappedChar);
|
||
if (index < 0) {
|
||
return;
|
||
}
|
||
|
||
const value = Number(cipher.values[index]);
|
||
if (!Number.isFinite(value)) {
|
||
return;
|
||
}
|
||
|
||
count += 1;
|
||
total += value;
|
||
letterParts.push(`${mappedChar.toUpperCase()}(${value})`);
|
||
});
|
||
});
|
||
|
||
return {
|
||
total,
|
||
count,
|
||
breakdown: letterParts.join(" + ")
|
||
};
|
||
}
|
||
|
||
function renderForwardGematriaResult() {
|
||
const { resultEl, breakdownEl } = getElements();
|
||
if (!resultEl || !breakdownEl) {
|
||
return;
|
||
}
|
||
|
||
const db = state.db || getFallbackGematriaDb();
|
||
if (!(state.scriptCharMap instanceof Map) || !state.scriptCharMap.size) {
|
||
refreshScriptMap(db.baseAlphabet);
|
||
}
|
||
|
||
const cipher = getActiveGematriaCipher();
|
||
if (!cipher) {
|
||
resultEl.textContent = "Total: --";
|
||
breakdownEl.textContent = "No ciphers available.";
|
||
return;
|
||
}
|
||
|
||
const { total, count, breakdown } = computeGematria(state.forwardInputText, cipher, db.baseAlphabet);
|
||
|
||
resultEl.textContent = `Total: ${total}`;
|
||
if (!count) {
|
||
breakdownEl.textContent = `Using ${cipher.name}. Enter English, Greek, or Hebrew letters to calculate.`;
|
||
return;
|
||
}
|
||
|
||
breakdownEl.textContent = `${cipher.name} · ${count} letters · ${breakdown} = ${total}`;
|
||
}
|
||
|
||
function renderGematriaResult() {
|
||
updateModeUi();
|
||
|
||
if (isReverseMode()) {
|
||
void renderReverseLookupResult();
|
||
return;
|
||
}
|
||
|
||
renderForwardGematriaResult();
|
||
}
|
||
|
||
function bindGematriaListeners() {
|
||
const { cipherEl, inputEl, modeToggleEl } = getElements();
|
||
if (state.listenersBound || !cipherEl || !inputEl) {
|
||
return;
|
||
}
|
||
|
||
cipherEl.addEventListener("change", () => {
|
||
state.activeCipherId = String(cipherEl.value || "").trim();
|
||
renderGematriaResult();
|
||
});
|
||
|
||
inputEl.addEventListener("input", () => {
|
||
if (isReverseMode()) {
|
||
state.reverseInputText = inputEl.value || "";
|
||
} else {
|
||
state.forwardInputText = inputEl.value || "";
|
||
}
|
||
renderGematriaResult();
|
||
});
|
||
|
||
modeToggleEl?.addEventListener("change", () => {
|
||
state.activeMode = modeToggleEl.checked ? "reverse" : "forward";
|
||
updateModeUi();
|
||
renderGematriaResult();
|
||
});
|
||
|
||
state.listenersBound = true;
|
||
}
|
||
|
||
function ensureCalculator() {
|
||
const { cipherEl, inputEl, resultEl, breakdownEl } = getElements();
|
||
if (!cipherEl || !inputEl || !resultEl || !breakdownEl) {
|
||
return;
|
||
}
|
||
|
||
bindGematriaListeners();
|
||
updateModeUi();
|
||
|
||
void loadGematriaDb().then(() => {
|
||
refreshScriptMap((state.db || getFallbackGematriaDb()).baseAlphabet);
|
||
renderGematriaCipherOptions();
|
||
renderGematriaResult();
|
||
});
|
||
}
|
||
|
||
function init(nextConfig = {}) {
|
||
config = {
|
||
...config,
|
||
...nextConfig
|
||
};
|
||
|
||
const configuredDb = getConfiguredGematriaDb();
|
||
if (configuredDb) {
|
||
state.db = sanitizeGematriaDb(configuredDb);
|
||
}
|
||
}
|
||
|
||
window.AlphabetGematriaUi = {
|
||
...(window.AlphabetGematriaUi || {}),
|
||
init,
|
||
refreshScriptMap,
|
||
ensureCalculator
|
||
};
|
||
})(); |