archive issue fix
Some checks failed
CI / check (push) Failing after 5m5s

This commit is contained in:
2026-09-10 19:29:39 +05:30
parent 6249e00a3a
commit f522b6508e
20 changed files with 2444 additions and 207 deletions

View File

@@ -45,6 +45,9 @@ export function resolveUiEdit({ question, ui }) {
page can actually hold — the same scope the visual editor's picker uses,
so the two can never disagree about what is addable here. */
page: ui.page,
/* The node this conversation last changed. Nothing is remembered inside the
matcher: continuity is a fact the caller holds and passes in. */
focus: ui.focus || null,
});
if (!match) return null;
@@ -155,8 +158,52 @@ export function resolveUiEdit({ question, ui }) {
followUp: [{ label: 'What is on this page?', prompt: 'What is on this page?' }],
};
case 'refused':
return { kind: 'ui-answer', doc: doc(text(match.message)) };
/**
* Understood, and not possible — with the reason and the way forward.
*
* A refusal that only says no leaves a person guessing at a vocabulary they
* cannot see. When the engine knows what this reading *could* be drawn as,
* it says so and offers the choices as chips, so "no, but here" costs one
* click rather than another round of guessing.
*/
case 'refused': {
const offered = match.alternatives || [];
return {
kind: 'ui-answer',
doc: offered.length
? doc(
text(match.message),
text(`It can be shown as: ${offered.map((o) => o.label).join(', ')}.`)
)
: doc(text(match.message)),
followUp: offered.slice(0, 3).map((option) => ({
label: option.label,
prompt: `Show ${match.node?.title || match.node?.label || 'it'} as a ${option.label}`,
})),
};
}
/**
* Every way this reading could honestly be drawn.
*
* The options are the registry's answer, not a suggestion: each is a
* component the application ships, each will draw this node's own figures,
* and choosing one produces exactly the operation `planReplace` would have
* produced from the same words. Nothing here is generated.
*/
case 'options':
return {
kind: 'ui-answer',
doc: doc(
text(`${match.subject} can be drawn these ways. Each one uses the same figures.`),
list(match.options.map((option) => `${option.label} — ${option.summary}`)),
note('Pick one to preview it. Nothing is saved until you apply.')
),
followUp: match.options.map((option) => ({
label: option.label,
prompt: `Show ${match.node.title || match.node.label} as a ${option.label}`,
})),
};
default:
return null;

View File

