Skip to content
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
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ It wraps the MIT-licensed [`milsymbol`](https://www.npmjs.com/package/milsymbol)
- build known SIDCs from structured parts
- render SIDCs to SVG with `milsymbol`

V0 can render syntactically valid 30-digit SIDCs that `milsymbol` supports. Search, explain, and build intentionally support only a tiny curated set and do not claim exhaustive MIL-STD-2525 or STANAG APP-6 semantic coverage.
V0 can render syntactically valid 30-digit SIDCs that `milsymbol` supports. Search and build intentionally support only a tiny curated set. Explain returns curated semantics when a SIDC is in that set and partial field decomposition for other renderable number SIDCs where `milsymbol` or the curated function-ID table provides a label. It does not claim exhaustive MIL-STD-2525 or STANAG APP-6 semantic coverage.

## Install

Expand Down Expand Up @@ -59,7 +59,15 @@ Performs deterministic lexical matching over curated names, aliases, and part la

### `explainSidc(sidc)`

Explains a curated 30-digit SIDC into a stable JSON-serializable object. Unknown but syntactically valid SIDCs fail with `UNSUPPORTED_SIDC`.
Explains a 30-digit SIDC into a stable JSON-serializable object.

Curated SIDCs return `coverage: "curated"` with `name`, `aliases`, and the curated `parts` object. Non-curated SIDCs that `milsymbol` can validate return `coverage: "partial"` with:

- `parts`: only the interpreted fields
- `fields`: per-field `code`, optional `value`, and `coverage`
- `unknownFields`: field names that were present in the SIDC but not interpreted

Unsupported or malformed SIDCs still fail with typed `SidcKitError` codes such as `INVALID_SIDC` or `UNSUPPORTED_SIDC`.

### `buildSidc(parts)`

Expand All @@ -74,6 +82,8 @@ Renders a syntactically valid 30-digit SIDC with `milsymbol` and returns SVG plu

Rendering coverage follows the installed `milsymbol` package. The curated semantic set includes a few common land-unit examples such as friendly infantry platoon, hostile infantry platoon, armor platoon, artillery platoon, reconnaissance platoon, and infantry company.

Partial decomposition is intentionally limited to affiliation, symbol set, status, domain, echelon, and entity. Entity labels come from function IDs already present in the curated table; unknown function IDs are reported through `unknownFields` instead of guessed. Status is labeled only when it is present or when `milsymbol` exposes a semantic condition label; otherwise status is reported through `unknownFields`.

Image-based reverse lookup is intentionally deferred.

## Changelog
Expand Down
247 changes: 227 additions & 20 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import ms from "milsymbol";
import ms, { type SymbolMetadata } from "milsymbol";

import { curatedSymbols, type CuratedSymbol, type SymbolParts } from "./data/symbols.js";
import { SidcKitError } from "./errors.js";
import { SidcKitError, type SidcKitErrorCode } from "./errors.js";

export { SidcKitError } from "./errors.js";
export type { CuratedSymbol, SymbolParts } from "./data/symbols.js";
Expand All @@ -25,19 +25,48 @@ export type RenderSymbolResult = {
};
};

