mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-17 23:27:41 +02:00
297 lines
9.1 KiB
TypeScript
297 lines
9.1 KiB
TypeScript
export function sortData<T, K extends Extract<keyof T, string>>(
|
|
data: T[],
|
|
key: K,
|
|
direction: "asc" | "desc",
|
|
locale?: string,
|
|
): T[] {
|
|
const get = (obj: T, k: K): unknown => (obj as Record<string, unknown>)[k];
|
|
const collator = new Intl.Collator(locale, {
|
|
numeric: true,
|
|
sensitivity: "base",
|
|
});
|
|
return [...data].sort((a, b) => {
|
|
const aVal = get(a, key);
|
|
const bVal = get(b, key);
|
|
|
|
// Handle nulls
|
|
if (aVal == null && bVal == null) return 0;
|
|
if (aVal == null) return 1;
|
|
if (bVal == null) return -1;
|
|
|
|
// Type-specific comparison
|
|
// Numbers
|
|
if (typeof aVal === "number" && typeof bVal === "number") {
|
|
return direction === "asc" ? aVal - bVal : bVal - aVal;
|
|
}
|
|
// Dates (Date instances)
|
|
if (aVal instanceof Date && bVal instanceof Date) {
|
|
const diff = aVal.getTime() - bVal.getTime();
|
|
return direction === "asc" ? diff : -diff;
|
|
}
|
|
// Booleans: false < true
|
|
if (typeof aVal === "boolean" && typeof bVal === "boolean") {
|
|
const diff = aVal === bVal ? 0 : aVal ? 1 : -1;
|
|
return direction === "asc" ? diff : -diff;
|
|
}
|
|
// Arrays: compare length
|
|
if (Array.isArray(aVal) && Array.isArray(bVal)) {
|
|
const diff = aVal.length - bVal.length;
|
|
return direction === "asc" ? diff : -diff;
|
|
}
|
|
// Strings that look like numbers -> numeric compare
|
|
if (typeof aVal === "string" && typeof bVal === "string") {
|
|
const numA = parseNumericLike(aVal);
|
|
const numB = parseNumericLike(bVal);
|
|
if (numA != null && numB != null) {
|
|
const diff = numA - numB;
|
|
return direction === "asc" ? diff : -diff;
|
|
}
|
|
// ISO-like date strings
|
|
if (/^\d{4}-\d{2}-\d{2}/.test(aVal) && /^\d{4}-\d{2}-\d{2}/.test(bVal)) {
|
|
const da = new Date(aVal).getTime();
|
|
const db = new Date(bVal).getTime();
|
|
const diff = da - db;
|
|
return direction === "asc" ? diff : -diff;
|
|
}
|
|
}
|
|
|
|
// Fallback: locale-aware string compare with numeric collation
|
|
const aStr = String(aVal);
|
|
const bStr = String(bVal);
|
|
const comparison = collator.compare(aStr, bStr);
|
|
return direction === "asc" ? comparison : -comparison;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Return a human-friendly identifier for a row using common keys
|
|
*
|
|
* Accepts any JSON-serializable primitive or array of primitives.
|
|
* Arrays are converted to comma-separated strings.
|
|
*/
|
|
function getRowIdentifier(
|
|
row: Record<
|
|
string,
|
|
string | number | boolean | null | (string | number | boolean | null)[]
|
|
>,
|
|
identifierKey?: string,
|
|
): string {
|
|
const candidate =
|
|
(identifierKey ? row[identifierKey] : undefined) ??
|
|
(row as Record<string, unknown>).name ??
|
|
(row as Record<string, unknown>).title ??
|
|
(row as Record<string, unknown>).id;
|
|
|
|
if (candidate == null) {
|
|
return "";
|
|
}
|
|
|
|
// Handle arrays by joining them
|
|
if (Array.isArray(candidate)) {
|
|
return candidate.map((v) => (v === null ? "null" : String(v))).join(", ");
|
|
}
|
|
|
|
return String(candidate).trim();
|
|
}
|
|
|
|
function stableStringify(value: unknown): string {
|
|
if (value == null) return "null";
|
|
if (typeof value === "string") return JSON.stringify(value);
|
|
if (
|
|
typeof value === "number" ||
|
|
typeof value === "boolean" ||
|
|
typeof value === "bigint"
|
|
) {
|
|
return String(value);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
}
|
|
if (typeof value === "object") {
|
|
const entries = Object.entries(value as Record<string, unknown>).sort(
|
|
([a], [b]) => a.localeCompare(b),
|
|
);
|
|
return `{${entries
|
|
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
|
|
.join(",")}}`;
|
|
}
|
|
return JSON.stringify(String(value));
|
|
}
|
|
|
|
function hashString(value: string): string {
|
|
let hash = 5381;
|
|
for (let i = 0; i < value.length; i++) {
|
|
hash = (hash * 33) ^ value.charCodeAt(i);
|
|
}
|
|
return (hash >>> 0).toString(36);
|
|
}
|
|
|
|
/**
|
|
* Create deterministic, reorder-stable React keys for DataTable rows.
|
|
*
|
|
* - Uses `identifierKey` or common identifier fields as the primary base.
|
|
* - Falls back to stable content fingerprints when no identifier exists.
|
|
* - Disambiguates duplicates without relying on array index.
|
|
*/
|
|
export function createDataTableRowKeys(
|
|
rows: Array<Record<string, unknown>>,
|
|
identifierKey?: string,
|
|
): string[] {
|
|
const canonicalRows = rows.map((row) => stableStringify(row));
|
|
|
|
const baseKeys = rows.map((row, index) => {
|
|
const identifier = getRowIdentifier(
|
|
row as Record<
|
|
string,
|
|
string | number | boolean | null | (string | number | boolean | null)[]
|
|
>,
|
|
identifierKey,
|
|
);
|
|
|
|
if (identifier) {
|
|
return `id:${identifier}`;
|
|
}
|
|
|
|
return `row:${hashString(canonicalRows[index])}`;
|
|
});
|
|
|
|
const baseCounts = new Map<string, number>();
|
|
baseKeys.forEach((key) => {
|
|
baseCounts.set(key, (baseCounts.get(key) ?? 0) + 1);
|
|
});
|
|
|
|
const usedKeys = new Map<string, number>();
|
|
|
|
return rows.map((row, index) => {
|
|
const baseKey = baseKeys[index];
|
|
if ((baseCounts.get(baseKey) ?? 0) === 1) {
|
|
return baseKey;
|
|
}
|
|
|
|
const rowFingerprint = hashString(canonicalRows[index]);
|
|
let disambiguatedKey = `${baseKey}::${rowFingerprint}`;
|
|
|
|
const seenCount = usedKeys.get(disambiguatedKey) ?? 0;
|
|
usedKeys.set(disambiguatedKey, seenCount + 1);
|
|
if (seenCount > 0) {
|
|
disambiguatedKey = `${disambiguatedKey}::d${seenCount + 1}`;
|
|
}
|
|
|
|
return disambiguatedKey;
|
|
});
|
|
}
|
|
|
|
function sanitizeDomIdToken(value: string): string {
|
|
return encodeURIComponent(value).replace(/%/g, "_");
|
|
}
|
|
|
|
export function getDataTableMobileDescriptionId(surfaceId: string): string {
|
|
return `${sanitizeDomIdToken(surfaceId)}-mobile-table-description`;
|
|
}
|
|
|
|
/**
|
|
* Parse a string that represents a numeric value, handling various formats:
|
|
* - Currency symbols: $, €, £, ¥, etc.
|
|
* - Percent symbols: %
|
|
* - Accounting negatives: (1234) → -1234
|
|
* - Thousands/decimal separators: 1,234.56 or 1.234,56
|
|
* - Compact notation: 2.8T (trillion), 1.5M (million), 500K (thousand)
|
|
* - Byte suffixes: 768B (bytes), 1.5KB, 2GB, 1TB
|
|
*
|
|
* Note: Single "B" is disambiguated - integers < 1024 are bytes, otherwise billions.
|
|
*
|
|
* @param input - String to parse
|
|
* @returns Parsed number or null if unparseable
|
|
*
|
|
* @example
|
|
* parseNumericLike("$1,234.56") // 1234.56
|
|
* parseNumericLike("2.8T") // 2800000000000
|
|
* parseNumericLike("768B") // 768
|
|
* parseNumericLike("50%") // 50
|
|
* parseNumericLike("(1234)") // -1234
|
|
*/
|
|
function parseNumericLike(input: string): number | null {
|
|
// Normalize whitespace (spaces, NBSPs, thin spaces)
|
|
let s = input.replace(/[\u00A0\u202F\s]/g, "").trim();
|
|
if (!s) return null;
|
|
|
|
// Accounting negatives: (1234) -> -1234
|
|
s = s.replace(/^\((.*)\)$/g, "-$1");
|
|
|
|
// Strip common currency and percent symbols
|
|
s = s.replace(/[%$€£¥₩₹₽₺₪₫฿₦₴₡₲₵₸]/g, "");
|
|
|
|
function hasGroupedThousands(value: string, sep: "," | "."): boolean {
|
|
const unsigned = value.replace(/^[+-]/, "");
|
|
const parts = unsigned.split(sep);
|
|
if (parts.length < 2) return false;
|
|
if (parts.some((part) => part.length === 0)) return false;
|
|
if (!/^\d{1,3}$/.test(parts[0])) return false;
|
|
if (parts[0] === "0") return false;
|
|
return parts.slice(1).every((part) => /^\d{3}$/.test(part));
|
|
}
|
|
|
|
const lastComma = s.lastIndexOf(",");
|
|
const lastDot = s.lastIndexOf(".");
|
|
if (lastComma !== -1 && lastDot !== -1) {
|
|
// Decide decimal by whichever occurs last
|
|
const decimalSep = lastComma > lastDot ? "," : ".";
|
|
const thousandSep = decimalSep === "," ? "." : ",";
|
|
s = s.split(thousandSep).join("");
|
|
s = s.replace(decimalSep, ".");
|
|
} else if (lastComma !== -1) {
|
|
// Only comma present
|
|
if (hasGroupedThousands(s, ",")) {
|
|
s = s.replace(/,/g, "");
|
|
} else {
|
|
const frac = s.length - lastComma - 1;
|
|
if (frac >= 1 && frac <= 3) s = s.replace(/,/g, ".");
|
|
else s = s.replace(/,/g, "");
|
|
}
|
|
} else if (lastDot !== -1) {
|
|
// Only dot present; normalize grouped thousands separators.
|
|
if (hasGroupedThousands(s, ".")) {
|
|
s = s.replace(/\./g, "");
|
|
} else if ((s.match(/\./g) || []).length > 1) {
|
|
s = s.replace(/\./g, "");
|
|
}
|
|
}
|
|
|
|
// Handle compact notation (K, M, B, T, P, G) and byte suffixes (KB, MB, GB, TB, PB)
|
|
const compactMatch = s.match(/^([+-]?\d+\.?\d*|\d*\.\d+)([KMBTPG]B?|B)$/i);
|
|
if (compactMatch) {
|
|
const baseNum = Number(compactMatch[1]);
|
|
if (Number.isNaN(baseNum)) return null;
|
|
|
|
const suffix = compactMatch[2].toUpperCase();
|
|
|
|
// Disambiguate single "B" (bytes vs billions)
|
|
// If whole number < 1024, treat as bytes. Otherwise, billions.
|
|
if (suffix === "B") {
|
|
const isLikelyBytes = Number.isInteger(baseNum) && baseNum < 1024;
|
|
return isLikelyBytes ? baseNum : baseNum * 1e9;
|
|
}
|
|
|
|
const multipliers: Record<string, number> = {
|
|
K: 1e3,
|
|
KB: 1024, // Kilo: metric vs binary
|
|
M: 1e6,
|
|
MB: 1024 ** 2, // Mega
|
|
G: 1e9,
|
|
GB: 1024 ** 3, // Giga
|
|
T: 1e12,
|
|
TB: 1024 ** 4, // Tera
|
|
P: 1e15,
|
|
PB: 1024 ** 5, // Peta
|
|
};
|
|
|
|
return baseNum * (multipliers[suffix] ?? 1);
|
|
}
|
|
|
|
if (/^[+-]?(?:\d+\.?\d*|\d*\.\d+)$/.test(s)) {
|
|
const n = Number(s);
|
|
return Number.isNaN(n) ? null : n;
|
|
}
|
|
return null;
|
|
}
|