fix: improve fuzzy handling

This commit is contained in:
2026-05-30 19:32:01 +07:00 Unverified
parent 4bf2466b3d
commit 39f1eb6988
13 changed files with 280 additions and 264 deletions
+229 -228
View File
@@ -1,253 +1,254 @@
<!DOCTYPE html> <!doctype html>
<html> <html>
<head>
<head> <meta charset="utf-8" />
<meta charset="utf-8" /> <style>
<style> body {
body { margin: 0;
margin: 0; background: #111;
background: #111; color: white;
color: white; font-family: sans-serif;
font-family: sans-serif; overflow: auto;
overflow: auto;
}
.bar {
position: sticky;
top: 0;
z-index: 10;
background: #000c;
padding: 8px;
display: flex;
gap: 12px;
align-items: center;
}
button,
select,
input {
font: inherit;
}
canvas {
display: block;
margin: 12px auto;
background: #000;
}
</style>
</head>
<body>
<div class="bar">
<button id="refresh">refresh</button>
<label>
mode
<select id="mode">
<option value="edge">edge diff</option>
<option value="diff">raw diff</option>
<option value="overlay">red/green overlay</option>
</select>
</label>
<label>
threshold
<input id="threshold" type="range" min="0" max="120" value="85">
<span id="thresholdText">85</span>
</label>
<label>
time: <span id="timingText">Loading...</span><span style="color: gray;">(image load complete in browser)</span>
</label>
</div>
<canvas id="canvas" width="1870" height="778"></canvas>
<script>
const urlParams = new URLSearchParams(window.location.search);
const type = urlParams.get('type');
let MINE, ORIG, W, H;
if (type === 'list') {
MINE = "/v1/card/genshin/775956514";
ORIG = "/v1/card/genshin/diff/listoriginal.webp";
W = 1200;
H = 732;
} else {
MINE = "/v1/card/genshin/775956514/ororon";
ORIG = "/v1/card/genshin/diff/ororo.png";
W = 1870;
H = 778;
}
/**
* @type {HTMLCanvasElement}
*/
const canvas = document.getElementById("canvas");
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const mode = document.getElementById("mode");
const threshold = document.getElementById("threshold");
const thresholdText = document.getElementById("thresholdText");
const timingText = document.getElementById("timingText");
let mineData, origData, refreshing = false;
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src + (src.includes("?") ? "&" : "?") + "cacheBust=" + Date.now();
});
}
function drawToData(img) {
const c = document.createElement("canvas");
c.width = W;
c.height = H;
const x = c.getContext("2d", { willReadFrequently: true });
x.drawImage(img, 0, 0, W, H);
return x.getImageData(0, 0, W, H);
}
async function refresh() {
if (refreshing) return;
refreshing = true;
ctx.fillStyle = "#00000088";
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = "white";
let line = 30;
function log(text) {
ctx.fillText(text, 20, line);
line += 16;
} }
log("fetching...");
const start = Date.now(); .bar {
const [mineImg, origImg] = await Promise.all([ position: sticky;
loadImage(MINE).finally(() => timingText.innerText = `${Date.now() - start}ms`), top: 0;
loadImage(ORIG), z-index: 10;
]); background: #000c;
padding: 8px;
display: flex;
gap: 12px;
align-items: center;
}
log("loading..."); button,
mineData = drawToData(mineImg); select,
if (!origData) origData = drawToData(origImg); input {
font: inherit;
}
log("rendering..."); canvas {
render(); display: block;
refreshing = false; margin: 12px auto;
} background: #000;
}
</style>
</head>
function lum(d, i) { <body>
return d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114; <div class="bar">
} <button id="refresh">refresh</button>
<label>
mode
<select id="mode">
<option value="edge">edge diff</option>
<option value="diff">raw diff</option>
<option value="overlay">red/green overlay</option>
</select>
</label>
<label>
threshold
<input id="threshold" type="range" min="0" max="120" value="85" />
<span id="thresholdText">85</span>
</label>
<label>
time: <span id="timingText">Loading...</span
><span style="color: gray">(image load complete in browser)</span>
</label>
</div>
function edgeAt(data, x, y) { <canvas id="canvas" width="1870" height="778"></canvas>
const d = data.data;
const i = (y * W + x) * 4;
const l = lum(d, i); <script>
const lx = x > 0 ? lum(d, i - 4) : l; const urlParams = new URLSearchParams(window.location.search);
const rx = x < W - 1 ? lum(d, i + 4) : l; const type = urlParams.get("type");
const uy = y > 0 ? lum(d, i - W * 4) : l;
const dy = y < H - 1 ? lum(d, i + W * 4) : l;
return Math.abs(rx - lx) + Math.abs(dy - uy); let MINE, ORIG, W, H;
} if (type === "list") {
MINE = "/v1/card/genshin/775956514";
ORIG = "/v1/card/genshin/diff/listoriginal.webp";
W = 1200;
H = 732;
} else {
MINE = "/v1/card/genshin/775956514/ororon";
ORIG = "/v1/card/genshin/diff/ororo.png";
W = 1870;
H = 778;
}
function render() { /**
if (!mineData || !origData) return; * @type {HTMLCanvasElement}
*/
const canvas = document.getElementById("canvas");
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const out = ctx.createImageData(W, H); const mode = document.getElementById("mode");
const o = out.data; const threshold = document.getElementById("threshold");
const m = mineData.data; const thresholdText = document.getElementById("thresholdText");
const r = origData.data; const timingText = document.getElementById("timingText");
const t = Number(threshold.value);
thresholdText.textContent = t; let mineData,
origData,
refreshing = false;
for (let y = 0; y < H; y++) { function loadImage(src) {
for (let x = 0; x < W; x++) { return new Promise((resolve, reject) => {
const i = (y * W + x) * 4; const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = reject;
img.src =
src + (src.includes("?") ? "&" : "?") + "cacheBust=" + Date.now();
});
}
if (mode.value === "edge") { function drawToData(img) {
const me = edgeAt(mineData, x, y); const c = document.createElement("canvas");
const oe = edgeAt(origData, x, y); c.width = W;
c.height = H;
const x = c.getContext("2d", { willReadFrequently: true });
x.drawImage(img, 0, 0, W, H);
return x.getImageData(0, 0, W, H);
}
const mineEdge = me > t; async function refresh() {
const origEdge = oe > t; if (refreshing) return;
refreshing = true;
ctx.fillStyle = "#00000088";
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = "white";
let line = 30;
function log(text) {
ctx.fillText(text, 20, line);
line += 16;
}
log("fetching...");
if (mineEdge && origEdge) { const start = Date.now();
// matching = green const [mineImg, origImg] = await Promise.all([
o[i] = 0; loadImage(MINE).finally(
o[i + 1] = 255; () => (timingText.innerText = `${Date.now() - start}ms`),
o[i + 2] = 0; ),
o[i + 3] = 255; loadImage(ORIG),
} else if (origEdge) { ]);
// original missing from mine = red
o[i] = 255; log("loading...");
o[i + 1] = 0; mineData = drawToData(mineImg);
o[i + 2] = 0; if (!origData) origData = drawToData(origImg);
o[i + 3] = 255;
} else if (mineEdge) { log("rendering...");
// extra in mine = yellow render();
o[i] = 255; refreshing = false;
o[i + 1] = 255; }
o[i + 2] = 0;
o[i + 3] = 255; function lum(d, i) {
return d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114;
}
function edgeAt(data, x, y) {
const d = data.data;
const i = (y * W + x) * 4;
const l = lum(d, i);
const lx = x > 0 ? lum(d, i - 4) : l;
const rx = x < W - 1 ? lum(d, i + 4) : l;
const uy = y > 0 ? lum(d, i - W * 4) : l;
const dy = y < H - 1 ? lum(d, i + W * 4) : l;
return Math.abs(rx - lx) + Math.abs(dy - uy);
}
function render() {
if (!mineData || !origData) return;
const out = ctx.createImageData(W, H);
const o = out.data;
const m = mineData.data;
const r = origData.data;
const t = Number(threshold.value);
thresholdText.textContent = t;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4;
if (mode.value === "edge") {
const me = edgeAt(mineData, x, y);
const oe = edgeAt(origData, x, y);
const mineEdge = me > t;
const origEdge = oe > t;
if (mineEdge && origEdge) {
// matching = green
o[i] = 0;
o[i + 1] = 255;
o[i + 2] = 0;
o[i + 3] = 255;
} else if (origEdge) {
// original missing from mine = red
o[i] = 255;
o[i + 1] = 0;
o[i + 2] = 0;
o[i + 3] = 255;
} else if (mineEdge) {
// extra in mine = yellow
o[i] = 255;
o[i + 1] = 255;
o[i + 2] = 0;
o[i + 3] = 255;
} else {
o[i + 3] = 0;
}
} else if (mode.value === "diff") {
const dr = Math.abs(m[i] - r[i]);
const dg = Math.abs(m[i + 1] - r[i + 1]);
const db = Math.abs(m[i + 2] - r[i + 2]);
const diff = Math.max(dr, dg, db);
o[i] = diff;
o[i + 1] = diff;
o[i + 2] = diff;
o[i + 3] = diff > t ? 255 : 0;
} else { } else {
o[i + 3] = 0; o[i] = m[i]; // mine red-ish
o[i + 1] = r[i + 1]; // original green-ish
o[i + 2] = 0;
o[i + 3] = 220;
} }
} }
else if (mode.value === "diff") {
const dr = Math.abs(m[i] - r[i]);
const dg = Math.abs(m[i + 1] - r[i + 1]);
const db = Math.abs(m[i + 2] - r[i + 2]);
const diff = Math.max(dr, dg, db);
o[i] = diff;
o[i + 1] = diff;
o[i + 2] = diff;
o[i + 3] = diff > t ? 255 : 0;
}
else {
o[i] = m[i]; // mine red-ish
o[i + 1] = r[i + 1]; // original green-ish
o[i + 2] = 0;
o[i + 3] = 220;
}
} }
ctx.putImageData(out, 0, 0);
} }
ctx.putImageData(out, 0, 0); document.getElementById("refresh").onclick = refresh;
} mode.oninput = render;
threshold.oninput = render;
document.getElementById("refresh").onclick = refresh; refresh().catch((err) => {
mode.oninput = render; console.error(err);
threshold.oninput = render; ctx.fillStyle = "red";
ctx.fillText(String(err), 20, 30);
});
refresh().catch(err => { window.addEventListener("keydown", (e) => {
console.error(err); if (
ctx.fillStyle = "red"; e.key === "Enter" &&
ctx.fillText(String(err), 20, 30); !["INPUT", "SELECT"].includes(e.target.tagName)
}); ) {
e.preventDefault();
refresh();
}
});
window.addEventListener("keydown", (e) => { // window.addEventListener("focus", () => {
if (e.key === "Enter" && !["INPUT", "SELECT"].includes(e.target.tagName)) { // refresh();
e.preventDefault(); // });
refresh(); </script>
} </body>
}); </html>
// window.addEventListener("focus", () => {
// refresh();
// });
</script>
</body>
</html>
+6 -3
View File
@@ -55,7 +55,8 @@ export function endfieldCard(app: App) {
params: t.Object({ uid: t.String() }), params: t.Object({ uid: t.String() }),
detail: { detail: {
tags: ["Endfield"], tags: ["Endfield"],
description: "Preload data required to render an Endfield template card.", description:
"Preload data required to render an Endfield template card.",
}, },
afterResponse: ({ log }) => log`Data preload complete`, afterResponse: ({ log }) => log`Data preload complete`,
}, },
@@ -121,7 +122,8 @@ export function endfieldCard(app: App) {
{ http: "http", card: "card" }, { http: "http", card: "card" },
{ {
default: "http", default: "http",
description: "Type of error to return when card fails to render.", description:
"Type of error to return when card fails to render.",
}, },
), ),
debug: t.Boolean({ debug: t.Boolean({
@@ -134,7 +136,8 @@ export function endfieldCard(app: App) {
), ),
detail: { detail: {
tags: ["Endfield"], tags: ["Endfield"],
description: "Generate a placeholder Endfield profile card image from a UID.", description:
"Generate a placeholder Endfield profile card image from a UID.",
}, },
afterResponse: ({ log }) => log`Rendering completed`, afterResponse: ({ log }) => log`Rendering completed`,
}, },
+8 -3
View File
@@ -61,7 +61,9 @@ export function EndfieldTemplate({
<div tw="text-[1.55em] leading-[1.15] font-semibold"> <div tw="text-[1.55em] leading-[1.15] font-semibold">
Route + Card Shell Route + Card Shell
</div> </div>
<div tw="text-white/60 mt-[.35em]">Ready for game-specific layout work</div> <div tw="text-white/60 mt-[.35em]">
Ready for game-specific layout work
</div>
</div> </div>
<div tw="rounded-[1em] border border-white/10 bg-black/20 p-[1.2em]"> <div tw="rounded-[1em] border border-white/10 bg-black/20 p-[1.2em]">
@@ -71,7 +73,9 @@ export function EndfieldTemplate({
<div tw="text-[1.55em] leading-[1.15] font-semibold"> <div tw="text-[1.55em] leading-[1.15] font-semibold">
Character Grid Character Grid
</div> </div>
<div tw="text-white/60 mt-[.35em]">Replace placeholder sections with Endfield data</div> <div tw="text-white/60 mt-[.35em]">
Replace placeholder sections with Endfield data
</div>
</div> </div>
</div> </div>
@@ -80,7 +84,8 @@ export function EndfieldTemplate({
Signature Signature
</div> </div>
<div tw="text-[1.05em] leading-[1.45] text-white/82 whitespace-pre-wrap"> <div tw="text-[1.05em] leading-[1.45] text-white/82 whitespace-pre-wrap">
{signature?.trim() || "No signature available in the fetched payload yet."} {signature?.trim() ||
"No signature available in the fetched payload yet."}
</div> </div>
</div> </div>
</div> </div>
+5 -6
View File
@@ -3,7 +3,7 @@ import { mkdir, readdir, rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { file, hash, write } from "bun"; import { file, hash, write } from "bun";
import type { ResolvedPlayerInfo} from "enka.ts"; import type { ResolvedPlayerInfo } from "enka.ts";
import { TTLCache } from "enka.ts"; import { TTLCache } from "enka.ts";
import type { import type {
@@ -13,11 +13,9 @@ import type {
EnkaNamecards, EnkaNamecards,
EnkaPfps, EnkaPfps,
RelicStore, RelicStore,
WeaponStore} from "@/games/genshin/types/enka"; WeaponStore,
import {
ElementList
} from "@/games/genshin/types/enka"; } from "@/games/genshin/types/enka";
import { ElementList } from "@/games/genshin/types/enka";
import { Deduplicator } from "@/lib/dedupe"; import { Deduplicator } from "@/lib/dedupe";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
@@ -210,10 +208,11 @@ export async function getEnkaStore<K extends keyof StoreTypeMap>(
return data; return data;
} }
// DEPRECATED: No longer needed
export function getAvatar( export function getAvatar(
store: AvatarStore, store: AvatarStore,
playerInfo: ResolvedPlayerInfo, playerInfo: ResolvedPlayerInfo,
id: number, id: string | number,
) { ) {
return ( return (
Object.entries(store).find( Object.entries(store).find(
+6 -3
View File
@@ -1,9 +1,9 @@
import type { BaseAvatar } from "enka.ts"; import type { BaseAvatar } from "enka.ts";
import { distance } from "fastest-levenshtein"; import { distance } from "fastest-levenshtein";
function shortAvatarId(id: number) { function shortAvatarId(id: string | number) {
// 10000120 -> 120 // 10000120 -> 120
return String(id).replace(/^10*/, ""); return String(id).replace(/^10*/, "").replace(/^-\d+/, "");
} }
function norm(s: string) { function norm(s: string) {
@@ -60,5 +60,8 @@ function fuzzyNameMatch<T extends { avatar: BaseAvatar }>(
} }
function avatarAliases(name: string) { function avatarAliases(name: string) {
return [name, ...name.split(" ")]; const aliases: Record<string, string[]> = {
Traveler: ["Aether", "Lumine"],
};
return [name, ...name.split(" "), ...(aliases[name] ?? [])];
} }
+4 -1
View File
@@ -2,7 +2,10 @@ import type { ResolvedAvatarInfo } from "enka.ts";
import { AvatarElement } from "enka.ts"; import { AvatarElement } from "enka.ts";
import type { EnkaLocsLanguage } from "@/games/genshin/types/enka"; import type { EnkaLocsLanguage } from "@/games/genshin/types/enka";
import { FightPropMapEnum, PercentageFightProp } from "@/games/genshin/types/fightPropMap"; import {
FightPropMapEnum,
PercentageFightProp,
} from "@/games/genshin/types/fightPropMap";
// Types // Types
interface DisplayRow { interface DisplayRow {
+4 -1
View File
@@ -1,5 +1,8 @@
import type { Affix } from "@/games/genshin/types/enka"; import type { Affix } from "@/games/genshin/types/enka";
import { FightPropMapEnum, PercentageFightProp } from "@/games/genshin/types/fightPropMap"; import {
FightPropMapEnum,
PercentageFightProp,
} from "@/games/genshin/types/fightPropMap";
export type RollSummary = { export type RollSummary = {
id: string; id: string;
-1
View File
@@ -19,7 +19,6 @@ import {
} from "@/games/genshin/types/enka"; } from "@/games/genshin/types/enka";
import type { Logger } from "@/lib/logger"; import type { Logger } from "@/lib/logger";
import { arrRandom } from "@/lib/util"; import { arrRandom } from "@/lib/util";
import { CardResponse } from "@/v1/response"; import { CardResponse } from "@/v1/response";
export async function ListPage( export async function ListPage(
+1 -1
View File
@@ -1,6 +1,6 @@
import { t } from "elysia"; import { t } from "elysia";
export type AvatarStore = Record<number, Avatar>; export type AvatarStore = Record<string | number, Avatar>;
export interface Avatar { export interface Avatar {
Element: ElementType; Element: ElementType;
+1 -1
View File
@@ -1,3 +1,4 @@
import { cors } from "@elysia/cors";
import openapi from "@elysiajs/openapi"; import openapi from "@elysiajs/openapi";
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
@@ -5,7 +6,6 @@ import { version } from "../package.json";
import { pm } from "./lib/auth"; import { pm } from "./lib/auth";
import { createLogger } from "./lib/logger"; import { createLogger } from "./lib/logger";
import { v1routes } from "./v1"; import { v1routes } from "./v1";
import { cors } from '@elysia/cors'
const log = createLogger("APP"); const log = createLogger("APP");
+1 -1
View File
@@ -1,5 +1,5 @@
import { feature } from "bun:bundle"; import { feature } from "bun:bundle";
import type { InspectColor} from "node:util"; import type { InspectColor } from "node:util";
import { styleText } from "node:util"; import { styleText } from "node:util";
import { inspect } from "bun"; import { inspect } from "bun";
+14 -14
View File
@@ -10,19 +10,19 @@ export function v1routes(app: App) {
.use(card) .use(card)
// .use(endfieldCard) // .use(endfieldCard)
.get( .get(
"/status/:status", "/status/:status",
async ({ params: { status }, set }) => { async ({ params: { status }, set }) => {
const res = await fetch( const res = await fetch(
`http://mts.dgnr.us:3999/__error/${status}/testerror.astrxl.dev`, `http://mts.dgnr.us:3999/__error/${status}/testerror.astrxl.dev`,
); );
set.headers["content-type"] = "text/html"; set.headers["content-type"] = "text/html";
return res; return res;
}, },
{ {
params: t.Object({ params: t.Object({
status: t.RegExp(/^[1-5][0-9][0-9]$/), status: t.RegExp(/^[1-5][0-9][0-9]$/),
}), }),
}, },
), ),
); );
} }
+1 -1
View File
@@ -1,6 +1,6 @@
import { join } from "node:path"; import { join } from "node:path";
import type { MaybePromise} from "bun"; import type { MaybePromise } from "bun";
import { $, write } from "bun"; import { $, write } from "bun";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";