UNPKG

@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.

133 lines (132 loc) • 5.12 kB
/** Bind catalog data roles to columns. One field fills one role. */ export function shapeSpec(base, entry, fields, classified) { const spec = { ...base }; const roles = entry?.dataRoles ?? []; const requiredRoles = roles.filter((role) => role.required && role.id !== 'fields'); const optionalRoles = roles.filter((role) => !role.required && role.id !== 'fields'); const needsFieldsRole = roles.some((role) => role.required && role.id === 'fields'); const namesResult = normalizeFieldNames(fields, requiredRoles.map((role) => role.id)); if (!namesResult.ok) { return fail('Catalog field names must be a list of non-empty strings on the server context, not row objects.'); } const names = namesResult.names; if (requiredRoles.length > 0 && !classified?.length && names.length < requiredRoles.length) { return fail('Catalog required data roles need column names; pass fields on the server context, not in tool arguments.'); } const binds = []; const usedFields = new Set(); for (let index = 0; index < requiredRoles.length; index += 1) { const role = requiredRoles[index]; const field = classified?.length ? fieldForRole(role.id, classified, usedFields) : unusedName(names, index, usedFields); if (!field) { return fail(`No unused column matches catalog role "${role.id}". A field cannot fill two roles.`); } binds.push({ role: role.id, field }); usedFields.add(field); } if (classified?.length) { const usedRoles = new Set(binds.map((item) => item.role)); for (const role of optionalRoles) { if (usedRoles.has(role.id)) continue; const field = fieldForRole(role.id, classified, usedFields); if (!field || usedFields.has(field)) continue; binds.push({ role: role.id, field }); usedFields.add(field); } } if (binds.length > 0) spec.binds = binds; if (entry?.accessibility?.nameFrom) { spec.label = labelFromBinds(binds) ?? entry.id; } if (needsFieldsRole || binds.length > 0) { const labels = classified?.length ? classified.map((column) => column.column.name) : names.length > 0 ? names : ['Field']; spec.fields = labels.map((name) => ({ name, label: name })); } return { ok: true, spec }; } /** Catalog dataRole id → profiler column roles. First unused match wins. */ const ROLE_COLUMN = { category: ['category'], series: ['category'], group: ['category'], groupBy: ['category'], /** Ordered axis first, then a category, for line and area. */ x: ['temporal', 'category'], /** Category first, then time, for pie, lollipop, and the other discrete marks. */ label: ['category', 'temporal'], y: ['metric'], /** Two numeric axes. Each bind takes the next unused metric. */ mx: ['metric'], my: ['metric'], source: ['link', 'category'], target: ['link', 'category'], metric: ['metric'], distribution: ['metric'], geo: ['geo'], proposal: ['entity', 'claim'], claim: ['claim'], entity: ['entity'], nodes: ['node', 'link'], links: ['link'], events: ['event'], sources: ['sources'], rows: ['category', 'metric', 'entity', 'geo'], }; function labelFromBinds(binds) { const field = (role) => binds.find((item) => item.role === role)?.field; const metric = field('metric') ?? field('y') ?? field('distribution'); const category = field('category') ?? field('label') ?? field('x') ?? field('geo'); const nodes = field('nodes'); const links = field('links'); if (metric && nodes && links && metric !== nodes && metric !== links) { return `${metric} from ${nodes} to ${links}`; } if (metric && category && metric !== category) return `${metric} by ${category}`; if (metric) return metric; return undefined; } function fieldForRole(roleId, classified, used) { const wanted = ROLE_COLUMN[roleId] ?? [roleId]; for (const role of wanted) { const hit = classified.find((entry) => entry.role === role && !used.has(entry.column.name)); if (hit) return hit.column.name; } return undefined; } function unusedName(names, index, used) { const preferred = names[index]; if (preferred && !used.has(preferred)) return preferred; return names.find((name) => !used.has(name)); } function normalizeFieldNames(fields, fallback) { if (fields === undefined) return { ok: true, names: fallback }; if (!Array.isArray(fields)) return { ok: false }; const names = []; for (const item of fields) { if (typeof item !== 'string') return { ok: false }; const name = item.trim(); if (!name) return { ok: false }; names.push(name); } return { ok: true, names }; } function fail(reason) { return { ok: false, code: 'missing_binds', reason }; }