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>
  );
}