[verified] feat: add diagram toolbox capabilities

This commit is contained in:
2026-09-03 19:11:50 +00:00
parent 5146b14d43
commit fa62a5fc4c
16 changed files with 971 additions and 7 deletions
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import { OFFLINE_SHAPE_CATALOG, searchShapes } from "./shape-catalog.js";
test("offline catalog entries carry explicit public provenance and licensing", () => {
assert.ok(OFFLINE_SHAPE_CATALOG.length >= 6);
for (const entry of OFFLINE_SHAPE_CATALOG) {
assert.equal(entry.provenance.license, "Apache-2.0");
assert.match(entry.provenance.sourceUrl, /^https:\/\//);
assert.match(entry.style, /shape=/);
}
});
test("shape search ranks exact, alias, and fuzzy matches deterministically", () => {
assert.deepEqual(searchShapes("database"), searchShapes("database"));
assert.equal(searchShapes("database").matches[0].id, "cylinder");
assert.equal(searchShapes("database").matches[0].matchType, "alias");
assert.equal(searchShapes("rhombus").matches[0].matchType, "exact");
const fuzzy = searchShapes("proces box").matches[0];
assert.equal(fuzzy.id, "rectangle");
assert.equal(fuzzy.matchType, "fuzzy");
assert.ok(fuzzy.confidence > 0 && fuzzy.confidence < 0.9);
});
test("shape search returns an explicit generic fallback for unknown or blank queries", () => {
for (const query of ["quantum-flibbertigibbet", " "]) {
const result = searchShapes(query);
assert.equal(result.matches.length, 0);
assert.equal(result.fallback.id, "rectangle");
assert.equal(result.fallback.matchType, "fallback");
assert.equal(result.fallback.confidence, 0);
}
});
@@ -0,0 +1,98 @@
export interface ShapeProvenance {
sourceName: string;
sourceUrl: string;
license: "Apache-2.0";
note: string;
}
export interface ShapeCatalogEntry {
id: string;
name: string;
aliases: readonly string[];
style: string;
provenance: ShapeProvenance;
}
export type ShapeMatchType = "exact" | "alias" | "fuzzy" | "fallback";
export interface ShapeSearchMatch extends ShapeCatalogEntry {
confidence: number;
matchType: ShapeMatchType;
}
export interface ShapeSearchResult {
query: string;
matches: ShapeSearchMatch[];
fallback: ShapeSearchMatch;
}
const PROVENANCE: ShapeProvenance = {
sourceName: "diagrams.net generic built-in shape vocabulary",
sourceUrl: "https://github.com/jgraph/drawio",
license: "Apache-2.0",
note: "Hand-curated metadata for generic native Draw.io shapes; no third-party assets are bundled.",
};
export const OFFLINE_SHAPE_CATALOG: readonly ShapeCatalogEntry[] = Object.freeze([
entry("actor", "Actor", ["person", "user", "human"], "shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;"),
entry("cloud", "Cloud", ["internet", "external cloud"], "shape=cloud;"),
entry("cylinder", "Cylinder", ["database", "data store", "storage"], "shape=cylinder;"),
entry("document", "Document", ["file", "report", "paper"], "shape=document;"),
entry("ellipse", "Ellipse", ["oval", "start", "end"], "shape=ellipse;"),
entry("hexagon", "Hexagon", ["preparation", "compute"], "shape=hexagon;"),
entry("rectangle", "Rectangle", ["process", "box", "generic"], "shape=rectangle;"),
entry("rhombus", "Rhombus", ["decision", "diamond", "choice"], "shape=rhombus;"),
]);
function entry(id: string, name: string, aliases: readonly string[], style: string): ShapeCatalogEntry {
return Object.freeze({ id, name, aliases: Object.freeze([...aliases]), style, provenance: Object.freeze({ ...PROVENANCE }) });
}
function normalize(value: string): string {
return value.trim().toLocaleLowerCase("en-US").replace(/[_-]+/g, " ").replace(/\s+/g, " ");
}
function similarity(left: string, right: string): number {
if (left === right) return 1;
const a = [...left];
const b = [...right];
if (a.length === 0 || b.length === 0) return 0;
const previous = Array.from({ length: b.length + 1 }, (_, index) => index);
for (let row = 1; row <= a.length; row += 1) {
const current = [row];
for (let column = 1; column <= b.length; column += 1) {
current[column] = Math.min(
current[column - 1] + 1,
previous[column] + 1,
previous[column - 1] + (a[row - 1] === b[column - 1] ? 0 : 1),
);
}
previous.splice(0, previous.length, ...current);
}
return 1 - previous[b.length] / Math.max(a.length, b.length);
}
function match(entryValue: ShapeCatalogEntry, query: string): ShapeSearchMatch | undefined {
const id = normalize(entryValue.id);
const name = normalize(entryValue.name);
const aliases = entryValue.aliases.map(normalize);
if (query === id || query === name) return { ...entryValue, confidence: 1, matchType: "exact" };
if (aliases.includes(query)) return { ...entryValue, confidence: 0.95, matchType: "alias" };
const terms = [id, name, ...aliases];
const score = Math.max(...terms.map((term) => similarity(query, term)));
if (score < 0.45) return undefined;
return { ...entryValue, confidence: Number((score * 0.85).toFixed(6)), matchType: "fuzzy" };
}
export function searchShapes(rawQuery: string, limit = 5): ShapeSearchResult {
if (!Number.isInteger(limit) || limit < 1) throw new Error("Shape search limit must be a positive integer");
const query = normalize(rawQuery);
const generic = OFFLINE_SHAPE_CATALOG.find(({ id }) => id === "rectangle")!;
const fallback: ShapeSearchMatch = { ...generic, confidence: 0, matchType: "fallback" };
const matches = query === "" ? [] : OFFLINE_SHAPE_CATALOG
.map((candidate) => match(candidate, query))
.filter((candidate): candidate is ShapeSearchMatch => candidate !== undefined)
.sort((left, right) => right.confidence - left.confidence || left.id.localeCompare(right.id, "en-US"))
.slice(0, limit);
return { query: rawQuery, matches, fallback };
}
export const searchShapeCatalog = searchShapes;