This commit is contained in:
@@ -524,6 +524,20 @@ export const owliverCapabilityLabel = (id) => owliverCapabilityFor(id)?.label ||
|
||||
* position id, a candidate id, or nothing. A source is resolved by
|
||||
* `dataResolver.js`; a definition cannot reach a store directly, cannot write,
|
||||
* and cannot name a field that is not offered here.
|
||||
*
|
||||
* **`series`** is the optional half that says what a reading's figures *mean*,
|
||||
* as one of the closed kinds in `SERIES_KINDS` — `periodic` (ordered in time),
|
||||
* `cumulative` (each step drawn from the one before) or `parts` (disjoint
|
||||
* shares of one whole). `shapes` answers whether a component *can* draw a
|
||||
* reading; this answers whether doing so would be true, and it is what refuses
|
||||
* a pie chart of a timeline.
|
||||
*
|
||||
* It is declared only where the reading's own definition already states the
|
||||
* meaning, and it is deliberately absent from most of them. An absent `series`
|
||||
* is "not established", and a component that has declared which meanings it
|
||||
* draws refuses rather than guesses — which is the safe direction. Adding one
|
||||
* is a statement about what the resolver actually returns, so it is added when
|
||||
* that is known and not before.
|
||||
*/
|
||||
export const DATA_SOURCES = [
|
||||
{
|
||||
@@ -532,6 +546,9 @@ export const DATA_SOURCES = [
|
||||
context: 'positionId',
|
||||
summary: 'Applications to this position, counted over time.',
|
||||
shapes: ['flow', 'stats', 'timeline', 'table', 'insight', 'card'],
|
||||
/* "counted over time": the resolver buckets by period and returns
|
||||
the buckets in order, so the order is part of the reading. */
|
||||
series: 'periodic',
|
||||
options: ['periods'],
|
||||
},
|
||||
{
|
||||
@@ -540,6 +557,9 @@ export const DATA_SOURCES = [
|
||||
context: 'positionId',
|
||||
summary: 'Applied → screened → shortlisted → interviewed → hired.',
|
||||
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
|
||||
/* A funnel: everyone screened applied first, so the stages overlap
|
||||
and do not add up to a whole. */
|
||||
series: 'cumulative',
|
||||
},
|
||||
{
|
||||
id: 'position.candidates',
|
||||
@@ -594,6 +614,9 @@ export const DATA_SOURCES = [
|
||||
context: null,
|
||||
summary: 'Every candidate, counted by stage.',
|
||||
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
|
||||
/* The same funnel across the workspace, and cumulative for the same
|
||||
reason. */
|
||||
series: 'cumulative',
|
||||
},
|
||||
{
|
||||
id: 'candidates.activity',
|
||||
@@ -601,6 +624,8 @@ export const DATA_SOURCES = [
|
||||
context: null,
|
||||
summary: 'Applications across the workspace, counted over time.',
|
||||
shapes: ['flow', 'stats', 'timeline', 'table', 'card'],
|
||||
/* "counted over time", as above. */
|
||||
series: 'periodic',
|
||||
options: ['periods'],
|
||||
},
|
||||
{
|
||||
@@ -738,6 +763,9 @@ export const DATA_SOURCES = [
|
||||
context: null,
|
||||
summary: 'Events by type and by account.',
|
||||
shapes: ['stats', 'table', 'list', 'progress', 'flow', 'card'],
|
||||
/* Every event has exactly one type and one account, so counting by
|
||||
either partitions the same total: these are shares of one whole. */
|
||||
series: 'parts',
|
||||
options: ['periods', 'limit'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
* name, page name or component name appears below.
|
||||
*/
|
||||
|
||||
import { dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
import { dataSourceFor } from '@/lib/skills/surfaces';
|
||||
import { kindOfBinding, labelOfBinding, seriesFor } from './series';
|
||||
import { locate, walk } from './node';
|
||||
import { nodeRegistry } from './registry';
|
||||
|
||||
@@ -44,8 +45,23 @@ export function describeNode(node, { registry = nodeRegistry, parent = null, ind
|
||||
* supplies a `describe` and says where this one is; nothing here knows what
|
||||
* a placement is.
|
||||
*/
|
||||
/**
|
||||
* Last, the name of what it is reading.
|
||||
*
|
||||
* Identity has to survive a replacement. A built-in section carries its
|
||||
* name in its type label — "Hiring activity" — and replacing it with a
|
||||
* chart left a node whose only name was "Bar chart", so the very phrase
|
||||
* that had just worked stopped resolving and "change it back" answered
|
||||
* that there was no such thing on the page.
|
||||
*
|
||||
* The binding is the continuous fact across a replacement: the node is
|
||||
* still reading the same series, and the series has a name. Using it is
|
||||
* not a fallback invented for charts — a node is named by what it shows,
|
||||
* which is how a person refers to it either way.
|
||||
*/
|
||||
title: String(node.props?.title || '').trim()
|
||||
|| (typeof entry?.describe === 'function' ? String(entry.describe(node) || '').trim() || null : null),
|
||||
|| (typeof entry?.describe === 'function' ? String(entry.describe(node) || '').trim() || null : null)
|
||||
|| (node.data ? String(labelOfBinding(node.data) || '').trim() || null : null),
|
||||
known: Boolean(entry),
|
||||
container: Boolean(entry?.container),
|
||||
origin: node.origin,
|
||||
@@ -53,12 +69,24 @@ export function describeNode(node, { registry = nodeRegistry, parent = null, ind
|
||||
locked: node.locked === true,
|
||||
parent: parent?.id ?? null,
|
||||
index,
|
||||
/**
|
||||
* The binding, in whichever of the two kinds it is.
|
||||
*
|
||||
* A described node is what the conversation and the editor both reason
|
||||
* about, so it has to carry enough to answer "what can this become" — and
|
||||
* that now includes a page's own series. Projected rather than passed
|
||||
* through so a consumer still cannot reach a resolver from here.
|
||||
*/
|
||||
data: node.data
|
||||
? {
|
||||
source: node.data.source,
|
||||
label: dataSourceLabel(node.data.source),
|
||||
source: node.data.source || null,
|
||||
series: node.data.series || null,
|
||||
label: labelOfBinding(node.data),
|
||||
kind: kindOfBinding(node.data),
|
||||
params: node.data.params || {},
|
||||
known: Boolean(dataSourceFor(node.data.source)),
|
||||
known: node.data.series
|
||||
? Boolean(seriesFor(node.data.series))
|
||||
: Boolean(dataSourceFor(node.data.source)),
|
||||
}
|
||||
: null,
|
||||
layout: { ...(node.layout || {}) },
|
||||
@@ -120,11 +148,15 @@ export function outlineTree(nodes, { registry = nodeRegistry } = {}) {
|
||||
for (const node of list || []) {
|
||||
const entry = registry.get(node.type);
|
||||
const title = String(node.props?.title || '').trim();
|
||||
/* Named by the reading when it has one, for the same reason `describeNode`
|
||||
is: it is what a person calls the thing, and it survives a change of
|
||||
component. */
|
||||
const reading = node.data ? String(labelOfBinding(node.data) || '').trim() : '';
|
||||
const bits = [
|
||||
`${' '.repeat(depth)}${title || entry?.label || node.type}`,
|
||||
`${' '.repeat(depth)}${title || reading || entry?.label || node.type}`,
|
||||
`(${node.id})`,
|
||||
node.hidden ? '· hidden' : '',
|
||||
node.data?.source ? `· ${dataSourceLabel(node.data.source)}` : '',
|
||||
reading && reading !== (title || reading) ? `· ${reading}` : '',
|
||||
].filter(Boolean);
|
||||
lines.push(bits.join(' '));
|
||||
if (node.children?.length) visit(node.children, depth + 1);
|
||||
@@ -173,7 +205,10 @@ export function resolveTarget(nodes, phrase, {
|
||||
const entry = registry.get(node.type);
|
||||
const title = canon(node.props?.title);
|
||||
const label = canon(entry?.label || node.type);
|
||||
const source = canon(node.data ? dataSourceLabel(node.data.source) : '');
|
||||
/* `labelOfBinding`, not `dataSourceLabel`: a node bound to a page's own
|
||||
series has no source id, and scoring only the source made every such
|
||||
node unfindable by the name of the thing it draws. */
|
||||
const source = canon(node.data ? labelOfBinding(node.data) : '');
|
||||
const id = canon(node.id);
|
||||
|
||||
let score = 0;
|
||||
|
||||
@@ -29,6 +29,7 @@ import { SUPPORTED_DATA_SOURCES, dataSourceFor, dataSourceLabel } from '@/lib/sk
|
||||
import { addableTypes, describeNode, resolveTarget } from './inspect';
|
||||
import { freeNodeId, walk } from './node';
|
||||
import { nodeRegistry } from './registry';
|
||||
import { kindOfBinding, labelOfBinding, refuseSeries } from './series';
|
||||
|
||||
/** Lower-case, punctuation-free. The one normaliser, shared with `inspect`. */
|
||||
const canon = (value) => String(value ?? '')
|
||||
@@ -62,6 +63,28 @@ const VERBS = [
|
||||
*/
|
||||
{ op: 'present', words: ['compact', 'comfortable', 'spacious', 'emphasis', 'emphasise', 'emphasize', 'subtle', 'denser', 'tighter', 'roomier'] },
|
||||
|
||||
/**
|
||||
* "I don't like this. What else could it be?"
|
||||
*
|
||||
* Before `replace`, because the sentences overlap — "show me other options"
|
||||
* and "show me some other designs" both contain words `replace` and `show`
|
||||
* would claim — and only this one is a question rather than an instruction.
|
||||
*
|
||||
* Every phrase is two words or more on purpose. A bare "options" is what a
|
||||
* person says about a position or a shift, and stealing it would turn an
|
||||
* ordinary question into a layout answer.
|
||||
*/
|
||||
{ op: 'options', words: [
|
||||
'other options', 'other option', 'more options', 'some other options',
|
||||
'other designs', 'another design', 'different design', 'other design',
|
||||
'alternatives', 'alternative', 'other ways', 'another way', 'different way',
|
||||
'other visualisations', 'other visualizations', 'other visualisation',
|
||||
'other visualization', 'another visualisation', 'another visualization',
|
||||
'different visualisation', 'different visualization',
|
||||
'other look', 'another look', 'different look',
|
||||
'what else can', 'what else could', 'what else is', 'something else',
|
||||
] },
|
||||
|
||||
{ op: 'replace', words: ['change', 'turn', 'switch', 'convert', 'make it a', 'show as', 'show it as'] },
|
||||
{ op: 'add', words: ['add', 'insert', 'create a', 'put a new'] },
|
||||
{ op: 'layout', words: ['columns', 'column', 'side by side', 'two up', 'wider', 'narrower'] },
|
||||
@@ -94,6 +117,27 @@ const UI_WORDS = [
|
||||
|
||||
const SHOW_CONFIDENCE = 18;
|
||||
|
||||
/**
|
||||
* The words that mean "the thing we were just talking about".
|
||||
*
|
||||
* Deliberately a closed list of pronouns and demonstratives, and deliberately
|
||||
* not a general reference resolver. A phrase carrying one of these, that names
|
||||
* nothing else on the page, is asking about whatever was last acted on — and
|
||||
* saying so out loud is what makes the fallback safe: a request that *does*
|
||||
* name something is never redirected, and a request that names nothing and
|
||||
* says nothing pronominal is still unknown.
|
||||
*
|
||||
* "back" and "again" are here because "change it back" and "do that again" are
|
||||
* how the follow-up is actually said.
|
||||
*/
|
||||
const ANAPHORA = new Set([
|
||||
'it', 'this', 'that', 'these', 'those', 'them', 'same',
|
||||
'back', 'again', 'instead',
|
||||
]);
|
||||
|
||||
/** Does this phrase refer to something already under discussion? */
|
||||
const refersBack = (text) => text.split(' ').some((word) => ANAPHORA.has(word));
|
||||
|
||||
const NUMBER_WORDS = { one: 1, two: 2, three: 3, four: 4, six: 6, twelve: 12 };
|
||||
|
||||
/**
|
||||
@@ -109,11 +153,36 @@ export function matchUiEdit(question, {
|
||||
keeps "add a recent hiring timeline" from being answerable on a page that
|
||||
publishes none of the records such a section reads. */
|
||||
page = null,
|
||||
/**
|
||||
* The node this conversation last acted on, if any.
|
||||
*
|
||||
* The whole of "it". A conversation about a page has a subject, and asking a
|
||||
* person to re-name it in every sentence is not how anyone speaks — "change
|
||||
* Hiring activity to a bar chart" followed by "change it back to a line
|
||||
* chart" is one thought in two sentences, and the second one used to be
|
||||
* answered with "I could not find that on this page."
|
||||
*
|
||||
* An id, and only an id: the caller records which node an operation named
|
||||
* and hands it back next turn. Nothing is remembered here, nothing is
|
||||
* inferred from the model, and a focus that is no longer in the tree simply
|
||||
* does not resolve.
|
||||
*/
|
||||
focus = null,
|
||||
} = {}) {
|
||||
const text = canon(question);
|
||||
if (!text) return null;
|
||||
|
||||
const verb = VERBS.find((v) => has(text, ...v.words));
|
||||
/**
|
||||
* The verb, from the table — or from the registry's own nouns.
|
||||
*
|
||||
* "I want a different chart" asks the same question as "show me other
|
||||
* options" and shares not one word with it. The phrase table cannot grow to
|
||||
* cover it without writing component names into the language layer, so the
|
||||
* second reading derives the noun from the registry instead. See
|
||||
* `asksToRedraw`.
|
||||
*/
|
||||
const verb = VERBS.find((v) => has(text, ...v.words))
|
||||
|| (asksToRedraw(text, registry) ? VERBS.find((v) => v.op === 'options') : null);
|
||||
if (!verb) return null;
|
||||
|
||||
/**
|
||||
@@ -160,18 +229,36 @@ export function matchUiEdit(question, {
|
||||
if (verb.op === 'show') return planShow(text, tree, registry);
|
||||
if (verb.op === 'unhide') return planUnhide(text, tree, registry);
|
||||
|
||||
/**
|
||||
* "Show me something else" is asked before the interface-words gate.
|
||||
*
|
||||
* The gate wants a request to name a node, a registered type, or a word like
|
||||
* "section" — and the sentence people actually type names none of the three.
|
||||
* "I don't like this design. Show me other options." was refused by the gate
|
||||
* and fell through to the model, which is exactly the wrong place for it: the
|
||||
* answer is a list of registered components, and a model does not have one.
|
||||
*
|
||||
* Safe to run early because this verb decides for itself whether it has a
|
||||
* subject, and returns null when it does not — so "what are my options for
|
||||
* this position?" is still nobody's layout request.
|
||||
*/
|
||||
if (verb.op === 'options') return planOptions(text, tree, registry, page, focus);
|
||||
|
||||
const named = namedType(text, registry, page);
|
||||
const mentionsUi = has(text, ...UI_WORDS) || Boolean(named);
|
||||
const anyTarget = walk(tree).some((node) => resolveTarget(tree, text, { registry }).length > 0);
|
||||
if (!mentionsUi && !anyTarget) return null;
|
||||
/* A phrase that refers back to what was just changed names its subject
|
||||
without naming it, so the focus is evidence in its own right. */
|
||||
const carriesOn = Boolean(focus) && refersBack(text);
|
||||
if (!mentionsUi && !anyTarget && !carriesOn) return null;
|
||||
|
||||
switch (verb.op) {
|
||||
case 'hide': return planVisibility(text, tree, registry, true);
|
||||
case 'hide': return planVisibility(text, tree, registry, true, focus);
|
||||
case 'move': return planMove(text, tree, registry);
|
||||
case 'replace': return planReplace(text, tree, registry, page);
|
||||
case 'replace': return planReplace(text, tree, registry, page, focus);
|
||||
case 'add': return planAdd(text, tree, registry, named, role, page);
|
||||
case 'layout': return planLayout(text, tree, registry);
|
||||
case 'present': return planPresent(text, tree, registry);
|
||||
case 'layout': return planLayout(text, tree, registry, focus);
|
||||
case 'present': return planPresent(text, tree, registry, focus);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -183,7 +270,7 @@ export function matchUiEdit(question, {
|
||||
* while somebody is looking at another one is the failure this exists to
|
||||
* prevent. Two candidates come back as a question with both named.
|
||||
*/
|
||||
function target(text, tree, registry, { exclude = [], preferHidden = false } = {}) {
|
||||
function target(text, tree, registry, { exclude = [], preferHidden = false, focus = null } = {}) {
|
||||
const all = resolveTarget(tree, text, { registry })
|
||||
.filter((node) => !exclude.includes(node.id));
|
||||
/* "Bring back the timeline" means the hidden one, when a hidden one fits.
|
||||
@@ -191,6 +278,24 @@ function target(text, tree, registry, { exclude = [], preferHidden = false } = {
|
||||
const hiddenOnly = all.filter((node) => node.hidden);
|
||||
const hits = preferHidden && hiddenOnly.length ? hiddenOnly : all;
|
||||
|
||||
/**
|
||||
* Nothing named, but something referred to.
|
||||
*
|
||||
* The last resort, and it is fenced on three sides: the phrase has to carry
|
||||
* a pronoun or a demonstrative, the caller has to have recorded a subject,
|
||||
* and that subject has to still be on the page. Any of the three missing and
|
||||
* this is an unknown target exactly as before.
|
||||
*
|
||||
* It runs only when the words resolved to nothing, so a request that names a
|
||||
* section is never quietly redirected to a different one — which would be the
|
||||
* failure this whole resolver exists to prevent, reintroduced by the back
|
||||
* door.
|
||||
*/
|
||||
if (!hits.length && focus && refersBack(text) && !exclude.includes(focus)) {
|
||||
const carried = resolveTarget(tree, focus, { registry }).find((node) => node.id === focus);
|
||||
if (carried) return { node: carried };
|
||||
}
|
||||
|
||||
if (!hits.length) return { kind: 'unknown', phrase: text };
|
||||
|
||||
/**
|
||||
@@ -209,8 +314,8 @@ function target(text, tree, registry, { exclude = [], preferHidden = false } = {
|
||||
|
||||
|
||||
/** Hide or show a node the phrase named. */
|
||||
function planVisibility(text, tree, registry, hidden) {
|
||||
const found = target(text, tree, registry);
|
||||
function planVisibility(text, tree, registry, hidden, focus = null) {
|
||||
const found = target(text, tree, registry, { focus });
|
||||
if (found.kind) return found;
|
||||
return visibilityPlan(found.node, hidden);
|
||||
}
|
||||
@@ -356,8 +461,188 @@ function namedType(text, registry, page = null) {
|
||||
return hit?.entry || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What this node could be shown as, and what to call it.
|
||||
*
|
||||
* The single place either half of the product asks the question. `planReplace`
|
||||
* uses it to check one answer and `planOptions` to list them all, so a type
|
||||
* offered in a list can never be one the same request would refuse — which is
|
||||
* the bug that two copies of this rule would eventually produce.
|
||||
*/
|
||||
function alternatives(node, registry, page) {
|
||||
const binding = node.data || null;
|
||||
const shapes = binding?.source ? dataSourceFor(binding.source)?.shapes || [] : null;
|
||||
const types = registry.replacements(node.type, {
|
||||
shapes,
|
||||
page,
|
||||
seriesKind: kindOfBinding(binding),
|
||||
seriesBound: Boolean(binding?.series),
|
||||
/* Whether there is a reading at all — which is what tells the registry
|
||||
apart "nothing to be wrong about" from "a reading that never said what
|
||||
it means". */
|
||||
bound: Boolean(binding),
|
||||
});
|
||||
return { binding, types, entries: types.map((type) => registry.get(type)).filter(Boolean) };
|
||||
}
|
||||
|
||||
/**
|
||||
* "Show me something else" — answered from the registry, never invented.
|
||||
*
|
||||
* This is the request that most invites a model to make something up, so it is
|
||||
* the one most firmly deterministic: the answer is the same list `planReplace`
|
||||
* would check a single name against, in the same order, produced by the same
|
||||
* function. Nothing here proposes a design; it reports which registered
|
||||
* components can honestly draw what this node is already reading.
|
||||
*
|
||||
* The target is the harder half, because the sentence rarely names one — "I
|
||||
* don't like this, show me other options" names nothing at all. So: resolve it
|
||||
* from the words if the words say; otherwise let the *tree* answer, and only
|
||||
* when the tree's answer is unambiguous. One node with alternatives is the
|
||||
* subject. Several is a question back, never a guess.
|
||||
*/
|
||||
function planOptions(text, tree, registry, page = null, focus = null) {
|
||||
/* "An alternative candidate" is not a layout request, whatever else the
|
||||
sentence contains. Checked first, so no amount of page state can turn it
|
||||
into one. */
|
||||
if (qualifiedElsewhere(text, registry)) return null;
|
||||
|
||||
const named = target(text, tree, registry, { focus });
|
||||
const subject = named.kind ? null : named.node;
|
||||
|
||||
if (!subject) {
|
||||
const changeable = walk(tree)
|
||||
.filter((node) => !node.hidden)
|
||||
.map((node) => describeNode(node, { registry }))
|
||||
.filter((node) => node.capabilities.includes('replace'))
|
||||
.filter((node) => alternatives(node, registry, page).entries.length > 0);
|
||||
|
||||
/**
|
||||
* Nothing on this page could be drawn another way.
|
||||
*
|
||||
* Answered by *not* answering. This verb now runs before the
|
||||
* interface-words gate, so it sees sentences that were never about the
|
||||
* layout, and the only honest thing to do with one of those, on a page with
|
||||
* nothing to offer, is leave it to whoever else can answer it. It used to
|
||||
* say "Nothing on this page can be drawn another way yet." — a true
|
||||
* sentence, and the wrong reply to a question about candidates.
|
||||
*/
|
||||
if (!changeable.length) return null;
|
||||
/* One thing it could be about is not ambiguity; several is a question. */
|
||||
if (changeable.length > 1) {
|
||||
return { kind: 'ambiguous', candidates: changeable.slice(0, 4) };
|
||||
}
|
||||
return offer(changeable[0], registry, page);
|
||||
}
|
||||
|
||||
if (!subject.capabilities.includes('replace')) {
|
||||
return { kind: 'refused', message: `\`${subject.title || subject.label}\` cannot be drawn another way.` };
|
||||
}
|
||||
return offer(subject, registry, page);
|
||||
}
|
||||
|
||||
/**
|
||||
* "A different chart", "another table" — with the nouns read from the registry.
|
||||
*
|
||||
* The phrase list above cannot cover this, because the noun a person reaches
|
||||
* for is the name of a kind of component, and writing those out here would put
|
||||
* component names back into the language layer — the one thing this file is
|
||||
* checked for. So the nouns are derived: the last word of every registered
|
||||
* type's own label, which is "chart" for five of them and "table", "card" or
|
||||
* "timeline" for the others. A type registered tomorrow is askable for by its
|
||||
* own noun, tomorrow, with this unchanged.
|
||||
*/
|
||||
function asksToRedraw(text, registry) {
|
||||
return [...redrawNouns(registry)].some((noun) => (
|
||||
new RegExp(`\\b(another|different|other)\\s+(?:\\w+\\s+)?${noun}\\b`).test(text)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* The nouns a person can ask for another *of*.
|
||||
*
|
||||
* The last word of every registered type's own label — "chart", "table",
|
||||
* "card", "timeline" — so the vocabulary is the registry's rather than a list
|
||||
* kept here. Page-bound types are left out on purpose: their labels are the
|
||||
* names of readings ("Hiring activity", "Position candidates"), and "another
|
||||
* candidate" is a question about people, not about panels.
|
||||
*/
|
||||
function redrawNouns(registry) {
|
||||
return new Set(
|
||||
registry.all()
|
||||
.filter((entry) => !entry.page)
|
||||
.map((entry) => canon(entry.label).split(' ').pop())
|
||||
.filter((noun) => noun && noun.length > 2)
|
||||
);
|
||||
}
|
||||
|
||||
/** The words a bare "another" is about when it is about the interface. */
|
||||
const REDRAW_WORDS = [
|
||||
'design', 'designs', 'look', 'looks', 'option', 'options', 'way', 'ways',
|
||||
'alternative', 'alternatives', 'version', 'versions', 'one', 'ones',
|
||||
'visualisation', 'visualisations', 'visualization', 'visualizations',
|
||||
];
|
||||
|
||||
/** The words that carry no evidence about what is being asked for. */
|
||||
const FILLER = new Set([
|
||||
'the', 'this', 'that', 'for', 'and', 'with', 'from', 'some', 'any', 'you',
|
||||
'can', 'could', 'are', 'was', 'all', 'out', 'into', 'please', 'give', 'show',
|
||||
'want', 'like', 'have', 'use', 'about', 'here',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Is the "other" in this sentence about something that is not the interface?
|
||||
*
|
||||
* The one test that keeps "give me an alternative candidate" a question about
|
||||
* people. Both sentences say "alternative"; the difference is the noun that
|
||||
* follows it, and it is decided by looking rather than by guessing:
|
||||
*
|
||||
* - nothing follows → about the interface ("give me alternatives")
|
||||
* - one of the interface words → about the interface ("other options")
|
||||
* - a registered type's noun → about the interface ("a different chart")
|
||||
* - anything else → not ours, and left entirely alone
|
||||
*
|
||||
* A false positive here costs a person a wrong answer about their candidates,
|
||||
* which is worse than a layout question going unanswered — so the doubtful case
|
||||
* falls through rather than being claimed.
|
||||
*/
|
||||
function qualifiedElsewhere(text, registry) {
|
||||
const ours = new Set([...REDRAW_WORDS, ...UI_WORDS, ...redrawNouns(registry)]);
|
||||
const words = text.split(' ');
|
||||
|
||||
return words.some((word, i) => {
|
||||
if (!['other', 'another', 'different', 'alternative', 'alternatives'].includes(word)) {
|
||||
return false;
|
||||
}
|
||||
const following = words.slice(i + 1, i + 3)
|
||||
.filter((next) => next.length > 2 && !FILLER.has(next));
|
||||
return following.length > 0 && !ours.has(following[0]);
|
||||
});
|
||||
}
|
||||
|
||||
/** The list, or the reason there is not one. */
|
||||
function offer(node, registry, page) {
|
||||
const { binding, entries } = alternatives(node, registry, page);
|
||||
if (!entries.length) {
|
||||
return {
|
||||
kind: 'refused',
|
||||
message: `There is no other way to draw ${binding ? labelOfBinding(binding) : node.title || node.label} yet.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'options',
|
||||
node,
|
||||
subject: binding ? labelOfBinding(binding) : node.title || node.label,
|
||||
/* Four, because a choice a person reads at a glance is a choice they make.
|
||||
The cap is on what is offered, never on what is possible — the operation
|
||||
path accepts any registered type the same rule allows. */
|
||||
options: entries.slice(0, 4).map((entry) => ({
|
||||
type: entry.type, label: entry.label, summary: entry.summary,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Turn one node into another type. */
|
||||
function planReplace(text, tree, registry, page = null) {
|
||||
function planReplace(text, tree, registry, page = null, focus = null) {
|
||||
/**
|
||||
* The type asked for is the one after the connector.
|
||||
*
|
||||
@@ -385,7 +670,15 @@ function planReplace(text, tree, registry, page = null) {
|
||||
/* The subject is whatever came before the connector; with no connector, the
|
||||
sentence minus the type name. */
|
||||
const subject = split ? text.slice(0, split.index) : text.replace(canon(named.label), ' ');
|
||||
const found = target(subject, tree, registry);
|
||||
/**
|
||||
* "Change **it** back to a line chart."
|
||||
*
|
||||
* The half of the sentence before the connector is a pronoun, which names
|
||||
* nothing and resolves to nothing — so the subject is whatever this
|
||||
* conversation last changed. That is the ordinary reading of the sentence,
|
||||
* and producing "I could not find that on this page" instead was the gap.
|
||||
*/
|
||||
const found = target(subject, tree, registry, { focus });
|
||||
if (found.kind) return found;
|
||||
|
||||
const node = found.node;
|
||||
@@ -398,14 +691,26 @@ function planReplace(text, tree, registry, page = null) {
|
||||
|
||||
/* The binding decides what it can become. Asking the registry rather than
|
||||
deciding here is what keeps this free of type knowledge. */
|
||||
const shapes = node.data ? dataSourceFor(node.data.source)?.shapes || [] : null;
|
||||
const allowed = registry.replacements(node.type, { shapes, page });
|
||||
if (!allowed.includes(named.type)) {
|
||||
const { binding, entries } = alternatives(node, registry, page);
|
||||
if (!entries.some((entry) => entry.type === named.type)) {
|
||||
/**
|
||||
* Refused, with the reason and the way forward.
|
||||
*
|
||||
* A bare no is the worst of the three things this could say. The reason
|
||||
* comes from what the reading *means* — computed once, in `series.js`, so
|
||||
* the editor's refusal and this one are the same sentence — and the
|
||||
* alternatives are the very list the next question would produce.
|
||||
*/
|
||||
const why = binding
|
||||
? refuseSeries(named, binding)
|
||||
: `\`${node.label}\` cannot become a ${named.label}.`;
|
||||
return {
|
||||
kind: 'refused',
|
||||
message: node.data
|
||||
? `\`${dataSourceLabel(node.data.source)}\` cannot be shown as a ${named.label}.`
|
||||
: `\`${node.label}\` cannot become a ${named.label}.`,
|
||||
message: why || (binding?.source
|
||||
? `\`${dataSourceLabel(binding.source)}\` cannot be shown as a ${named.label}.`
|
||||
: `\`${node.label}\` cannot become a ${named.label}.`),
|
||||
node,
|
||||
alternatives: entries.map((entry) => ({ type: entry.type, label: entry.label })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -514,7 +819,7 @@ function namedSource(text, named, answerable = null) {
|
||||
* putting something back is the request people actually make after trying
|
||||
* something, and it has to be sayable.
|
||||
*/
|
||||
function planPresent(text, tree, registry) {
|
||||
function planPresent(text, tree, registry, focus = null) {
|
||||
const wants = {};
|
||||
if (has(text, 'compact', 'denser', 'tighter')) wants.density = 'compact';
|
||||
if (has(text, 'comfortable', 'spacious', 'roomier')) wants.density = 'comfortable';
|
||||
@@ -523,7 +828,7 @@ function planPresent(text, tree, registry) {
|
||||
if (has(text, 'default', 'reset', 'normal')) wants.variant = 'default';
|
||||
if (!Object.keys(wants).length) return { kind: 'unknown', phrase: text };
|
||||
|
||||
const found = target(text, tree, registry);
|
||||
const found = target(text, tree, registry, { focus });
|
||||
if (found.kind) return found;
|
||||
|
||||
const node = found.node;
|
||||
@@ -556,13 +861,13 @@ function planPresent(text, tree, registry) {
|
||||
}
|
||||
|
||||
/** Column counts. */
|
||||
function planLayout(text, tree, registry) {
|
||||
function planLayout(text, tree, registry, focus = null) {
|
||||
const digit = /(\d+)\s*(?:column|col)/.exec(text)?.[1];
|
||||
const word = Object.keys(NUMBER_WORDS).find((w) => has(text, `${w} column`));
|
||||
const columns = Number(digit) || NUMBER_WORDS[word] || (has(text, 'side by side', 'two up') ? 2 : null);
|
||||
if (!columns) return { kind: 'unknown', phrase: text };
|
||||
|
||||
const found = target(text, tree, registry);
|
||||
const found = target(text, tree, registry, { focus });
|
||||
if (found.kind) return found;
|
||||
|
||||
const node = found.node;
|
||||
|
||||
@@ -102,6 +102,30 @@ export const VARIANT_VALUES = ['default', 'subtle', 'emphasis'];
|
||||
/** How tightly a node is packed. `comfortable` is today's spacing, unchanged. */
|
||||
export const DENSITY_VALUES = ['comfortable', 'compact'];
|
||||
|
||||
/**
|
||||
* What a numeric series *means*, as a closed vocabulary.
|
||||
*
|
||||
* Shape and meaning are different questions and the engine had only the first.
|
||||
* `flow` says a reading is a labelled numeric series; it does not say whether
|
||||
* the labels are days, funnel stages or disjoint categories — and that is
|
||||
* exactly the difference between a pie chart that is true and one that is a
|
||||
* lie. `SECTION_TYPES` even admits it: a flow is "a sequence of stages **or**
|
||||
* periods".
|
||||
*
|
||||
* - `periodic` points ordered in time. The order carries the meaning and
|
||||
* the values do not add up to a whole.
|
||||
* - `cumulative` a funnel: each step is a subset of the one before it, so
|
||||
* the steps overlap and summing them counts people twice.
|
||||
* - `parts` disjoint categories that together make up one total. The
|
||||
* only meaning for which "share of the whole" is true.
|
||||
*
|
||||
* A type declares which of these it can honestly draw (`seriesKinds`); a
|
||||
* binding is asked what it is. Neither is inferred from a component name, and a
|
||||
* binding that cannot say stays unknown — which leaves it exactly as permissive
|
||||
* as it was before this vocabulary existed, rather than guessing.
|
||||
*/
|
||||
export const SERIES_KINDS = ['periodic', 'cumulative', 'parts'];
|
||||
|
||||
/**
|
||||
* A node, with every field settled.
|
||||
*
|
||||
@@ -158,6 +182,18 @@ function normalizeBinding(value) {
|
||||
return source ? { source, params: {} } : null;
|
||||
}
|
||||
if (typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
/**
|
||||
* A page's own series, rather than a skill's reading.
|
||||
*
|
||||
* The second kind of binding, and the reason built-in charts can be changed
|
||||
* at all. A page section's figures are not in the skill data vocabulary —
|
||||
* they are derived by the page from what it already loaded — so a node that
|
||||
* draws them can only name them, the same way a skill node names a source.
|
||||
* Which one a binding is, is decided by which key it carries; both are
|
||||
* resolved through a registry and neither can carry a value.
|
||||
*/
|
||||
const series = String(value.series ?? '').trim();
|
||||
if (series) return { series, params: plainObject(value.params) };
|
||||
const source = String(value.source ?? '').trim();
|
||||
if (!source) return null;
|
||||
return { source, params: plainObject(value.params) };
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
cloneNode, findNode, locate, makeNode, mapChildren, mapNode,
|
||||
} from './node';
|
||||
import { nodeRegistry } from './registry';
|
||||
import { refuseSeries } from './series';
|
||||
import { takenIds, validateTree } from './validate';
|
||||
|
||||
/** The operation names the engine understands. Anything else is refused by name. */
|
||||
@@ -355,7 +356,28 @@ const BUILDERS = {
|
||||
return fail(nodes, `\`${entry.label}\` cannot show ${label}.`);
|
||||
}
|
||||
}
|
||||
if (!binding?.source && entry.dataRequired) {
|
||||
/**
|
||||
* A page's own series is compatible with the types that draw one.
|
||||
*
|
||||
* The structural question for this kind of binding is not "which shapes"
|
||||
* — a page series is not in the shape vocabulary and should not be — it is
|
||||
* whether the candidate draws a series at all, which is what a non-empty
|
||||
* `seriesKinds` says.
|
||||
*/
|
||||
if (binding?.series && !entry.seriesKinds.length) {
|
||||
return fail(nodes, `\`${entry.label}\` cannot draw a series.`);
|
||||
}
|
||||
/**
|
||||
* And the semantic question, for either kind of binding.
|
||||
*
|
||||
* Refused by what the reading *is*, not by what the component is called, so
|
||||
* the sentence a person gets back tells them why rather than merely that.
|
||||
* Silence — an unknown kind — is not a refusal.
|
||||
*/
|
||||
const untrue = binding ? refuseSeries(entry, binding) : null;
|
||||
if (untrue && entry.seriesKinds.length) return fail(nodes, untrue);
|
||||
|
||||
if (!binding?.source && !binding?.series && entry.dataRequired) {
|
||||
return fail(nodes, `\`${entry.label}\` needs a reading, and \`${before.id}\` has none.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
DENSITY_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_CAPABILITIES, NODE_ID_PATTERN, VARIANT_VALUES,
|
||||
DENSITY_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_CAPABILITIES, NODE_ID_PATTERN, SERIES_KINDS,
|
||||
VARIANT_VALUES,
|
||||
} from './node';
|
||||
|
||||
/**
|
||||
@@ -87,6 +88,7 @@ export class NodeTypeRegistry {
|
||||
};
|
||||
refuseUnknown('variant', definition.variants, VARIANT_VALUES);
|
||||
refuseUnknown('density', definition.densities, DENSITY_VALUES);
|
||||
refuseUnknown('series kind', definition.seriesKinds, SERIES_KINDS);
|
||||
|
||||
/* A type offered by a picker and unable to be removed is a dead end: a
|
||||
person creates a node and has no way to take it back. Said here rather
|
||||
@@ -167,6 +169,22 @@ export class NodeTypeRegistry {
|
||||
variants: Object.freeze([...(definition.variants || [])]),
|
||||
densities: Object.freeze([...(definition.densities || [])]),
|
||||
|
||||
/**
|
||||
* The series *meanings* this type can honestly draw.
|
||||
*
|
||||
* Empty means two things at once, and both are the conservative reading:
|
||||
* this type is not a visualization of a series, and it makes no claim
|
||||
* about meaning. So a section registered before this field existed is
|
||||
* unaffected — it is neither offered as a chart nor vetoed as one.
|
||||
*
|
||||
* A non-empty list is a promise. `pie-chart` says `parts` and nothing
|
||||
* else, and that single word is what refuses to draw twelve days of
|
||||
* hiring as twelve slices of a whole. The alternative — letting the
|
||||
* shape vocabulary decide — cannot express it: `flow` is, in its own
|
||||
* words, "a sequence of stages *or* periods".
|
||||
*/
|
||||
seriesKinds: Object.freeze([...(definition.seriesKinds || [])]),
|
||||
|
||||
/**
|
||||
* The page this type belongs to, or `null` for one that belongs anywhere.
|
||||
*
|
||||
@@ -303,7 +321,19 @@ export class NodeTypeRegistry {
|
||||
* coupling it to data sources would make a UI type impossible to register
|
||||
* without one.
|
||||
*/
|
||||
replacements(type, { shapes = null, page = null } = {}) {
|
||||
replacements(type, {
|
||||
shapes = null, page = null, seriesKind = null, seriesBound = false,
|
||||
/**
|
||||
* Whether this node reads anything at all.
|
||||
*
|
||||
* Separate from `seriesKind`, because "no binding" and "a binding that
|
||||
* never said what it means" are different situations with different right
|
||||
* answers. An unbound node has nothing to be untrue about; a bound one
|
||||
* whose meaning was never established is exactly the case the semantic
|
||||
* veto exists for.
|
||||
*/
|
||||
bound = false,
|
||||
} = {}) {
|
||||
const current = this.get(type);
|
||||
if (!current) return [];
|
||||
|
||||
@@ -325,12 +355,47 @@ export class NodeTypeRegistry {
|
||||
* in every page's "Show as" list.
|
||||
*/
|
||||
.filter((entry) => this.offersChild(null, entry.type, { page }))
|
||||
/**
|
||||
* The semantic veto, applied to every binding.
|
||||
*
|
||||
* A type that has said which meanings it can draw may only draw those.
|
||||
* This is the one filter that is about truth rather than about structure,
|
||||
* and it is why "show hiring activity as a pie chart" is refused while
|
||||
* "as a bar chart" is not — both are structurally possible and only one
|
||||
* of them is honest. A kind of `null` is "not known", and vetoes nothing.
|
||||
*/
|
||||
.filter((entry) => {
|
||||
/* A type that claims no meanings makes no claim to be wrong about. */
|
||||
if (!entry.seriesKinds.length) return true;
|
||||
/* Nothing bound yet: the binding decides this, and there is not one. */
|
||||
if (!bound) return true;
|
||||
/**
|
||||
* Bound, and only offerable if the meaning is known and drawable.
|
||||
*
|
||||
* The `!seriesKind` case is the one that changed: an undeclared
|
||||
* meaning used to fall through as "no objection", which is how a card
|
||||
* reading `Recent hires` came to be offered a pie chart. Not knowing
|
||||
* is not permission — see `refuseSeries`, which produces the sentence
|
||||
* a person is given when they ask for one of these by name.
|
||||
*/
|
||||
return Boolean(seriesKind) && entry.seriesKinds.includes(seriesKind);
|
||||
})
|
||||
.filter((entry) => (
|
||||
/* A type that reads no data can only be swapped for another that reads
|
||||
none — a divider is not an alternative rendering of a chart. */
|
||||
wanted.length
|
||||
? entry.dataShapes.some((shape) => wanted.includes(shape))
|
||||
: entry.dataShapes.length === 0
|
||||
/**
|
||||
* The structural half, in the currency of whichever binding this is.
|
||||
*
|
||||
* A node bound to a page's own series is compatible with the types that
|
||||
* draw a series — which is what `seriesKinds` being non-empty means.
|
||||
* A node bound to a skill reading is compared shape to shape, as it has
|
||||
* always been. A type that reads no data can only be swapped for
|
||||
* another that reads none: a divider is not an alternative rendering of
|
||||
* a chart.
|
||||
*/
|
||||
seriesBound
|
||||
? entry.seriesKinds.length > 0
|
||||
: (wanted.length
|
||||
? entry.dataShapes.some((shape) => wanted.includes(shape))
|
||||
: entry.dataShapes.length === 0)
|
||||
))
|
||||
.map((entry) => entry.type);
|
||||
}
|
||||
|
||||
300
src/lib/ui/series.js
Normal file
300
src/lib/ui/series.js
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* What a series is, what it means, and where a page publishes one.
|
||||
*
|
||||
* Two things live here, and they are the two halves of "can this reading be
|
||||
* drawn that way".
|
||||
*
|
||||
* **The series registry.** A page derives figures from the records it has
|
||||
* already loaded — Control Center buckets applications, screenings, interviews
|
||||
* and hires by day — and those figures are not in the skill data vocabulary and
|
||||
* should not be: nothing outside that page can compute them. So a page declares
|
||||
* them here, by name, with a pure `read` that takes the bag the page publishes
|
||||
* and returns rows. A node then *names* the series, exactly as a skill node
|
||||
* names a source. **A binding never carries values**, so there is still nowhere
|
||||
* for an invented figure to live.
|
||||
*
|
||||
* **The meaning.** `dataShapes` answers whether a component can draw a shape;
|
||||
* it cannot answer whether doing so would be true. A pie of a time series is
|
||||
* structurally fine and semantically a lie — it throws the timeline away and
|
||||
* presents days as slices of a whole. `SERIES_KINDS` is the missing half, and
|
||||
* `kindOfBinding` is where a binding is asked which one it is.
|
||||
*
|
||||
* The rule for not knowing is deliberate, and it is strict: **an unknown kind
|
||||
* is a refusal.** A component that has declared which meanings it can draw may
|
||||
* only draw a meaning that was actually declared — by a page's series, by a
|
||||
* reading in the data-source vocabulary, or by the binding's own periods. This
|
||||
* is the one place the system could otherwise produce something structurally
|
||||
* valid and factually untrue, and silence is not evidence that it would be
|
||||
* true. Declaring a meaning is one field; guessing one is not offered.
|
||||
*
|
||||
* Components that declare no meanings at all — a table, a list, a card — are
|
||||
* untouched by any of this. They make no claim about what their rows mean, so
|
||||
* there is nothing for them to be wrong about.
|
||||
*/
|
||||
|
||||
import { SERIES_KINDS } from './node';
|
||||
import { dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
|
||||
export { SERIES_KINDS };
|
||||
|
||||
/** Why a kind is what it is, in words a refusal can use. */
|
||||
const KIND_REASON = {
|
||||
periodic: 'it is ordered in time, and that order is the reading',
|
||||
cumulative: 'each step is part of the one before it, so the steps overlap',
|
||||
parts: 'it is a set of parts that make up one whole',
|
||||
};
|
||||
|
||||
/** @type {Map<string, any>} */
|
||||
const providers = new Map();
|
||||
|
||||
/** A series id looks like a node id: lower-case, dashes, and a dotted namespace. */
|
||||
const SERIES_ID_PATTERN = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$/;
|
||||
|
||||
/**
|
||||
* Declare a series a page publishes.
|
||||
*
|
||||
* Called at module scope beside the page that computes it, the same way a page
|
||||
* registers its composition — so a series and the page that can answer for it
|
||||
* are deployed together or not at all.
|
||||
*
|
||||
* Re-registering replaces, because the dev server re-runs the module on every
|
||||
* save and throwing there would make the file uneditable.
|
||||
*/
|
||||
/**
|
||||
* A control a series publishes, checked at registration.
|
||||
*
|
||||
* The missing half of "replacing a visualization must not remove what was
|
||||
* there". A page's built-in chart often ships with a control that governs the
|
||||
* reading itself — Control Center's 7D/30D/90D toggle changes *which rows the
|
||||
* series returns*, not how they are drawn — and replacing the component threw
|
||||
* it away, because the control lived inside the component.
|
||||
*
|
||||
* Declaring it on the **series** is what fixes that generically: the control
|
||||
* belongs to the reading, so every component that can draw the reading draws
|
||||
* the control too, and nothing anywhere names a range, a page or a chart. A
|
||||
* series that publishes none is unaffected, and no control is ever invented —
|
||||
* `read` and `write` are the page's own, so a page that stops publishing the
|
||||
* state stops publishing the control with it.
|
||||
*/
|
||||
function normalizeControl(id, control) {
|
||||
const controlId = String(control?.id ?? '').trim();
|
||||
if (!controlId) throw new Error(`registerSeries: \`${id}\` declares a control with no \`id\`.`);
|
||||
|
||||
const options = (control.options || []).map((option) => ({
|
||||
value: String(option?.value ?? '').trim(),
|
||||
label: String(option?.label ?? '').trim(),
|
||||
}));
|
||||
if (!options.length) {
|
||||
throw new Error(`registerSeries: \`${id}\` control \`${controlId}\` needs at least one option.`);
|
||||
}
|
||||
const blank = options.find((option) => !option.value || !option.label);
|
||||
if (blank) {
|
||||
throw new Error(`registerSeries: \`${id}\` control \`${controlId}\` has an option with no value or label.`);
|
||||
}
|
||||
if (typeof control.read !== 'function' || typeof control.write !== 'function') {
|
||||
throw new Error(`registerSeries: \`${id}\` control \`${controlId}\` needs a \`read\` and a \`write\`.`);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
id: controlId,
|
||||
label: String(control.label ?? controlId).trim(),
|
||||
options: Object.freeze(options.map(Object.freeze)),
|
||||
read: control.read,
|
||||
write: control.write,
|
||||
});
|
||||
}
|
||||
|
||||
export function registerSeries(definition) {
|
||||
const id = String(definition?.id ?? '').trim();
|
||||
if (!id) throw new Error('registerSeries: a series needs an `id`.');
|
||||
if (!SERIES_ID_PATTERN.test(id)) {
|
||||
throw new Error(`registerSeries: \`${id}\` must be lower-case letters, numbers, dashes and dots.`);
|
||||
}
|
||||
const kind = String(definition.kind ?? '').trim();
|
||||
if (!SERIES_KINDS.includes(kind)) {
|
||||
throw new Error(
|
||||
`registerSeries: \`${id}\` declares unknown kind \`${kind || '(none)'}\`. `
|
||||
+ `Known: ${SERIES_KINDS.join(', ')}.`
|
||||
);
|
||||
}
|
||||
if (typeof definition.read !== 'function') {
|
||||
throw new Error(`registerSeries: \`${id}\` needs a \`read(context)\`.`);
|
||||
}
|
||||
|
||||
const measures = (definition.measures || []).map((measure) => ({
|
||||
key: String(measure?.key ?? '').trim(),
|
||||
label: String(measure?.label ?? '').trim(),
|
||||
}));
|
||||
if (!measures.length) {
|
||||
throw new Error(`registerSeries: \`${id}\` needs at least one measure.`);
|
||||
}
|
||||
const blank = measures.find((measure) => !measure.key || !measure.label);
|
||||
if (blank) throw new Error(`registerSeries: \`${id}\` has a measure with no key or label.`);
|
||||
|
||||
const entry = Object.freeze({
|
||||
id,
|
||||
label: String(definition.label ?? id).trim(),
|
||||
kind,
|
||||
measures: Object.freeze(measures.map(Object.freeze)),
|
||||
/* The key on each row that names the point — the x axis, or the slice. */
|
||||
labelKey: String(definition.labelKey ?? 'label').trim() || 'label',
|
||||
read: definition.read,
|
||||
emptyNote: String(definition.emptyNote ?? 'Nothing to chart yet.').trim(),
|
||||
/* Optional, and absent for every series that does not publish one. */
|
||||
control: definition.control ? normalizeControl(id, definition.control) : null,
|
||||
});
|
||||
|
||||
providers.set(id, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** The registration, or null. */
|
||||
export const seriesFor = (id) => providers.get(String(id ?? '').trim()) || null;
|
||||
|
||||
/** Every registered series. Used by tests and by anything that has to list them. */
|
||||
export const listSeries = () => [...providers.values()];
|
||||
|
||||
/** Forget everything. Tests only. */
|
||||
export const resetSeries = () => providers.clear();
|
||||
|
||||
/**
|
||||
* What this binding means, or null for a binding that cannot say.
|
||||
*
|
||||
* Three answers, in order of how much they actually know:
|
||||
*
|
||||
* 1. A registered series declared its kind. That is a fact its author wrote.
|
||||
* 2. A reading bound to time periods is periodic. Also a fact — `periods` is
|
||||
* in the closed option vocabulary and the resolver buckets by it.
|
||||
* 3. Otherwise unknown, and unknown vetoes nothing.
|
||||
*/
|
||||
export function kindOfBinding(binding) {
|
||||
if (!binding) return null;
|
||||
if (binding.series) return seriesFor(binding.series)?.kind || null;
|
||||
if (binding.source) {
|
||||
if (binding.params?.periods?.length) return 'periodic';
|
||||
return dataSourceFor(binding.source)?.series || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** What to call this binding in a sentence. */
|
||||
export function labelOfBinding(binding) {
|
||||
if (!binding) return 'this';
|
||||
if (binding.series) return seriesFor(binding.series)?.label || binding.series;
|
||||
if (binding.source) return dataSourceLabel(binding.source) || binding.source;
|
||||
return 'this';
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a type may not draw this binding — one sentence, or null if it may.
|
||||
*
|
||||
* Written here rather than at the call sites so the editor and the conversation
|
||||
* refuse in the same words, and so the reason is about the *data* rather than
|
||||
* about the component. "A pie chart cannot show Hiring activity" tells nobody
|
||||
* anything; saying that the reading is ordered in time does.
|
||||
*/
|
||||
export function refuseSeries(entry, binding) {
|
||||
if (!entry.seriesKinds.length) {
|
||||
return `\`${entry.label}\` cannot draw ${labelOfBinding(binding)}.`;
|
||||
}
|
||||
const kind = kindOfBinding(binding);
|
||||
/**
|
||||
* Not knowing is a refusal, not a permission.
|
||||
*
|
||||
* This used to be the other way round — an unknown kind vetoed nothing — and
|
||||
* the consequence was visible in the product: a card reading `Recent hires`
|
||||
* was offered a pie chart, because nothing had ever said what those figures
|
||||
* mean and silence was read as consent. A component that has declared which
|
||||
* meanings it can honestly draw cannot draw one that was never declared, so
|
||||
* the honest answer is no, with the reason.
|
||||
*
|
||||
* Saying yes here is the only way this system can produce a chart that is
|
||||
* structurally valid and factually a lie, which is why the default is the
|
||||
* strict one. Establishing compatibility is one field on the reading — see
|
||||
* `series` in the data-source vocabulary — and is deliberately the author's
|
||||
* statement rather than a guess made here from a label or a shape.
|
||||
*/
|
||||
if (!kind) {
|
||||
return (
|
||||
`\`${entry.label}\` cannot show ${labelOfBinding(binding)}: `
|
||||
+ 'it does not say what its figures mean, so there is no way to know '
|
||||
+ 'that drawing them this way would be true.'
|
||||
);
|
||||
}
|
||||
if (entry.seriesKinds.includes(kind)) return null;
|
||||
return (
|
||||
`\`${entry.label}\` cannot show ${labelOfBinding(binding)}: `
|
||||
+ `${KIND_REASON[kind]}.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The control this binding publishes, resolved against the page, or null.
|
||||
*
|
||||
* Two facts have to line up before a control exists: the series declared one,
|
||||
* and the page is actually publishing the state it names. Either missing means
|
||||
* no control — nothing is drawn from a default, and nothing is fabricated to
|
||||
* fill the gap a replaced component left behind.
|
||||
*/
|
||||
export function controlOfBinding(binding, context = {}) {
|
||||
const entry = binding?.series ? seriesFor(binding.series) : null;
|
||||
const control = entry?.control;
|
||||
if (!control) return null;
|
||||
|
||||
const value = control.read(context || {});
|
||||
if (value == null) return null;
|
||||
|
||||
return {
|
||||
id: control.id,
|
||||
label: control.label,
|
||||
options: control.options,
|
||||
value,
|
||||
set: (next) => control.write(context || {}, next),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows a binding resolves to, in the one shape every visualization reads.
|
||||
*
|
||||
* `{ rows, measures, emptyNote }` — rows carry a label and one number per
|
||||
* measure, so a single-measure skill reading and a four-measure page series are
|
||||
* the same object by the time a chart sees them. Nothing is aggregated,
|
||||
* re-bucketed or re-ordered on the way: a chart draws what the page or the
|
||||
* resolver already computed, and a transformation that changed the meaning
|
||||
* would have to be written somewhere, and there is nowhere.
|
||||
*
|
||||
* `resolve` is passed in rather than imported so this stays free of React and
|
||||
* of the skill resolver — the component knows how to get a skill reading, and
|
||||
* this knows what to do with either kind.
|
||||
*/
|
||||
export function readSeries(binding, { context = {}, resolveSource = null } = {}) {
|
||||
const empty = { rows: [], measures: [], emptyNote: 'Nothing to chart yet.', label: '' };
|
||||
if (!binding) return empty;
|
||||
|
||||
if (binding.series) {
|
||||
const entry = seriesFor(binding.series);
|
||||
if (!entry) return empty;
|
||||
const raw = entry.read(context || {}) || [];
|
||||
const rows = (Array.isArray(raw) ? raw : []).map((row, i) => {
|
||||
const point = { id: String(row?.id ?? i), label: String(row?.[entry.labelKey] ?? '') };
|
||||
for (const measure of entry.measures) point[measure.key] = Number(row?.[measure.key]) || 0;
|
||||
return point;
|
||||
}).filter((row) => row.label);
|
||||
return { rows, measures: entry.measures, emptyNote: entry.emptyNote, label: entry.label };
|
||||
}
|
||||
|
||||
if (!binding.source || typeof resolveSource !== 'function') return empty;
|
||||
const data = resolveSource(binding) || {};
|
||||
const rows = (data.steps || data.items || []).map((step, i) => ({
|
||||
id: String(step?.id ?? i),
|
||||
label: String(step?.label || step?.title || ''),
|
||||
value: Number(step?.value) || 0,
|
||||
})).filter((row) => row.label);
|
||||
|
||||
return {
|
||||
rows,
|
||||
measures: [{ key: 'value', label: dataSourceLabel(binding.source) || 'Value' }],
|
||||
emptyNote: data.emptyNote || 'Nothing to chart yet.',
|
||||
label: dataSourceLabel(binding.source) || binding.source,
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, dataSourceFor, sourceSupportsOption,
|
||||
sourceSupportsShape,
|
||||
} from '@/lib/skills/surfaces';
|
||||
import { refuseSeries, seriesFor } from './series';
|
||||
import {
|
||||
ALIGN_VALUES, DENSITY_VALUES, GAP_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_ID_PATTERN,
|
||||
PRESENTATION_KEYS, SPACING_VALUES, VARIANT_VALUES, walk,
|
||||
@@ -303,6 +304,31 @@ function validateBinding(node, entry) {
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page's own series.
|
||||
*
|
||||
* Checked against the series registry rather than the data-source vocabulary,
|
||||
* because it is a different closed table — but the gate is the same one and
|
||||
* for the same reason: a binding may only *name* something already
|
||||
* registered, so there is still nowhere for a fabricated figure to live. The
|
||||
* meaning is checked too, so a stored patch that turned a time series into a
|
||||
* pie is refused on load rather than drawn.
|
||||
*/
|
||||
if (binding.series) {
|
||||
const series = seriesFor(binding.series);
|
||||
if (!series) {
|
||||
problems.push(problem(at, `Unsupported series: ${binding.series}.`));
|
||||
return problems;
|
||||
}
|
||||
if (!entry.seriesKinds.length) {
|
||||
problems.push(problem(at, `\`${entry.type}\` does not draw a series.`));
|
||||
return problems;
|
||||
}
|
||||
const untrue = refuseSeries(entry, binding);
|
||||
if (untrue) problems.push(problem(at, untrue));
|
||||
return problems;
|
||||
}
|
||||
|
||||
if (!entry.dataShapes.length) {
|
||||
problems.push(problem(at, `\`${entry.type}\` does not read data, so it cannot take a source.`));
|
||||
return problems;
|
||||
@@ -329,6 +355,19 @@ function validateBinding(node, entry) {
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* And the semantic half, for a skill reading as well as for a page series.
|
||||
*
|
||||
* The same call, the same sentence, one rule — so a stored patch that binds a
|
||||
* component to a reading it cannot honestly draw is refused on load exactly
|
||||
* as the operation that would have created it is refused, rather than being
|
||||
* replayed into the tree because nobody re-asked the question here.
|
||||
*/
|
||||
if (entry.seriesKinds.length) {
|
||||
const untrue = refuseSeries(entry, binding);
|
||||
if (untrue) problems.push(problem(at, untrue));
|
||||
}
|
||||
|
||||
const params = binding.params || {};
|
||||
|
||||
if (params.periods != null) {
|
||||
|
||||
Reference in New Issue
Block a user