@@ -588,6 +588,10 @@ export function useConversation({
property of the page, not of the registry, and this is how
the conversation learns it. */
page: uiEditing.page || null,
/* What this session last changed, so "change it back" has an
"it". A node id and nothing else — see `focus` on the
editing provider. */
focus: uiEditing.focus || null,
}
: null,
}),

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Briefcase, MapPin, Wallet, Sparkles, Compass, ArrowRight } from 'lucide-react';
/**
* Opportunities — the Employee opportunity-discovery grid.
*
* Replaces the old demand-feed cards on the Employee jobs surface. Each card is
* an opportunity, identified by its JOB TITLE — never a company/client name.
* "View Position" navigates (route-based, no drawer) to the full-page detail at
* /opportunities/:id.
*
* It takes the same `{ job, match }` records the dashboard already computes with
* recommendJobs(profile, postings, 6); nothing about the matching changes here.
*
* COMPANY PRIVACY: this component reads only the fields the Employee experience
* needs — id, title, role_category, location, pay_range_min/max, status, match —
* and never `job.company`.
*/
function OpportunityCard({ rec }) {
const navigate = useNavigate();
const { job, match } = rec;
const pay = job.pay_range_min && job.pay_range_max ? `$${job.pay_range_min}–$${job.pay_range_max}/hr` : null;
const isHiring = job.status === 'active';
return (
<button
type="button"
onClick={() => navigate(`/opportunities/${job.id}`)}
className="group text-left w-full rounded-2xl bg-white border border-[#E5E7EB] p-5 transition-all
hover:border-[#0838E0]/40 hover:shadow-lg hover:shadow-[#0838E0]/5 hover:-translate-y-0.5
focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
>
<div className="flex items-start justify-between gap-3">
<div className="w-11 h-11 rounded-xl bg-[#EEF3FE] text-[#0838E0] flex items-center justify-center shrink-0">
<Briefcase className="w-5 h-5" />
</div>
{isHiring && (
<span className="inline-flex items-center text-[11px] font-semibold text-white bg-[#0838E0] px-2.5 py-1 rounded-full">
Hiring
</span>
)}
</div>
{/* The job title is the opportunity's identity — no company name. */}
<h3 className="mt-3 text-[16px] font-bold text-[#0F172A] leading-snug line-clamp-2">{job.title}</h3>
{job.role_category && (
<p className="mt-0.5 text-[13px] text-[#6B7280] capitalize">{job.role_category}</p>
)}
<div className="mt-3 space-y-1.5">
<p className="text-[13px] text-[#6B7280] flex items-center gap-1.5">
<MapPin className="w-3.5 h-3.5 text-[#9CA3AF]" /> {job.location || 'Remote'}
</p>
{pay && (
<p className="text-[13px] font-medium text-[#111827] flex items-center gap-1.5">
<Wallet className="w-3.5 h-3.5 text-[#9CA3AF]" /> {pay}
</p>
)}
</div>
<div className="mt-4 flex items-center justify-between pt-4 border-t border-[#F3F4F6]">
<span className="inline-flex items-center gap-1 text-[12px] font-bold text-[#0838E0] bg-[#EEF3FE] px-2.5 py-1 rounded-full">
<Sparkles className="w-3 h-3" /> {match}% match
</span>
<span className="inline-flex items-center gap-1 text-[13px] font-semibold text-[#0838E0] group-hover:gap-1.5 transition-all">
View Position <ArrowRight className="w-4 h-4" />
</span>
</div>
</button>
);
}
export default function Opportunities({ jobRecs = [] }) {
return (
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
<div className="flex items-center justify-between mb-5">
<div>
<h2 className="text-[18px] font-bold text-[#0F172A] flex items-center gap-2">
<Compass className="w-4 h-4 text-[#0838E0]" /> Opportunities for you
</h2>
<p className="text-[13px] text-[#6B7280] mt-0.5">Matched to your profile. Open one to see why you fit.</p>
</div>
{jobRecs.length > 0 && (
<span className="inline-flex items-center text-[12px] font-semibold text-[#0838E0] bg-[#EEF3FE] px-3 py-1.5 rounded-full">
{jobRecs.length} match{jobRecs.length === 1 ? '' : 'es'}
</span>
)}
</div>
{jobRecs.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 rounded-2xl bg-[#F9FAFB] text-[#9CA3AF] flex items-center justify-center mx-auto mb-3">
<Compass className="w-6 h-6" />
</div>
<p className="text-[14px] font-medium text-[#0F172A]">No matched opportunities yet — keep leveling up.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{jobRecs.map((rec) => (
<OpportunityCard key={rec.job.id} rec={rec} />
))}
</div>
)}
</section>
);
}

View File

@@ -6,6 +6,7 @@ import {
ALIGN_VALUES, GAP_VALUES, MAX_COLUMNS, MIN_COLUMNS, SPACING_VALUES,
} from '@/lib/ui/node';
import { dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
import { kindOfBinding } from '@/lib/ui/series';
import { nodeRegistry } from '@/lib/ui/registry';
import { hideOp, layoutOp, nudgeOp, presentationOp, propOp, removeOp, replaceOp } from './ops';
@@ -43,8 +44,25 @@ export function NodeInspector({ node, siblings, page = null, onOperate, registry
if (op) onOperate(op);
};
const shapes = node.data ? dataSourceFor(node.data.source)?.shapes || [] : null;
const replacements = can('replace') ? registry.replacements(node.type, { shapes, page }) : [];
/**
* What this node could be shown as.
*
* The same five facts the conversation passes, in the same order, to the same
* registry call — the editor holds no compatibility rule of its own, so a
* type in this list is exactly a type "show this as a …" would accept. Two
* readings of the rule is how a picker comes to offer something the operation
* then refuses.
*/
const shapes = node.data?.source ? dataSourceFor(node.data.source)?.shapes || [] : null;
const replacements = can('replace')
? registry.replacements(node.type, {
shapes,
page,
seriesKind: kindOfBinding(node.data),
seriesBound: Boolean(node.data?.series),
bound: Boolean(node.data),
})
: [];
return (
<div className="space-y-4">

View File

@@ -33,6 +33,20 @@ const UiEditingContext = React.createContext(null);
/** The editing session for the page around it. Null outside a provider. */
export const useUiEditing = () => React.useContext(UiEditingContext);
/**
* The node an operation is about, or null.
*
* Read from the operation's own shape rather than from a table of operation
* names with special cases: every operation that concerns one node names it in
* `target`, and `add` names the node it is creating. `reorder` concerns a whole
* list and has no single subject, so it leaves the previous one standing —
* which is right: reordering the page does not change what "it" means.
*/
function subjectOf(op) {
const named = String(op?.target ?? op?.node?.id ?? '').trim();
return named || null;
}
export function UiEditingProvider({ page, role = null, registry = nodeRegistry, children }) {
const { layouts, save, saving } = useUiLayouts();
@@ -44,6 +58,14 @@ export function UiEditingProvider({ page, role = null, registry = nodeRegistry,
*/
const [preview, setPreview] = React.useState(() => emptyPatch(page));
const [problems, setProblems] = React.useState([]);
/**
* The subject of the conversation, as a node id.
*
* What makes "change it back to a line chart" answerable. Recorded from the
* operation that stood, so it is always a node that really exists and really
* changed — never a guess, and never something the model supplied.
*/
const [focus, setFocus] = React.useState(null);
/* A page change is a new editing session. Anything unsaved was about the page
that is no longer on screen. */
@@ -91,6 +113,10 @@ export function UiEditingProvider({ page, role = null, registry = nodeRegistry,
}
setProblems([]);
setPreview((current) => pushOp(current, op));
/* Only once the operation stood. A refused change never becomes the thing
"it" refers to. */
const named = subjectOf(op);
if (named) setFocus(named);
return result;
}, [composed.tree, registry, role]);
@@ -98,6 +124,7 @@ export function UiEditingProvider({ page, role = null, registry = nodeRegistry,
const discard = React.useCallback(() => {
setPreview(emptyPatch(page));
setProblems([]);
setFocus(null);
}, [page]);
/**
@@ -141,6 +168,7 @@ export function UiEditingProvider({ page, role = null, registry = nodeRegistry,
const reset = React.useCallback(async () => {
setPreview(emptyPatch(page));
setProblems([]);
setFocus(null);
if (saved.ops.length) await save(page, clearOps(saved));
return { ok: true };
}, [page, saved, save]);
@@ -156,6 +184,15 @@ export function UiEditingProvider({ page, role = null, registry = nodeRegistry,
preview,
problems,
previewing: preview.ops.length > 0,
/**
* The node this session last changed, and therefore what "it" means.
*
* Session state, not stored state: it is a fact about the conversation
* rather than about the layout, so it is deliberately not persisted and is
* gone on reload. Kept across an Apply — applying does not end the subject —
* and cleared by Discard and Reset, which do.
*/
focus,
customised: saved.ops.length > 0,
saving,
propose,
@@ -164,7 +201,7 @@ export function UiEditingProvider({ page, role = null, registry = nodeRegistry,
undo,
reset,
}), [
page, composed, saved, preview, problems, saving, propose, discard, apply, undo, reset,
page, composed, saved, preview, problems, saving, focus, propose, discard, apply, undo, reset,
]);
return <UiEditingContext.Provider value={value}>{children}</UiEditingContext.Provider>;

View File

@@ -1,74 +1,79 @@
import React from 'react';
import {
Bar, BarChart, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Legend, Line, LineChart, Pie, PieChart,
ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import { cn } from '@/lib/utils';
import { AXIS_PROPS, CHART_COLORS, CHART_TONES } from '@/components/ds/ChartContainer';
import { SECTION_TYPES } from '@/lib/skills/surfaces';
import { SegmentedToggle } from '@/components/ds/Toggle';
import { useSkillDataContext } from '@/components/skills/SkillSurface';
import { resolveSkillData } from '@/lib/skills/dataResolver';
import { DENSITY_VALUES, VARIANT_VALUES } from '@/lib/ui/node';
import { registerNodeType } from '@/lib/ui/registry';
import { layoutClasses, presentationClasses } from './UiTreeRenderer';
import { controlOfBinding, readSeries } from '@/lib/ui/series';
import { layoutClasses, presentationClasses, useUiContext } from './UiTreeRenderer';
/**
* Two ways of drawing a series: as bars, and as parts of a whole.
* Five ways of drawing one series, and one table that says which is which.
*
* These are the first node types that are **not** skill section types. The nine
* readings exist in `surfaces.js` because a Markdown definition may name them;
* chart and pie are named by nobody but this registry, so adding them here adds
* nothing to the MD vocabulary, obliges no change in the Go parser, and cannot
* drift from its conformance oracle. A person reaches them by turning an
* existing node into one.
* These are the node types a person reaches by asking for them — "show this as
* a bar chart" — and everything that decides whether they may is declared here
* as data. There is no branch anywhere on which chart is being drawn: the
* registry compares `seriesKinds` to what the binding means, and a sixth chart
* added to `VISUALIZATIONS` below is addressable by name, in conversation and
* in the editor, with no other file touched.
*
* **Nothing is transformed and nothing is invented.** A `flow` or a `stats`
* reading already resolves to `{ steps: [{ id, label, value }] }` — a labelled
* numeric series, which is precisely what a bar chart and a pie chart each
* need. So these draw the same payload the Flow section draws, from the same
* resolver, keyed by the same source. There is no adapter layer here because
* there is nothing to adapt.
* **Nothing is transformed and nothing is invented.** Every one of these reads
* `readSeries`, which either resolves a skill reading through the ordinary
* resolver or calls a page's own registered `read` — and then relabels neither.
* A bar chart of hiring activity draws the same four numbers per day that the
* page's own composed chart draws, because it is literally the same array.
*
* The consequence is the compatibility rule, and it is the existing one: a
* source can become a chart exactly when it declares `flow` or `stats`, decided
* by `sourceSupportsShape` like every other replacement. No new shape, no new
* vocabulary, no per-source table.
* The interesting column is `seriesKinds`, and it is the whole reason a person
* can be told *why* rather than merely *no*. `flow` cannot express it — it is,
* in `SECTION_TYPES`' own words, "a sequence of stages **or** periods" — so a
* pie was structurally free to slice twelve days into twelve wedges. Saying
* `parts` here is what makes that impossible, and the refusal explains itself
* in terms of the reading rather than the component.
*/
/** The series a reading resolved to, in the one shape both charts read. */
/** The series this node is bound to, resolved through whichever binding it has. */
function useSeries(node) {
const context = useSkillDataContext(null);
const section = React.useMemo(() => ({
const skillContext = useSkillDataContext(null);
const pageContext = useUiContext();
const resolveSource = React.useCallback((binding) => resolveSkillData({
id: node.id,
/* `flow` because that is the shape whose payload these draw. Resolvers are
keyed by source and never read this, but a section without a type is not
a section, and naming the shape it consumes keeps that honest. */
type: 'flow',
source: node.data?.source || '',
periods: node.data?.params?.periods || [],
limit: node.data?.params?.limit || null,
}), [node.id, node.data]);
source: binding.source,
periods: binding.params?.periods || [],
limit: binding.params?.limit || null,
}, skillContext), [node.id, skillContext]);
const data = React.useMemo(() => resolveSkillData(section, context), [section, context]);
const rows = (data.steps || data.items || [])
.map((step, i) => ({
id: step.id || `${i}`,
label: String(step.label || step.title || ''),
value: Number(step.value) || 0,
}))
/* A slice of nothing is not a slice, and a bar of nothing is a gap. Both
charts read real numbers or draw the empty note. */
.filter((row) => row.label);
return { data, rows };
return React.useMemo(
() => readSeries(node.data, { context: pageContext, resolveSource }),
[node.data, pageContext, resolveSource]
);
}
/** How tall the plot is, and therefore how big a pie fits in it. */
const plotHeight = (presentation) => (presentation?.density === 'compact' ? 160 : 240);
const plotHeight = (presentation) => (presentation?.density === 'compact' ? 180 : 260);
/** The panel both share — the same chrome every reading section draws. */
function ChartPanel({ node, attrs, layout, presentation, title, children, empty }) {
/**
* The panel every one of them shares — the same chrome a reading section draws.
*
* `control` is whatever the *reading* publishes, drawn here so that replacing
* one visualization with another does not quietly take a control away. Nothing
* about it is known to this file beyond the shape declared in `series.js`: a
* label, a closed set of options, the value the page is publishing, and the
* page's own setter. A reading that publishes none renders none.
*/
function ChartPanel({ attrs, layout, presentation, title, children, empty, control = null }) {
const look = presentationClasses(presentation);
const height = plotHeight(presentation);
return (
<section
@@ -76,95 +81,210 @@ function ChartPanel({ node, attrs, layout, presentation, title, children, empty
aria-label={title || undefined}
className={cn('rounded-2xl border', look.variant, look.padding, layoutClasses(layout))}
>
{title && (
<div className={look.gap}>
<h3 className={cn('font-heading font-semibold text-ink-1', look.title)}>{title}</h3>
{(title || control) && (
<div className={cn('flex flex-wrap items-center justify-between gap-x-3 gap-y-2', look.gap)}>
{title
? <h3 className={cn('font-heading font-semibold text-ink-1', look.title)}>{title}</h3>
: <span />}
{control && (
<SegmentedToggle
options={control.options}
value={control.value}
onChange={control.set}
size="sm"
ariaLabel={control.label}
/>
)}
</div>
)}
{empty
? <p className="text-caption text-ink-3">{empty}</p>
: <div style={{ height }}>{children}</div>}
: <div style={{ height: plotHeight(presentation) }}>{children}</div>}
</section>
);
}
/** Bars, one per step, in the brand's own colour. */
function ChartNode({ node, attrs = {}, layout = null, presentation = null, title = null }) {
const { data, rows } = useSeries(node);
const empty = !rows.length ? (data.emptyNote || 'Nothing to chart yet.') : null;
/** The axes, grid, tooltip and legend every cartesian chart here draws alike. */
function Cartesian({ measures }) {
return (
<>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" minTickGap={16} height={32} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={32} />
<Tooltip cursor={{ fill: CHART_TONES.mint, fillOpacity: 0.25 }} />
{/* One series needs no key: the title already names it. */}
{measures.length > 1 && (
<Legend
verticalAlign="top" align="right" height={24}
iconType="circle" iconSize={8}
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
/>
)}
</>
);
}
const colour = (i) => CHART_COLORS[i % CHART_COLORS.length];
/**
* The five, as data.
*
* `draw` returns the recharts element for a resolved series; everything else —
* the panel, the empty state, the identity, layout and presentation — is shared
* above and below. Adding one is adding a row.
*/
const VISUALIZATIONS = [
{
type: 'bar-chart',
label: 'Bar chart',
summary: 'A bar for each point in a reading.',
/* Bars compare magnitudes and claim nothing about order or wholeness, which
is why this is the one drawing every meaning can take. */
seriesKinds: ['periodic', 'cumulative', 'parts'],
draw: ({ rows, measures }) => (
<BarChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
<Cartesian measures={measures} />
{measures.map((measure, i) => (
<Bar key={measure.key} dataKey={measure.key} name={measure.label}
fill={colour(i)} radius={[4, 4, 0, 0]} />
))}
</BarChart>
),
},
{
type: 'line-chart',
label: 'Line chart',
summary: 'The points joined in order.',
/* A line asserts that the gap between two points means something, so it may
only draw readings that are actually ordered. */
seriesKinds: ['periodic', 'cumulative'],
draw: ({ rows, measures }) => (
<LineChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
<Cartesian measures={measures} />
{measures.map((measure, i) => (
<Line key={measure.key} type="monotone" dataKey={measure.key} name={measure.label}
stroke={colour(i)} strokeWidth={2} dot={false} isAnimationActive={false} />
))}
</LineChart>
),
},
{
type: 'area-chart',
label: 'Area chart',
summary: 'The points joined in order, filled to the axis.',
seriesKinds: ['periodic', 'cumulative'],
draw: ({ rows, measures }) => (
<AreaChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
<Cartesian measures={measures} />
{measures.map((measure, i) => (
<Area key={measure.key} type="monotone" dataKey={measure.key} name={measure.label}
stroke={colour(i)} strokeWidth={2} fill={colour(i)} fillOpacity={0.18}
isAnimationActive={false} />
))}
</AreaChart>
),
},
{
type: 'pie-chart',
label: 'Pie chart',
summary: 'The points as parts of one whole.',
/* The narrow one, and deliberately. A slice is a share of a total, which is
only true when the points are disjoint parts of that total. */
seriesKinds: ['parts'],
draw: ({ rows, measures }) => <Slices rows={rows} measure={measures[0]} inner={0} />,
/* A disc of zeroes is a blank panel that every automated check calls a
success because the elements are there. */
guard: ({ rows, measures }) => (
rows.reduce((sum, row) => sum + (row[measures[0]?.key] || 0), 0) === 0
? 'Every value here is zero.' : null
),
},
{
type: 'donut-chart',
label: 'Donut chart',
summary: 'The same parts, drawn as a ring.',
seriesKinds: ['parts'],
draw: ({ rows, measures }) => <Slices rows={rows} measure={measures[0]} inner={0.55} />,
guard: ({ rows, measures }) => (
rows.reduce((sum, row) => sum + (row[measures[0]?.key] || 0), 0) === 0
? 'Every value here is zero.' : null
),
},
];
/**
* The wedges a pie and a donut share.
*
* Centre and radii given explicitly, in pixels. Percentage radii with no
* `cx`/`cy` produced sectors that existed in the DOM, carried no fill and drew
* nothing — the failure the guard above is about. The one pie already in this
* repository does it this way, and it works.
*/
function Slices({ rows, measure, inner }) {
const outer = 92;
return (
<PieChart>
<Pie
data={rows}
dataKey={measure?.key || 'value'}
nameKey="label"
cx="50%"
cy="50%"
innerRadius={Math.round(outer * inner)}
outerRadius={outer}
isAnimationActive={false}
>
{rows.map((row, i) => <Cell key={row.id} fill={colour(i)} />)}
</Pie>
<Tooltip />
</PieChart>
);
}
/** One visualization node — identical for all five but for the `draw` it was given. */
function Visualization({ spec, node, attrs = {}, layout = null, presentation = null, title = null }) {
const series = useSeries(node);
const pageContext = useUiContext();
const empty = !series.rows.length || !series.measures.length
? (series.emptyNote || 'Nothing to chart yet.')
: (spec.guard?.(series) || null);
/**
* The control the reading publishes, and the reading's own name.
*
* Both exist so that a replacement is a change of renderer rather than a
* quiet loss of what was on the panel. The title falls back to the name of
* the thing being drawn — not to a made-up one — so a built-in section
* replaced by a chart still says what it is showing.
*/
const control = controlOfBinding(node.data, pageContext);
return (
<ChartPanel node={node} attrs={attrs} layout={layout} presentation={presentation} title={title} empty={empty}>
<ChartPanel attrs={attrs} layout={layout} presentation={presentation}
title={title || series.label || null} empty={empty} control={control}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<XAxis dataKey="label" {...AXIS_PROPS} interval={0} height={40} />
<YAxis {...AXIS_PROPS} allowDecimals={false} />
<Tooltip cursor={{ fill: CHART_TONES.mint, fillOpacity: 0.25 }} />
<Bar dataKey="value" fill={CHART_TONES.brand} radius={[4, 4, 0, 0]} />
</BarChart>
{spec.draw(series)}
</ResponsiveContainer>
</ChartPanel>
);
}
/** The same series as parts of a whole. */
function PieNode({ node, attrs = {}, layout = null, presentation = null, title = null }) {
const { data, rows } = useSeries(node);
const total = rows.reduce((sum, row) => sum + row.value, 0);
/* A pie of nothing is a blank disc, so an all-zero series is empty rather
than drawn — the reading has values, they are simply all zero. */
const empty = !rows.length
? (data.emptyNote || 'Nothing to chart yet.')
: (total === 0 ? 'Every value here is zero.' : null);
const height = plotHeight(presentation);
const radius = { outer: Math.round(height * 0.38) };
return (
<ChartPanel node={node} attrs={attrs} layout={layout} presentation={presentation} title={title} empty={empty}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
{/* Centre and radii given explicitly, in pixels.
Percentage radii with no `cx`/`cy` produced sectors that existed in
the DOM, carried no fill and drew nothing — a blank panel that
every automated check called a success because the elements were
there. The one pie already in this repository does it this way,
and it works. */}
<Pie
data={rows}
dataKey="value"
nameKey="label"
cx="50%"
cy="50%"
outerRadius={radius.outer}
isAnimationActive={false}
>
{rows.map((row, i) => (
<Cell key={row.id} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</ChartPanel>
);
}
/* The shapes whose payload both of these draw, read from the vocabulary rather
than written out, so a shape renamed there cannot leave these claiming one
that no longer exists. */
/* The shapes whose payload these draw, read from the vocabulary rather than
written out, so a shape renamed there cannot leave these claiming one that no
longer exists. */
const SERIES_SHAPES = ['flow', 'stats'].filter((shape) => SECTION_TYPES.some((t) => t.id === shape));
for (const [type, label, summary, component] of [
['chart', 'Chart', 'A bar for each step in a reading.', ChartNode],
['pie', 'Pie', 'The same steps as parts of a whole.', PieNode],
]) {
for (const spec of VISUALIZATIONS) {
const Component = (props) => <Visualization spec={spec} {...props} />;
Component.displayName = spec.label;
registerNodeType({
type,
label,
summary,
component,
type: spec.type,
label: spec.label,
summary: spec.summary,
component: Component,
dataShapes: SERIES_SHAPES,
seriesKinds: spec.seriesKinds,
dataRequired: true,
propSchema: { title: { type: 'string', label: 'Title' } },
variants: VARIANT_VALUES,
@@ -174,3 +294,5 @@ for (const [type, label, summary, component] of [
capabilities: ['update', 'remove', 'move', 'replace', 'hide'],
});
}
export { VISUALIZATIONS };