@fusedashlabs/ui9000-workspace
Version:
UI9000 workspace MCP. One tool, show_workspace, draws a chart from your table. Set TYPESAFE_API_KEY to let Jev choose the chart.
147 lines (146 loc) • 5.26 kB
JavaScript
/**
* Column → role classification.
*
* Every column gets at most one role, first match wins, in the precedence
* below. One role per column is what keeps a profile reproducible: `lat` is
* geo and never also a numeric metric, `year` is temporal and never a metric.
*
* Roles are named after the catalog `dataRoles` ids they feed (geo, metric,
* category, nodes, links, events, claim, sources, entity). Nothing here maps
* to a DataProfile field that does not exist — see spec/data-profile.ts.
*/
import { normalizeName } from './table.js';
/** Link endpoints are only links in pairs: a lone `source` is a citation. */
const LINK_PAIRS = [
['source', 'target'],
['from', 'to'],
['src', 'dst'],
];
const NODE_NAMES = ['node', 'nodes', 'nodeid'];
/** Shared with widget-payload lat/lng marker detection. */
export const GEO_LAT_NAMES = ['lat', 'latitude'];
export const GEO_LNG_NAMES = ['lng', 'lon', 'long', 'longitude'];
/** Region ids that join to GeoJSON / PMTiles, per map-chart's geo dataRole. */
const REGION_NAMES = [
'country',
'countrycode',
'state',
'province',
'region',
'county',
'city',
'geo',
'geoid',
'postcode',
'zip',
];
const TEMPORAL_NAMES = [
'date',
'time',
'datetime',
'ts',
'timestamp',
'day',
'week',
'month',
'quarter',
'year',
'period',
'createdat',
'updatedat',
'occurredat',
];
const ENTITY_NAMES = ['id', 'entityid', 'entity', 'uid', 'uuid', 'recordid'];
const CLAIM_NAMES = ['claim', 'claims', 'assertion', 'statement'];
const SOURCE_NAMES = ['source', 'sources', 'citation', 'citations', 'reference', 'references'];
const EVENT_NAMES = ['event', 'events', 'eventtype', 'activity', 'action', 'milestone'];
/** A control table describes form controls, one per row — not dataset rows. */
const CONTROL_NAMES = ['control', 'controls'];
const LABEL_NAMES = ['label', 'labels', 'title', 'caption'];
const NUMBER_PATTERN = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
export function classifyColumns(table) {
const keys = table.columns.map((column) => normalizeName(column.name));
const present = new Set(keys);
const isControlTable = keys.some((key) => CONTROL_NAMES.includes(key));
const columns = table.columns.map((column, index) => ({
column,
key: keys[index],
role: isControlTable ? controlRoleOf(keys[index]) : dataRoleOf(keys[index], column, present),
}));
return { columns, isControlTable };
}
function controlRoleOf(key) {
if (CONTROL_NAMES.includes(key))
return 'control';
if (LABEL_NAMES.includes(key))
return 'label';
return 'empty';
}
function dataRoleOf(key, column, present) {
if (isLinkEndpoint(key, present))
return 'link';
if (NODE_NAMES.includes(key))
return 'node';
if (isGeo(key, present))
return 'geo';
if (isTemporal(key, column))
return 'temporal';
if (ENTITY_NAMES.includes(key) && hasUniqueValues(column))
return 'entity';
if (CLAIM_NAMES.includes(key))
return 'claim';
if (SOURCE_NAMES.includes(key))
return 'sources';
if (EVENT_NAMES.includes(key))
return 'event';
if (isNumeric(column))
return 'metric';
if (filled(column).length > 0)
return 'category';
return 'empty';
}
function isLinkEndpoint(key, present) {
return LINK_PAIRS.some(([from, to]) => (key === from || key === to) && present.has(from) && present.has(to));
}
function isGeo(key, present) {
if (REGION_NAMES.includes(key))
return true;
const hasPair = GEO_LAT_NAMES.some((n) => present.has(n)) && GEO_LNG_NAMES.some((n) => present.has(n));
return (hasPair &&
(GEO_LAT_NAMES.includes(key) ||
GEO_LNG_NAMES.includes(key)));
}
/** A header hint, or values that are all ISO 8601 — never a bare number column. */
function isTemporal(key, column) {
if (TEMPORAL_NAMES.includes(key))
return true;
const values = filled(column);
return values.length > 0 && values.every((value) => ISO_DATE_PATTERN.test(value));
}
const NON_NUMBER = new Set(['n/a', 'na', 'null', 'none', 'nan', '-']);
/** A column is numeric when at least 90% of its filled cells are numbers. Sentinels such as n/a do not count. */
function isNumeric(column) {
const values = filled(column).filter((value) => !NON_NUMBER.has(value.toLowerCase()));
if (values.length === 0)
return false;
const numeric = values.filter((value) => NUMBER_PATTERN.test(value) && Number.isFinite(Number(value)));
return numeric.length / values.length >= 0.9;
}
function hasUniqueValues(column) {
const values = filled(column);
return values.length > 0 && new Set(values).size === values.length;
}
/** Non-empty cells, trimmed. Blank cells never carry a signal. */
export function filled(column) {
const values = [];
for (const value of column.values) {
const text = value.trim();
if (text !== '')
values.push(text);
}
return values;
}
export function distinctCount(column) {
return new Set(filled(column)).size;
}