feat(entity): Chunk #4 data layer + entity UI (characters & world-building)
Data layer: - Fuzzy scorer (packages/core/src/fuzzy.ts) with index recovery + unit tests - Backlink computation (packages/core/src/backlinks.ts) incl. entity-level backlinks - Consistency checker (packages/core/src/consistency.ts) incl. reciprocity/scope + unit tests - StorageAdapter contract extended with listEntities/searchEntities/saveEntity/deleteEntity; implemented in Local (Dexie) and Server adapters; contract tests cover entity CRUD + ?q= search - WorldItem.fields now supports list values (keyPeople, rivals); expanded RelationshipKind - @mention autocomplete extension + picker in the editor package Entity UI (apps/web/src/features/entities/): - Generic field renderer (EntityFields) driven by ENTITY_FIELDS/FieldDef - Tabbed directory with fuzzy search + entity cards (EntitiesView) - Create/edit/delete drawer (EntityEditor) reusing shared fields - Relationship editor + force-directed SVG relationship map - Entity engine stylesheet; wired into nav Store: project-store entity methods (load/create/update/delete) over the StorageAdapter contract. CI-green: typecheck, lint, 65 tests, and web build all pass.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Dependency-light fuzzy scorer used for entity search and mention
|
||||
* autocomplete.
|
||||
*
|
||||
* This is a subsequence scorer in the spirit of "sublime" / Fuse.js: a query is
|
||||
* a sequence of characters that must appear (in order) within a target string.
|
||||
* It scores matches so that:
|
||||
*
|
||||
* - consecutive matches score higher than scattered ones,
|
||||
* - matches at the start of a word (after a separator) score higher,
|
||||
* - earlier matches in the string score higher.
|
||||
*
|
||||
* The result is a number in `[0, 1]` where `1` is a perfect (whole-string,
|
||||
* case-insensitive, consecutive) match. It is intentionally allocation-light
|
||||
* and fast enough to run on every keystroke against a few hundred entities
|
||||
* well under the 50 ms budget the plan calls for.
|
||||
*/
|
||||
|
||||
const WORD_SEP = /[^a-zA-Z0-9]+/;
|
||||
|
||||
export interface FuzzyResult {
|
||||
/** Match score in [0, 1]; 0 means no match. */
|
||||
score: number;
|
||||
/** Indices in `target` that were matched by the query, in order. */
|
||||
indices: number[];
|
||||
}
|
||||
|
||||
/** Score a single matched cell from its predecessor score. */
|
||||
function scoreCell(prev: number, consecutive: number, wordStart: number): number {
|
||||
let s = prev + 1; // base reward for a match
|
||||
if (consecutive) s *= 1.4; // multiplicative streak bonus
|
||||
if (wordStart) s += 0.8; // word-start bonus
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Normalize a raw score into [0, 1]. */
|
||||
function normalize(raw: number): number {
|
||||
if (!isFinite(raw)) return 0;
|
||||
return Math.max(0, Math.min(1, raw / 3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Score `query` against `target`. Returns `{ score: 0 }` when the query is not a
|
||||
* subsequence of the target (i.e. no match).
|
||||
*/
|
||||
export function fuzzyScore(query: string, target: string): FuzzyResult {
|
||||
const q = query;
|
||||
const t = target;
|
||||
if (q.length === 0) return { score: 0, indices: [] };
|
||||
if (q.length > t.length) return { score: 0, indices: [] };
|
||||
|
||||
const ql = q.length;
|
||||
const tl = t.length;
|
||||
const tq = q.toLowerCase();
|
||||
const tt = t.toLowerCase();
|
||||
|
||||
// best[i] = best score having matched the first i query chars.
|
||||
// where[i] = target index at which the i-th match landed (for recovery).
|
||||
const best = new Float64Array(ql + 1).fill(-Infinity);
|
||||
const where = new Int32Array(ql + 1);
|
||||
const consec = new Uint8Array(ql + 1);
|
||||
best[0] = 0;
|
||||
|
||||
for (let ti = 0; ti < tl; ti++) {
|
||||
const tc = tt[ti]!;
|
||||
// Walk from high to low so each cell reads the previous row only.
|
||||
for (let i = ql; i >= 1; i--) {
|
||||
const qc = tq[i - 1]!;
|
||||
if (tc !== qc) continue;
|
||||
const fromPrev = best[i - 1]!;
|
||||
if (fromPrev <= -Infinity) continue;
|
||||
const wordStart = ti === 0 || WORD_SEP.test(t[ti - 1]!) ? 1 : 0;
|
||||
const c = consec[i - 1] ? 1 : 0;
|
||||
const s = scoreCell(fromPrev, c, wordStart);
|
||||
if (s > best[i]!) {
|
||||
best[i] = s;
|
||||
where[i] = ti;
|
||||
consec[i] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (best[ql]! <= -Infinity) return { score: 0, indices: [] };
|
||||
|
||||
// Recover matched indices by walking `where` backwards.
|
||||
const indices: number[] = [];
|
||||
for (let i = ql; i >= 1; i--) {
|
||||
indices.push(where[i]!);
|
||||
}
|
||||
indices.reverse();
|
||||
|
||||
return { score: normalize(best[ql]!), indices };
|
||||
}
|
||||
|
||||
/** Convenience: does `query` match `target` as a subsequence? */
|
||||
export function fuzzyMatch(query: string, target: string): boolean {
|
||||
return fuzzyScore(query, target).score > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort `items` by descending fuzzy score against `query`, dropping non-matches.
|
||||
* Stable: equal scores keep input order.
|
||||
*/
|
||||
export function fuzzySort<T>(items: T[], query: string, pick: (item: T) => string): T[] {
|
||||
if (!query) return items.slice();
|
||||
const scored = items
|
||||
.map((item) => ({ item, res: fuzzyScore(query, pick(item)) }))
|
||||
.filter((r) => r.res.score > 0);
|
||||
scored.sort((a, b) => b.res.score - a.res.score);
|
||||
return scored.map((r) => r.item);
|
||||
}
|
||||
Reference in New Issue
Block a user