{
  "name": "chart-utils",
  "type": "registry:lib",
  "description": "Chart scaling and SVG path helpers",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/bases/default/lib/chart-utils.ts",
      "type": "registry:lib",
      "content": "/**\n * Chart maths for the charts & metrics layer.\n *\n * Everything here is pure and frame-independent — components own the timing,\n * this file owns the geometry. Screen coordinates are produced in one place so\n * a line, its area fill, its gridlines and its axis labels can never drift out\n * of alignment with each other.\n */\n\nexport type ChartDatum = {\n  label: string;\n  value: number;\n  /** Overrides the series colour for this bar/point. */\n  color?: string;\n  /** Short change annotation, e.g. `\"+18%\"`. */\n  delta?: string;\n};\n\nexport type ChartPoint = {\n  x: number;\n  y: number;\n  /** Axis tick label. Falls back to the x value when omitted. */\n  label?: string;\n};\n\nexport type ChartDomain = {\n  min: number;\n  max: number;\n  span: number;\n};\n\n/** Inner drawing rectangle, in SVG user units. */\nexport type PlotArea = {\n  left: number;\n  top: number;\n  right: number;\n  bottom: number;\n  width: number;\n  height: number;\n};\n\nexport type PlotInsets = {\n  top?: number;\n  right?: number;\n  bottom?: number;\n  left?: number;\n};\n\n/** A point projected into plot coordinates, with its source values kept. */\nexport type PlottedPoint = {\n  x: number;\n  y: number;\n  value: number;\n  label?: string;\n};\n\nconst clamp01 = (value: number) => Math.min(1, Math.max(0, value));\n\n/** Raw min/max of a series. `includeZero` anchors bar charts to a true zero. */\nexport function getChartDomain(\n  values: number[],\n  { includeZero = false }: { includeZero?: boolean } = {},\n): ChartDomain {\n  const finite = values.filter((value) => Number.isFinite(value));\n\n  if (finite.length === 0) {\n    return { min: 0, max: 1, span: 1 };\n  }\n\n  const min = Math.min(...finite, includeZero ? 0 : Infinity);\n  const max = Math.max(...finite, includeZero ? 0 : -Infinity);\n\n  // A flat series still needs a span, otherwise every point lands on one line.\n  if (min === max) {\n    const padding = Math.abs(max) || 1;\n    return { min: min - padding, max: max + padding, span: padding * 2 };\n  }\n\n  return { min, max, span: max - min };\n}\n\n/** Rounds a raw step up to the nearest 1 / 2 / 5 × 10ⁿ so ticks read cleanly. */\nfunction niceStep(rawStep: number): number {\n  if (rawStep <= 0) return 1;\n  const magnitude = 10 ** Math.floor(Math.log10(rawStep));\n  const normalized = rawStep / magnitude;\n  const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;\n  return step * magnitude;\n}\n\n/**\n * Domain snapped outward to round numbers, with the tick values that land\n * inside it. Charts read as measured rather than arbitrary when the axis stops\n * at 60K instead of 58.4K.\n */\nexport function niceDomain(\n  values: number[],\n  {\n    includeZero = true,\n    tickCount = 4,\n  }: { includeZero?: boolean; tickCount?: number } = {},\n): ChartDomain & { ticks: number[] } {\n  const raw = getChartDomain(values, { includeZero });\n  const step = niceStep(raw.span / Math.max(1, tickCount));\n  const min = Math.floor(raw.min / step) * step;\n  const max = Math.ceil(raw.max / step) * step;\n  const ticks: number[] = [];\n\n  // Float steps accumulate error, so walk an integer index instead.\n  for (let index = 0; min + index * step <= max + step * 1e-6; index += 1) {\n    ticks.push(Number((min + index * step).toPrecision(12)));\n  }\n\n  return { min, max, span: max - min || 1, ticks };\n}\n\n/** Normalized 0→1 position of a value inside a domain. */\nexport function scaleValue(value: number, min: number, max: number): number {\n  if (max === min) return 0;\n  return (value - min) / (max - min);\n}\n\n/**\n * Inner rectangle of a chart. Insets are per-edge because the left gutter has\n * to hold axis labels while the right edge only needs room for the line cap.\n */\nexport function getPlotArea(\n  width: number,\n  height: number,\n  insets: PlotInsets = {},\n): PlotArea {\n  const top = insets.top ?? 24;\n  const right = insets.right ?? 24;\n  const bottom = insets.bottom ?? 24;\n  const left = insets.left ?? 24;\n\n  return {\n    left,\n    top,\n    right: width - right,\n    bottom: height - bottom,\n    width: Math.max(1, width - left - right),\n    height: Math.max(1, height - top - bottom),\n  };\n}\n\n/**\n * Projects data points into plot coordinates.\n *\n * The x domain is the index range rather than the x values when points are\n * evenly spaced categories — pass real x values and they are honoured.\n */\nexport function plotPoints(\n  points: ChartPoint[],\n  plot: PlotArea,\n  yDomain: ChartDomain,\n): PlottedPoint[] {\n  const xDomain = getChartDomain(points.map((point) => point.x));\n\n  return points.map((point) => ({\n    x: plot.left + scaleValue(point.x, xDomain.min, xDomain.max) * plot.width,\n    y:\n      plot.bottom -\n      clamp01(scaleValue(point.y, yDomain.min, yDomain.max)) * plot.height,\n    value: point.y,\n    label: point.label,\n  }));\n}\n\n/** Straight polyline through the projected points. */\nexport function buildLinePath(points: { x: number; y: number }[]): string {\n  if (points.length === 0) return \"\";\n  return points\n    .map((point, index) => `${index === 0 ? \"M\" : \"L\"} ${round(point.x)} ${round(point.y)}`)\n    .join(\" \");\n}\n\n/**\n * Cardinal spline through the points, with control points clamped inside each\n * segment's own y range. Unclamped splines overshoot past local minima, which\n * on a chart reads as data that was never in the series.\n */\nexport function buildSmoothPath(\n  points: { x: number; y: number }[],\n  tension = 0.42,\n): string {\n  if (points.length < 3) return buildLinePath(points);\n\n  const parts = [`M ${round(points[0].x)} ${round(points[0].y)}`];\n\n  for (let index = 0; index < points.length - 1; index += 1) {\n    const previous = points[index - 1] ?? points[index];\n    const current = points[index];\n    const next = points[index + 1];\n    const after = points[index + 2] ?? next;\n\n    const lowerBound = Math.min(current.y, next.y);\n    const upperBound = Math.max(current.y, next.y);\n    const clampY = (value: number) =>\n      Math.min(upperBound, Math.max(lowerBound, value));\n\n    const c1x = current.x + ((next.x - previous.x) / 6) * tension * 2;\n    const c1y = clampY(current.y + ((next.y - previous.y) / 6) * tension * 2);\n    const c2x = next.x - ((after.x - current.x) / 6) * tension * 2;\n    const c2y = clampY(next.y - ((after.y - current.y) / 6) * tension * 2);\n\n    parts.push(\n      `C ${round(c1x)} ${round(c1y)}, ${round(c2x)} ${round(c2y)}, ${round(next.x)} ${round(next.y)}`,\n    );\n  }\n\n  return parts.join(\" \");\n}\n\n/** Closes a line path down to a baseline so it can be filled. */\nexport function buildAreaPath(\n  linePath: string,\n  points: { x: number; y: number }[],\n  baselineY: number,\n): string {\n  if (points.length === 0 || linePath === \"\") return \"\";\n  const first = points[0];\n  const last = points[points.length - 1];\n  return `${linePath} L ${round(last.x)} ${round(baselineY)} L ${round(first.x)} ${round(baselineY)} Z`;\n}\n\n/** `124000` → `\"124K\"`. The default label format across the chart layer. */\nexport function formatCompactNumber(\n  value: number,\n  maximumFractionDigits = 1,\n): string {\n  return new Intl.NumberFormat(\"en\", {\n    notation: \"compact\",\n    maximumFractionDigits,\n  }).format(value);\n}\n\n/**\n * Axis ticks drop the fraction that `formatCompactNumber` keeps — an axis\n * reading 0 / 30K / 60K is quieter than 0 / 30.0K / 60.0K.\n */\nexport function formatAxisValue(value: number): string {\n  return formatCompactNumber(value, Math.abs(value) < 10 ? 1 : 0);\n}\n\n/**\n * Splits a delta string into its direction and text so callers can colour it.\n * Anything that is not clearly signed stays neutral rather than guessing.\n */\nexport function readDelta(delta: string | undefined): {\n  direction: \"up\" | \"down\" | \"flat\";\n  text: string;\n} {\n  if (!delta) return { direction: \"flat\", text: \"\" };\n  const trimmed = delta.trim();\n  if (trimmed.startsWith(\"+\")) return { direction: \"up\", text: trimmed };\n  if (trimmed.startsWith(\"-\") || trimmed.startsWith(\"−\")) {\n    return { direction: \"down\", text: trimmed };\n  }\n  return { direction: \"flat\", text: trimmed };\n}\n\nfunction round(value: number): number {\n  return Math.round(value * 100) / 100;\n}\n"
    }
  ]
}