export type ExplainSidcResult = {
export type ExplainSidcCoverage = "curated" | "partial";

export type SidcFieldCoverage = "curated" | "known" | "unknown";

export type SidcField = {
code: string;
coverage: SidcFieldCoverage;
value?: string;
};

export type SidcFieldName = "affiliation" | "symbolSet" | "status" | "domain" | "echelon" | "entity";

export type ExplainSidcFields = Record<SidcFieldName, SidcField>;

export type PartialSymbolParts = Partial<SymbolParts>;

type BaseExplainSidcResult = {
sidc: string;
name: string;
aliases: string[];
fields: ExplainSidcFields;
unknownFields: SidcFieldName[];
};

export type CuratedExplainSidcResult = BaseExplainSidcResult & {
name: string;
parts: SymbolParts;
coverage: "curated";
};

export type PartialExplainSidcResult = BaseExplainSidcResult & {
name?: never;
parts: PartialSymbolParts;
coverage: "partial";
};

export type ExplainSidcResult = CuratedExplainSidcResult | PartialExplainSidcResult;

export type SymbolSearchOptions = {
limit?: number;
};

export type SymbolSearchResult = ExplainSidcResult & {
export type SymbolSearchResult = CuratedExplainSidcResult & {
score: number;
};

Expand All @@ -50,16 +79,25 @@ export type BuildSidcInput = Partial<Omit<SymbolParts, "standard" | "status" | "
const sidcPattern = /^\d{30}$/;
const disambiguatingPartKeys = ["entityType", "entitySubtype", "echelon"] as const;
type DisambiguatingPartKey = (typeof disambiguatingPartKeys)[number];
type MilsymbolSymbol = InstanceType<typeof ms.Symbol>;
type EntityParts = Pick<SymbolParts, "entity"> & Partial<Pick<SymbolParts, "entityType" | "entitySubtype">>;

const functionEntityParts = new Map<string, EntityParts>(
(curatedSymbols as readonly CuratedSymbol[]).map((symbol) => [
getFunctionId(symbol.sidc),
{
entity: symbol.parts.entity,
...(symbol.parts.entityType ? { entityType: symbol.parts.entityType } : {}),
...(symbol.parts.entitySubtype ? { entitySubtype: symbol.parts.entitySubtype } : {})
}
])
);

export function renderSymbol(sidc: string, options: RenderSymbolOptions = {}): RenderSymbolResult {
const normalizedSidc = normalizeSidc(sidc);

try {
const symbol = new ms.Symbol(normalizedSidc, options);
const metadata = symbol.getMetadata();
if (symbol.isValid() !== true || metadata.dimensionUnknown) {
throw new SidcKitError("RENDER_FAILED", `milsymbol does not support SIDC ${normalizedSidc}.`);
}
const { symbol } = createSupportedSymbol(normalizedSidc, options, "RENDER_FAILED", "render");

const svg = symbol.asSVG();
const anchor = toPoint(symbol.getAnchor?.());
Expand All @@ -85,8 +123,13 @@ export function renderSymbol(sidc: string, options: RenderSymbolOptions = {}): R

export function explainSidc(sidc: string): ExplainSidcResult {
const normalizedSidc = normalizeSidc(sidc);
const symbol = requireCuratedSidc(normalizedSidc);
return explainSymbol(symbol);
const symbol = findCuratedSidc(normalizedSidc);
if (symbol) {
return explainSymbol(symbol);
}

const { metadata } = createSupportedSymbol(normalizedSidc, {}, "UNSUPPORTED_SIDC", "explain");
return explainPartialSidc(normalizedSidc, metadata);
}

export function searchSymbols(query: string, options: SymbolSearchOptions = {}): SymbolSearchResult[] {
Expand Down Expand Up @@ -147,21 +190,34 @@ function normalizeSidc(sidc: string): string {
return normalizedSidc;
}

function requireCuratedSidc(sidc: string): CuratedSymbol {
const symbol = curatedSymbols.find((candidate) => candidate.sidc === sidc);
if (!symbol) {
throw new SidcKitError("UNSUPPORTED_SIDC", `SIDC ${sidc} is not in the curated V0 fixture set.`);
}
return symbol;
function findCuratedSidc(sidc: string): CuratedSymbol | undefined {
return curatedSymbols.find((candidate) => candidate.sidc === sidc);
}

function explainSymbol(symbol: CuratedSymbol): ExplainSidcResult {
function explainSymbol(symbol: CuratedSymbol): CuratedExplainSidcResult {
const fields = buildFields(symbol.sidc, symbol.parts, "curated");
return {
sidc: symbol.sidc,
name: symbol.name,
aliases: [...symbol.aliases],
parts: { ...symbol.parts },
coverage: "curated"
coverage: "curated",
fields,
unknownFields: getUnknownFields(fields)
};
}

function explainPartialSidc(sidc: string, metadata: SymbolMetadata): PartialExplainSidcResult {
const parts = buildPartialParts(sidc, metadata);
const fields = buildFields(sidc, parts, "known");

return {
sidc,
aliases: [],
parts,
coverage: "partial",
fields,
unknownFields: getUnknownFields(fields)
};
}

Expand Down Expand Up @@ -228,6 +284,157 @@ function formatPartList(parts: readonly string[]): string {
return `${parts.slice(0, -1).join(", ")}, or ${parts[parts.length - 1]}`;
}

function createSupportedSymbol(
sidc: string,
options: RenderSymbolOptions,
failureCode: SidcKitErrorCode,
action: "explain" | "render"
): { symbol: MilsymbolSymbol; metadata: SymbolMetadata } {
try {
const symbol = new ms.Symbol(sidc, options);
const metadata = symbol.getMetadata();
if (symbol.isValid() !== true || metadata.dimensionUnknown) {
throw new SidcKitError(failureCode, `milsymbol does not support SIDC ${sidc}.`);
}

return { symbol, metadata };
} catch (error) {
if (error instanceof SidcKitError) {
throw error;
}

throw new SidcKitError(
failureCode,
`Failed to ${action} SIDC ${sidc}: ${error instanceof Error ? error.message : String(error)}`
);
}
}

function buildPartialParts(sidc: string, metadata: SymbolMetadata): PartialSymbolParts {
const domain = normalizeDimension(metadata.dimension);
const entityParts = functionEntityParts.get(metadata.functionid);
const symbolSet = getSymbolSetLabel(domain, metadata);
const affiliation = normalizeMetadataLabel(metadata.affiliation);
const status = getStatusLabel(sidc.slice(6, 7), metadata);
const echelon = normalizeMetadataLabel(metadata.echelon);
const parts: PartialSymbolParts = {};

if (symbolSet) {
parts.symbolSet = symbolSet;
}
if (affiliation) {
parts.affiliation = affiliation;
}
if (status) {
parts.status = status;
}
if (domain) {
parts.domain = domain;
}
if (entityParts) {
Object.assign(parts, entityParts);
}
if (echelon) {
parts.echelon = echelon;
}

return parts;
}

function buildFields(
sidc: string,
parts: PartialSymbolParts,
knownCoverage: Exclude<SidcFieldCoverage, "unknown">
): ExplainSidcFields {
return {
affiliation: buildField(sidc.slice(2, 4), parts.affiliation, knownCoverage),
symbolSet: buildField(sidc.slice(4, 6), parts.symbolSet, knownCoverage),
status: buildField(sidc.slice(6, 7), parts.status, knownCoverage),
domain: buildField(sidc.slice(4, 6), parts.domain, knownCoverage),
echelon: buildField(sidc.slice(8, 10), parts.echelon, knownCoverage),
entity: buildField(getFunctionId(sidc), parts.entity, knownCoverage)
};
}

function buildField(
code: string,
value: string | undefined,
knownCoverage: Exclude<SidcFieldCoverage, "unknown">
): SidcField {
if (!value) {
return {
code,
coverage: "unknown"
};
}

return {
code,
value,
coverage: knownCoverage
};
}

function getUnknownFields(fields: ExplainSidcFields): SidcFieldName[] {
return Object.entries(fields)
.filter(([, field]) => field.coverage === "unknown")
.map(([fieldName]) => fieldName as SidcFieldName);
}

function getFunctionId(sidc: string): string {
return sidc.slice(10, 20);
}

function getSymbolSetLabel(domain: string | undefined, metadata: SymbolMetadata): string | undefined {
if (!domain) {
return undefined;
}

if (metadata.unit === true) {
return `${domain} unit`;
}

if (metadata.installation === true) {
return `${domain} installation`;
}

if (metadata.activity === true) {
return `${domain} activity`;
}

return undefined;
}

function getStatusLabel(statusCode: string, metadata: SymbolMetadata): string | undefined {
const condition = normalizeMetadataLabel(metadata.condition);
if (condition) {
return condition;
}

if (statusCode === "0") {
return "present";
}

return undefined;
}

function normalizeDimension(value: SymbolMetadata["dimension"]): string | undefined {
if (value === "Ground") {
return "land";
}

return normalizeMetadataLabel(value);
}

function normalizeMetadataLabel(value: string | undefined): string | undefined {
const normalized = value?.trim();
if (!normalized || normalized === "undefined") {
return undefined;
}

return normalized.toLowerCase();
}

function toPoint(value: unknown): RenderSymbolResult["anchor"] {
if (!isObject(value)) {
return undefined;
Expand Down
Loading
Loading