GraphLegend.tsx3.2 KBView on GitHub 'use client';
/**
* The colour key, along the top of the canvas.
*
* ── Why the top, and why always visible ──
*
* A tint is only worth having if it is read WITHOUT being looked up, and it only becomes
* readable-without-lookup after you have seen the key once. Tucked inside a popover it is a
* thing nobody opens, so the colours stay decorative. Along the top it is read once on arrival
* and then never again, which is exactly the lifecycle a legend should have.
*
* It is also the schema made visible: it is generated from `view.colorField` and that field's
* `optionColors`, so changing the schema changes this, and a value in use that the schema never
* gave a colour shows up here as uncoloured rather than silently reading as "no category".
*/
import { cn } from '@/lib/utils';
import { nodeColorFor } from './node-colors';
import type { GraphSchema } from '@zero/server/graph';
export interface GraphLegendProps {
schema: GraphSchema;
/** Values actually in use on this graph, so the key describes THIS chart, not the vocabulary. */
valuesInUse: ReadonlySet<string>;
}
export function GraphLegend({ schema, valuesInUse }: GraphLegendProps) {
const key=[redacted];
const field = key ? schema.nodeSchema.fields.find((f) => f.key === key) : undefined;
if (!field?.optionColors) return null;
// The field's DECLARED option order, filtered to what this graph actually uses. A key listing
// eleven buying roles for a chart that uses two is a key nobody finishes reading.
const declared = (Array.isArray(field.options) ? field.options : []).map(String);
const shown = declared.filter((value) => valuesInUse.has(value));
// A value on a node that the field never declared still gets a slot, uncoloured — the same
// "undeclared keys are rendered, not hidden" rule the rest of the type follows.
const undeclared = [...valuesInUse].filter((v) => !declared.includes(v)).sort();
if (shown.length === 0 && undeclared.length === 0) return null;
return (
<div className="flex items-center gap-3 rounded-lg border border-surface-border bg-raised px-2.5 py-1.5 shadow-xs">
<span className="text-xs font-medium text-muted-foreground">{field.label}</span>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
{shown.map((value) => (
<Swatch key=[redacted] value={value} colorName={field.optionColors?.[value]} />
))}
{undeclared.map((value) => (
<Swatch key=[redacted] value={value} colorName={undefined} />
))}
</div>
</div>
);
}
function Swatch({ value, colorName }: { value: string; colorName: string | undefined }) {
const color = nodeColorFor(colorName);
return (
<span className="flex items-center gap-1.5 text-xs">
<span
aria-hidden
className={cn(
'size-2.5 shrink-0 rounded-full',
// No colour is drawn as an OUTLINE, not as a grey dot: a filled grey reads as the
// `slate` category, which is a real value here.
color ? color.swatchClassName : 'border border-dashed border-muted-foreground/60',
)}
/>
<span className={cn(!color && 'text-muted-foreground')}>{value}</span>
</span>
);
}